From d5b5a425a15aab43c6c0c43c4ea0ae32b26cafd4 Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Thu, 22 Jul 2021 16:19:17 +0300 Subject: [PATCH 01/45] [Canvas] Number ui argument refactor. #105720 (#105721) * Refactored `number` arg view from `recompose` to `hooks`. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../canvas_plugin_src/uis/arguments/number.js | 76 +++++++++---------- 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/number.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/number.js index 3362c9b2483e6..cf360c5d648ab 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/number.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/number.js @@ -5,12 +5,9 @@ * 2.0. */ -import React from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; -import { compose, withProps } from 'recompose'; import { EuiFieldNumber, EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { get } from 'lodash'; -import { createStatefulPropHoc } from '../../../public/components/enhance/stateful_prop'; import { templateFromReactComponent } from '../../../public/lib/template_from_react_component'; import { ArgumentStrings } from '../../../i18n'; @@ -21,53 +18,50 @@ const { Number: strings } = ArgumentStrings; // most understandable way to do this. There, I said it. // TODO: Support max/min as options -const NumberArgInput = ({ updateValue, value, confirm, commit, argId }) => ( - - - commit(Number(ev.target.value))} - /> - - {confirm && ( - - commit(Number(value))}> - {confirm} - +const NumberArgInput = ({ argId, argValue, typeInstance, onValueChange }) => { + const [value, setValue] = useState(argValue); + const confirm = typeInstance?.options?.confirm; + + useEffect(() => { + setValue(argValue); + }, [argValue]); + + const onChange = useCallback( + (ev) => { + const onChangeFn = confirm ? setValue : onValueChange; + onChangeFn(ev.target.value); + }, + [confirm, onValueChange] + ); + + return ( + + + - )} - -); -NumberArgInput.propTypes = { - argId: PropTypes.string.isRequired, - updateValue: PropTypes.func.isRequired, - value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, - confirm: PropTypes.string, - commit: PropTypes.func.isRequired, + {confirm && ( + + onValueChange(Number(value))}> + {confirm} + + + )} + + ); }; -const EnhancedNumberArgInput = compose( - withProps(({ onValueChange, typeInstance, argValue }) => ({ - confirm: get(typeInstance, 'options.confirm'), - commit: onValueChange, - value: argValue, - })), - createStatefulPropHoc('value') -)(NumberArgInput); - -EnhancedNumberArgInput.propTypes = { - argValue: PropTypes.any.isRequired, - onValueChange: PropTypes.func.isRequired, +NumberArgInput.propTypes = { + argId: PropTypes.string.isRequired, + argValue: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, typeInstance: PropTypes.object.isRequired, + onValueChange: PropTypes.func.isRequired, }; export const number = () => ({ name: 'number', displayName: strings.getDisplayName(), help: strings.getHelp(), - simpleTemplate: templateFromReactComponent(EnhancedNumberArgInput), + simpleTemplate: templateFromReactComponent(NumberArgInput), default: '0', }); From 58e2dd328e6ccd4df3fe80fc64ea281ff369d278 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 22 Jul 2021 15:36:40 +0200 Subject: [PATCH 02/45] [Reporting] Add deprecation notice to the upgrade assistant (#104303) * add deprecation notice to the upgrade assistant * fix types and update jest snapshot Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/reporting/server/deprecations.ts | 52 ---------- .../reporting/server/deprecations/index.ts | 28 ++++++ ...igrage_existing_indices_ilm_policy.test.ts | 98 +++++++++++++++++++ .../migrate_existing_indices_ilm_policy.ts | 62 ++++++++++++ .../reporting_role.test.ts} | 37 ++++--- .../server/deprecations/reporting_role.ts | 48 +++++++++ x-pack/plugins/reporting/server/plugin.ts | 5 +- 7 files changed, 264 insertions(+), 66 deletions(-) delete mode 100644 x-pack/plugins/reporting/server/deprecations.ts create mode 100644 x-pack/plugins/reporting/server/deprecations/index.ts create mode 100644 x-pack/plugins/reporting/server/deprecations/migrage_existing_indices_ilm_policy.test.ts create mode 100644 x-pack/plugins/reporting/server/deprecations/migrate_existing_indices_ilm_policy.ts rename x-pack/plugins/reporting/server/{deprecations.test.ts => deprecations/reporting_role.test.ts} (83%) create mode 100644 x-pack/plugins/reporting/server/deprecations/reporting_role.ts diff --git a/x-pack/plugins/reporting/server/deprecations.ts b/x-pack/plugins/reporting/server/deprecations.ts deleted file mode 100644 index 61074fff012a2..0000000000000 --- a/x-pack/plugins/reporting/server/deprecations.ts +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { CoreSetup, DeprecationsDetails, RegisterDeprecationsConfig } from 'src/core/server'; -import { ReportingCore } from '.'; - -const deprecatedRole = 'reporting_user'; -const upgradableConfig = 'xpack.reporting.roles.enabled: false'; - -export async function registerDeprecations( - reporting: ReportingCore, - { deprecations: deprecationsService }: CoreSetup -) { - const deprecationsConfig: RegisterDeprecationsConfig = { - getDeprecations: async ({ esClient }) => { - const usingDeprecatedConfig = !reporting.getContract().usesUiCapabilities(); - const deprecations: DeprecationsDetails[] = []; - const { body: users } = await esClient.asCurrentUser.security.getUser(); - - const reportingUsers = Object.entries(users) - .filter(([username, user]) => user.roles.includes(deprecatedRole)) - .map(([, user]) => user.username); - const numReportingUsers = reportingUsers.length; - - if (numReportingUsers > 0) { - const usernames = reportingUsers.join('", "'); - deprecations.push({ - message: `The deprecated "${deprecatedRole}" role has been found for ${numReportingUsers} user(s): "${usernames}"`, - documentationUrl: 'https://www.elastic.co/guide/en/kibana/current/secure-reporting.html', - level: 'critical', - correctiveActions: { - manualSteps: [ - ...(usingDeprecatedConfig ? [`Set "${upgradableConfig}" in kibana.yml`] : []), - `Create one or more custom roles that provide Kibana application privileges to reporting features in **Management > Security > Roles**.`, - `Assign the custom role(s) as desired, and remove the "${deprecatedRole}" role from the user(s).`, - ], - }, - }); - } - - return deprecations; - }, - }; - - deprecationsService.registerDeprecations(deprecationsConfig); - - return deprecationsConfig; -} diff --git a/x-pack/plugins/reporting/server/deprecations/index.ts b/x-pack/plugins/reporting/server/deprecations/index.ts new file mode 100644 index 0000000000000..9ecb3b7ab88ad --- /dev/null +++ b/x-pack/plugins/reporting/server/deprecations/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { CoreSetup } from 'src/core/server'; +import { ReportingCore } from '../core'; + +import { getDeprecationsInfo as getIlmPolicyDeprecationsInfo } from './migrate_existing_indices_ilm_policy'; +import { getDeprecationsInfo as getReportingRoleDeprecationsInfo } from './reporting_role'; + +export const registerDeprecations = ({ + core, + reportingCore, +}: { + core: CoreSetup; + reportingCore: ReportingCore; +}) => { + core.deprecations.registerDeprecations({ + getDeprecations: async (ctx) => { + return [ + ...(await getIlmPolicyDeprecationsInfo(ctx, { reportingCore })), + ...(await getReportingRoleDeprecationsInfo(ctx, { reportingCore })), + ]; + }, + }); +}; diff --git a/x-pack/plugins/reporting/server/deprecations/migrage_existing_indices_ilm_policy.test.ts b/x-pack/plugins/reporting/server/deprecations/migrage_existing_indices_ilm_policy.test.ts new file mode 100644 index 0000000000000..485c4e62a208f --- /dev/null +++ b/x-pack/plugins/reporting/server/deprecations/migrage_existing_indices_ilm_policy.test.ts @@ -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 type { GetDeprecationsContext } from 'src/core/server'; +import { elasticsearchServiceMock, savedObjectsClientMock } from 'src/core/server/mocks'; + +import { ReportingCore } from '../core'; +import { createMockConfigSchema, createMockReportingCore } from '../test_helpers'; + +import { getDeprecationsInfo } from './migrate_existing_indices_ilm_policy'; + +type ScopedClusterClientMock = ReturnType< + typeof elasticsearchServiceMock.createScopedClusterClient +>; + +const { createApiResponse } = elasticsearchServiceMock; + +describe("Migrate existing indices' ILM policy deprecations", () => { + let esClient: ScopedClusterClientMock; + let deprecationsCtx: GetDeprecationsContext; + let reportingCore: ReportingCore; + + beforeEach(async () => { + esClient = elasticsearchServiceMock.createScopedClusterClient(); + deprecationsCtx = { esClient, savedObjectsClient: savedObjectsClientMock.create() }; + reportingCore = await createMockReportingCore(createMockConfigSchema()); + }); + + const createIndexSettings = (lifecycleName: string) => ({ + aliases: {}, + mappings: {}, + settings: { + index: { + lifecycle: { + name: lifecycleName, + }, + }, + }, + }); + + it('returns deprecation information when reporting indices are not using the reporting ILM policy', async () => { + esClient.asInternalUser.indices.getSettings.mockResolvedValueOnce( + createApiResponse({ + body: { + indexA: createIndexSettings('not-reporting-lifecycle'), + indexB: createIndexSettings('kibana-reporting'), + }, + }) + ); + + expect(await getDeprecationsInfo(deprecationsCtx, { reportingCore })).toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "api": Object { + "method": "PUT", + "path": "/api/reporting/deprecations/migrate_ilm_policy", + }, + "manualSteps": Array [ + "Update all reporting indices to use the \\"kibana-reporting\\" policy using the index settings API.", + ], + }, + "level": "warning", + "message": "New reporting indices will be managed by the \\"kibana-reporting\\" provisioned ILM policy. You must edit this policy to manage the report lifecycle. This change targets all indices prefixed with \\".reporting-*\\".", + }, + ] + `); + }); + + it('does not return deprecations when all reporting indices are managed by the provisioned ILM policy', async () => { + esClient.asInternalUser.indices.getSettings.mockResolvedValueOnce( + createApiResponse({ + body: { + indexA: createIndexSettings('kibana-reporting'), + indexB: createIndexSettings('kibana-reporting'), + }, + }) + ); + + expect(await getDeprecationsInfo(deprecationsCtx, { reportingCore })).toMatchInlineSnapshot( + `Array []` + ); + + esClient.asInternalUser.indices.getSettings.mockResolvedValueOnce( + createApiResponse({ + body: {}, + }) + ); + + expect(await getDeprecationsInfo(deprecationsCtx, { reportingCore })).toMatchInlineSnapshot( + `Array []` + ); + }); +}); diff --git a/x-pack/plugins/reporting/server/deprecations/migrate_existing_indices_ilm_policy.ts b/x-pack/plugins/reporting/server/deprecations/migrate_existing_indices_ilm_policy.ts new file mode 100644 index 0000000000000..a3dd4205b9e65 --- /dev/null +++ b/x-pack/plugins/reporting/server/deprecations/migrate_existing_indices_ilm_policy.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; +import { DeprecationsDetails, GetDeprecationsContext } from 'src/core/server'; +import { API_MIGRATE_ILM_POLICY_URL, ILM_POLICY_NAME } from '../../common/constants'; +import { ReportingCore } from '../core'; +import { deprecations } from '../lib/deprecations'; + +interface ExtraDependencies { + reportingCore: ReportingCore; +} + +export const getDeprecationsInfo = async ( + { esClient }: GetDeprecationsContext, + { reportingCore }: ExtraDependencies +): Promise => { + const store = await reportingCore.getStore(); + const indexPattern = store.getReportingIndexPattern(); + + const migrationStatus = await deprecations.checkIlmMigrationStatus({ + reportingCore, + elasticsearchClient: esClient.asInternalUser, + }); + + if (migrationStatus !== 'ok') { + return [ + { + level: 'warning', + message: i18n.translate('xpack.reporting.deprecations.migrateIndexIlmPolicyActionMessage', { + defaultMessage: `New reporting indices will be managed by the "{reportingIlmPolicy}" provisioned ILM policy. You must edit this policy to manage the report lifecycle. This change targets all indices prefixed with "{indexPattern}".`, + values: { + reportingIlmPolicy: ILM_POLICY_NAME, + indexPattern, + }, + }), + correctiveActions: { + manualSteps: [ + i18n.translate( + 'xpack.reporting.deprecations.migrateIndexIlmPolicy.manualStepOneMessage', + { + defaultMessage: + 'Update all reporting indices to use the "{reportingIlmPolicy}" policy using the index settings API.', + values: { reportingIlmPolicy: ILM_POLICY_NAME }, + } + ), + ], + api: { + method: 'PUT', + path: API_MIGRATE_ILM_POLICY_URL, + }, + }, + }, + ]; + } + + return []; +}; diff --git a/x-pack/plugins/reporting/server/deprecations.test.ts b/x-pack/plugins/reporting/server/deprecations/reporting_role.test.ts similarity index 83% rename from x-pack/plugins/reporting/server/deprecations.test.ts rename to x-pack/plugins/reporting/server/deprecations/reporting_role.test.ts index cce4721b941a0..b52d51d3e9311 100644 --- a/x-pack/plugins/reporting/server/deprecations.test.ts +++ b/x-pack/plugins/reporting/server/deprecations/reporting_role.test.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { ReportingCore } from '.'; -import { registerDeprecations } from './deprecations'; -import { createMockConfigSchema, createMockReportingCore } from './test_helpers'; -import { coreMock, elasticsearchServiceMock } from 'src/core/server/mocks'; +import { ReportingCore } from '..'; +import { getDeprecationsInfo } from './reporting_role'; +import { createMockConfigSchema, createMockReportingCore } from '../test_helpers'; +import { elasticsearchServiceMock } from 'src/core/server/mocks'; import { GetDeprecationsContext, IScopedClusterClient } from 'kibana/server'; let reportingCore: ReportingCore; @@ -26,17 +26,22 @@ beforeEach(async () => { }); test('logs no deprecations when setup has no issues', async () => { - const { getDeprecations } = await registerDeprecations(reportingCore, coreMock.createSetup()); - expect(await getDeprecations(context)).toMatchInlineSnapshot(`Array []`); + expect( + await getDeprecationsInfo(context, { + reportingCore, + }) + ).toMatchInlineSnapshot(`Array []`); }); test('logs a plain message when only a reporting_user role issue is found', async () => { esClient.asCurrentUser.security.getUser = jest.fn().mockResolvedValue({ body: { reportron: { username: 'reportron', roles: ['kibana_admin', 'reporting_user'] } }, }); - - const { getDeprecations } = await registerDeprecations(reportingCore, coreMock.createSetup()); - expect(await getDeprecations(context)).toMatchInlineSnapshot(` + expect( + await getDeprecationsInfo(context, { + reportingCore, + }) + ).toMatchInlineSnapshot(` Array [ Object { "correctiveActions": Object { @@ -61,8 +66,11 @@ test('logs multiple entries when multiple reporting_user role issues are found', }, }); - const { getDeprecations } = await registerDeprecations(reportingCore, coreMock.createSetup()); - expect(await getDeprecations(context)).toMatchInlineSnapshot(` + expect( + await getDeprecationsInfo(context, { + reportingCore, + }) + ).toMatchInlineSnapshot(` Array [ Object { "correctiveActions": Object { @@ -87,8 +95,11 @@ test('logs an expanded message when a config issue and a reporting_user role iss const mockReportingConfig = createMockConfigSchema({ roles: { enabled: true } }); reportingCore = await createMockReportingCore(mockReportingConfig); - const { getDeprecations } = await registerDeprecations(reportingCore, coreMock.createSetup()); - expect(await getDeprecations(context)).toMatchInlineSnapshot(` + expect( + await getDeprecationsInfo(context, { + reportingCore, + }) + ).toMatchInlineSnapshot(` Array [ Object { "correctiveActions": Object { diff --git a/x-pack/plugins/reporting/server/deprecations/reporting_role.ts b/x-pack/plugins/reporting/server/deprecations/reporting_role.ts new file mode 100644 index 0000000000000..d5138043060a6 --- /dev/null +++ b/x-pack/plugins/reporting/server/deprecations/reporting_role.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 type { GetDeprecationsContext, DeprecationsDetails } from 'src/core/server'; +import { ReportingCore } from '..'; + +const deprecatedRole = 'reporting_user'; +const upgradableConfig = 'xpack.reporting.roles.enabled: false'; + +interface ExtraDependencies { + reportingCore: ReportingCore; +} + +export const getDeprecationsInfo = async ( + { esClient }: GetDeprecationsContext, + { reportingCore }: ExtraDependencies +): Promise => { + const usingDeprecatedConfig = !reportingCore.getContract().usesUiCapabilities(); + const deprecations: DeprecationsDetails[] = []; + const { body: users } = await esClient.asCurrentUser.security.getUser(); + + const reportingUsers = Object.entries(users) + .filter(([username, user]) => user.roles.includes(deprecatedRole)) + .map(([, user]) => user.username); + const numReportingUsers = reportingUsers.length; + + if (numReportingUsers > 0) { + const usernames = reportingUsers.join('", "'); + deprecations.push({ + message: `The deprecated "${deprecatedRole}" role has been found for ${numReportingUsers} user(s): "${usernames}"`, + documentationUrl: 'https://www.elastic.co/guide/en/kibana/current/secure-reporting.html', + level: 'critical', + correctiveActions: { + manualSteps: [ + ...(usingDeprecatedConfig ? [`Set "${upgradableConfig}" in kibana.yml`] : []), + `Create one or more custom roles that provide Kibana application privileges to reporting features in **Management > Security > Roles**.`, + `Assign the custom role(s) as desired, and remove the "${deprecatedRole}" role from the user(s).`, + ], + }, + }); + } + + return deprecations; +}; diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index dc0ddf27a53b3..185b47a980bfe 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -65,7 +65,10 @@ export class ReportingPlugin }); registerUiSettings(core); - registerDeprecations(reportingCore, core); + registerDeprecations({ + core, + reportingCore, + }); registerReportingUsageCollector(reportingCore, plugins); registerRoutes(reportingCore, this.logger); From 385b6588ff69c3c9428349d3cb8983ebb6c327f5 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 22 Jul 2021 15:37:00 +0200 Subject: [PATCH 03/45] [Reporting] Only show migration callout to authzd users (#105181) * wrapped deprecations endpoints in authz wrapper * remove unused import * added KibanaProvider interface * removed second call of "handler" and moved api functional test to authd version of supertest * fix api integration tests * added api integration test for authzd users * do not check for privileges if security is disbaled * run organize imports * updated jest test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/reporting/common/constants.ts | 1 - .../lib/reporting_api_client/context.tsx | 8 +- .../public/lib/reporting_api_client/hooks.ts | 6 +- .../report_listing.test.tsx.snap | 495 ++++++++++++++++++ .../management/mount_management_section.tsx | 29 +- .../public/management/report_listing.test.tsx | 66 ++- .../public/management/report_listing.tsx | 32 +- .../reporting/public/shared_imports.ts | 6 + x-pack/plugins/reporting/public/types.ts | 13 + .../reporting/server/routes/deprecations.ts | 92 +++- .../ilm_migration_apis.ts | 37 +- .../reporting_and_security/index.ts | 1 + .../reporting_without_security/index.ts | 1 - .../services/scenarios.ts | 17 +- 14 files changed, 723 insertions(+), 81 deletions(-) create mode 100644 x-pack/plugins/reporting/public/types.ts rename x-pack/test/reporting_api_integration/{reporting_without_security => reporting_and_security}/ilm_migration_apis.ts (77%) diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index c95c837c4959f..c9a763fae52fe 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -92,7 +92,6 @@ export const API_LIST_URL = `${API_BASE_URL}/jobs`; export const API_DIAGNOSE_URL = `${API_BASE_URL}/diagnose`; export const API_GET_ILM_POLICY_STATUS = `${API_BASE_URL}/ilm_policy_status`; -export const API_CREATE_ILM_POLICY_URL = `${API_BASE_URL}/ilm_policy`; export const API_MIGRATE_ILM_POLICY_URL = `${API_BASE_URL}/deprecations/migrate_ilm_policy`; export const ILM_POLICY_NAME = 'kibana-reporting'; diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx b/x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx index 37857943774d4..4070f0d6d388d 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx @@ -9,6 +9,7 @@ import type { HttpSetup } from 'src/core/public'; import type { FunctionComponent } from 'react'; import React, { createContext, useContext } from 'react'; +import { useKibana } from '../../shared_imports'; import type { ReportingAPIClient } from './reporting_api_client'; interface ContextValue { @@ -19,9 +20,12 @@ interface ContextValue { const InternalApiClientContext = createContext(undefined); export const InternalApiClientClientProvider: FunctionComponent<{ - http: HttpSetup; apiClient: ReportingAPIClient; -}> = ({ http, apiClient, children }) => { +}> = ({ apiClient, children }) => { + const { + services: { http }, + } = useKibana(); + return ( {children} diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/hooks.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client/hooks.ts index afd8222fd3831..0b697b333dddd 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client/hooks.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/hooks.ts @@ -10,9 +10,11 @@ import { IlmPolicyStatusResponse } from '../../../common/types'; import { API_GET_ILM_POLICY_STATUS } from '../../../common/constants'; -import { useInternalApiClient } from './context'; +import { useKibana } from '../../shared_imports'; export const useCheckIlmPolicyStatus = (): UseRequestResponse => { - const { http } = useInternalApiClient(); + const { + services: { http }, + } = useKibana(); return useRequest(http, { path: API_GET_ILM_POLICY_STATUS, method: 'get' }); }; diff --git a/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap index 4dac77d4c1db4..d0ed2d737b584 100644 --- a/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap +++ b/x-pack/plugins/reporting/public/management/__snapshots__/report_listing.test.tsx.snap @@ -659,6 +659,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -849,6 +860,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -1039,6 +1061,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -1230,6 +1263,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -1387,6 +1431,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -1930,6 +1985,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -2120,6 +2186,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -2310,6 +2387,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -2501,6 +2589,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -2658,6 +2757,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -3201,6 +3311,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -3438,6 +3559,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -3628,6 +3760,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -3819,6 +3962,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -3976,6 +4130,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -4546,6 +4711,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -4785,6 +4961,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -4977,6 +5164,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -5170,6 +5368,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -5327,6 +5536,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -5870,6 +6090,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -6107,6 +6338,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -6297,6 +6539,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -6488,6 +6741,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -6645,6 +6909,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -7188,6 +7463,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -7425,6 +7711,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -7615,6 +7912,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -7806,6 +8114,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -7963,6 +8282,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -8506,6 +8836,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -8743,6 +9084,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -8933,6 +9285,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -9124,6 +9487,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -9281,6 +9655,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -9824,6 +10209,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -10061,6 +10457,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -10251,6 +10658,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -10442,6 +10860,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -10599,6 +11028,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -11142,6 +11582,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -11379,6 +11830,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -11569,6 +12031,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -11760,6 +12233,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, @@ -11917,6 +12401,17 @@ exports[`ReportListing Report job listing with some items 1`] = ` "total": [Function], } } + capabilities={ + Object { + "catalogue": Object {}, + "management": Object { + "data": Object { + "index_lifecycle_management": true, + }, + }, + "navLinks": Object {}, + } + } ilmPolicyContextValue={ Object { "isLoading": false, diff --git a/x-pack/plugins/reporting/public/management/mount_management_section.tsx b/x-pack/plugins/reporting/public/management/mount_management_section.tsx index 8d147628c6662..20ea2988f3b8b 100644 --- a/x-pack/plugins/reporting/public/management/mount_management_section.tsx +++ b/x-pack/plugins/reporting/public/management/mount_management_section.tsx @@ -15,6 +15,7 @@ import { ReportingAPIClient, InternalApiClientClientProvider } from '../lib/repo import { IlmPolicyStatusContextProvider } from '../lib/ilm_policy_status_context'; import { ClientConfigType } from '../plugin'; import type { ManagementAppMountParams, SharePluginSetup } from '../shared_imports'; +import { KibanaContextProvider } from '../shared_imports'; import { ReportListing } from './report_listing'; export async function mountManagementSection( @@ -28,18 +29,22 @@ export async function mountManagementSection( ) { render( - - - - - + + + + + + + , params.element ); diff --git a/x-pack/plugins/reporting/public/management/report_listing.test.tsx b/x-pack/plugins/reporting/public/management/report_listing.test.tsx index 0c9b85c2f8cbb..b2eb6f0029580 100644 --- a/x-pack/plugins/reporting/public/management/report_listing.test.tsx +++ b/x-pack/plugins/reporting/public/management/report_listing.test.tsx @@ -11,13 +11,18 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { Observable } from 'rxjs'; import type { NotificationsSetup } from '../../../../../src/core/public'; -import { httpServiceMock, notificationServiceMock } from '../../../../../src/core/public/mocks'; +import { + applicationServiceMock, + httpServiceMock, + notificationServiceMock, +} from '../../../../../src/core/public/mocks'; import type { LocatorPublic, SharePluginSetup } from '../../../../../src/plugins/share/public'; import type { ILicense } from '../../../licensing/public'; import { IlmPolicyMigrationStatus, ReportApiJSON } from '../../common/types'; import { IlmPolicyStatusContextProvider } from '../lib/ilm_policy_status_context'; import { Job } from '../lib/job'; import { InternalApiClientClientProvider, ReportingAPIClient } from '../lib/reporting_api_client'; +import { KibanaContextProvider } from '../shared_imports'; import { Props, ReportListing } from './report_listing'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { @@ -70,6 +75,7 @@ const mockPollConfig = { describe('ReportListing', () => { let httpService: ReturnType; + let applicationService: ReturnType; let ilmLocator: undefined | LocatorPublic; let urlService: SharePluginSetup['url']; let testBed: UnwrapPromise>; @@ -77,22 +83,21 @@ describe('ReportListing', () => { const createTestBed = registerTestBed( (props?: Partial) => ( - - - - - + + + + + + + ), { memoryRouter: { wrapComponent: false } } ); @@ -127,6 +132,12 @@ describe('ReportListing', () => { beforeEach(async () => { toasts = notificationServiceMock.createSetupContract().toasts; httpService = httpServiceMock.createSetupContract(); + applicationService = applicationServiceMock.createStartContract(); + applicationService.capabilities = { + catalogue: {}, + navLinks: {}, + management: { data: { index_lifecycle_management: true } }, + }; ilmLocator = ({ getUrl: jest.fn(), } as unknown) as LocatorPublic; @@ -255,5 +266,26 @@ describe('ReportListing', () => { expect(actions.hasIlmMigrationBanner()).toBe(true); expect(actions.hasIlmPolicyLink()).toBe(true); }); + + it('only shows the link to the ILM policy if UI capabilities allow it', async () => { + applicationService.capabilities = { + catalogue: {}, + navLinks: {}, + management: { data: { index_lifecycle_management: false } }, + }; + await runSetup(); + + expect(testBed.actions.hasIlmPolicyLink()).toBe(false); + + applicationService.capabilities = { + catalogue: {}, + navLinks: {}, + management: { data: { index_lifecycle_management: true } }, + }; + + await runSetup(); + + expect(testBed.actions.hasIlmPolicyLink()).toBe(true); + }); }); }); diff --git a/x-pack/plugins/reporting/public/management/report_listing.tsx b/x-pack/plugins/reporting/public/management/report_listing.tsx index 30c9325a0f34f..02fe3aeaef5a1 100644 --- a/x-pack/plugins/reporting/public/management/report_listing.tsx +++ b/x-pack/plugins/reporting/public/management/report_listing.tsx @@ -31,6 +31,7 @@ import { checkLicense } from '../lib/license_check'; import { ReportingAPIClient, useInternalApiClient } from '../lib/reporting_api_client'; import { ClientConfigType } from '../plugin'; import type { SharePluginSetup } from '../shared_imports'; +import { useKibana } from '../shared_imports'; import { ReportDeleteButton, ReportDownloadButton, ReportErrorButton, ReportInfoButton } from './'; import { IlmPolicyLink } from './ilm_policy_link'; import { MigrateIlmPolicyCallOut } from './migrate_ilm_policy_callout'; @@ -39,6 +40,7 @@ import { ReportDiagnostic } from './report_diagnostic'; export interface Props { intl: InjectedIntl; apiClient: ReportingAPIClient; + capabilities: ApplicationStart['capabilities']; license$: LicensingPluginSetup['license$']; pollConfig: ClientConfigType['poll']; redirect: ApplicationStart['navigateToApp']; @@ -122,7 +124,7 @@ class ReportListingUi extends Component { } public render() { - const { ilmPolicyContextValue, urlService, navigateToUrl } = this.props; + const { ilmPolicyContextValue, urlService, navigateToUrl, capabilities } = this.props; const ilmLocator = urlService.locators.get('ILM_LOCATOR_ID'); const hasIlmPolicy = ilmPolicyContextValue.status !== 'policy-not-found'; const showIlmPolicyLink = Boolean(ilmLocator && hasIlmPolicy); @@ -149,15 +151,17 @@ class ReportListingUi extends Component { - - {ilmPolicyContextValue.isLoading ? ( - - ) : ( - showIlmPolicyLink && ( - - ) - )} - + {capabilities?.management?.data?.index_lifecycle_management && ( + + {ilmPolicyContextValue.isLoading ? ( + + ) : ( + showIlmPolicyLink && ( + + ) + )} + + )} @@ -519,14 +523,20 @@ class ReportListingUi extends Component { const PrivateReportListing = injectI18n(ReportListingUi); export const ReportListing = ( - props: Omit + props: Omit ) => { const ilmPolicyStatusValue = useIlmPolicyStatus(); const { apiClient } = useInternalApiClient(); + const { + services: { + application: { capabilities }, + }, + } = useKibana(); return ( ); diff --git a/x-pack/plugins/reporting/public/shared_imports.ts b/x-pack/plugins/reporting/public/shared_imports.ts index 010da46c07401..02717351e315f 100644 --- a/x-pack/plugins/reporting/public/shared_imports.ts +++ b/x-pack/plugins/reporting/public/shared_imports.ts @@ -13,6 +13,12 @@ export type { export { useRequest, UseRequestResponse } from '../../../../src/plugins/es_ui_shared/public'; +export { KibanaContextProvider } from '../../../../src/plugins/kibana_react/public'; + +import { useKibana as _useKibana } from '../../../../src/plugins/kibana_react/public'; +import { KibanaContext } from './types'; +export const useKibana = () => _useKibana(); + export type { SerializableState } from 'src/plugins/kibana_utils/common'; export type { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; diff --git a/x-pack/plugins/reporting/public/types.ts b/x-pack/plugins/reporting/public/types.ts new file mode 100644 index 0000000000000..cb1344bb982ec --- /dev/null +++ b/x-pack/plugins/reporting/public/types.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { HttpSetup, ApplicationStart } from 'src/core/public'; + +export interface KibanaContext { + http: HttpSetup; + application: ApplicationStart; +} diff --git a/x-pack/plugins/reporting/server/routes/deprecations.ts b/x-pack/plugins/reporting/server/routes/deprecations.ts index 7a38faf60f6bb..0daa56274cc00 100644 --- a/x-pack/plugins/reporting/server/routes/deprecations.ts +++ b/x-pack/plugins/reporting/server/routes/deprecations.ts @@ -5,6 +5,7 @@ * 2.0. */ import { errors } from '@elastic/elasticsearch'; +import { RequestHandler } from 'src/core/server'; import { API_MIGRATE_ILM_POLICY_URL, API_GET_ILM_POLICY_STATUS, @@ -18,42 +19,83 @@ import { IlmPolicyManager, LevelLogger as Logger } from '../lib'; export const registerDeprecationsRoutes = (reporting: ReportingCore, logger: Logger) => { const { router } = reporting.getPluginSetupDeps(); + const authzWrapper = (handler: RequestHandler): RequestHandler => { + return async (ctx, req, res) => { + const { security } = reporting.getPluginSetupDeps(); + if (!security) { + return handler(ctx, req, res); + } + + const { + core: { elasticsearch }, + } = ctx; + + const store = await reporting.getStore(); + + try { + const { body } = await elasticsearch.client.asCurrentUser.security.hasPrivileges({ + body: { + index: [ + { + privileges: ['manage'], // required to do anything with the reporting indices + names: [store.getReportingIndexPattern()], + }, + ], + }, + }); + + if (!body.has_all_requested) { + return res.notFound(); + } + } catch (e) { + return res.customError({ statusCode: e.statusCode, body: e.message }); + } + + return handler(ctx, req, res); + }; + }; + router.get( { path: API_GET_ILM_POLICY_STATUS, validate: false, }, - async ( - { - core: { - elasticsearch: { client: scopedClient }, + authzWrapper( + async ( + { + core: { + elasticsearch: { client: scopedClient }, + }, }, - }, - req, - res - ) => { - const checkIlmMigrationStatus = () => { - return deprecations.checkIlmMigrationStatus({ - reportingCore: reporting, - // We want to make the current status visible to all reporting users - elasticsearchClient: scopedClient.asInternalUser, - }); - }; - - try { - const response: IlmPolicyStatusResponse = { - status: await checkIlmMigrationStatus(), + req, + res + ) => { + const checkIlmMigrationStatus = () => { + return deprecations.checkIlmMigrationStatus({ + reportingCore: reporting, + // We want to make the current status visible to all reporting users + elasticsearchClient: scopedClient.asInternalUser, + }); }; - return res.ok({ body: response }); - } catch (e) { - return res.customError({ statusCode: e?.statusCode ?? 500, body: { message: e.message } }); + + try { + const response: IlmPolicyStatusResponse = { + status: await checkIlmMigrationStatus(), + }; + return res.ok({ body: response }); + } catch (e) { + return res.customError({ + statusCode: e?.statusCode ?? 500, + body: { message: e.message }, + }); + } } - } + ) ); router.put( { path: API_MIGRATE_ILM_POLICY_URL, validate: false }, - async ({ core: { elasticsearch } }, req, res) => { + authzWrapper(async ({ core: { elasticsearch } }, req, res) => { const store = await reporting.getStore(); const { client: { asCurrentUser: client }, @@ -105,6 +147,6 @@ export const registerDeprecationsRoutes = (reporting: ReportingCore, logger: Log throw err; } - } + }) ); }; diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/ilm_migration_apis.ts b/x-pack/test/reporting_api_integration/reporting_and_security/ilm_migration_apis.ts similarity index 77% rename from x-pack/test/reporting_api_integration/reporting_without_security/ilm_migration_apis.ts rename to x-pack/test/reporting_api_integration/reporting_and_security/ilm_migration_apis.ts index a9b6798a0224f..fd49e2b237217 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/ilm_migration_apis.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/ilm_migration_apis.ts @@ -15,8 +15,10 @@ import { ILM_POLICY_NAME } from '../../../plugins/reporting/common/constants'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const es = getService('es'); - const supertestNoAuth = getService('supertestWithoutAuth'); + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); const reportingAPI = getService('reportingAPI'); + const security = getService('security'); describe('ILM policy migration APIs', () => { before(async () => { @@ -38,7 +40,7 @@ export default function ({ getService }: FtrProviderContext) { expect(await reportingAPI.checkIlmMigrationStatus()).to.eql('ok'); // try creating a report - await supertestNoAuth + await supertest .post(`/api/reporting/generate/csv`) .set('kbn-xsrf', 'xxx') .send({ jobParams: JOB_PARAMS_RISON_CSV_DEPRECATED }); @@ -51,7 +53,7 @@ export default function ({ getService }: FtrProviderContext) { // TODO: Remove "any" when no longer through type issue "policy_id" missing await es.ilm.deleteLifecycle({ policy: ILM_POLICY_NAME } as any); - await supertestNoAuth + await supertest .post(`/api/reporting/generate/csv`) .set('kbn-xsrf', 'xxx') .send({ jobParams: JOB_PARAMS_RISON_CSV_DEPRECATED }); @@ -64,7 +66,7 @@ export default function ({ getService }: FtrProviderContext) { it('detects when reporting indices should be migrated due to unmanaged indices', async () => { await reportingAPI.makeAllReportingIndicesUnmanaged(); - await supertestNoAuth + await supertest .post(`/api/reporting/generate/csv`) .set('kbn-xsrf', 'xxx') .send({ jobParams: JOB_PARAMS_RISON_CSV_DEPRECATED }); @@ -111,5 +113,32 @@ export default function ({ getService }: FtrProviderContext) { expect(policy).to.eql(customLifecycle.policy); }); + + it('is not available to unauthorized users', async () => { + const UNAUTHZD_TEST_USERNAME = 'UNAUTHZD_TEST_USERNAME'; + const UNAUTHZD_TEST_USER_PASSWORD = 'UNAUTHZD_TEST_USER_PASSWORD'; + + await security.user.create(UNAUTHZD_TEST_USERNAME, { + password: UNAUTHZD_TEST_USER_PASSWORD, + roles: [], + full_name: 'an unauthzd user', + }); + + try { + await supertestWithoutAuth + .put(reportingAPI.routes.API_MIGRATE_ILM_POLICY_URL) + .auth(UNAUTHZD_TEST_USERNAME, UNAUTHZD_TEST_USER_PASSWORD) + .set('kbn-xsrf', 'xxx') + .expect(404); + + await supertestWithoutAuth + .get(reportingAPI.routes.API_GET_ILM_POLICY_STATUS) + .auth(UNAUTHZD_TEST_USERNAME, UNAUTHZD_TEST_USER_PASSWORD) + .set('kbn-xsrf', 'xxx') + .expect(404); + } finally { + await security.user.delete(UNAUTHZD_TEST_USERNAME); + } + }); }); } diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts index b7d7605ec00bc..d279081b5320c 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts @@ -26,5 +26,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./network_policy')); loadTestFile(require.resolve('./spaces')); loadTestFile(require.resolve('./usage')); + loadTestFile(require.resolve('./ilm_migration_apis')); }); } diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/index.ts b/x-pack/test/reporting_api_integration/reporting_without_security/index.ts index 5d3854a17e6e6..81ca3e05e4dd0 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security/index.ts @@ -13,6 +13,5 @@ export default function ({ loadTestFile }: FtrProviderContext) { this.tags('ciGroup13'); loadTestFile(require.resolve('./job_apis_csv')); loadTestFile(require.resolve('./job_apis_csv_deprecated')); - loadTestFile(require.resolve('./ilm_migration_apis')); }); } diff --git a/x-pack/test/reporting_api_integration/services/scenarios.ts b/x-pack/test/reporting_api_integration/services/scenarios.ts index 08c07e0e257ed..917ab3e978222 100644 --- a/x-pack/test/reporting_api_integration/services/scenarios.ts +++ b/x-pack/test/reporting_api_integration/services/scenarios.ts @@ -6,6 +6,10 @@ */ import rison, { RisonValue } from 'rison-node'; +import { + API_GET_ILM_POLICY_STATUS, + API_MIGRATE_ILM_POLICY_URL, +} from '../../../plugins/reporting/common/constants'; import { JobParamsCSV } from '../../../plugins/reporting/server/export_types/csv_searchsource/types'; import { JobParamsDownloadCSV } from '../../../plugins/reporting/server/export_types/csv_searchsource_immediate/types'; import { JobParamsPNG } from '../../../plugins/reporting/server/export_types/png/types'; @@ -166,8 +170,8 @@ export function createScenarios({ getService }: Pick { log.debug('ReportingAPI.checkIlmMigrationStatus'); - const { body } = await supertestWithoutAuth - .get('/api/reporting/ilm_policy_status') + const { body } = await supertest + .get(API_GET_ILM_POLICY_STATUS) .set('kbn-xsrf', 'xxx') .expect(200); return body.status; @@ -175,10 +179,7 @@ export function createScenarios({ getService }: Pick { log.debug('ReportingAPI.migrateReportingIndices'); - await supertestWithoutAuth - .put('/api/reporting/deprecations/migrate_ilm_policy') - .set('kbn-xsrf', 'xxx') - .expect(200); + await supertest.put(API_MIGRATE_ILM_POLICY_URL).set('kbn-xsrf', 'xxx').expect(200); }; const makeAllReportingIndicesUnmanaged = async () => { @@ -201,6 +202,10 @@ export function createScenarios({ getService }: Pick Date: Thu, 22 Jul 2021 06:44:34 -0700 Subject: [PATCH 04/45] [Metrics UI] Increase number of saved objects fetched to 1000 (#106310) --- .../infra/public/hooks/use_find_saved_object.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/infra/public/hooks/use_find_saved_object.tsx b/x-pack/plugins/infra/public/hooks/use_find_saved_object.tsx index 78198717f8a03..df6cef5df7bea 100644 --- a/x-pack/plugins/infra/public/hooks/use_find_saved_object.tsx +++ b/x-pack/plugins/infra/public/hooks/use_find_saved_object.tsx @@ -7,9 +7,11 @@ import { useState, useCallback } from 'react'; import { SavedObjectAttributes, SavedObjectsBatchResponse } from 'src/core/public'; +import { useUiTracker } from '../../../observability/public'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; export const useFindSavedObject = (type: string) => { + const trackMetric = useUiTracker({ app: 'infra_metrics' }); const kibana = useKibana(); const [data, setData] = useState | null>(null); const [error, setError] = useState(null); @@ -27,10 +29,17 @@ export const useFindSavedObject = 1000) { + trackMetric({ metric: `over_1000_saved_objects_for_${type}` }); + } else { + trackMetric({ metric: `under_1000_saved_objects_for_${type}` }); + } } catch (e) { setLoading(false); setError(e); @@ -38,7 +47,7 @@ export const useFindSavedObject = { From a9743715d6e0880ceaa8c361b08d9d99c9541db9 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Thu, 22 Jul 2021 09:50:23 -0400 Subject: [PATCH 05/45] [Fleet] OpenAPI - add missing routes and clean up names, types, etc (#106464) ### Add specs for several missing routes See additions in `openapi/entrypoint.yaml` - `/agents/{agentId}/reassign` - `/agents/bulk_reassign` - `/agents/bulk_unenroll` - `/package_policies/delete` ### Add enum of string values for delete package response See 5c04dfc ### Fixed inaccurate operation spec spec for `delete-agent-policy` operation currently shows it accepting an array of ids`agentPolicyIds: string[]` and returning an array of results, but it actually accepts a single `agentPolicyId: string` id an returns a single object ### Internal changes #### Better / more consistent operation names e.g. ```diff - operationId: post-agent-policy + operationId: create-agent-policy ``` and ```diff - operationId: put-agent-policy-agentPolicyId + operationId: update-agent-policy ``` #### Better schema titles e.g. ```diff - title: UpdatePackagePolicy + title: Update package policy ``` --- .../plugins/fleet/common/openapi/bundled.json | 558 ++++++++++++++---- .../plugins/fleet/common/openapi/bundled.yaml | 382 +++++++++--- .../components/schemas/access_api_key.yaml | 3 - .../components/schemas/agent_metadata.yaml | 2 +- .../components/schemas/agent_policy.yaml | 7 +- .../components/schemas/agent_status.yaml | 2 +- .../components/schemas/agent_type.yaml | 2 +- .../schemas/bulk_upgrade_agents.yaml | 49 +- .../schemas/elasticsearch_asset_type.yaml | 9 + .../schemas/enrollment_api_key.yaml | 2 +- .../schemas/kibana_saved_object_type.yaml | 11 + .../components/schemas/new_agent_policy.yaml | 2 +- .../schemas/new_package_policy.yaml | 2 +- .../components/schemas/package_info.yaml | 2 +- .../components/schemas/package_policy.yaml | 2 +- .../components/schemas/search_result.yaml | 2 +- .../schemas/update_package_policy.yaml | 2 +- .../components/schemas/upgrade_agent.yaml | 2 +- .../fleet/common/openapi/entrypoint.yaml | 8 + .../common/openapi/paths/agent_policies.yaml | 2 +- .../openapi/paths/agent_policies@delete.yaml | 30 +- .../agent_policies@{agent_policy_id}.yaml | 2 +- .../common/openapi/paths/agent_status.yaml | 2 +- .../fleet/common/openapi/paths/agents.yaml | 2 +- .../openapi/paths/agents@bulk_reassign.yaml | 39 ++ .../openapi/paths/agents@bulk_unenroll.yaml | 40 ++ .../openapi/paths/agents@bulk_upgrade.yaml | 13 +- .../common/openapi/paths/agents@setup.yaml | 4 +- .../openapi/paths/agents@{agent_id}.yaml | 6 +- .../paths/agents@{agent_id}@reassign.yaml | 31 + .../paths/agents@{agent_id}@unenroll.yaml | 2 +- .../paths/agents@{agent_id}@upgrade.yaml | 2 +- .../openapi/paths/enrollment_api_keys.yaml | 4 +- .../paths/enrollment_api_keys@{key_id}.yaml | 4 +- .../common/openapi/paths/epm@categories.yaml | 4 +- .../common/openapi/paths/epm@packages.yaml | 2 +- .../openapi/paths/epm@packages@{pkgkey}.yaml | 22 +- .../openapi/paths/package_policies.yaml | 24 +- .../paths/package_policies@delete.yaml | 38 ++ .../package_policies@{package_policy_id}.yaml | 4 +- .../fleet/common/openapi/paths/setup.yaml | 2 +- 41 files changed, 1016 insertions(+), 312 deletions(-) delete mode 100644 x-pack/plugins/fleet/common/openapi/components/schemas/access_api_key.yaml create mode 100644 x-pack/plugins/fleet/common/openapi/components/schemas/elasticsearch_asset_type.yaml create mode 100644 x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml create mode 100644 x-pack/plugins/fleet/common/openapi/paths/agents@bulk_reassign.yaml create mode 100644 x-pack/plugins/fleet/common/openapi/paths/agents@bulk_unenroll.yaml create mode 100644 x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@reassign.yaml create mode 100644 x-pack/plugins/fleet/common/openapi/paths/package_policies@delete.yaml diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index 16b8c419fdff8..f16bb8ac9a436 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -51,7 +51,7 @@ } } }, - "operationId": "post-setup", + "operationId": "setup", "parameters": [ { "$ref": "#/components/parameters/kbn_xsrf" @@ -61,7 +61,7 @@ }, "/epm/categories": { "get": { - "summary": "Packages - Categories", + "summary": "Package categories", "tags": [], "responses": { "200": { @@ -94,7 +94,7 @@ } } }, - "operationId": "get-epm-categories" + "operationId": "get-package-categories" } }, "/epm/packages": { @@ -116,7 +116,7 @@ } } }, - "operationId": "get-epm-list" + "operationId": "list-all-packages" }, "parameters": [] }, @@ -163,7 +163,7 @@ } } }, - "operationId": "get-epm-package-pkgkey", + "operationId": "get-package", "security": [ { "basicAuth": [] @@ -200,7 +200,14 @@ "type": "string" }, "type": { - "type": "string" + "oneOf": [ + { + "$ref": "#/components/schemas/kibana_saved_object_type" + }, + { + "$ref": "#/components/schemas/elasticsearch_asset_type" + } + ] } }, "required": [ @@ -218,13 +225,27 @@ } } }, - "operationId": "post-epm-install-pkgkey", + "operationId": "install-package", "description": "", "parameters": [ { "$ref": "#/components/parameters/kbn_xsrf" } - ] + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "force": { + "type": "boolean" + } + } + } + } + } + } }, "delete": { "summary": "Packages - Delete", @@ -246,7 +267,14 @@ "type": "string" }, "type": { - "type": "string" + "oneOf": [ + { + "$ref": "#/components/schemas/kibana_saved_object_type" + }, + { + "$ref": "#/components/schemas/elasticsearch_asset_type" + } + ] } }, "required": [ @@ -264,7 +292,7 @@ } } }, - "operationId": "post-epm-delete-pkgkey", + "operationId": "delete-package", "parameters": [ { "$ref": "#/components/parameters/kbn_xsrf" @@ -302,7 +330,7 @@ } } }, - "operationId": "get-agents-setup", + "operationId": "get-agents-setup-status", "security": [ { "basicAuth": [] @@ -311,7 +339,7 @@ }, "post": { "summary": "Agents setup - Create", - "operationId": "post-agents-setup", + "operationId": "setup-agents", "responses": { "200": { "description": "OK", @@ -404,7 +432,7 @@ } } }, - "operationId": "get-fleet-agent-status", + "operationId": "get-agent-status", "parameters": [ { "schema": { @@ -456,7 +484,7 @@ } } }, - "operationId": "get-fleet-agents", + "operationId": "get-agents", "security": [ { "basicAuth": [] @@ -474,7 +502,21 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/bulk_upgrade_agents" + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + } } } } @@ -490,7 +532,7 @@ } } }, - "operationId": "post-fleet-agents-bulk-upgrade", + "operationId": "bulk-upgrade-agents", "parameters": [ { "$ref": "#/components/parameters/kbn_xsrf" @@ -542,7 +584,7 @@ } } }, - "operationId": "get-fleet-agents-agentId" + "operationId": "get-agent" }, "put": { "summary": "Agent - Update", @@ -567,7 +609,7 @@ } } }, - "operationId": "put-fleet-agents-agentId", + "operationId": "update-agent", "parameters": [ { "$ref": "#/components/parameters/kbn_xsrf" @@ -600,7 +642,7 @@ } } }, - "operationId": "delete-fleet-agents-agentId", + "operationId": "delete-agent", "parameters": [ { "$ref": "#/components/parameters/kbn_xsrf" @@ -608,6 +650,58 @@ ] } }, + "/agents/{agentId}/reassign": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "agentId", + "in": "path", + "required": true + } + ], + "put": { + "summary": "Agent - Reassign", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "operationId": "reassign-agent", + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "policy_id": { + "type": "string" + } + }, + "required": [ + "policy_id" + ] + } + } + } + } + } + }, "/agents/{agentId}/unenroll": { "parameters": [ { @@ -658,7 +752,7 @@ } } }, - "operationId": "post-fleet-agents-unenroll", + "operationId": "unenroll-agent", "parameters": [ { "$ref": "#/components/parameters/kbn_xsrf" @@ -719,7 +813,7 @@ } } }, - "operationId": "post-fleet-agents-upgrade", + "operationId": "upgrade-agent", "parameters": [ { "$ref": "#/components/parameters/kbn_xsrf" @@ -737,6 +831,146 @@ } } }, + "/agents/bulk_reassign": { + "post": { + "summary": "Agents - Bulk reassign", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + } + } + } + } + } + }, + "operationId": "bulk-reassign-agents", + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "policy_id": { + "type": "string" + }, + "agents": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "policy_id", + "agents" + ] + } + } + } + } + } + }, + "/agents/bulk_unenroll": { + "post": { + "summary": "Agents - Bulk unenroll", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": [ + "success" + ] + } + } + } + } + } + }, + "operationId": "bulk-unenroll-agents", + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "revoke": { + "type": "boolean" + }, + "force": { + "type": "boolean" + }, + "agents": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + }, + "required": [ + "agents" + ] + } + } + } + } + } + }, "/agent_policies": { "get": { "summary": "Agent policies - List", @@ -810,7 +1044,7 @@ } } }, - "operationId": "post-agent-policy", + "operationId": "create-agent-policy", "requestBody": { "content": { "application/json": { @@ -889,7 +1123,7 @@ } } }, - "operationId": "put-agent-policy-agentPolicyId", + "operationId": "update-agent-policy", "requestBody": { "content": { "application/json": { @@ -971,29 +1205,26 @@ "/agent_policies/delete": { "post": { "summary": "Agent policy - Delete", - "operationId": "post-agent-policy-delete", + "operationId": "delete-agent-policy", "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "success": { - "type": "boolean" - } + "type": "object", + "properties": { + "id": { + "type": "string" }, - "required": [ - "id", - "success" - ] - } + "success": { + "type": "boolean" + } + }, + "required": [ + "id", + "success" + ] } } } @@ -1005,13 +1236,13 @@ "schema": { "type": "object", "properties": { - "agentPolicyIds": { - "type": "array", - "items": { - "type": "string" - } + "agentPolicyId": { + "type": "string" } - } + }, + "required": [ + "agentPolicyId" + ] } } } @@ -1063,7 +1294,7 @@ } } }, - "operationId": "get-fleet-enrollment-api-keys", + "operationId": "get-enrollment-api-keys", "parameters": [] }, "post": { @@ -1092,7 +1323,7 @@ } } }, - "operationId": "post-fleet-enrollment-api-keys", + "operationId": "create-enrollment-api-keys", "parameters": [ { "$ref": "#/components/parameters/kbn_xsrf" @@ -1134,7 +1365,7 @@ } } }, - "operationId": "get-fleet-enrollment-api-keys-keyId" + "operationId": "get-enrollment-api-key" }, "delete": { "summary": "Enrollment API Key - Delete", @@ -1162,7 +1393,7 @@ } } }, - "operationId": "delete-fleet-enrollment-api-keys-keyId", + "operationId": "delete-enrollment-api-key", "parameters": [ { "$ref": "#/components/parameters/kbn_xsrf" @@ -1206,24 +1437,123 @@ } } }, - "operationId": "get-packagePolicies", + "operationId": "get-package-policies", "security": [], "parameters": [] }, "parameters": [], "post": { "summary": "Package policy - Create", - "operationId": "post-packagePolicies", + "operationId": "create-package-policy", "responses": { "200": { - "description": "OK" + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/package_policy" + } + }, + "required": [ + "item" + ] + } + } + } } }, "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/new_package_policy" + "allOf": [ + { + "$ref": "#/components/schemas/new_package_policy" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "force": { + "type": "boolean" + } + } + } + ] + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ] + } + }, + "/package_policies/delete": { + "post": { + "summary": "Package policy - Delete", + "operationId": "delete-package-policy", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "packagePolicyIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "force": { + "type": "boolean" + } + }, + "required": [ + "packagePolicyIds" + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "id", + "success" + ] + } + } } } } @@ -1259,7 +1589,7 @@ } } }, - "operationId": "get-packagePolicies-packagePolicyId" + "operationId": "get-package-policy" }, "parameters": [ { @@ -1273,7 +1603,7 @@ ], "put": { "summary": "Package policy - Update", - "operationId": "put-packagePolicies-packagePolicyId", + "operationId": "update-package-policy", "requestBody": { "content": { "application/json": { @@ -1404,7 +1734,7 @@ ] }, "search_result": { - "title": "SearchResult", + "title": "Search result", "type": "object", "properties": { "description": { @@ -1451,7 +1781,7 @@ ] }, "package_info": { - "title": "PackageInfo", + "title": "Package information", "type": "object", "properties": { "name": { @@ -1628,6 +1958,32 @@ "path" ] }, + "kibana_saved_object_type": { + "title": "Kibana saved object asset type", + "type": "string", + "enum": [ + "dashboard", + "visualization", + "search", + "index-pattern", + "map", + "lens", + "ml-module", + "security-rule" + ] + }, + "elasticsearch_asset_type": { + "title": "Elasticsearch asset type", + "type": "string", + "enum": [ + "component_template", + "ingest_pipeline", + "index_template", + "ilm_policy", + "transform", + "data_stream_ilm_policy" + ] + }, "fleet_status_response": { "title": "Fleet status response", "type": "object", @@ -1656,7 +2012,7 @@ }, "agent_type": { "type": "string", - "title": "AgentType", + "title": "Agent type", "enum": [ "PERMANENT", "EPHEMERAL", @@ -1664,12 +2020,12 @@ ] }, "agent_metadata": { - "title": "AgentMetadata", + "title": "Agent metadata", "type": "object" }, "agent_status": { "type": "string", - "title": "AgentStatus", + "title": "Agent status", "enum": [ "offline", "error", @@ -1744,69 +2100,36 @@ ] }, "bulk_upgrade_agents": { - "title": "BulkUpgradeAgents", - "oneOf": [ - { - "type": "object", - "properties": { - "version": { - "type": "string" - }, - "agents": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "version", - "agents" - ] + "title": "Bulk upgrade agents", + "type": "object", + "properties": { + "version": { + "type": "string" }, - { - "type": "object", - "properties": { - "version": { - "type": "string" - }, - "source_uri": { - "type": "string" - }, - "agents": { + "source_uri": { + "type": "string" + }, + "agents": { + "oneOf": [ + { "type": "array", "items": { "type": "string" } - } - }, - "required": [ - "version", - "agents" - ] - }, - { - "type": "object", - "properties": { - "version": { - "type": "string" - }, - "source_uri": { - "type": "string" }, - "agents": { + { "type": "string" } - }, - "required": [ - "version", - "agents" ] } + }, + "required": [ + "agents", + "version" ] }, "upgrade_agent": { - "title": "UpgradeAgent", + "title": "Upgrade agent", "oneOf": [ { "type": "object", @@ -1836,7 +2159,7 @@ ] }, "new_agent_policy": { - "title": "NewAgentPolicy", + "title": "New agent policy", "type": "object", "properties": { "name": { @@ -1851,7 +2174,7 @@ } }, "new_package_policy": { - "title": "NewPackagePolicy", + "title": "New package policy", "type": "object", "description": "", "properties": { @@ -1936,7 +2259,7 @@ ] }, "package_policy": { - "title": "PackagePolicy", + "title": "Package policy", "allOf": [ { "type": "object", @@ -1983,17 +2306,18 @@ "packagePolicies": { "oneOf": [ { + "type": "array", "items": { "type": "string" } }, { + "type": "array", "items": { "$ref": "#/components/schemas/package_policy" } } - ], - "type": "array" + ] }, "updated_on": { "type": "string", @@ -2017,7 +2341,7 @@ ] }, "enrollment_api_key": { - "title": "EnrollmentApiKey", + "title": "Enrollment API key", "type": "object", "properties": { "id": { @@ -2051,7 +2375,7 @@ ] }, "update_package_policy": { - "title": "UpdatePackagePolicy", + "title": "Update package policy", "allOf": [ { "type": "object", diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index c5e4a02b13574..ceefd3337925e 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -33,12 +33,12 @@ paths: properties: message: type: string - operationId: post-setup + operationId: setup parameters: - $ref: '#/components/parameters/kbn_xsrf' /epm/categories: get: - summary: Packages - Categories + summary: Package categories tags: [] responses: '200': @@ -60,7 +60,7 @@ paths: - id - title - count - operationId: get-epm-categories + operationId: get-package-categories /epm/packages: get: summary: Packages - List @@ -74,7 +74,7 @@ paths: type: array items: $ref: '#/components/schemas/search_result' - operationId: get-epm-list + operationId: list-all-packages parameters: [] '/epm/packages/{pkgkey}': get: @@ -102,7 +102,7 @@ paths: required: - status - savedObject - operationId: get-epm-package-pkgkey + operationId: get-package security: - basicAuth: [] parameters: @@ -130,16 +130,26 @@ paths: id: type: string type: - type: string + oneOf: + - $ref: '#/components/schemas/kibana_saved_object_type' + - $ref: '#/components/schemas/elasticsearch_asset_type' required: - id - type required: - response - operationId: post-epm-install-pkgkey + operationId: install-package description: '' parameters: - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + content: + application/json: + schema: + type: object + properties: + force: + type: boolean delete: summary: Packages - Delete tags: [] @@ -159,13 +169,15 @@ paths: id: type: string type: - type: string + oneOf: + - $ref: '#/components/schemas/kibana_saved_object_type' + - $ref: '#/components/schemas/elasticsearch_asset_type' required: - id - type required: - response - operationId: post-epm-delete-pkgkey + operationId: delete-package parameters: - $ref: '#/components/parameters/kbn_xsrf' requestBody: @@ -187,12 +199,12 @@ paths: application/json: schema: $ref: '#/components/schemas/fleet_status_response' - operationId: get-agents-setup + operationId: get-agents-setup-status security: - basicAuth: [] post: summary: Agents setup - Create - operationId: post-agents-setup + operationId: setup-agents responses: '200': description: OK @@ -252,7 +264,7 @@ paths: - other - total - updating - operationId: get-fleet-agent-status + operationId: get-agent-status parameters: - schema: type: string @@ -286,7 +298,7 @@ paths: - total - page - perPage - operationId: get-fleet-agents + operationId: get-agents security: - basicAuth: [] /agents/bulk_upgrade: @@ -299,14 +311,23 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/bulk_upgrade_agents' + type: object + additionalProperties: + type: object + properties: + success: + type: boolean + error: + type: string + required: + - success '400': description: BAD REQUEST content: application/json: schema: $ref: '#/components/schemas/upgrade_agent' - operationId: post-fleet-agents-bulk-upgrade + operationId: bulk-upgrade-agents parameters: - $ref: '#/components/parameters/kbn_xsrf' requestBody: @@ -337,7 +358,7 @@ paths: $ref: '#/components/schemas/agent' required: - item - operationId: get-fleet-agents-agentId + operationId: get-agent put: summary: Agent - Update tags: [] @@ -353,7 +374,7 @@ paths: $ref: '#/components/schemas/agent' required: - item - operationId: put-fleet-agents-agentId + operationId: update-agent parameters: - $ref: '#/components/parameters/kbn_xsrf' delete: @@ -373,9 +394,40 @@ paths: - deleted required: - action - operationId: delete-fleet-agents-agentId + operationId: delete-agent + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + '/agents/{agentId}/reassign': + parameters: + - schema: + type: string + name: agentId + in: path + required: true + put: + summary: Agent - Reassign + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + operationId: reassign-agent parameters: - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + policy_id: + type: string + required: + - policy_id '/agents/{agentId}/unenroll': parameters: - schema: @@ -408,7 +460,7 @@ paths: type: number enum: - 400 - operationId: post-fleet-agents-unenroll + operationId: unenroll-agent parameters: - $ref: '#/components/parameters/kbn_xsrf' requestBody: @@ -444,7 +496,7 @@ paths: application/json: schema: $ref: '#/components/schemas/upgrade_agent' - operationId: post-fleet-agents-upgrade + operationId: upgrade-agent parameters: - $ref: '#/components/parameters/kbn_xsrf' requestBody: @@ -453,6 +505,87 @@ paths: application/json: schema: $ref: '#/components/schemas/upgrade_agent' + /agents/bulk_reassign: + post: + summary: Agents - Bulk reassign + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + additionalProperties: + type: object + properties: + success: + type: boolean + error: + type: string + required: + - success + operationId: bulk-reassign-agents + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + content: + application/json: + schema: + type: object + properties: + policy_id: + type: string + agents: + oneOf: + - type: string + - type: array + items: + type: string + required: + - policy_id + - agents + /agents/bulk_unenroll: + post: + summary: Agents - Bulk unenroll + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + additionalProperties: + type: object + properties: + success: + type: boolean + error: + type: string + required: + - success + operationId: bulk-unenroll-agents + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + requestBody: + content: + application/json: + schema: + type: object + properties: + revoke: + type: boolean + force: + type: boolean + agents: + oneOf: + - type: string + - type: array + items: + type: string + required: + - agents /agent_policies: get: summary: Agent policies - List @@ -499,7 +632,7 @@ paths: properties: item: $ref: '#/components/schemas/agent_policy' - operationId: post-agent-policy + operationId: create-agent-policy requestBody: content: application/json: @@ -548,7 +681,7 @@ paths: $ref: '#/components/schemas/agent_policy' required: - item - operationId: put-agent-policy-agentPolicyId + operationId: update-agent-policy requestBody: content: application/json: @@ -596,34 +729,32 @@ paths: /agent_policies/delete: post: summary: Agent policy - Delete - operationId: post-agent-policy-delete + operationId: delete-agent-policy responses: '200': description: OK content: application/json: schema: - type: array - items: - type: object - properties: - id: - type: string - success: - type: boolean - required: - - id - - success + type: object + properties: + id: + type: string + success: + type: boolean + required: + - id + - success requestBody: content: application/json: schema: type: object properties: - agentPolicyIds: - type: array - items: - type: string + agentPolicyId: + type: string + required: + - agentPolicyId parameters: - $ref: '#/components/parameters/kbn_xsrf' parameters: [] @@ -654,7 +785,7 @@ paths: - page - perPage - total - operationId: get-fleet-enrollment-api-keys + operationId: get-enrollment-api-keys parameters: [] post: summary: Enrollment API Key - Create @@ -673,7 +804,7 @@ paths: type: string enum: - created - operationId: post-fleet-enrollment-api-keys + operationId: create-enrollment-api-keys parameters: - $ref: '#/components/parameters/kbn_xsrf' '/enrollment-api-keys/{keyId}': @@ -698,7 +829,7 @@ paths: $ref: '#/components/schemas/enrollment_api_key' required: - item - operationId: get-fleet-enrollment-api-keys-keyId + operationId: get-enrollment-api-key delete: summary: Enrollment API Key - Delete tags: [] @@ -716,7 +847,7 @@ paths: - deleted required: - action - operationId: delete-fleet-enrollment-api-keys-keyId + operationId: delete-enrollment-api-key parameters: - $ref: '#/components/parameters/kbn_xsrf' /package_policies: @@ -743,21 +874,78 @@ paths: type: number required: - items - operationId: get-packagePolicies + operationId: get-package-policies security: [] parameters: [] parameters: [] post: summary: Package policy - Create - operationId: post-packagePolicies + operationId: create-package-policy responses: '200': description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/package_policy' + required: + - item + requestBody: + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/new_package_policy' + - type: object + properties: + id: + type: string + - type: object + properties: + force: + type: boolean + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + /package_policies/delete: + post: + summary: Package policy - Delete + operationId: delete-package-policy requestBody: content: application/json: schema: - $ref: '#/components/schemas/new_package_policy' + type: object + properties: + packagePolicyIds: + type: array + items: + type: string + force: + type: boolean + required: + - packagePolicyIds + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + success: + type: boolean + required: + - id + - success parameters: - $ref: '#/components/parameters/kbn_xsrf' '/package_policies/{packagePolicyId}': @@ -776,7 +964,7 @@ paths: $ref: '#/components/schemas/package_policy' required: - item - operationId: get-packagePolicies-packagePolicyId + operationId: get-package-policy parameters: - schema: type: string @@ -785,7 +973,7 @@ paths: required: true put: summary: Package policy - Update - operationId: put-packagePolicies-packagePolicyId + operationId: update-package-policy requestBody: content: application/json: @@ -874,7 +1062,7 @@ components: - isInitialized - nonFatalErrors search_result: - title: SearchResult + title: Search result type: object properties: description: @@ -908,7 +1096,7 @@ components: - version - status package_info: - title: PackageInfo + title: Package information type: object properties: name: @@ -1026,6 +1214,28 @@ components: - format_version - download - path + kibana_saved_object_type: + title: Kibana saved object asset type + type: string + enum: + - dashboard + - visualization + - search + - index-pattern + - map + - lens + - ml-module + - security-rule + elasticsearch_asset_type: + title: Elasticsearch asset type + type: string + enum: + - component_template + - ingest_pipeline + - index_template + - ilm_policy + - transform + - data_stream_ilm_policy fleet_status_response: title: Fleet status response type: object @@ -1047,17 +1257,17 @@ components: - missing_requirements agent_type: type: string - title: AgentType + title: Agent type enum: - PERMANENT - EPHEMERAL - TEMPORARY agent_metadata: - title: AgentMetadata + title: Agent metadata type: object agent_status: type: string - title: AgentStatus + title: Agent status enum: - offline - error @@ -1110,45 +1320,24 @@ components: - id - status bulk_upgrade_agents: - title: BulkUpgradeAgents - oneOf: - - type: object - properties: - version: - type: string - agents: - type: array - items: - type: string - required: - - version - - agents - - type: object - properties: - version: - type: string - source_uri: - type: string - agents: - type: array + title: Bulk upgrade agents + type: object + properties: + version: + type: string + source_uri: + type: string + agents: + oneOf: + - type: array items: type: string - required: - - version - - agents - - type: object - properties: - version: - type: string - source_uri: - type: string - agents: - type: string - required: - - version - - agents + - type: string + required: + - agents + - version upgrade_agent: - title: UpgradeAgent + title: Upgrade agent oneOf: - type: object properties: @@ -1165,7 +1354,7 @@ components: required: - version new_agent_policy: - title: NewAgentPolicy + title: New agent policy type: object properties: name: @@ -1175,7 +1364,7 @@ components: description: type: string new_package_policy: - title: NewPackagePolicy + title: New package policy type: object description: '' properties: @@ -1234,7 +1423,7 @@ components: - policy_id - name package_policy: - title: PackagePolicy + title: Package policy allOf: - type: object properties: @@ -1263,11 +1452,12 @@ components: - inactive packagePolicies: oneOf: - - items: + - type: array + items: type: string - - items: + - type: array + items: $ref: '#/components/schemas/package_policy' - type: array updated_on: type: string format: date-time @@ -1281,7 +1471,7 @@ components: - id - status enrollment_api_key: - title: EnrollmentApiKey + title: Enrollment API key type: object properties: id: @@ -1305,7 +1495,7 @@ components: - active - created_at update_package_policy: - title: UpdatePackagePolicy + title: Update package policy allOf: - type: object properties: diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/access_api_key.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/access_api_key.yaml deleted file mode 100644 index 31e2072ddefbe..0000000000000 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/access_api_key.yaml +++ /dev/null @@ -1,3 +0,0 @@ -type: string -title: AccessApiKey -format: byte diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_metadata.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_metadata.yaml index d37321f59a58b..5ec2d745dd14c 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_metadata.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_metadata.yaml @@ -1,2 +1,2 @@ -title: AgentMetadata +title: Agent metadata type: object diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy.yaml index 7395e45365ea9..7eed85eb2e3bc 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy.yaml @@ -11,11 +11,12 @@ allOf: - inactive packagePolicies: oneOf: - - items: + - type: array + items: type: string - - items: + - type: array + items: $ref: ./package_policy.yaml - type: array updated_on: type: string format: date-time diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_status.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_status.yaml index 076a7cc5036bb..da6df3a1b776d 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_status.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_status.yaml @@ -1,5 +1,5 @@ type: string -title: AgentStatus +title: Agent status enum: - offline - error diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_type.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_type.yaml index da42f95c9e1d9..421babbb1d5e4 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_type.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_type.yaml @@ -1,5 +1,5 @@ type: string -title: AgentType +title: Agent type enum: - PERMANENT - EPHEMERAL diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/bulk_upgrade_agents.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/bulk_upgrade_agents.yaml index da06aa6fa8252..31209d43fb58d 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/bulk_upgrade_agents.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/bulk_upgrade_agents.yaml @@ -1,37 +1,16 @@ -title: BulkUpgradeAgents -oneOf: - - type: object - properties: - version: - type: string - agents: - type: array +title: Bulk upgrade agents +type: object +properties: + version: + type: string + source_uri: + type: string + agents: + oneOf: + - type: array items: type: string - required: - - version - - agents - - type: object - properties: - version: - type: string - source_uri: - type: string - agents: - type: array - items: - type: string - required: - - version - - agents - - type: object - properties: - version: - type: string - source_uri: - type: string - agents: - type: string - required: - - version - - agents + - type: string +required: + - agents + - version diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/elasticsearch_asset_type.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/elasticsearch_asset_type.yaml new file mode 100644 index 0000000000000..19b3328d78346 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/elasticsearch_asset_type.yaml @@ -0,0 +1,9 @@ +title: Elasticsearch asset type +type: string +enum: + - component_template + - ingest_pipeline + - index_template + - ilm_policy + - transform + - data_stream_ilm_policy diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/enrollment_api_key.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/enrollment_api_key.yaml index e8491504d8416..7be406cf5b831 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/enrollment_api_key.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/enrollment_api_key.yaml @@ -1,4 +1,4 @@ -title: EnrollmentApiKey +title: Enrollment API key type: object properties: id: diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml new file mode 100644 index 0000000000000..4ec82e7507166 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/kibana_saved_object_type.yaml @@ -0,0 +1,11 @@ +title: Kibana saved object asset type +type: string +enum: + - dashboard + - visualization + - search + - index-pattern + - map + - lens + - ml-module + - security-rule diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/new_agent_policy.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/new_agent_policy.yaml index 7070876cbea59..06048c81d979a 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/new_agent_policy.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/new_agent_policy.yaml @@ -1,4 +1,4 @@ -title: NewAgentPolicy +title: New agent policy type: object properties: name: diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/new_package_policy.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/new_package_policy.yaml index 61b1fa678d407..e5e4451881b57 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/new_package_policy.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/new_package_policy.yaml @@ -1,4 +1,4 @@ -title: NewPackagePolicy +title: New package policy type: object description: '' properties: diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml index 3e0742c1879cb..ec4f18af8a223 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/package_info.yaml @@ -1,4 +1,4 @@ -title: PackageInfo +title: Package information type: object properties: name: diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/package_policy.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/package_policy.yaml index 99bc64f793379..4aead940ea27e 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/package_policy.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/package_policy.yaml @@ -1,4 +1,4 @@ -title: PackagePolicy +title: Package policy allOf: - type: object properties: diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/search_result.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/search_result.yaml index b67ff61c5ab60..89832f47db8cb 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/search_result.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/search_result.yaml @@ -1,4 +1,4 @@ -title: SearchResult +title: Search result type: object properties: description: diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/update_package_policy.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/update_package_policy.yaml index 054a0e1a48be0..8f7f856a6649f 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/update_package_policy.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/update_package_policy.yaml @@ -1,4 +1,4 @@ -title: UpdatePackagePolicy +title: Update package policy allOf: - type: object properties: diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/upgrade_agent.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/upgrade_agent.yaml index 11a2b5846ba1e..19c796f8f8404 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/upgrade_agent.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/upgrade_agent.yaml @@ -1,4 +1,4 @@ -title: UpgradeAgent +title: Upgrade agent oneOf: - type: object properties: diff --git a/x-pack/plugins/fleet/common/openapi/entrypoint.yaml b/x-pack/plugins/fleet/common/openapi/entrypoint.yaml index 0f7129a2a7cec..0cf197e27ab82 100644 --- a/x-pack/plugins/fleet/common/openapi/entrypoint.yaml +++ b/x-pack/plugins/fleet/common/openapi/entrypoint.yaml @@ -34,10 +34,16 @@ paths: $ref: paths/agents@bulk_upgrade.yaml '/agents/{agentId}': $ref: 'paths/agents@{agent_id}.yaml' + '/agents/{agentId}/reassign': + $ref: 'paths/agents@{agent_id}@reassign.yaml' '/agents/{agentId}/unenroll': $ref: 'paths/agents@{agent_id}@unenroll.yaml' '/agents/{agentId}/upgrade': $ref: 'paths/agents@{agent_id}@upgrade.yaml' + '/agents/bulk_reassign': + $ref: 'paths/agents@bulk_reassign.yaml' + '/agents/bulk_unenroll': + $ref: 'paths/agents@bulk_unenroll.yaml' /agent_policies: $ref: paths/agent_policies.yaml '/agent_policies/{agentPolicyId}': @@ -52,6 +58,8 @@ paths: $ref: 'paths/enrollment_api_keys@{key_id}.yaml' /package_policies: $ref: paths/package_policies.yaml + /package_policies/delete: + $ref: paths/package_policies@delete.yaml '/package_policies/{packagePolicyId}': $ref: 'paths/package_policies@{package_policy_id}.yaml' components: diff --git a/x-pack/plugins/fleet/common/openapi/paths/agent_policies.yaml b/x-pack/plugins/fleet/common/openapi/paths/agent_policies.yaml index 9c17680b6c6bd..b075d42d34af9 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/agent_policies.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/agent_policies.yaml @@ -43,7 +43,7 @@ post: properties: item: $ref: ../components/schemas/agent_policy.yaml - operationId: post-agent-policy + operationId: create-agent-policy requestBody: content: application/json: diff --git a/x-pack/plugins/fleet/common/openapi/paths/agent_policies@delete.yaml b/x-pack/plugins/fleet/common/openapi/paths/agent_policies@delete.yaml index ae975274d80e5..f136afb559603 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/agent_policies@delete.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/agent_policies@delete.yaml @@ -1,33 +1,31 @@ post: summary: Agent policy - Delete - operationId: post-agent-policy-delete + operationId: delete-agent-policy responses: '200': description: OK content: application/json: schema: - type: array - items: - type: object - properties: - id: - type: string - success: - type: boolean - required: - - id - - success + type: object + properties: + id: + type: string + success: + type: boolean + required: + - id + - success requestBody: content: application/json: schema: type: object properties: - agentPolicyIds: - type: array - items: - type: string + agentPolicyId: + type: string + required: + - agentPolicyId parameters: - $ref: ../components/headers/kbn_xsrf.yaml parameters: [] diff --git a/x-pack/plugins/fleet/common/openapi/paths/agent_policies@{agent_policy_id}.yaml b/x-pack/plugins/fleet/common/openapi/paths/agent_policies@{agent_policy_id}.yaml index 15910b0116b7f..9a60d197ed24a 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/agent_policies@{agent_policy_id}.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/agent_policies@{agent_policy_id}.yaml @@ -37,7 +37,7 @@ put: $ref: ../components/schemas/agent_policy.yaml required: - item - operationId: put-agent-policy-agentPolicyId + operationId: update-agent-policy requestBody: content: application/json: diff --git a/x-pack/plugins/fleet/common/openapi/paths/agent_status.yaml b/x-pack/plugins/fleet/common/openapi/paths/agent_status.yaml index 1b55cbd96733d..adc0ea79629af 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/agent_status.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/agent_status.yaml @@ -34,7 +34,7 @@ get: - other - total - updating - operationId: get-fleet-agent-status + operationId: get-agent-status parameters: - schema: type: string diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents.yaml index bb3905eab7c0e..4a217eda5c5ed 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/agents.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/agents.yaml @@ -24,6 +24,6 @@ get: - total - page - perPage - operationId: get-fleet-agents + operationId: get-agents security: - basicAuth: [] diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents@bulk_reassign.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents@bulk_reassign.yaml new file mode 100644 index 0000000000000..a7d70f747cf92 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/paths/agents@bulk_reassign.yaml @@ -0,0 +1,39 @@ +post: + summary: Agents - Bulk reassign + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + additionalProperties: + type: object + properties: + success: + type: boolean + error: + type: string + required: + - success + operationId: bulk-reassign-agents + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + requestBody: + content: + application/json: + schema: + type: object + properties: + policy_id: + type: string + agents: + oneOf: + - type: string + - type: array + items: + type: string + required: + - policy_id + - agents diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents@bulk_unenroll.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents@bulk_unenroll.yaml new file mode 100644 index 0000000000000..55b10def7da7f --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/paths/agents@bulk_unenroll.yaml @@ -0,0 +1,40 @@ +post: + summary: Agents - Bulk unenroll + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + additionalProperties: + type: object + properties: + success: + type: boolean + error: + type: string + required: + - success + operationId: bulk-unenroll-agents + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + requestBody: + content: + application/json: + schema: + type: object + properties: + revoke: + type: boolean + force: + type: boolean + agents: + oneOf: + - type: string + - type: array + items: + type: string + required: + - agents diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents@bulk_upgrade.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents@bulk_upgrade.yaml index 37c7ad31c5b01..1467d1f98aa22 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/agents@bulk_upgrade.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/agents@bulk_upgrade.yaml @@ -7,14 +7,23 @@ post: content: application/json: schema: - $ref: ../components/schemas/bulk_upgrade_agents.yaml + type: object + additionalProperties: + type: object + properties: + success: + type: boolean + error: + type: string + required: + - success '400': description: BAD REQUEST content: application/json: schema: $ref: ../components/schemas/upgrade_agent.yaml - operationId: post-fleet-agents-bulk-upgrade + operationId: bulk-upgrade-agents parameters: - $ref: ../components/headers/kbn_xsrf.yaml requestBody: diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents@setup.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents@setup.yaml index 773872ae3407a..7d7f9561b2190 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/agents@setup.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/agents@setup.yaml @@ -8,12 +8,12 @@ get: application/json: schema: $ref: ../components/schemas/fleet_status_response.yaml - operationId: get-agents-setup + operationId: get-agents-setup-status security: - basicAuth: [] post: summary: Agents setup - Create - operationId: post-agents-setup + operationId: setup-agents responses: '200': description: OK diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}.yaml index a898b9b563f17..c139fe8e7e997 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}.yaml @@ -19,7 +19,7 @@ get: $ref: ../components/schemas/agent.yaml required: - item - operationId: get-fleet-agents-agentId + operationId: get-agent put: summary: Agent - Update tags: [] @@ -35,7 +35,7 @@ put: $ref: ../components/schemas/agent.yaml required: - item - operationId: put-fleet-agents-agentId + operationId: update-agent parameters: - $ref: ../components/headers/kbn_xsrf.yaml delete: @@ -55,6 +55,6 @@ delete: - deleted required: - action - operationId: delete-fleet-agents-agentId + operationId: delete-agent parameters: - $ref: ../components/headers/kbn_xsrf.yaml diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@reassign.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@reassign.yaml new file mode 100644 index 0000000000000..6d2253be3bbc2 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@reassign.yaml @@ -0,0 +1,31 @@ +parameters: + - schema: + type: string + name: agentId + in: path + required: true +put: + summary: Agent - Reassign + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + operationId: reassign-agent + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + policy_id: + type: string + required: + - policy_id + diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@unenroll.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@unenroll.yaml index 5b848b715080b..b9664ae650112 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@unenroll.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@unenroll.yaml @@ -29,7 +29,7 @@ post: type: number enum: - 400 - operationId: post-fleet-agents-unenroll + operationId: unenroll-agent parameters: - $ref: ../components/headers/kbn_xsrf.yaml requestBody: diff --git a/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@upgrade.yaml b/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@upgrade.yaml index 14a0598ce6ecb..52489636f2fe9 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@upgrade.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/agents@{agent_id}@upgrade.yaml @@ -20,7 +20,7 @@ post: application/json: schema: $ref: ../components/schemas/upgrade_agent.yaml - operationId: post-fleet-agents-upgrade + operationId: upgrade-agent parameters: - $ref: ../components/headers/kbn_xsrf.yaml requestBody: diff --git a/x-pack/plugins/fleet/common/openapi/paths/enrollment_api_keys.yaml b/x-pack/plugins/fleet/common/openapi/paths/enrollment_api_keys.yaml index f00c46e7683e5..6cfbede4a7ead 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/enrollment_api_keys.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/enrollment_api_keys.yaml @@ -24,7 +24,7 @@ get: - page - perPage - total - operationId: get-fleet-enrollment-api-keys + operationId: get-enrollment-api-keys parameters: [] post: summary: Enrollment API Key - Create @@ -43,6 +43,6 @@ post: type: string enum: - created - operationId: post-fleet-enrollment-api-keys + operationId: create-enrollment-api-keys parameters: - $ref: ../components/headers/kbn_xsrf.yaml diff --git a/x-pack/plugins/fleet/common/openapi/paths/enrollment_api_keys@{key_id}.yaml b/x-pack/plugins/fleet/common/openapi/paths/enrollment_api_keys@{key_id}.yaml index 021f6582641ed..37c390897ef67 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/enrollment_api_keys@{key_id}.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/enrollment_api_keys@{key_id}.yaml @@ -19,7 +19,7 @@ get: $ref: ../components/schemas/enrollment_api_key.yaml required: - item - operationId: get-fleet-enrollment-api-keys-keyId + operationId: get-enrollment-api-key delete: summary: Enrollment API Key - Delete tags: [] @@ -37,6 +37,6 @@ delete: - deleted required: - action - operationId: delete-fleet-enrollment-api-keys-keyId + operationId: delete-enrollment-api-key parameters: - $ref: ../components/headers/kbn_xsrf.yaml diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@categories.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@categories.yaml index 567d621a2e21d..b673ea71b7786 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/epm@categories.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/epm@categories.yaml @@ -1,5 +1,5 @@ get: - summary: Packages - Categories + summary: Package categories tags: [] responses: '200': @@ -21,4 +21,4 @@ get: - id - title - count - operationId: get-epm-categories + operationId: get-package-categories diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@packages.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@packages.yaml index fe79a9a4186b2..8ab62d3318070 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/epm@packages.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/epm@packages.yaml @@ -10,5 +10,5 @@ get: type: array items: $ref: ../components/schemas/search_result.yaml - operationId: get-epm-list + operationId: list-all-packages parameters: [] diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkgkey}.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkgkey}.yaml index 2c4567bd36ba1..1b15ddc4b22a3 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkgkey}.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkgkey}.yaml @@ -23,7 +23,7 @@ get: required: - status - savedObject - operationId: get-epm-package-pkgkey + operationId: get-package security: - basicAuth: [] parameters: @@ -51,16 +51,26 @@ post: id: type: string type: - type: string + oneOf: + - $ref: ../components/schemas/kibana_saved_object_type.yaml + - $ref: ../components/schemas/elasticsearch_asset_type.yaml required: - id - type required: - response - operationId: post-epm-install-pkgkey + operationId: install-package description: '' parameters: - $ref: ../components/headers/kbn_xsrf.yaml + requestBody: + content: + application/json: + schema: + type: object + properties: + force: + type: boolean delete: summary: Packages - Delete tags: [] @@ -80,13 +90,15 @@ delete: id: type: string type: - type: string + oneOf: + - $ref: ../components/schemas/kibana_saved_object_type.yaml + - $ref: ../components/schemas/elasticsearch_asset_type.yaml required: - id - type required: - response - operationId: post-epm-delete-pkgkey + operationId: delete-package parameters: - $ref: ../components/headers/kbn_xsrf.yaml requestBody: diff --git a/x-pack/plugins/fleet/common/openapi/paths/package_policies.yaml b/x-pack/plugins/fleet/common/openapi/paths/package_policies.yaml index 24b3e091f1bc2..1d1263f16b01d 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/package_policies.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/package_policies.yaml @@ -21,20 +21,38 @@ get: type: number required: - items - operationId: get-packagePolicies + operationId: get-package-policies security: [] parameters: [] parameters: [] post: summary: Package policy - Create - operationId: post-packagePolicies + operationId: create-package-policy responses: '200': description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: ../components/schemas/package_policy.yaml + required: + - item requestBody: content: application/json: schema: - $ref: ../components/schemas/new_package_policy.yaml + allOf: + - $ref: ../components/schemas/new_package_policy.yaml + - type: object + properties: + id: + type: string + - type: object + properties: + force: + type: boolean parameters: - $ref: ../components/headers/kbn_xsrf.yaml diff --git a/x-pack/plugins/fleet/common/openapi/paths/package_policies@delete.yaml b/x-pack/plugins/fleet/common/openapi/paths/package_policies@delete.yaml new file mode 100644 index 0000000000000..ad907c6160803 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/paths/package_policies@delete.yaml @@ -0,0 +1,38 @@ +post: + summary: Package policy - Delete + operationId: delete-package-policy + requestBody: + content: + application/json: + schema: + type: object + properties: + packagePolicyIds: + type: array + items: + type: string + force: + type: boolean + required: + - packagePolicyIds + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + success: + type: boolean + required: + - id + - success + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml diff --git a/x-pack/plugins/fleet/common/openapi/paths/package_policies@{package_policy_id}.yaml b/x-pack/plugins/fleet/common/openapi/paths/package_policies@{package_policy_id}.yaml index 9a8a1477fea78..7bd20ab17fdd3 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/package_policies@{package_policy_id}.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/package_policies@{package_policy_id}.yaml @@ -13,7 +13,7 @@ get: $ref: ../components/schemas/package_policy.yaml required: - item - operationId: get-packagePolicies-packagePolicyId + operationId: get-package-policy parameters: - schema: type: string @@ -22,7 +22,7 @@ parameters: required: true put: summary: Package policy - Update - operationId: put-packagePolicies-packagePolicyId + operationId: update-package-policy requestBody: content: application/json: diff --git a/x-pack/plugins/fleet/common/openapi/paths/setup.yaml b/x-pack/plugins/fleet/common/openapi/paths/setup.yaml index d917059442baa..abc17c0c9f6df 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/setup.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/setup.yaml @@ -17,6 +17,6 @@ post: properties: message: type: string - operationId: post-setup + operationId: setup parameters: - $ref: ../components/headers/kbn_xsrf.yaml From c54b4e54be83dc9b333346e358a549beb926b2d8 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Thu, 22 Jul 2021 10:18:42 -0400 Subject: [PATCH 06/45] [Docs] Remove unused page drilldowns.asciidoc (#106487) --- docs/user/dashboard/drilldowns.asciidoc | 252 ------------------ .../images/drilldown_on_piechart.gif | Bin 566762 -> 0 bytes 2 files changed, 252 deletions(-) delete mode 100644 docs/user/dashboard/drilldowns.asciidoc delete mode 100644 docs/user/dashboard/images/drilldown_on_piechart.gif diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc deleted file mode 100644 index 84c33db31d575..0000000000000 --- a/docs/user/dashboard/drilldowns.asciidoc +++ /dev/null @@ -1,252 +0,0 @@ -[role="xpack"] -[[drilldowns]] -== Create custom dashboard actions - -Custom dashboard actions, or _drilldowns_, allow you to create workflows for analyzing and troubleshooting your data. -Drilldowns apply only to the panel that you created the drilldown from, and are not shared across all panels. Each panel can have multiple drilldowns. - -Third-party developers can create drilldowns. To learn how to code drilldowns, refer to {kib-repo}blob/{branch}/x-pack/examples/ui_actions_enhanced_examples[this example plugin]. - -[float] -[[supported-drilldowns]] -=== Supported drilldowns - -{kib} supports dashboard and URL drilldowns. - -[float] -[[dashboard-drilldowns]] -==== Dashboard drilldowns - -Dashboard drilldowns enable you to open a dashboard from another dashboard, -taking the time range, filters, and other parameters with you -so the context remains the same. Dashboard drilldowns help you to continue your analysis from a new perspective. - -For example, if you have a dashboard that shows the overall status of multiple data center, -you can create a drilldown that navigates from the overall status dashboard to a dashboard -that shows a single data center or server. - -[role="screenshot"] -image:images/drilldown_on_piechart.gif[Drilldown on pie chart that navigates to another dashboard] - -[float] -[[url-drilldowns]] -==== URL drilldowns - -URL drilldowns enable you to navigate from a dashboard to internal or external URLs. -Destination URLs can be dynamic, depending on the dashboard context or user interaction with a panel. -For example, if you have a dashboard that shows data from a Github repository, you can create a URL drilldown -that opens Github from the dashboard panel. - -[role="screenshot"] -image:images/url_drilldown_go_to_github.gif[Drilldown on pie chart that navigates to Github] - -Some panels support multiple interactions, also known as triggers. -The <> you use to create a <> depends on the trigger you choose. URL drilldowns support these types of triggers: - -* *Single click* — A single data point in the panel. - -* *Range selection* — A range of values in a panel. - -For example, *Single click* has `{{event.value}}` and *Range selection* has `{{event.from}}` and `{{event.to}}`. - -[float] -[[dashboard-drilldown-supported-panels]] -=== Supported panel types - -The following panel types support drilldowns. - -[options="header"] -|=== - -| Panel | Dashboard drilldown | URL drilldown - -| Lens -^| X -^| X - -| Area -^| X -^| X - -| Controls -^| -^| - -| Data Table -^| X -^| X - -| Gauge -^| -^| - -| Goal -^| -^| - -| Heat map -^| X -^| X - -| Horizontal Bar -^| X -^| X - -| Line -^| X -^| X - -| Maps -^| X -^| X - -| Markdown -^| -^| - -| Metric -^| -^| - -| Pie -^| X -^| X - -| TSVB (only for time series visualizations) -^| X -^| X - -| Tag Cloud -^| X -^| X - -| Timelion -^| X -^| - -| Vega -^| X -^| - -| Vertical Bar -^| X -^| X - -|=== - -[float] -[[drilldowns-example]] -=== Create a dashboard drilldown - -To create dashboard drilldowns, you create or locate the dashboards you want to connect, then configure the drilldown that allows you to easily open one dashboard from the other dashboard. - -[float] -==== Create the dashboard - -. Add the *Sample web logs* data. - -. Create a new dashboard, then add the following panels from the *Visualize Library*: - -* *[Logs] Heatmap* -* *[Logs] Host, Visits, and Bytes Table* -* *[Logs] Total Requests and Bytes* -* *[Logs] Visitors by OS* -+ -If you don’t see the data on a panel, change the <>. - -. Save the dashboard. In the *Title* field, enter `Host Overview`. - -. Open the *[Logs] Web traffic* dashboard. - -. Set a search and filter. -+ -[%hardbreaks] -Search: `extension.keyword: ("gz" or "css" or "deb")` -Filter: `geo.src: CN` - -[float] -==== Create the drilldown - -. In the toolbar, click *Edit*. - -. Open the *[Logs] Visitors by OS* panel menu, then select *Create drilldown*. - -. Click *Go to dashboard*. - -.. Give the drilldown a name. For example, `My Drilldown`. - -.. From the *Choose a destination dashboard* dropdown, select *Host Overview*. - -.. To use the geo.src filter, KQL query, and time filter, select *Use filters and query from origin dashboard* and *Use date range from origin dashboard*. - -.. Click *Create drilldown*. - -. Save the dashboard. - -. In the *[Logs] Visitors by OS* panel, click *win 8*, then select `My Drilldown`. -+ -[role="screenshot"] -image::images/drilldown_on_panel.png[Drilldown on pie chart that navigates to another dashboard] - -. On the *Host Overview* dashboard, verify that the geo.src filter, KQL query, and time filter are applied. - -[float] -[[create-a-url-drilldown]] -=== Create a URL drilldown - -To create URL drilldowns, you add <> to a URL template, which configures the behavior of the drilldown. - -. Add the *Sample web logs* data. - -. Open the *[Logs] Web traffic* dashboard. - -. In the toolbar, click *Edit*. - -. Open the *[Logs] Visitors by OS* panel menu, then select *Create drilldown*. - -. Click *Go to URL*. - -.. Give the drilldown a name. For example, `Show on Github`. - -.. For the *Trigger*, select *Single click*. - -.. To navigate to the {kib} repository Github issues, enter the following in the *Enter URL* field: -+ -[source, bash] ----- -https://github.com/elastic/kibana/issues?q=is:issue+is:open+{{event.value}} ----- -+ -`{{event.value}}` is substituted with a value associated with a selected pie slice. - -.. Click *Create drilldown*. - -. Save the dashboard. - -. On the *[Logs] Visitors by OS* panel, click any chart slice, then select *Show on Github*. -+ -[role="screenshot"] -image:images/url_drilldown_popup.png[URL drilldown popup] - -. In the list of {kib} repository issues, verify that the slice value appears. -+ -[role="screenshot"] -image:images/url_drilldown_github.png[Github] - -[float] -[[manage-drilldowns]] -=== Manage drilldowns - -Make changes to your drilldowns, make a copy of your drilldowns for another panel, and delete drilldowns. - -. Open the panel menu that includes the drilldown, then click *Manage drilldowns*. - -. On the *Manage* tab, use the following options: - -* To change drilldowns, click *Edit* next to the drilldown you want to change, make your changes, then click *Save*. - -* To make a copy, click *Copy* next to the drilldown you want to change, enter the drilldown name, then click *Create drilldown*. - -* To delete a drilldown, select the drilldown you want to delete, then click *Delete*. - -include::url-drilldown.asciidoc[] diff --git a/docs/user/dashboard/images/drilldown_on_piechart.gif b/docs/user/dashboard/images/drilldown_on_piechart.gif deleted file mode 100644 index c438e1437188731fb4efb061930f0a3c2ef0155f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 566762 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๽&Q+pRgAoCJy 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

nu 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

K*@G%yBMzUPJHkM_1l;sUeiGa*! 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

00Zv{p~Ze&zwDH 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%0AlY@uDs2J+)fI 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 Date: Thu, 22 Jul 2021 10:40:02 -0400 Subject: [PATCH 07/45] [Security Solution][RAC] - Change json view tab name to json (#106524) --- .../common/components/event_details/event_details.test.tsx | 4 ++-- .../public/common/components/event_details/translations.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx index f323a8c8b4a08..3af97615d6153 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx @@ -63,7 +63,7 @@ describe('EventDetails', () => { }); describe('tabs', () => { - ['Table', 'JSON View'].forEach((tab) => { + ['Table', 'JSON'].forEach((tab) => { test(`it renders the ${tab} tab`, () => { expect( wrapper @@ -82,7 +82,7 @@ describe('EventDetails', () => { }); describe('alerts tabs', () => { - ['Overview', 'Threat Intel', 'Table', 'JSON View'].forEach((tab) => { + ['Overview', 'Threat Intel', 'Table', 'JSON'].forEach((tab) => { test(`it renders the ${tab} tab`, () => { const expectedCopy = tab === 'Threat Intel' ? `${tab} (1)` : tab; expect( diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts index 306580ef8e3e5..98fd0c61a5393 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts @@ -34,7 +34,7 @@ export const TABLE = i18n.translate('xpack.securitySolution.eventDetails.table', }); export const JSON_VIEW = i18n.translate('xpack.securitySolution.eventDetails.jsonView', { - defaultMessage: 'JSON View', + defaultMessage: 'JSON', }); export const FIELD = i18n.translate('xpack.securitySolution.eventDetails.field', { From 7f758731ae3125f93393cad2403d6ea45f3c0b72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Thu, 22 Jul 2021 16:42:18 +0200 Subject: [PATCH 08/45] [Security solution] [Endpoint] Unify subtitle text in flyout and modal for event filters (#106401) * Unify subtitle text in flyout and modal for event filters * Change variable name and make it more consistent with trusted apps showing subtitle only when adding event filters Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../view/components/form/index.tsx | 18 ++++++++---------- .../view/components/form/translations.ts | 7 ------- .../view/event_filters_list_page.tsx | 7 ++----- .../pages/event_filters/view/translations.ts | 6 ++++++ 4 files changed, 16 insertions(+), 22 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx index db5c42241a0cc..29723a5fd3cf8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx @@ -31,16 +31,10 @@ import { ExceptionBuilder } from '../../../../../../shared_imports'; import { useEventFiltersSelector } from '../../hooks'; import { getFormEntryStateMutable, getHasNameError, getNewComment } from '../../../store/selector'; -import { - FORM_DESCRIPTION, - NAME_LABEL, - NAME_ERROR, - NAME_PLACEHOLDER, - OS_LABEL, - RULE_NAME, -} from './translations'; +import { NAME_LABEL, NAME_ERROR, NAME_PLACEHOLDER, OS_LABEL, RULE_NAME } from './translations'; import { OS_TITLES } from '../../../../../common/translations'; import { ENDPOINT_EVENT_FILTERS_LIST_ID, EVENT_FILTER_LIST_TYPE } from '../../../constants'; +import { ABOUT_EVENT_FILTERS } from '../../translations'; const OPERATING_SYSTEMS: readonly OperatingSystem[] = [ OperatingSystem.MAC, @@ -205,8 +199,12 @@ export const EventFiltersForm: React.FC = memo( return !isIndexPatternLoading && exception ? ( - {FORM_DESCRIPTION} - + {!exception || !exception.item_id ? ( + + {ABOUT_EVENT_FILTERS} + + + ) : null} {nameInputMemo} {allowSelectOs ? ( diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts index 7391251a936e6..bfb828699118e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts @@ -7,13 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const FORM_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.eventFilter.modal.description', - { - defaultMessage: "Events are filtered when the rule's conditions are met:", - } -); - export const NAME_PLACEHOLDER = i18n.translate( 'xpack.securitySolution.eventFilter.form.name.placeholder', { diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx index 2d608bdc6e157..95f3e856a6ff6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx @@ -44,6 +44,7 @@ import { EventFilterDeleteModal } from './components/event_filter_delete_modal'; import { SearchBar } from '../../../components/search_bar'; import { BackToExternalAppButton } from '../../../components/back_to_external_app_button'; +import { ABOUT_EVENT_FILTERS } from './translations'; type EventListPaginatedContent = PaginatedContentProps< Immutable, @@ -195,11 +196,7 @@ export const EventFiltersListPage = memo(() => { defaultMessage="Event Filters" /> } - subtitle={i18n.translate('xpack.securitySolution.eventFilters.aboutInfo', { - defaultMessage: - 'Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch. Event ' + - 'filters are processed by the Endpoint Security integration, and are applied to hosts running this integration on their agents.', - })} + subtitle={ABOUT_EVENT_FILTERS} actions={ doesDataExist && ( { values: { error: getError.message }, }); }; + +export const ABOUT_EVENT_FILTERS = i18n.translate('xpack.securitySolution.eventFilters.aboutInfo', { + defaultMessage: + 'Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch. Event ' + + 'filters are processed by the Endpoint Security integration, and are applied to hosts running this integration on their agents.', +}); From 9616a55f817d58e36187fa66bee15e547d976105 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Thu, 22 Jul 2021 10:50:44 -0400 Subject: [PATCH 09/45] [Lens] Add render complete tags to empty states (#106163) * [Lens] Add render complete tags to empty states * Fix typo Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/table_basic.test.tsx | 2 ++ .../components/table_basic.tsx | 10 ++++++- .../heatmap_visualization/chart_component.tsx | 4 +-- .../metric_visualization/expression.test.tsx | 26 ++++++++++++----- .../metric_visualization/expression.tsx | 28 +++++++++---------- .../render_function.test.tsx | 4 +++ .../pie_visualization/render_function.tsx | 10 ++++++- .../save_search_session_relative_time.ts | 3 +- 8 files changed, 59 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx index 4a34bd030429e..ae51f7d42312f 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.test.tsx @@ -11,6 +11,7 @@ import { act } from 'react-dom/test-utils'; import { mountWithIntl } from '@kbn/test/jest'; import { EuiDataGrid } from '@elastic/eui'; import { IAggType, IFieldFormat } from 'src/plugins/data/public'; +import { VisualizationContainer } from '../../visualization_container'; import { EmptyPlaceholder } from '../../shared_components'; import { LensIconChartDatatable } from '../../assets/chart_datatable'; import { DataContext, DatatableComponent } from './table_basic'; @@ -357,6 +358,7 @@ describe('DatatableComponent', () => { uiSettings={({ get: jest.fn() } as unknown) as IUiSettingsClient} /> ); + expect(component.find(VisualizationContainer)).toHaveLength(1); expect(component.find(EmptyPlaceholder).prop('icon')).toEqual(LensIconChartDatatable); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx index 25a7d3a35050c..8ef64e4acdccc 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx @@ -329,7 +329,15 @@ export const DatatableComponent = (props: DatatableRenderProps) => { }, [columnConfig.columns, alignments, firstTable, columns]); if (isEmpty) { - return ; + return ( + + + + ); } const dataGridAriaLabel = diff --git a/x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx b/x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx index 865ebb417a4ca..3e8e9d184ed8a 100644 --- a/x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx +++ b/x-pack/plugins/lens/public/heatmap_visualization/chart_component.tsx @@ -314,8 +314,6 @@ export const HeatmapComponent: FC = ({ return ; } - // const colorPalette = euiPaletteForTemperature(5); - return ( { setState({ isReady: true }); diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx b/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx index b31125a1912ef..27a7659b1c817 100644 --- a/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/expression.test.tsx @@ -249,10 +249,16 @@ describe('metric_expression', () => { /> ) ).toMatchInlineSnapshot(` - - `); + + + + `); }); test('it renders an EmptyPlaceholder when null value is passed as data', () => { @@ -269,9 +275,15 @@ describe('metric_expression', () => { /> ) ).toMatchInlineSnapshot(` - + + + `); }); diff --git a/x-pack/plugins/lens/public/metric_visualization/expression.tsx b/x-pack/plugins/lens/public/metric_visualization/expression.tsx index e21fa08b97410..cf6921b2ca579 100644 --- a/x-pack/plugins/lens/public/metric_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/metric_visualization/expression.tsx @@ -113,32 +113,32 @@ export function MetricChart({ }: MetricChartProps & { formatFactory: FormatFactory }) { const { metricTitle, title, description, accessor, mode } = args; const firstTable = Object.values(data.tables)[0]; - if (!accessor) { - return ( - - ); - } - if (!firstTable) { - return ; + const getEmptyState = () => ( + + + + ); + + if (!accessor || !firstTable) { + return getEmptyState(); } const column = firstTable.columns.find(({ id }) => id === accessor); const row = firstTable.rows[0]; if (!column || !row) { - return ; + return getEmptyState(); } // NOTE: Cardinality and Sum never receives "null" as value, but always 0, even for empty dataset. // Mind falsy values here as 0! const shouldShowResults = row[accessor] != null; - if (!shouldShowResults) { - return ; + return getEmptyState(); } const value = diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx index a9e7e4adb9ca7..a3a10b803fcd3 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx @@ -19,6 +19,7 @@ import { shallow } from 'enzyme'; import { LensMultiTable } from '../types'; import { PieComponent } from './render_function'; import { PieExpressionArgs } from './types'; +import { VisualizationContainer } from '../visualization_container'; import { EmptyPlaceholder } from '../shared_components'; import { chartPluginMock } from '../../../../../src/plugins/charts/public/mocks'; import { LensIconChartDonut } from '../assets/chart_donut'; @@ -311,6 +312,7 @@ describe('PieVisualization component', () => { const component = shallow( ); + expect(component.find(VisualizationContainer)).toHaveLength(1); expect(component.find(EmptyPlaceholder)).toHaveLength(1); }); @@ -331,6 +333,7 @@ describe('PieVisualization component', () => { ); + expect(component.find(VisualizationContainer)).toHaveLength(1); expect(component.find(EmptyPlaceholder)).toHaveLength(0); expect(component.find(Chart)).toHaveLength(1); }); @@ -353,6 +356,7 @@ describe('PieVisualization component', () => { const component = shallow( ); + expect(component.find(VisualizationContainer)).toHaveLength(1); expect(component.find(EmptyPlaceholder).prop('icon')).toEqual(LensIconChartDonut); }); }); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index 2e5a06b4f705f..b161a81a835f1 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -233,7 +233,15 @@ export function PieComponent( isMetricEmpty; if (isEmpty) { - return ; + return ( + + ; + + ); } if (hasNegative) { diff --git a/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/save_search_session_relative_time.ts b/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/save_search_session_relative_time.ts index 371b0f8d0b0b3..71bf03365e66d 100644 --- a/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/save_search_session_relative_time.ts +++ b/x-pack/test/search_sessions_integration/tests/apps/dashboard/async_search/save_search_session_relative_time.ts @@ -29,8 +29,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardExpect = getService('dashboardExpect'); const searchSessions = getService('searchSessions'); - // Failing: See https://github.com/elastic/kibana/issues/97701 - describe.skip('save a search sessions with relative time', () => { + describe('save a search sessions with relative time', () => { before(async () => { await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { useActualUrl: true, From 4c3a3977d4f46698c23f26ffbea29584b2206c6a Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Thu, 22 Jul 2021 17:53:26 +0300 Subject: [PATCH 10/45] [Canvas] Dropdown filter refactor (#105707) * Refactered from `recompose` to `react hooks`. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../dropdown_filter.stories.storyshot | 2 ++ .../__stories__/dropdown_filter.stories.tsx | 26 +++++-------------- .../component/dropdown_filter.scss | 1 + .../component/dropdown_filter.tsx | 20 +++++++------- .../dropdown_filter/component/index.ts | 22 +--------------- .../filters/dropdown_filter/index.tsx | 4 +-- 6 files changed, 24 insertions(+), 51 deletions(-) diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/__stories__/__snapshots__/dropdown_filter.stories.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/__stories__/__snapshots__/dropdown_filter.stories.storyshot index b5c130bea3691..a14ad820586eb 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/__stories__/__snapshots__/dropdown_filter.stories.storyshot +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/filters/dropdown_filter/component/__stories__/__snapshots__/dropdown_filter.stories.storyshot @@ -8,6 +8,7 @@ exports[`Storyshots renderers/DropdownFilter default 1`] = ` className="canvasDropdownFilter__select" data-test-subj="canvasDropdownFilter__select" onChange={[Function]} + value="" >

+ +

+ + + + + + + + + + + + + <> + {selectedJobType === 'anomaly-detector' && ( + <> + {loadingADJobs === true ? ( + + ) : ( + <> + + + + + + + {adJobIds.map((id) => ( +
+ toggleSelectedJob(e.target.checked, id)} + /> + +
+ ))} + + )} + + )} + {selectedJobType === 'data-frame-analytics' && ( + <> + {loadingDFAJobs === true ? ( + + ) : ( + <> + + + + + + + {dfaJobIds.map((id) => ( +
+ toggleSelectedJob(e.target.checked, id)} + /> + +
+ ))} + + )} + + )} + +
+ + + + + + + + + + + + + + + + + {switchTabConfirmVisible === true ? ( + + ) : null} + + )} + + ); +}; + +const FlyoutButton: FC<{ isDisabled: boolean; onClick(): void }> = ({ isDisabled, onClick }) => { + return ( + + + + ); +}; + +const LoadingSpinner: FC = () => ( + <> + + + + + + + +); + +const SwitchTabsConfirm: FC<{ onCancel: () => void; onConfirm: () => void }> = ({ + onCancel, + onConfirm, +}) => ( + +

+ +

+
+); diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/export_jobs_flyout/index.ts b/x-pack/plugins/ml/public/application/components/import_export_jobs/export_jobs_flyout/index.ts new file mode 100644 index 0000000000000..270da6f35c100 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/export_jobs_flyout/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ExportJobsFlyout } from './export_jobs_flyout'; diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/export_jobs_flyout/jobs_export_service.ts b/x-pack/plugins/ml/public/application/components/import_export_jobs/export_jobs_flyout/jobs_export_service.ts new file mode 100644 index 0000000000000..d44c59fff938e --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/export_jobs_flyout/jobs_export_service.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// @ts-expect-error +import { saveAs } from '@elastic/filesaver'; +import type { MlApiServices } from '../../../services/ml_api_service'; +import type { JobType } from '../../../../../common/types/saved_objects'; +import type { Job, Datafeed } from '../../../../../common/types/anomaly_detection_jobs'; +import type { DataFrameAnalyticsConfig } from '../../../../../common/types/data_frame_analytics'; + +type ExportableConfigs = + | Array< + | { + job?: Job; + datafeed?: Datafeed; + } + | undefined + > + | DataFrameAnalyticsConfig[]; + +export class JobsExportService { + constructor(private _mlApiServices: MlApiServices) {} + + public async exportAnomalyDetectionJobs(jobIds: string[]) { + const configs = await Promise.all(jobIds.map(this._mlApiServices.jobs.jobForCloning)); + this._export(configs, 'anomaly-detector'); + } + + public async exportDataframeAnalyticsJobs(jobIds: string[]) { + const { + data_frame_analytics: configs, + } = await this._mlApiServices.dataFrameAnalytics.getDataFrameAnalytics(jobIds.join(','), true); + this._export(configs, 'data-frame-analytics'); + } + + private _export(configs: ExportableConfigs, jobType: JobType) { + const configsForExport = configs.length === 1 ? configs[0] : configs; + const blob = new Blob([JSON.stringify(configsForExport, null, 2)], { + type: 'application/json', + }); + const fileName = this._createFileName(jobType); + saveAs(blob, fileName); + } + + private _createFileName(jobType: JobType) { + return ( + (jobType === 'anomaly-detector' ? 'anomaly_detection' : 'data_frame_analytics') + '_jobs.json' + ); + } +} diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/cannot_import_jobs_callout.tsx b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/cannot_import_jobs_callout.tsx new file mode 100644 index 0000000000000..7d573462a6c8f --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/cannot_import_jobs_callout.tsx @@ -0,0 +1,80 @@ +/* + * 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, { FC } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { EuiCallOut, EuiText, EuiAccordion, EuiSpacer } from '@elastic/eui'; +import type { SkippedJobs } from './jobs_import_service'; + +interface Props { + jobs: SkippedJobs[]; + autoExpand?: boolean; +} + +export const CannotImportJobsCallout: FC = ({ jobs, autoExpand = false }) => { + if (jobs.length === 0) { + return null; + } + + return ( + <> + + {autoExpand ? ( + + ) : ( + + } + > + + + )} + + + + + ); +}; + +const SkippedJobList: FC<{ jobs: SkippedJobs[] }> = ({ jobs }) => ( + <> + {jobs.length > 0 && ( + <> + {jobs.map(({ jobId, missingIndices }) => ( + +
{jobId}
+ +
+ ))} + + )} + +); diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/cannot_read_file_callout.tsx b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/cannot_read_file_callout.tsx new file mode 100644 index 0000000000000..4c7a2471db9d6 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/cannot_read_file_callout.tsx @@ -0,0 +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. + */ + +import React, { FC } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; + +export const CannotReadFileCallout: FC = () => { + return ( + <> + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/import_jobs_flyout.tsx b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/import_jobs_flyout.tsx new file mode 100644 index 0000000000000..c156a41150420 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/import_jobs_flyout.tsx @@ -0,0 +1,513 @@ +/* + * 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, { FC, useState, useEffect, useCallback, useMemo } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import useDebounce from 'react-use/lib/useDebounce'; +import { i18n } from '@kbn/i18n'; + +import { + EuiFlyout, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiButton, + EuiButtonIcon, + EuiFlyoutBody, + EuiTitle, + EuiText, + EuiFilePicker, + EuiSpacer, + EuiPanel, + EuiFormRow, + EuiFieldText, +} from '@elastic/eui'; + +import type { DataFrameAnalyticsConfig } from '../../../data_frame_analytics/common'; +import type { JobType } from '../../../../../common/types/saved_objects'; +import { useMlApiContext, useMlKibana } from '../../../contexts/kibana'; +import { CannotImportJobsCallout } from './cannot_import_jobs_callout'; +import { CannotReadFileCallout } from './cannot_read_file_callout'; +import { toastNotificationServiceProvider } from '../../../services/toast_notification_service'; +import { JobImportService } from './jobs_import_service'; +import { useValidateIds } from './validate'; +import type { ImportedAdJob, JobIdObject, SkippedJobs } from './jobs_import_service'; +import { ErrorType, extractErrorProperties } from '../../../../../common/util/errors'; + +interface Props { + isDisabled: boolean; +} +export const ImportJobsFlyout: FC = ({ isDisabled }) => { + const { + jobs: { bulkCreateJobs }, + dataFrameAnalytics: { createDataFrameAnalytics }, + } = useMlApiContext(); + const { + services: { + data: { + indexPatterns: { getTitles: getIndexPatternTitles }, + }, + notifications: { toasts }, + }, + } = useMlKibana(); + + const jobImportService = useMemo(() => new JobImportService(), []); + + const [showFlyout, setShowFlyout] = useState(false); + const [adJobs, setAdJobs] = useState([]); + const [dfaJobs, setDfaJobs] = useState([]); + const [jobIdObjects, setJobIdObjects] = useState([]); + const [skippedJobs, setSkippedJobs] = useState([]); + const [importing, setImporting] = useState(false); + const [jobType, setJobType] = useState(null); + const [totalJobsRead, setTotalJobsRead] = useState(0); + const [importDisabled, setImportDisabled] = useState(true); + const [deleteDisabled, setDeleteDisabled] = useState(true); + const [idsMash, setIdsMash] = useState(''); + const [validatingJobs, setValidatingJobs] = useState(false); + const [showFileReadError, setShowFileReadError] = useState(false); + const { displayErrorToast, displaySuccessToast } = useMemo( + () => toastNotificationServiceProvider(toasts), + [toasts] + ); + + const [validateIds] = useValidateIds( + jobType, + jobIdObjects, + idsMash, + setJobIdObjects, + setValidatingJobs + ); + useDebounce(validateIds, 400, [idsMash]); + + const reset = useCallback((showFileError = false) => { + setAdJobs([]); + setDfaJobs([]); + setJobIdObjects([]); + setIdsMash(''); + setImporting(false); + setJobType(null); + setTotalJobsRead(0); + setValidatingJobs(false); + setShowFileReadError(showFileError); + }, []); + + useEffect( + function onFlyoutChange() { + reset(); + }, + [showFlyout] + ); + + function toggleFlyout() { + setShowFlyout(!showFlyout); + } + + const onFilePickerChange = useCallback(async (files: any) => { + setShowFileReadError(false); + + if (files.length === 0) { + reset(); + return; + } + + try { + const loadedFile = await jobImportService.readJobConfigs(files[0]); + if (loadedFile.jobType === null) { + reset(true); + return; + } + + setTotalJobsRead(loadedFile.jobs.length); + + const validatedJobs = await jobImportService.validateJobs( + loadedFile.jobs, + loadedFile.jobType, + getIndexPatternTitles + ); + + if (loadedFile.jobType === 'anomaly-detector') { + const tempJobs = (loadedFile.jobs as ImportedAdJob[]).filter((j) => + validatedJobs.jobs.map(({ jobId }) => jobId).includes(j.job.job_id) + ); + setAdJobs(tempJobs); + } else if (loadedFile.jobType === 'data-frame-analytics') { + const tempJobs = (loadedFile.jobs as DataFrameAnalyticsConfig[]).filter((j) => + validatedJobs.jobs.map(({ jobId }) => jobId).includes(j.id) + ); + setDfaJobs(tempJobs); + } + + setJobType(loadedFile.jobType); + setJobIdObjects( + validatedJobs.jobs.map(({ jobId, destIndex }) => ({ + jobId, + originalId: jobId, + jobIdValid: true, + jobIdInvalidMessage: '', + jobIdValidated: false, + destIndex, + originalDestIndex: destIndex, + destIndexValid: true, + destIndexInvalidMessage: '', + destIndexValidated: false, + })) + ); + + const ids = createIdsMash(validatedJobs.jobs as JobIdObject[], loadedFile.jobType); + setIdsMash(ids); + setValidatingJobs(true); + setSkippedJobs(validatedJobs.skippedJobs); + } catch (error) { + displayErrorToast(error); + } + }, []); + + const onImport = useCallback(async () => { + setImporting(true); + if (jobType === 'anomaly-detector') { + const renamedJobs = jobImportService.renameAdJobs(jobIdObjects, adJobs); + try { + await bulkCreateADJobs(renamedJobs); + } catch (error) { + // display unexpected error + displayErrorToast(error); + } + } else if (jobType === 'data-frame-analytics') { + const renamedJobs = jobImportService.renameDfaJobs(jobIdObjects, dfaJobs); + await bulkCreateDfaJobs(renamedJobs); + } + + setImporting(false); + setShowFlyout(false); + }, [jobType, jobIdObjects, adJobs, dfaJobs]); + + const bulkCreateADJobs = useCallback(async (jobs: ImportedAdJob[]) => { + const results = await bulkCreateJobs(jobs); + let successCount = 0; + const errors: ErrorType[] = []; + Object.entries(results).forEach(([jobId, { job, datafeed }]) => { + if (job.error || datafeed.error) { + if (job.error) { + errors.push(job.error); + } + if (datafeed.error) { + errors.push(datafeed.error); + } + } else { + successCount++; + } + }); + + if (successCount > 0) { + displayImportSuccessToast(successCount); + } + if (errors.length > 0) { + displayImportErrorToast(errors); + } + }, []); + + const bulkCreateDfaJobs = useCallback(async (jobs: DataFrameAnalyticsConfig[]) => { + const errors: ErrorType[] = []; + const results = await Promise.all( + jobs.map(async ({ id, ...config }) => { + try { + return await createDataFrameAnalytics(id, config); + } catch (error) { + errors.push(error); + } + }) + ); + const successCount = Object.values(results).filter((job) => job !== undefined).length; + if (successCount > 0) { + displayImportSuccessToast(successCount); + } + if (errors.length > 0) { + displayImportErrorToast(errors); + } + }, []); + + const displayImportSuccessToast = useCallback((count: number) => { + const title = i18n.translate('xpack.ml.importExport.importFlyout.importJobSuccessToast', { + defaultMessage: '{count, plural, one {# job} other {# jobs}} successfully imported', + values: { count }, + }); + displaySuccessToast(title); + }, []); + + const displayImportErrorToast = useCallback((errors: ErrorType[]) => { + const title = i18n.translate('xpack.ml.importExport.importFlyout.importJobErrorToast', { + defaultMessage: '{count, plural, one {# job} other {# jobs}} failed to import correctly', + values: { count: errors.length }, + }); + + const errorList = errors.map(extractErrorProperties); + displayErrorToast((errorList as unknown) as ErrorType, title); + }, []); + + const deleteJob = useCallback( + (index: number) => { + if (jobType === 'anomaly-detector') { + const js = [...adJobs]; + js.splice(index, 1); + setAdJobs(js); + } else if (jobType === 'data-frame-analytics') { + const js = [...dfaJobs]; + js.splice(index, 1); + setDfaJobs(js); + } + const js = [...jobIdObjects]; + js.splice(index, 1); + setJobIdObjects(js); + + const ids = createIdsMash(js, jobType); + setIdsMash(ids); + setValidatingJobs(true); + }, + [jobIdObjects, adJobs, dfaJobs] + ); + + useEffect(() => { + const disabled = + jobIdObjects.length === 0 || + importing === true || + validatingJobs === true || + jobIdObjects.some( + ({ jobIdValid, destIndexValid }) => jobIdValid === false || destIndexValid === false + ); + setImportDisabled(disabled); + + setDeleteDisabled(importing === true || validatingJobs === true); + }, [jobIdObjects, idsMash, validatingJobs, importing]); + + const renameJob = useCallback( + (id: string, i: number) => { + jobIdObjects[i].jobId = id; + jobIdObjects[i].jobIdValid = false; + jobIdObjects[i].jobIdValidated = false; + setJobIdObjects([...jobIdObjects]); + + const ids = createIdsMash(jobIdObjects, jobType); + setIdsMash(ids); + setValidatingJobs(true); + }, + [jobIdObjects] + ); + + const renameDestIndex = useCallback( + (id: string, i: number) => { + jobIdObjects[i].destIndex = id; + jobIdObjects[i].destIndexValid = false; + jobIdObjects[i].destIndexValidated = false; + jobIdObjects[i].destIndexInvalidMessage = ''; + setJobIdObjects([...jobIdObjects]); + + const ids = createIdsMash(jobIdObjects, jobType); + setIdsMash(ids); + setValidatingJobs(true); + }, + [jobIdObjects] + ); + + const DeleteJobButton: FC<{ index: number }> = ({ index }) => ( + deleteJob(index)} + /> + ); + + return ( + <> + + + {showFlyout === true && isDisabled === false && ( + + + +

+ +

+
+
+ + <> + + + + + {showFileReadError ? : null} + + {totalJobsRead > 0 && jobType !== null && ( + <> + + {jobType === 'anomaly-detector' && ( + + )} + + {jobType === 'data-frame-analytics' && ( + + )} + + + + + + + + + {jobIdObjects.map((jobId, i) => ( +
+ + + + + renameJob(e.target.value, i)} + isInvalid={jobId.jobIdValid === false} + /> + + + {jobType === 'data-frame-analytics' && ( + + renameDestIndex(e.target.value, i)} + isInvalid={jobId.destIndexValid === false} + /> + + )} + + + + + + + +
+ ))} + + )} + +
+ + + + + + + + + + + + + + +
+ )} + + ); +}; + +const FlyoutButton: FC<{ isDisabled: boolean; onClick(): void }> = ({ isDisabled, onClick }) => { + return ( + + + + ); +}; + +function createIdsMash(jobIdObjects: JobIdObject[], jobType: JobType | null) { + return ( + jobIdObjects.map(({ jobId }) => jobId).join('') + + (jobType === 'data-frame-analytics' + ? jobIdObjects.map(({ destIndex }) => destIndex).join('') + : '') + ); +} diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/index.ts b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/index.ts new file mode 100644 index 0000000000000..873ba9573f46f --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ImportJobsFlyout } from './import_jobs_flyout'; diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/jobs_import_service.ts b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/jobs_import_service.ts new file mode 100644 index 0000000000000..85028426fa23d --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/jobs_import_service.ts @@ -0,0 +1,168 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { JobType } from '../../../../../common/types/saved_objects'; +import type { Job, Datafeed } from '../../../../../common/types/anomaly_detection_jobs'; +import type { DataFrameAnalyticsConfig } from '../../../data_frame_analytics/common'; + +export interface ImportedAdJob { + job: Job; + datafeed: Datafeed; +} + +export interface JobIdObject { + jobId: string; + originalId: string; + jobIdValid: boolean; + jobIdInvalidMessage: string; + + jobIdValidated: boolean; + + destIndex?: string; + originalDestIndex?: string; + destIndexValid: boolean; + destIndexInvalidMessage: string; + + destIndexValidated: boolean; +} + +export interface SkippedJobs { + jobId: string; + missingIndices: string[]; +} + +function isImportedAdJobs(obj: any): obj is ImportedAdJob[] { + return Array.isArray(obj) && obj.some((o) => o.job && o.datafeed); +} + +function isDataFrameAnalyticsConfigs(obj: any): obj is DataFrameAnalyticsConfig[] { + return Array.isArray(obj) && obj.some((o) => o.dest && o.analysis); +} + +export class JobImportService { + private _readFile(file: File) { + return new Promise((resolve, reject) => { + if (file && file.size) { + const reader = new FileReader(); + reader.readAsText(file); + + reader.onload = (() => { + return () => { + const data = reader.result; + if (typeof data === 'string') { + try { + const json = JSON.parse(data); + resolve(json); + } catch (error) { + reject(); + } + } else { + reject(); + } + }; + })(); + } else { + reject(); + } + }); + } + public async readJobConfigs( + file: File + ): Promise<{ + jobs: ImportedAdJob[] | DataFrameAnalyticsConfig[]; + jobIds: string[]; + jobType: JobType | null; + }> { + try { + const json = await this._readFile(file); + const jobs = Array.isArray(json) ? json : [json]; + + if (isImportedAdJobs(jobs)) { + const jobIds = jobs.map((j) => j.job.job_id); + return { jobs, jobIds, jobType: 'anomaly-detector' }; + } else if (isDataFrameAnalyticsConfigs(jobs)) { + const jobIds = jobs.map((j) => j.id); + return { jobs, jobIds, jobType: 'data-frame-analytics' }; + } else { + return { jobs: [], jobIds: [], jobType: null }; + } + } catch (error) { + return { jobs: [], jobIds: [], jobType: null }; + } + } + + public renameAdJobs(jobIds: JobIdObject[], jobs: ImportedAdJob[]) { + if (jobs.length !== jobs.length) { + return jobs; + } + + return jobs.map((j, i) => { + const { jobId } = jobIds[i]; + j.job.job_id = jobId; + j.datafeed.job_id = jobId; + j.datafeed.datafeed_id = `datafeed-${jobId}`; + return j; + }); + } + + public renameDfaJobs(jobIds: JobIdObject[], jobs: DataFrameAnalyticsConfig[]) { + if (jobs.length !== jobs.length) { + return jobs; + } + + return jobs.map((j, i) => { + const { jobId, destIndex } = jobIds[i]; + j.id = jobId; + if (destIndex !== undefined) { + j.dest.index = destIndex; + } + return j; + }); + } + + public async validateJobs( + jobs: ImportedAdJob[] | DataFrameAnalyticsConfig[], + type: JobType, + getIndexPatternTitles: (refresh?: boolean) => Promise + ) { + const existingIndexPatterns = new Set(await getIndexPatternTitles()); + const tempJobs: Array<{ jobId: string; destIndex?: string }> = []; + const tempSkippedJobIds: SkippedJobs[] = []; + + const commonJobs: Array<{ jobId: string; indices: string[]; destIndex?: string }> = + type === 'anomaly-detector' + ? (jobs as ImportedAdJob[]).map((j) => ({ + jobId: j.job.job_id, + indices: j.datafeed.indices, + })) + : (jobs as DataFrameAnalyticsConfig[]).map((j) => ({ + jobId: j.id, + destIndex: j.dest.index, + indices: Array.isArray(j.source.index) ? j.source.index : [j.source.index], + })); + + commonJobs.forEach(({ jobId, indices, destIndex }) => { + const missingIndices = indices.filter((i) => existingIndexPatterns.has(i) === false); + if (missingIndices.length === 0) { + tempJobs.push({ + jobId, + ...(type === 'data-frame-analytics' ? { destIndex } : {}), + }); + } else { + tempSkippedJobIds.push({ + jobId, + missingIndices, + }); + } + }); + + return { + jobs: tempJobs, + skippedJobs: tempSkippedJobIds, + }; + } +} diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/validate.ts b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/validate.ts new file mode 100644 index 0000000000000..4c8ebe4e017aa --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/import_jobs_flyout/validate.ts @@ -0,0 +1,168 @@ +/* + * 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 { useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { JobType } from '../../../../../common/types/saved_objects'; +import { isValidIndexName } from '../../../../../common/util/es_utils'; +import { isJobIdValid } from '../../../../../common/util/job_utils'; +import { JOB_ID_MAX_LENGTH } from '../../../../../common/constants/validation'; +import type { JobIdObject } from './jobs_import_service'; +import { useMlApiContext } from '../../../contexts/kibana'; + +export const useValidateIds = ( + jobType: JobType | null, + jobIdObjects: JobIdObject[], + idsMash: string, + setJobIdObjects: (j: JobIdObject[]) => void, + setValidatingJobs: (b: boolean) => void +) => { + const { + jobs: { jobsExist: adJobsExist }, + dataFrameAnalytics: { jobsExist: dfaJobsExist }, + checkIndicesExists, + } = useMlApiContext(); + + const validateIds = useCallback(async () => { + const jobIdExistsChecks: string[] = []; + const destIndexExistsChecks: string[] = []; + + const skipDestIndexCheck = jobType === 'anomaly-detector'; + + jobIdObjects + .filter(({ jobIdValidated }) => jobIdValidated === false) + .forEach((j) => { + j.jobIdValid = true; + j.jobIdInvalidMessage = ''; + + if (j.jobId === '') { + j.jobIdValid = false; + j.jobIdInvalidMessage = jobEmpty; + j.jobIdValidated = skipDestIndexCheck; + } else if (j.jobId.length > JOB_ID_MAX_LENGTH) { + j.jobIdValid = false; + j.jobIdInvalidMessage = jobInvalidLength; + j.jobIdValidated = skipDestIndexCheck; + } else if (isJobIdValid(j.jobId) === false) { + j.jobIdValid = false; + j.jobIdInvalidMessage = jobInvalid; + j.jobIdValidated = skipDestIndexCheck; + } + + if (j.jobIdValid === true) { + jobIdExistsChecks.push(j.jobId); + } + }); + + if (jobType === 'data-frame-analytics') { + jobIdObjects + .filter(({ destIndexValidated }) => destIndexValidated === false) + .forEach((j) => { + if (j.destIndex === undefined) { + return; + } + j.destIndexValid = true; + j.destIndexInvalidMessage = ''; + + if (j.destIndex === '') { + j.destIndexValid = false; + j.destIndexInvalidMessage = destIndexEmpty; + j.destIndexValidated = true; + } else if (isValidIndexName(j.destIndex) === false) { + j.destIndexValid = false; + j.destIndexInvalidMessage = destIndexInvalid; + j.destIndexValidated = true; + } + + if (j.destIndexValid === true) { + destIndexExistsChecks.push(j.destIndex); + } + }); + } + + if (jobType !== null) { + const jobsExist = jobType === 'anomaly-detector' ? adJobsExist : dfaJobsExist; + const resp = await jobsExist(jobIdExistsChecks, true); + jobIdObjects.forEach((j) => { + const jobResp = resp[j.jobId]; + if (jobResp) { + const { exists } = jobResp; + j.jobIdValid = !exists; + j.jobIdInvalidMessage = exists ? jobExists : ''; + j.jobIdValidated = true; + } + }); + + if (jobType === 'data-frame-analytics') { + const resp2 = await checkIndicesExists({ indices: destIndexExistsChecks }); + jobIdObjects.forEach((j) => { + if (j.destIndex !== undefined && j.destIndexValidated === false) { + const exists = resp2[j.destIndex]?.exists === true; + j.destIndexInvalidMessage = exists ? destIndexExists : ''; + j.destIndexValidated = true; + } + }); + } + + setJobIdObjects([...jobIdObjects]); + setValidatingJobs(false); + } + }, [idsMash, jobIdObjects]); + + return [validateIds]; +}; + +const jobEmpty = i18n.translate('xpack.ml.importExport.importFlyout.validateJobId.jobNameEmpty', { + defaultMessage: 'Enter a valid job ID', +}); +const jobInvalid = i18n.translate( + 'xpack.ml.importExport.importFlyout.validateJobId.jobNameAllowedCharacters', + { + defaultMessage: + 'Job ID can contain lowercase alphanumeric (a-z and 0-9), hyphens or underscores; ' + + 'must start and end with an alphanumeric character', + } +); +const jobInvalidLength = i18n.translate( + 'xpack.ml.importExport.importFlyout.validateJobId.jobIdInvalidMaxLengthErrorMessage', + { + defaultMessage: + 'Job ID must be no more than {maxLength, plural, one {# character} other {# characters}} long.', + values: { + maxLength: JOB_ID_MAX_LENGTH, + }, + } +); +const jobExists = i18n.translate( + 'xpack.ml.importExport.importFlyout.validateJobId.jobNameAlreadyExists', + { + defaultMessage: + 'Job ID already exists. A job ID cannot be the same as an existing job or group.', + } +); + +const destIndexEmpty = i18n.translate( + 'xpack.ml.importExport.importFlyout.validateDestIndex.destIndexEmpty', + { + defaultMessage: 'Enter a valid destination index', + } +); + +const destIndexInvalid = i18n.translate( + 'xpack.ml.importExport.importFlyout.validateDestIndex.destIndexInvalid', + { + defaultMessage: 'Invalid destination index name.', + } +); + +const destIndexExists = i18n.translate( + 'xpack.ml.importExport.importFlyout.validateDestIndex.destIndexExists', + { + defaultMessage: + 'An index with this name already exists. Be aware that running this analytics job will modify this destination index.', + } +); diff --git a/x-pack/plugins/ml/public/application/components/import_export_jobs/index.ts b/x-pack/plugins/ml/public/application/components/import_export_jobs/index.ts new file mode 100644 index 0000000000000..3e9e0db4ea1e4 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/import_export_jobs/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ImportJobsFlyout } from './import_jobs_flyout'; +export { ExportJobsFlyout } from './export_jobs_flyout'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx index 5b36a3a1ccb96..478f3b0056de1 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx @@ -56,8 +56,8 @@ export const CreateAnalyticsAdvancedEditor: FC = (prop () => debounce(async () => { try { - const { results } = await ml.dataFrameAnalytics.jobsExists([jobId], true); - setFormState({ jobIdExists: results[jobId] }); + const results = await ml.dataFrameAnalytics.jobsExist([jobId], true); + setFormState({ jobIdExists: results[jobId].exists }); } catch (e) { toasts.addDanger( i18n.translate( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx index 5129fbb64c0ec..746b02d934002 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx @@ -83,8 +83,8 @@ export const DetailsStepForm: FC = ({ const debouncedIndexCheck = debounce(async () => { try { - const { exists } = await ml.checkIndexExists({ index: destinationIndex }); - setFormState({ destinationIndexNameExists: exists }); + const resp = await ml.checkIndicesExists({ indices: [destinationIndex] }); + setFormState({ destinationIndexNameExists: resp[destinationIndex].exists }); } catch (e) { notifications.toasts.addDanger( i18n.translate('xpack.ml.dataframe.analytics.create.errorCheckingIndexExists', { @@ -99,8 +99,8 @@ export const DetailsStepForm: FC = ({ () => debounce(async () => { try { - const { results } = await ml.dataFrameAnalytics.jobsExists([jobId], true); - setFormState({ jobIdExists: results[jobId] }); + const results = await ml.dataFrameAnalytics.jobsExist([jobId], true); + setFormState({ jobIdExists: results[jobId].exists }); } catch (e) { notifications.toasts.addDanger( i18n.translate('xpack.ml.dataframe.analytics.create.errorCheckingJobIdExists', { diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index 77f0e6123dfad..6a76b1e207ec5 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -18,9 +18,12 @@ import { EuiSpacer, EuiTabbedContent, EuiTabbedContentTab, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import type { SpacesContextProps } from 'src/plugins/spaces_oss/public'; +import type { DataPublicPluginStart } from 'src/plugins/data/public'; import { PLUGIN_ID } from '../../../../../../common/constants/app'; import { ManagementAppMountParams } from '../../../../../../../../../src/plugins/management/public/'; @@ -43,6 +46,8 @@ import { getDefaultAnomalyDetectionJobsListState } from '../../../../jobs/jobs_l import { getMlGlobalServices } from '../../../../app'; import { ListingPageUrlState } from '../../../../../../common/types/common'; import { getDefaultDFAListState } from '../../../../data_frame_analytics/pages/analytics_management/page'; +import { ExportJobsFlyout, ImportJobsFlyout } from '../../../../components/import_export_jobs'; +import { JobType } from '../../../../../../common/types/saved_objects'; interface Tab extends EuiTabbedContentTab { 'data-test-subj': string; @@ -76,7 +81,7 @@ function useTabs(isMlEnabledInSpace: boolean, spacesApi: SpacesPluginStart | und () => [ { 'data-test-subj': 'mlStackManagementJobsListAnomalyDetectionTab', - id: 'anomaly_detection_jobs', + id: 'anomaly-detector', name: i18n.translate('xpack.ml.management.jobsList.anomalyDetectionTab', { defaultMessage: 'Anomaly detection', }), @@ -95,7 +100,7 @@ function useTabs(isMlEnabledInSpace: boolean, spacesApi: SpacesPluginStart | und }, { 'data-test-subj': 'mlStackManagementJobsListAnalyticsTab', - id: 'analytics_jobs', + id: 'data-frame-analytics', name: i18n.translate('xpack.ml.management.jobsList.analyticsTab', { defaultMessage: 'Analytics', }), @@ -122,7 +127,8 @@ export const JobsListPage: FC<{ share: SharePluginStart; history: ManagementAppMountParams['history']; spacesApi?: SpacesPluginStart; -}> = ({ coreStart, share, history, spacesApi }) => { + data: DataPublicPluginStart; +}> = ({ coreStart, share, history, spacesApi, data }) => { const spacesEnabled = spacesApi !== undefined; const [initialized, setInitialized] = useState(false); const [accessDenied, setAccessDenied] = useState(false); @@ -130,7 +136,7 @@ export const JobsListPage: FC<{ const [showSyncFlyout, setShowSyncFlyout] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); const tabs = useTabs(isMlEnabledInSpace, spacesApi); - const [currentTabId, setCurrentTabId] = useState(tabs[0].id); + const [currentTabId, setCurrentTabId] = useState('anomaly-detector'); const I18nContext = coreStart.i18n.Context; const check = async () => { @@ -175,14 +181,12 @@ export const JobsListPage: FC<{ const docsLink = ( - {currentTabId === 'anomaly_detection_jobs' ? anomalyDetectionDocsLabel : analyticsDocsLabel} + {currentTabId === 'anomaly-detector' ? anomalyDetectionDocsLabel : analyticsDocsLabel} ); @@ -190,7 +194,7 @@ export const JobsListPage: FC<{ return ( { - setCurrentTabId(id); + setCurrentTabId(id as JobType); }} size="s" tabs={tabs} @@ -215,7 +219,7 @@ export const JobsListPage: FC<{ @@ -242,17 +246,27 @@ export const JobsListPage: FC<{ id="kibanaManagementMLSection" data-test-subj="mlPageStackManagementJobsList" > - {spacesEnabled && ( - <> - setShowSyncFlyout(true)}> - {i18n.translate('xpack.ml.management.jobsList.syncFlyoutButton', { - defaultMessage: 'Synchronize saved objects', - })} - - {showSyncFlyout && } - - - )} + + + {spacesEnabled && ( + <> + setShowSyncFlyout(true)}> + {i18n.translate('xpack.ml.management.jobsList.syncFlyoutButton', { + defaultMessage: 'Synchronize saved objects', + })} + + {showSyncFlyout && } + + + )} + + + + + + + + {renderTabs()} diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts index dde543ac6ac9c..039653af0d095 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/index.ts +++ b/x-pack/plugins/ml/public/application/management/jobs_list/index.ts @@ -8,6 +8,7 @@ import ReactDOM, { unmountComponentAtNode } from 'react-dom'; import React from 'react'; import { CoreSetup, CoreStart } from 'kibana/public'; +import type { DataPublicPluginStart } from 'src/plugins/data/public'; import { ManagementAppMountParams } from '../../../../../../../src/plugins/management/public/'; import { MlStartDependencies } from '../../../plugin'; import { JobsListPage } from './components'; @@ -22,10 +23,11 @@ const renderApp = ( history: ManagementAppMountParams['history'], coreStart: CoreStart, share: SharePluginStart, + data: DataPublicPluginStart, spacesApi?: SpacesPluginStart ) => { ReactDOM.render( - React.createElement(JobsListPage, { coreStart, history, share, spacesApi }), + React.createElement(JobsListPage, { coreStart, history, share, data, spacesApi }), element ); return () => { @@ -53,6 +55,7 @@ export async function mountApp( params.history, coreStart, pluginsStart.share, + pluginsStart.data, pluginsStart.spaces ); } diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts index 39662cfedd901..8aba633970a78 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts @@ -50,9 +50,7 @@ export interface DeleteDataFrameAnalyticsWithIndexResponse { } export interface JobsExistsResponse { - results: { - [jobId: string]: boolean; - }; + [jobId: string]: { exists: boolean }; } export const dataFrameAnalytics = { @@ -108,7 +106,7 @@ export const dataFrameAnalytics = { query: { treatAsRoot, type }, }); }, - jobsExists(analyticsIds: string[], allSpaces: boolean = false) { + jobsExist(analyticsIds: string[], allSpaces: boolean = false) { const body = JSON.stringify({ analyticsIds, allSpaces }); return http({ path: `${basePath()}/data_frame/analytics/jobs_exist`, diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index 1bfc597ba0b10..81a86e5a7f980 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -366,10 +366,10 @@ export function mlApiServicesProvider(httpService: HttpService) { }); }, - checkIndexExists({ index }: { index: string }) { - const body = JSON.stringify({ index }); + checkIndicesExists({ indices }: { indices: string[] }) { + const body = JSON.stringify({ indices }); - return httpService.http<{ exists: boolean }>({ + return httpService.http>({ path: `${basePath()}/index_exists`, method: 'POST', body, diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts index 5695e3d830890..a982b78d59914 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/jobs.ts @@ -27,7 +27,7 @@ import type { } from '../../../../common/types/categories'; import { CATEGORY_EXAMPLES_VALIDATION_STATUS } from '../../../../common/constants/categorization_job'; import type { Category } from '../../../../common/types/categories'; -import type { JobsExistResponse } from '../../../../common/types/job_service'; +import type { JobsExistResponse, BulkCreateResults } from '../../../../common/types/job_service'; import { ML_BASE_PATH } from '../../../../common/constants/app'; export const jobsApiProvider = (httpService: HttpService) => ({ @@ -364,4 +364,13 @@ export const jobsApiProvider = (httpService: HttpService) => ({ body, }); }, + + bulkCreateJobs(jobs: { job: Job; datafeed: Datafeed } | Array<{ job: Job; datafeed: Datafeed }>) { + const body = JSON.stringify(jobs); + return httpService.http({ + path: `${ML_BASE_PATH}/jobs/bulk_create`, + method: 'POST', + body, + }); + }, }); diff --git a/x-pack/plugins/ml/server/lib/request_authorization.ts b/x-pack/plugins/ml/server/lib/request_authorization.ts index 4aaeb7f611573..873d8a068ace0 100644 --- a/x-pack/plugins/ml/server/lib/request_authorization.ts +++ b/x-pack/plugins/ml/server/lib/request_authorization.ts @@ -7,7 +7,11 @@ import { KibanaRequest } from 'kibana/server'; -export function getAuthorizationHeader(request: KibanaRequest) { +export interface AuthorizationHeader { + headers?: { 'es-secondary-authorization': string | string[] }; +} + +export function getAuthorizationHeader(request: KibanaRequest): AuthorizationHeader { return request.headers.authorization === undefined ? {} : { diff --git a/x-pack/plugins/ml/server/models/job_service/jobs.ts b/x-pack/plugins/ml/server/models/job_service/jobs.ts index 6732c8fe7e2f1..ee336c96a9c0d 100644 --- a/x-pack/plugins/ml/server/models/job_service/jobs.ts +++ b/x-pack/plugins/ml/server/models/job_service/jobs.ts @@ -26,6 +26,7 @@ import { MlJobsResponse, MlJobsStatsResponse, JobsExistResponse, + BulkCreateResults, } from '../../../common/types/job_service'; import { GLOBAL_CALENDAR } from '../../../common/constants/calendars'; import { datafeedsProvider, MlDatafeedsResponse, MlDatafeedsStatsResponse } from './datafeeds'; @@ -43,6 +44,7 @@ import { isPopulatedObject } from '../../../common/util/object_utils'; import type { RulesClient } from '../../../../alerting/server'; import { ML_ALERT_TYPES } from '../../../common/constants/alerts'; import { MlAnomalyDetectionAlertParams } from '../../routes/schemas/alerting_schema'; +import type { AuthorizationHeader } from '../../lib/request_authorization'; interface Results { [id: string]: { @@ -576,6 +578,37 @@ export function jobsProvider( return job.node === undefined && job.state === JOB_STATE.OPENING; } + async function bulkCreate( + jobs: Array<{ job: Job; datafeed: Datafeed }>, + authHeader: AuthorizationHeader + ) { + const results: BulkCreateResults = {}; + await Promise.all( + jobs.map(async ({ job, datafeed }) => { + results[job.job_id] = { job: { success: false }, datafeed: { success: false } }; + + try { + await mlClient.putJob({ job_id: job.job_id, body: job }); + results[job.job_id].job = { success: true }; + } catch (error) { + results[job.job_id].job = { success: false, error: error.body ?? error }; + } + + try { + await mlClient.putDatafeed( + { datafeed_id: datafeed.datafeed_id, body: datafeed }, + authHeader + ); + results[job.job_id].datafeed = { success: true }; + } catch (error) { + results[job.job_id].datafeed = { success: false, error: error.body ?? error }; + } + }) + ); + + return results; + } + return { forceDeleteJob, deleteJobs, @@ -589,5 +622,6 @@ export function jobsProvider( jobsExist, getAllJobAndGroupIds, getLookBackProgress, + bulkCreate, }; } diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index 27944b542b93f..346bf510c6c0c 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -91,6 +91,7 @@ "DeletingJobTasks", "DeleteJobs", "RevertModelSnapshot", + "BulkCreateJobs", "Calendars", "PutCalendars", diff --git a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts index 2ed4fd6fcd31a..bedc70566a62f 100644 --- a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts +++ b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts @@ -623,7 +623,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense, routeGuard }: Rout routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => { try { const { analyticsIds, allSpaces } = request.body; - const results: { [id: string]: boolean } = {}; + const results: { [id: string]: { exists: boolean } } = {}; for (const id of analyticsIds) { try { const { body } = allSpaces @@ -633,17 +633,17 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense, routeGuard }: Rout : await mlClient.getDataFrameAnalytics({ id, }); - results[id] = body.data_frame_analytics.length > 0; + results[id] = { exists: body.data_frame_analytics.length > 0 }; } catch (error) { if (error.statusCode !== 404) { throw error; } - results[id] = false; + results[id] = { exists: false }; } } return response.ok({ - body: { results }, + body: results, }); } catch (e) { return response.customError(wrapError(e)); diff --git a/x-pack/plugins/ml/server/routes/job_service.ts b/x-pack/plugins/ml/server/routes/job_service.ts index 023997a35a6a6..63310827ad989 100644 --- a/x-pack/plugins/ml/server/routes/job_service.ts +++ b/x-pack/plugins/ml/server/routes/job_service.ts @@ -16,7 +16,7 @@ import { datafeedIdsSchema, forceStartDatafeedSchema, jobIdsSchema, - optionaljobIdsSchema, + optionalJobIdsSchema, jobsWithTimerangeSchema, lookBackProgressSchema, topCategoriesSchema, @@ -24,6 +24,7 @@ import { revertModelSnapshotSchema, jobsExistSchema, datafeedPreviewSchema, + bulkCreateSchema, } from './schemas/job_service_schema'; import { jobIdSchema } from './schemas/anomaly_detectors_schema'; @@ -31,6 +32,7 @@ import { jobIdSchema } from './schemas/anomaly_detectors_schema'; import { jobServiceProvider } from '../models/job_service'; import { categorizationExamplesProvider } from '../models/job_service/new_job'; import { getAuthorizationHeader } from '../lib/request_authorization'; +import { Datafeed, Job } from '../../common/types/anomaly_detection_jobs'; /** * Routes for job service @@ -215,7 +217,7 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { * For any supplied job IDs, full job information will be returned, which include the analysis configuration, * job stats, datafeed stats, and calendars. * - * @apiSchema (body) optionaljobIdsSchema + * @apiSchema (body) optionalJobIdsSchema * * @apiSuccess {Array} jobsList list of jobs. For any supplied job IDs, the job object will contain a fullJob property * which includes the full configuration and stats for the job. @@ -224,7 +226,7 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { { path: '/api/ml/jobs/jobs_summary', validate: { - body: optionaljobIdsSchema, + body: optionalJobIdsSchema, }, options: { tags: ['access:ml:canGetJobs'], @@ -323,13 +325,13 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { * @apiName CreateFullJobsList * @apiDescription Creates a list of jobs * - * @apiSchema (body) optionaljobIdsSchema + * @apiSchema (body) optionalJobIdsSchema */ router.post( { path: '/api/ml/jobs/jobs', validate: { - body: optionaljobIdsSchema, + body: optionalJobIdsSchema, }, options: { tags: ['access:ml:canGetJobs'], @@ -878,4 +880,42 @@ export function jobServiceRoutes({ router, routeGuard }: RouteInitialization) { } }) ); + + /** + * @apiGroup JobService + * + * @api {post} /api/ml/jobs/bulk_create Bulk create jobs and datafeeds + * @apiName BulkCreateJobs + * @apiDescription Bulk create jobs and datafeeds. + * + * @apiSchema (body) bulkCreateSchema + */ + router.post( + { + path: '/api/ml/jobs/bulk_create', + validate: { + body: bulkCreateSchema, + }, + options: { + tags: ['access:ml:canPreviewDatafeed'], + }, + }, + routeGuard.fullLicenseAPIGuard(async ({ client, mlClient, request, response }) => { + try { + const bulkJobs = request.body; + + const { bulkCreate } = jobServiceProvider(client, mlClient); + const jobs = (Array.isArray(bulkJobs) ? bulkJobs : [bulkJobs]) as Array<{ + job: Job; + datafeed: Datafeed; + }>; + const body = await bulkCreate(jobs, getAuthorizationHeader(request)); + return response.ok({ + body, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); } diff --git a/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts b/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts index df91dea101c7c..655350d367652 100644 --- a/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/job_service_schema.ts @@ -66,7 +66,7 @@ export const jobIdsSchema = schema.object({ jobIds: schema.arrayOf(schema.string()), }); -export const optionaljobIdsSchema = schema.object({ +export const optionalJobIdsSchema = schema.object({ /** Optional list of job IDs. */ jobIds: schema.maybe(schema.arrayOf(schema.string())), }); @@ -140,3 +140,16 @@ export const jobsExistSchema = schema.object({ jobIds: schema.arrayOf(schema.string()), allSpaces: schema.maybe(schema.boolean()), }); + +export const bulkCreateSchema = schema.oneOf([ + schema.arrayOf( + schema.object({ + job: schema.object(anomalyDetectionJobSchema), + datafeed: datafeedConfigSchema, + }) + ), + schema.object({ + job: schema.object(anomalyDetectionJobSchema), + datafeed: datafeedConfigSchema, + }), +]); diff --git a/x-pack/plugins/ml/server/routes/system.ts b/x-pack/plugins/ml/server/routes/system.ts index 79e579e30ed95..726d4d080ec19 100644 --- a/x-pack/plugins/ml/server/routes/system.ts +++ b/x-pack/plugins/ml/server/routes/system.ts @@ -215,7 +215,7 @@ export function systemRoutes( { path: '/api/ml/index_exists', validate: { - body: schema.object({ index: schema.string() }), + body: schema.object({ indices: schema.arrayOf(schema.string()) }), }, options: { tags: ['access:ml:canAccessML'], @@ -223,21 +223,21 @@ export function systemRoutes( }, routeGuard.basicLicenseAPIGuard(async ({ client, request, response }) => { try { - const { index } = request.body; + const { indices } = request.body; const options = { - index: [index], + index: indices, fields: ['*'], ignore_unavailable: true, allow_no_indices: true, }; const { body } = await client.asCurrentUser.fieldCaps(options); - const result = { exists: false }; - if (Array.isArray(body.indices) && body.indices.length !== 0) { - result.exists = true; - } + const result = indices.reduce((acc, cur) => { + acc[cur] = { exists: body.indices.includes(cur) }; + return acc; + }, {} as Record); return response.ok({ body: result, From 5b0d679c6044ff9bac1cf413ecd4aeba24faf11e Mon Sep 17 00:00:00 2001 From: "Joey F. Poon" Date: Thu, 22 Jul 2021 10:04:49 -0500 Subject: [PATCH 12/45] [Security Solution] fix metadata api tests (#106340) --- .../endpoint/metadata/api_feature/data.json | 36 +++++++++---------- .../metadata/destination_index/data.json | 12 +++---- .../apps/endpoint/endpoint_list.ts | 10 +++--- .../apis/metadata.ts | 7 ++-- 4 files changed, 32 insertions(+), 33 deletions(-) diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json b/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json index 30b4e19dcb1d1..b3d33f5d45345 100644 --- a/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json +++ b/x-pack/test/functional/es_archives/endpoint/metadata/api_feature/data.json @@ -4,7 +4,7 @@ "id": "3KVN2G8BYQH1gtPUuYk7", "index": "metrics-endpoint.metadata-default", "source": { - "@timestamp": 1618841405309, + "@timestamp": 1626897841950, "agent": { "id": "963b081e-60d1-482c-befd-a5815fa8290f", "version": "6.6.1", @@ -26,7 +26,7 @@ } }, "event": { - "created": 1618841405309, + "created": 1626897841950, "id": "32f5fda2-48e4-4fae-b89e-a18038294d14", "kind": "metric", "category": [ @@ -74,7 +74,7 @@ "id": "3aVN2G8BYQH1gtPUuYk7", "index": "metrics-endpoint.metadata-default", "source": { - "@timestamp": 1618841405309, + "@timestamp": 1626897841950, "agent": { "id": "b3412d6f-b022-4448-8fee-21cc936ea86b", "version": "6.0.0", @@ -96,7 +96,7 @@ } }, "event": { - "created": 1618841405309, + "created": 1626897841950, "id": "32f5fda2-48e4-4fae-b89e-a18038294d15", "kind": "metric", "category": [ @@ -143,7 +143,7 @@ "id": "3qVN2G8BYQH1gtPUuYk7", "index": "metrics-endpoint.metadata-default", "source": { - "@timestamp": 1618841405309, + "@timestamp": 1626897841950, "agent": { "id": "3838df35-a095-4af4-8fce-0b6d78793f2e", "version": "6.8.0", @@ -165,7 +165,7 @@ } }, "event": { - "created": 1618841405309, + "created": 1626897841950, "id": "32f5fda2-48e4-4fae-b89e-a18038294d16", "kind": "metric", "category": [ @@ -210,7 +210,7 @@ "id": "36VN2G8BYQH1gtPUuYk7", "index": "metrics-endpoint.metadata-default", "source": { - "@timestamp": 1618841405309, + "@timestamp": 1626897841950, "agent": { "id": "963b081e-60d1-482c-befd-a5815fa8290f", "version": "6.6.1", @@ -232,7 +232,7 @@ } }, "event": { - "created": 1618841405309, + "created": 1626897841950, "id": "32f5fda2-48e4-4fae-b89e-a18038294d18", "kind": "metric", "category": [ @@ -280,7 +280,7 @@ "id": "4KVN2G8BYQH1gtPUuYk7", "index": "metrics-endpoint.metadata-default", "source": { - "@timestamp": 1618841405309, + "@timestamp": 1626897841950, "agent": { "id": "b3412d6f-b022-4448-8fee-21cc936ea86b", "version": "6.0.0", @@ -302,7 +302,7 @@ } }, "event": { - "created": 1618841405309, + "created": 1626897841950, "id": "32f5fda2-48e4-4fae-b89e-a18038294d19", "kind": "metric", "category": [ @@ -348,7 +348,7 @@ "id": "4aVN2G8BYQH1gtPUuYk7", "index": "metrics-endpoint.metadata-default", "source": { - "@timestamp": 1618841405309, + "@timestamp": 1626897841950, "agent": { "id": "3838df35-a095-4af4-8fce-0b6d78793f2e", "version": "6.8.0", @@ -370,7 +370,7 @@ } }, "event": { - "created": 1618841405309, + "created": 1626897841950, "id": "32f5fda2-48e4-4fae-b89e-a18038294d39", "kind": "metric", "category": [ @@ -416,7 +416,7 @@ "id": "4qVN2G8BYQH1gtPUuYk7", "index": "metrics-endpoint.metadata-default", "source": { - "@timestamp": 1618841405309, + "@timestamp": 1626897841950, "agent": { "id": "963b081e-60d1-482c-befd-a5815fa8290f", "version": "6.6.1", @@ -438,7 +438,7 @@ } }, "event": { - "created": 1618841405309, + "created": 1626897841950, "id": "32f5fda2-48e4-4fae-b89e-a18038294d31", "kind": "metric", "category": [ @@ -485,7 +485,7 @@ "id": "46VN2G8BYQH1gtPUuYk7", "index": "metrics-endpoint.metadata-default", "source": { - "@timestamp": 1618841405309, + "@timestamp": 1626897841950, "agent": { "id": "b3412d6f-b022-4448-8fee-21cc936ea86b", "version": "6.0.0", @@ -507,7 +507,7 @@ } }, "event": { - "created": 1618841405309, + "created": 1626897841950, "id": "32f5fda2-48e4-4fae-b89e-a18038294d23", "kind": "metric", "category": [ @@ -553,7 +553,7 @@ "id": "5KVN2G8BYQH1gtPUuYk7", "index": "metrics-endpoint.metadata-default", "source": { - "@timestamp": 1618841405309, + "@timestamp": 1626897841950, "agent": { "id": "3838df35-a095-4af4-8fce-0b6d78793f2e", "version": "6.8.0", @@ -575,7 +575,7 @@ } }, "event": { - "created": 1618841405309, + "created": 1626897841950, "id": "32f5fda2-48e4-4fae-b89e-a18038294d35", "kind": "metric", "category": [ diff --git a/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json b/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json index 22f4afcf99d4d..b8994a05ea5cc 100644 --- a/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json +++ b/x-pack/test/functional/es_archives/endpoint/metadata/destination_index/data.json @@ -4,7 +4,7 @@ "id": "M92ScEJT9M9QusfIi3hpEb0AAAAAAAAA", "index": "metrics-endpoint.metadata_current_default", "source": { - "@timestamp": 1618841405309, + "@timestamp": 1626897841950, "Endpoint": { "policy": { "applied": { @@ -36,7 +36,7 @@ "category": [ "host" ], - "created": 1618841405309, + "created": 1626897841950, "dataset": "endpoint.metadata", "id": "32f5fda2-48e4-4fae-b89e-a18038294d16", "ingested": "2020-09-09T18:25:15.853783Z", @@ -78,7 +78,7 @@ "id": "OU3RgCJaNnR90byeDEHutp8AAAAAAAAA", "index": "metrics-endpoint.metadata_current_default", "source": { - "@timestamp": 1618841405309, + "@timestamp": 1626897841950, "Endpoint": { "policy": { "applied": { @@ -110,7 +110,7 @@ "category": [ "host" ], - "created": 1618841405309, + "created": 1626897841950, "dataset": "endpoint.metadata", "id": "32f5fda2-48e4-4fae-b89e-a18038294d14", "ingested": "2020-09-09T18:25:14.919526Z", @@ -155,7 +155,7 @@ "id": "YjqDCEuI6JmLeLOSyZx_NhMAAAAAAAAA", "index": "metrics-endpoint.metadata_current_default", "source": { - "@timestamp": 1618841405309, + "@timestamp": 1626897841950, "Endpoint": { "policy": { "applied": { @@ -187,7 +187,7 @@ "category": [ "host" ], - "created": 1618841405309, + "created": 1626897841950, "dataset": "endpoint.metadata", "id": "32f5fda2-48e4-4fae-b89e-a18038294d15", "ingested": "2020-09-09T18:25:15.853404Z", diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts index 1a5158adbd695..f61edf2895797 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts @@ -38,7 +38,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'windows 10.0', '10.101.149.26, 2606:a000:ffc0:39:11ef:37b9:3371:578c', '6.8.0', - 'Apr 19, 2021 @ 14:10:05.309', + 'Jul 21, 2021 @ 20:04:01.950', '', ], [ @@ -49,7 +49,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'windows 10.0', '10.192.213.130, 10.70.28.129', '6.6.1', - 'Apr 19, 2021 @ 14:10:05.309', + 'Jul 21, 2021 @ 20:04:01.950', '', ], [ @@ -60,7 +60,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'windows 10.0', '10.46.229.234', '6.0.0', - 'Apr 19, 2021 @ 14:10:05.309', + 'Jul 21, 2021 @ 20:04:01.950', '', ], ]; @@ -281,7 +281,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'windows 10.0', '10.192.213.130, 10.70.28.129', '6.6.1', - 'Apr 19, 2021 @ 14:10:05.309', + 'Jul 21, 2021 @ 20:04:01.950', '', ], [ @@ -292,7 +292,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'windows 10.0', '10.46.229.234', '6.0.0', - 'Apr 19, 2021 @ 14:10:05.309', + 'Jul 21, 2021 @ 20:04:01.950', '', ], ]; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index 4656edc7edfb4..cadb9a420708a 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -23,8 +23,7 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertest = getService('supertest'); - // Failing: See https://github.com/elastic/kibana/issues/106051 - describe.skip('test metadata api', () => { + describe('test metadata api', () => { describe(`POST ${HOST_METADATA_LIST_ROUTE} when index is empty`, () => { it('metadata api should return empty result when index is empty', async () => { await deleteMetadataStream(getService); @@ -222,7 +221,7 @@ export default function ({ getService }: FtrProviderContext) { (ip: string) => ip === targetEndpointIp ); expect(resultIp).to.eql([targetEndpointIp]); - expect(body.hosts[0].metadata.event.created).to.eql(1618841405309); + expect(body.hosts[0].metadata.event.created).to.eql(1626897841950); expect(body.hosts.length).to.eql(1); expect(body.request_page_size).to.eql(10); expect(body.request_page_index).to.eql(0); @@ -264,7 +263,7 @@ export default function ({ getService }: FtrProviderContext) { const resultElasticAgentId: string = body.hosts[0].metadata.elastic.agent.id; expect(resultHostId).to.eql(targetEndpointId); expect(resultElasticAgentId).to.eql(targetElasticAgentId); - expect(body.hosts[0].metadata.event.created).to.eql(1618841405309); + expect(body.hosts[0].metadata.event.created).to.eql(1626897841950); expect(body.hosts[0].host_status).to.eql('unhealthy'); expect(body.hosts.length).to.eql(1); expect(body.request_page_size).to.eql(10); From 745db3063a3763f6b704a0bf48d1f3093dba6161 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Thu, 22 Jul 2021 16:11:32 +0100 Subject: [PATCH 13/45] [Security Solution] Flyout overview hover actions (#106362) * flyout-overview * integrate with hover actions * fix types * fix types * move TopN into a popover * fix types * fix up * update field width * fix unit tests * fix agent status field --- .../event_details/alert_summary_view.tsx | 135 ++++++++++-------- .../components/event_details/helpers.tsx | 12 +- .../event_details/table/action_cell.tsx | 8 +- .../event_details/table/field_value_cell.tsx | 8 +- .../table/use_action_cell_data_provider.ts | 2 +- .../hover_actions/actions/show_top_n.tsx | 32 ++--- .../common/components/hover_actions/index.tsx | 8 +- 7 files changed, 114 insertions(+), 91 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx index 841aa6840cc0b..501ef78d550f9 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.tsx @@ -6,13 +6,11 @@ */ import { EuiBasicTableColumn, EuiSpacer, EuiHorizontalRule, EuiTitle, EuiText } from '@elastic/eui'; -import { get, getOr, find } from 'lodash/fp'; +import { get, getOr, find, isEmpty } from 'lodash/fp'; import React, { useMemo } from 'react'; import styled from 'styled-components'; import * as i18n from './translations'; -import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field'; -import { TimelineEventsDetailsItem } from '../../../../common/search_strategy'; import { BrowserFields } from '../../../../common/search_strategy/index_fields'; import { ALERTS_HEADERS_RISK_SCORE, @@ -25,6 +23,7 @@ import { TIMESTAMP, } from '../../../detections/components/alerts_table/translations'; import { + AGENT_STATUS_FIELD_NAME, IP_FIELD_TYPE, SIGNAL_RULE_NAME_FIELD_NAME, } from '../../../timelines/components/timeline/body/renderers/constants'; @@ -35,12 +34,21 @@ import { useRuleWithFallback } from '../../../detections/containers/detection_en import { MarkdownRenderer } from '../markdown_editor'; import { LineClamp } from '../line_clamp'; import { endpointAlertCheck } from '../../utils/endpoint_alert_check'; +import { getEmptyValue } from '../empty_value'; +import { ActionCell } from './table/action_cell'; +import { FieldValueCell } from './table/field_value_cell'; +import { TimelineEventsDetailsItem } from '../../../../common'; +import { EventFieldsData } from './types'; export const Indent = styled.div` padding: 0 8px; word-break: break-word; `; +const StyledEmptyComponent = styled.div` + padding: ${(props) => `${props.theme.eui.paddingSizes.xs} 0`}; +`; + const fields = [ { id: 'signal.status', label: SIGNAL_STATUS }, { id: '@timestamp', label: TIMESTAMP }, @@ -52,7 +60,7 @@ const fields = [ { id: 'signal.rule.severity', label: ALERTS_HEADERS_SEVERITY }, { id: 'signal.rule.risk_score', label: ALERTS_HEADERS_RISK_SCORE }, { id: 'host.name' }, - { id: 'agent.status' }, + { id: 'agent.id', overrideField: AGENT_STATUS_FIELD_NAME, label: i18n.AGENT_STATUS }, { id: 'user.name' }, { id: SOURCE_IP_FIELD_NAME, fieldType: IP_FIELD_TYPE }, { id: DESTINATION_IP_FIELD_NAME, fieldType: IP_FIELD_TYPE }, @@ -76,22 +84,43 @@ const networkFields = [ ]; const getDescription = ({ - contextId, + data, eventId, - fieldName, - value, - fieldType = '', + fieldFromBrowserField, linkValue, -}: AlertSummaryRow['description']) => ( - -); + timelineId, + values, +}: AlertSummaryRow['description']) => { + if (isEmpty(values)) { + return {getEmptyValue()}; + } + + const eventFieldsData = { + ...data, + ...(fieldFromBrowserField ? fieldFromBrowserField : {}), + } as EventFieldsData; + return ( + <> + + + + ); +}; const getSummaryRows = ({ data, @@ -120,25 +149,45 @@ const getSummaryRows = ({ return data != null ? tableFields.reduce((acc, item) => { + const initialDescription = { + contextId: timelineId, + eventId, + value: null, + fieldType: 'string', + linkValue: undefined, + timelineId, + }; const field = data.find((d) => d.field === item.id); if (!field) { - return acc; + return [ + ...acc, + { + title: item.label ?? item.id, + description: initialDescription, + }, + ]; } + const linkValueField = item.linkField != null && data.find((d) => d.field === item.linkField); const linkValue = getOr(null, 'originalValue.0', linkValueField); const value = getOr(null, 'originalValue.0', field); - const category = field.category; - const fieldType = get(`${category}.fields.${field.field}.type`, browserFields) as string; + const category = field.category ?? ''; + const fieldName = field.field ?? ''; + + const browserField = get([category, 'fields', fieldName], browserFields); const description = { - contextId: timelineId, - eventId, - fieldName: item.id, - value, - fieldType: item.fieldType ?? fieldType, + ...initialDescription, + data: { ...field, ...(item.overrideField ? { field: item.overrideField } : {}) }, + values: field.values, linkValue: linkValue ?? undefined, + fieldFromBrowserField: browserField, }; + if (item.id === 'agent.id' && !endpointAlertCheck({ data })) { + return acc; + } + if (item.id === 'signal.threshold_result.terms') { try { const terms = getOr(null, 'originalValue', field); @@ -149,14 +198,14 @@ const getSummaryRows = ({ title: `${entry.field} [threshold]`, description: { ...description, - value: entry.value, + values: [entry.value], }, }; } ); return [...acc, ...thresholdTerms]; } catch (err) { - return acc; + return [...acc]; } } @@ -169,7 +218,7 @@ const getSummaryRows = ({ title: ALERTS_HEADERS_THRESHOLD_CARDINALITY, description: { ...description, - value: `count(${parsedValue.field}) == ${parsedValue.value}`, + values: [`count(${parsedValue.field}) == ${parsedValue.value}`], }, }, ]; @@ -205,28 +254,6 @@ const AlertSummaryViewComponent: React.FC<{ timelineId, ]); - const isEndpointAlert = useMemo(() => { - return endpointAlertCheck({ data }); - }, [data]); - - const endpointId = useMemo(() => { - const findAgentId = find({ category: 'agent', field: 'agent.id' }, data)?.values; - return findAgentId ? findAgentId[0] : ''; - }, [data]); - - const agentStatusRow = { - title: i18n.AGENT_STATUS, - description: { - contextId: timelineId, - eventId, - fieldName: 'agent.status', - value: endpointId, - linkValue: undefined, - }, - }; - - const summaryRowsWithAgentStatus = [...summaryRows, agentStatusRow]; - const ruleId = useMemo(() => { const item = data.find((d) => d.field === 'signal.rule.id'); return Array.isArray(item?.originalValue) @@ -238,11 +265,7 @@ const AlertSummaryViewComponent: React.FC<{ return ( <> - + {maybeRule?.note && ( <> diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx index 2b300789c4d14..ecfa243f89246 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx @@ -23,7 +23,7 @@ import { } from '../../../timelines/components/timeline/body/constants'; import * as i18n from './translations'; -import { ColumnHeaderOptions } from '../../../../common'; +import { ColumnHeaderOptions, TimelineEventsDetailsItem } from '../../../../common'; /** * Defines the behavior of the search input that appears above the table of data @@ -55,12 +55,12 @@ export interface Item { export interface AlertSummaryRow { title: string; description: { - contextId: string; + data: TimelineEventsDetailsItem; eventId: string; - fieldName: string; - value: string; - fieldType: string; + fieldFromBrowserField?: Readonly>>; linkValue: string | undefined; + timelineId: string; + values: string[] | null | undefined; }; } @@ -213,7 +213,7 @@ export const getSummaryColumns = ( field: 'title', truncateText: false, render: getTitle, - width: '160px', + width: '33%', name: '', }, { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/action_cell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/action_cell.tsx index 795ecb266b092..f5cf600e281ad 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/action_cell.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/action_cell.tsx @@ -19,8 +19,9 @@ interface Props { data: EventFieldsData; disabled?: boolean; eventId: string; - fieldFromBrowserField: Readonly>>; - getLinkValue: (field: string) => string | null; + fieldFromBrowserField?: Readonly>>; + getLinkValue?: (field: string) => string | null; + linkValue?: string | null | undefined; onFilterAdded?: () => void; timelineId?: string; toggleColumn?: (column: ColumnHeaderOptions) => void; @@ -34,6 +35,7 @@ export const ActionCell: React.FC = React.memo( eventId, fieldFromBrowserField, getLinkValue, + linkValue, onFilterAdded, timelineId, toggleColumn, @@ -47,7 +49,7 @@ export const ActionCell: React.FC = React.memo( fieldFromBrowserField, fieldType: data.type, isObjectArray: data.isObjectArray, - linkValue: getLinkValue(data.field), + linkValue: (getLinkValue && getLinkValue(data.field)) ?? linkValue, values, }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx index b6524a8c9c895..2ac0ca23ca8c1 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/field_value_cell.tsx @@ -17,8 +17,9 @@ export interface FieldValueCellProps { contextId: string; data: EventFieldsData; eventId: string; - fieldFromBrowserField: Readonly>>; - getLinkValue: (field: string) => string | null; + fieldFromBrowserField?: Readonly>>; + getLinkValue?: (field: string) => string | null; + linkValue?: string | null | undefined; values: string[] | null | undefined; } @@ -29,6 +30,7 @@ export const FieldValueCell = React.memo( eventId, fieldFromBrowserField, getLinkValue, + linkValue, values, }: FieldValueCellProps) => { return ( @@ -55,7 +57,7 @@ export const FieldValueCell = React.memo( fieldType={data.type} isObjectArray={data.isObjectArray} value={value} - linkValue={getLinkValue(data.field)} + linkValue={(getLinkValue && getLinkValue(data.field)) ?? linkValue} /> )} diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts b/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts index e580ae6c1fdef..fbe9767759d28 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts @@ -31,7 +31,7 @@ export interface UseActionCellDataProvider { eventId?: string; field: string; fieldFormat?: string; - fieldFromBrowserField: Readonly>>; + fieldFromBrowserField?: Readonly>>; fieldType?: string; isObjectArray?: boolean; linkValue?: string | null; diff --git a/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx b/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx index 6e284289243f0..0fc8a74084521 100644 --- a/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx +++ b/x-pack/plugins/security_solution/public/common/components/hover_actions/actions/show_top_n.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiButtonIcon, EuiPopover, EuiToolTip } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { StatefulTopN } from '../../top_n'; @@ -44,15 +44,18 @@ export const ShowTopNButton: React.FC = React.memo( ? SourcererScopeName.detections : SourcererScopeName.default; const { browserFields, indexPattern } = useSourcererScope(activeScope); - const button = ( - + const button = useMemo( + () => ( + + ), + [field, onClick] ); return showTopN ? ( @@ -80,14 +83,7 @@ export const ShowTopNButton: React.FC = React.memo( /> } > - + {button} ); } diff --git a/x-pack/plugins/security_solution/public/common/components/hover_actions/index.tsx b/x-pack/plugins/security_solution/public/common/components/hover_actions/index.tsx index a7fdb26a525fb..31bdf78626e7c 100644 --- a/x-pack/plugins/security_solution/public/common/components/hover_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/hover_actions/index.tsx @@ -39,7 +39,7 @@ export const AdditionalContent = styled.div` AdditionalContent.displayName = 'AdditionalContent'; const StyledHoverActionsContainer = styled.div<{ $showTopN: boolean }>` - padding: ${(props) => (props.$showTopN ? 'none' : `0 ${props.theme.eui.paddingSizes.s}`)}; + padding: ${(props) => `0 ${props.theme.eui.paddingSizes.s}`}; display: flex; &:focus-within { @@ -58,7 +58,7 @@ const StyledHoverActionsContainer = styled.div<{ $showTopN: boolean }>` .timelines__hoverActionButton, .securitySolution__hoverActionButton { - opacity: 0; + opacity: ${(props) => (props.$showTopN ? 1 : 0)}; &:focus { opacity: 1; @@ -268,7 +268,7 @@ export const HoverActions: React.FC = React.memo( ] ); - const showFilters = !showTopN && values != null; + const showFilters = values != null; return ( @@ -342,7 +342,7 @@ export const HoverActions: React.FC = React.memo( value={values} /> )} - {!showTopN && ( + {showFilters && ( Date: Thu, 22 Jul 2021 10:18:19 -0500 Subject: [PATCH 14/45] Revert "[Security solution] [Endpoint] Unify subtitle text in flyout and modal for event filters (#106401)" This reverts commit 7f758731ae3125f93393cad2403d6ea45f3c0b72. --- .../view/components/form/index.tsx | 18 ++++++++++-------- .../view/components/form/translations.ts | 7 +++++++ .../view/event_filters_list_page.tsx | 7 +++++-- .../pages/event_filters/view/translations.ts | 6 ------ 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx index 29723a5fd3cf8..db5c42241a0cc 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx @@ -31,10 +31,16 @@ import { ExceptionBuilder } from '../../../../../../shared_imports'; import { useEventFiltersSelector } from '../../hooks'; import { getFormEntryStateMutable, getHasNameError, getNewComment } from '../../../store/selector'; -import { NAME_LABEL, NAME_ERROR, NAME_PLACEHOLDER, OS_LABEL, RULE_NAME } from './translations'; +import { + FORM_DESCRIPTION, + NAME_LABEL, + NAME_ERROR, + NAME_PLACEHOLDER, + OS_LABEL, + RULE_NAME, +} from './translations'; import { OS_TITLES } from '../../../../../common/translations'; import { ENDPOINT_EVENT_FILTERS_LIST_ID, EVENT_FILTER_LIST_TYPE } from '../../../constants'; -import { ABOUT_EVENT_FILTERS } from '../../translations'; const OPERATING_SYSTEMS: readonly OperatingSystem[] = [ OperatingSystem.MAC, @@ -199,12 +205,8 @@ export const EventFiltersForm: React.FC = memo( return !isIndexPatternLoading && exception ? ( - {!exception || !exception.item_id ? ( - - {ABOUT_EVENT_FILTERS} - - - ) : null} + {FORM_DESCRIPTION} + {nameInputMemo} {allowSelectOs ? ( diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts index bfb828699118e..7391251a936e6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts @@ -7,6 +7,13 @@ import { i18n } from '@kbn/i18n'; +export const FORM_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.eventFilter.modal.description', + { + defaultMessage: "Events are filtered when the rule's conditions are met:", + } +); + export const NAME_PLACEHOLDER = i18n.translate( 'xpack.securitySolution.eventFilter.form.name.placeholder', { diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx index 95f3e856a6ff6..2d608bdc6e157 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx @@ -44,7 +44,6 @@ import { EventFilterDeleteModal } from './components/event_filter_delete_modal'; import { SearchBar } from '../../../components/search_bar'; import { BackToExternalAppButton } from '../../../components/back_to_external_app_button'; -import { ABOUT_EVENT_FILTERS } from './translations'; type EventListPaginatedContent = PaginatedContentProps< Immutable, @@ -196,7 +195,11 @@ export const EventFiltersListPage = memo(() => { defaultMessage="Event Filters" /> } - subtitle={ABOUT_EVENT_FILTERS} + subtitle={i18n.translate('xpack.securitySolution.eventFilters.aboutInfo', { + defaultMessage: + 'Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch. Event ' + + 'filters are processed by the Endpoint Security integration, and are applied to hosts running this integration on their agents.', + })} actions={ doesDataExist && ( { values: { error: getError.message }, }); }; - -export const ABOUT_EVENT_FILTERS = i18n.translate('xpack.securitySolution.eventFilters.aboutInfo', { - defaultMessage: - 'Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch. Event ' + - 'filters are processed by the Endpoint Security integration, and are applied to hosts running this integration on their agents.', -}); From 10ef0e9e3e8bcdd767f4c3c02d21225d11e374cc Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 22 Jul 2021 17:30:41 +0200 Subject: [PATCH 15/45] [ML] Alerting rule for Anomaly Detection jobs monitoring (#106084) * [ML] init job health alerting rule type * [ML] add health checks selection ui * [ML] define schema * [ML] support all jobs selection * [ML] jobs health service * [ML] add logger * [ML] add context message * [ML] fix default message for i18n * [ML] check response size * [ML] add exclude jobs control * [ML] getResultJobsHealthRuleConfig * [ML] change naming for shared services * [ML] fix excluded jobs filtering * [ML] check for execution results * [ML] update context fields * [ML] unit tests for getResultJobsHealthRuleConfig * [ML] refactor and job ids check * [ML] rename datafeed * [ML] fix translation messages * [ML] hide non-implemented tests * [ML] remove jod ids join from the getJobs call * [ML] add validation for the tests config * [ML] fix excluded jobs udpate * [ML] update jobIdsDescription message * [ML] allow selection all jobs only for include * [ML] better ux for excluded jobs setup * [ML] change rule type name * [ML] fix typo * [ML] change instances names * [ML] fix messages * [ML] hide error callout, show health checks error in EuiFormRow * [ML] add check for job state * [ML] add alertingRules key to the doc links * [ML] update types * [ML] remove redundant type * [ML] fix job and datafeed states check * [ML] fix job and datafeed states check, add comments * [ML] add unit tests --- .../public/doc_links/doc_links_service.ts | 1 + x-pack/plugins/ml/common/constants/alerts.ts | 42 +--- x-pack/plugins/ml/common/types/alerts.ts | 35 ++++ x-pack/plugins/ml/common/util/alerts.test.ts | 52 ++++- x-pack/plugins/ml/common/util/alerts.ts | 25 +++ .../ml/public/alerting/job_selector.tsx | 80 ++++++-- ...aly_detection_jobs_health_rule_trigger.tsx | 148 ++++++++++++++ .../public/alerting/jobs_health_rule/index.ts | 8 + .../register_jobs_health_alerting_rule.ts | 69 +++++++ .../tests_selection_control.tsx | 125 ++++++++++++ .../ml/public/alerting/register_ml_alerts.ts | 5 +- .../ml/server/lib/alerts/alerting_service.ts | 4 +- .../lib/alerts/jobs_health_service.test.ts | 180 +++++++++++++++++ .../server/lib/alerts/jobs_health_service.ts | 185 ++++++++++++++++++ .../register_anomaly_detection_alert_type.ts | 32 +-- .../register_jobs_monitoring_rule_type.ts | 109 +++++++++++ .../server/lib/alerts/register_ml_alerts.ts | 4 + x-pack/plugins/ml/server/plugin.ts | 7 +- .../server/routes/schemas/alerting_schema.ts | 76 +++++-- .../server/shared_services/shared_services.ts | 34 +++- 20 files changed, 1135 insertions(+), 86 deletions(-) create mode 100644 x-pack/plugins/ml/public/alerting/jobs_health_rule/anomaly_detection_jobs_health_rule_trigger.tsx create mode 100644 x-pack/plugins/ml/public/alerting/jobs_health_rule/index.ts create mode 100644 x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts create mode 100644 x-pack/plugins/ml/public/alerting/jobs_health_rule/tests_selection_control.tsx create mode 100644 x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts create mode 100644 x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts create mode 100644 x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index e8453d009e720..7152c7eb3cb1b 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -242,6 +242,7 @@ export class DocLinksService { anomalyDetectionJobResource: `${ELASTICSEARCH_DOCS}ml-put-job.html#ml-put-job-path-parms`, anomalyDetectionJobResourceAnalysisConfig: `${ELASTICSEARCH_DOCS}ml-put-job.html#put-analysisconfig`, anomalyDetectionJobTips: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-finding-anomalies.html#ml-ad-job-tips`, + alertingRules: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-configuring-alerts.html`, anomalyDetectionModelMemoryLimits: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-finding-anomalies.html#ml-ad-model-memory-limits`, calendars: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-ad-finding-anomalies.html#ml-ad-calendars`, classificationEvaluation: `${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfa-classification.html#ml-dfanalytics-classification-evaluation`, diff --git a/x-pack/plugins/ml/common/constants/alerts.ts b/x-pack/plugins/ml/common/constants/alerts.ts index 30daf0d45c3ac..604d5ea8c4fc9 100644 --- a/x-pack/plugins/ml/common/constants/alerts.ts +++ b/x-pack/plugins/ml/common/constants/alerts.ts @@ -6,46 +6,22 @@ */ import { i18n } from '@kbn/i18n'; -import { ActionGroup } from '../../../alerting/common'; -import { MINIMUM_FULL_LICENSE } from '../license'; -import { PLUGIN_ID } from './app'; export const ML_ALERT_TYPES = { ANOMALY_DETECTION: 'xpack.ml.anomaly_detection_alert', + AD_JOBS_HEALTH: 'xpack.ml.anomaly_detection_jobs_health', } as const; export type MlAlertType = typeof ML_ALERT_TYPES[keyof typeof ML_ALERT_TYPES]; -export const ANOMALY_SCORE_MATCH_GROUP_ID = 'anomaly_score_match'; -export type AnomalyScoreMatchGroupId = typeof ANOMALY_SCORE_MATCH_GROUP_ID; -export const THRESHOLD_MET_GROUP: ActionGroup = { - id: ANOMALY_SCORE_MATCH_GROUP_ID, - name: i18n.translate('xpack.ml.anomalyDetectionAlert.actionGroupName', { - defaultMessage: 'Anomaly score matched the condition', - }), -}; - -export const ML_ALERT_TYPES_CONFIG: Record< - MlAlertType, - { - name: string; - actionGroups: Array>; - defaultActionGroupId: AnomalyScoreMatchGroupId; - minimumLicenseRequired: string; - producer: string; - } -> = { - [ML_ALERT_TYPES.ANOMALY_DETECTION]: { - name: i18n.translate('xpack.ml.anomalyDetectionAlert.name', { - defaultMessage: 'Anomaly detection alert', - }), - actionGroups: [THRESHOLD_MET_GROUP], - defaultActionGroupId: ANOMALY_SCORE_MATCH_GROUP_ID, - minimumLicenseRequired: MINIMUM_FULL_LICENSE, - producer: PLUGIN_ID, - }, -}; - export const ALERT_PREVIEW_SAMPLE_SIZE = 5; export const TOP_N_BUCKETS_COUNT = 1; + +export const ALL_JOBS_SELECTION = '*'; + +export const HEALTH_CHECK_NAMES = { + datafeed: i18n.translate('xpack.ml.alertTypes.jobsHealthAlertingRule.datafeedCheckName', { + defaultMessage: 'Datafeed is not started', + }), +}; diff --git a/x-pack/plugins/ml/common/types/alerts.ts b/x-pack/plugins/ml/common/types/alerts.ts index 1677a766544a1..877bb2d293365 100644 --- a/x-pack/plugins/ml/common/types/alerts.ts +++ b/x-pack/plugins/ml/common/types/alerts.ts @@ -108,3 +108,38 @@ export type MlAnomalyDetectionAlertRule = Omit; diff --git a/x-pack/plugins/ml/common/util/alerts.test.ts b/x-pack/plugins/ml/common/util/alerts.test.ts index d9896c967165b..430e10cc8ffa8 100644 --- a/x-pack/plugins/ml/common/util/alerts.test.ts +++ b/x-pack/plugins/ml/common/util/alerts.test.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { getLookbackInterval, resolveLookbackInterval } from './alerts'; +import { + getLookbackInterval, + getResultJobsHealthRuleConfig, + resolveLookbackInterval, +} from './alerts'; import type { CombinedJobWithStats, Datafeed, Job } from '../types/anomaly_detection_jobs'; describe('resolveLookbackInterval', () => { @@ -76,3 +80,49 @@ describe('getLookbackInterval', () => { expect(getLookbackInterval(testJobs)).toBe('32m'); }); }); + +describe('getResultJobsHealthRuleConfig', () => { + test('returns default config for empty configuration', () => { + expect(getResultJobsHealthRuleConfig(null)).toEqual({ + datafeed: { + enabled: true, + }, + mml: { + enabled: true, + }, + delayedData: { + enabled: true, + }, + behindRealtime: { + enabled: true, + }, + errorMessages: { + enabled: true, + }, + }); + }); + test('returns config with overridden values based on provided configuration', () => { + expect( + getResultJobsHealthRuleConfig({ + mml: { enabled: false }, + errorMessages: { enabled: true }, + }) + ).toEqual({ + datafeed: { + enabled: true, + }, + mml: { + enabled: false, + }, + delayedData: { + enabled: true, + }, + behindRealtime: { + enabled: true, + }, + errorMessages: { + enabled: true, + }, + }); + }); +}); diff --git a/x-pack/plugins/ml/common/util/alerts.ts b/x-pack/plugins/ml/common/util/alerts.ts index 5d68677d4fb97..b211423e65062 100644 --- a/x-pack/plugins/ml/common/util/alerts.ts +++ b/x-pack/plugins/ml/common/util/alerts.ts @@ -9,6 +9,7 @@ import { CombinedJobWithStats, Datafeed, Job } from '../types/anomaly_detection_ import { resolveMaxTimeInterval } from './job_utils'; import { isDefined } from '../types/guards'; import { parseInterval } from './parse_interval'; +import { JobsHealthRuleTestsConfig } from '../types/alerts'; const narrowBucketLength = 60; @@ -51,3 +52,27 @@ export function getTopNBuckets(job: Job): number { return Math.ceil(narrowBucketLength / bucketSpan.asSeconds()); } + +/** + * Returns tests configuration combined with default values. + * @param config + */ +export function getResultJobsHealthRuleConfig(config: JobsHealthRuleTestsConfig) { + return { + datafeed: { + enabled: config?.datafeed?.enabled ?? true, + }, + mml: { + enabled: config?.mml?.enabled ?? true, + }, + delayedData: { + enabled: config?.delayedData?.enabled ?? true, + }, + behindRealtime: { + enabled: config?.behindRealtime?.enabled ?? true, + }, + errorMessages: { + enabled: config?.errorMessages?.enabled ?? true, + }, + }; +} diff --git a/x-pack/plugins/ml/public/alerting/job_selector.tsx b/x-pack/plugins/ml/public/alerting/job_selector.tsx index d00d4efc25b8d..0ef7bba0ddbc5 100644 --- a/x-pack/plugins/ml/public/alerting/job_selector.tsx +++ b/x-pack/plugins/ml/public/alerting/job_selector.tsx @@ -5,12 +5,13 @@ * 2.0. */ -import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { FC, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiComboBox, EuiComboBoxOptionOption, EuiComboBoxProps, EuiFormRow } from '@elastic/eui'; import { JobId } from '../../common/types/anomaly_detection_jobs'; import { MlApiServices } from '../application/services/ml_api_service'; +import { ALL_JOBS_SELECTION } from '../../common/constants/alerts'; interface JobSelection { jobIds?: JobId[]; @@ -25,6 +26,17 @@ export interface JobSelectorControlProps { * Validation is handled by alerting framework */ errors: string[]; + /** Enables multiple selection of jobs and groups */ + multiSelect?: boolean; + label?: ReactNode; + /** + * Allows selecting all jobs, even those created afterward. + */ + allowSelectAll?: boolean; + /** + * Available options to select. By default suggest all existing jobs. + */ + options?: Array>; } export const JobSelectorControl: FC = ({ @@ -32,6 +44,10 @@ export const JobSelectorControl: FC = ({ onChange, adJobsApiService, errors, + multiSelect = false, + label, + allowSelectAll = false, + options: defaultOptions, }) => { const [options, setOptions] = useState>>([]); const jobIds = useMemo(() => new Set(), []); @@ -60,12 +76,39 @@ export const JobSelectorControl: FC = ({ }); setOptions([ + ...(allowSelectAll + ? [ + { + label: i18n.translate('xpack.ml.jobSelector.selectAllGroupLabel', { + defaultMessage: 'Select all', + }), + options: [ + { + label: i18n.translate('xpack.ml.jobSelector.selectAllOptionLabel', { + defaultMessage: '*', + }), + value: ALL_JOBS_SELECTION, + }, + ], + }, + ] + : []), { label: i18n.translate('xpack.ml.jobSelector.jobOptionsLabel', { defaultMessage: 'Jobs', }), options: jobIdOptions.map((v) => ({ label: v })), }, + ...(multiSelect + ? [ + { + label: i18n.translate('xpack.ml.jobSelector.groupOptionsLabel', { + defaultMessage: 'Groups', + }), + options: groupIdOptions.map((v) => ({ label: v })), + }, + ] + : []), ]); } catch (e) { // TODO add error handling @@ -73,25 +116,33 @@ export const JobSelectorControl: FC = ({ }, [adJobsApiService]); const onSelectionChange: EuiComboBoxProps['onChange'] = useCallback( - (selectionUpdate) => { + ((selectionUpdate) => { + if (selectionUpdate.some((selectedOption) => selectedOption.value === ALL_JOBS_SELECTION)) { + onChange({ jobIds: [ALL_JOBS_SELECTION] }); + return; + } + const selectedJobIds: JobId[] = []; const selectedGroupIds: string[] = []; - selectionUpdate.forEach(({ label }: { label: string }) => { - if (jobIds.has(label)) { - selectedJobIds.push(label); - } else if (groupIds.has(label)) { - selectedGroupIds.push(label); + selectionUpdate.forEach(({ label: selectedLabel }: { label: string }) => { + if (jobIds.has(selectedLabel)) { + selectedJobIds.push(selectedLabel); + } else if (groupIds.has(selectedLabel)) { + selectedGroupIds.push(selectedLabel); + } else if (defaultOptions?.some((v) => v.options?.some((o) => o.label === selectedLabel))) { + selectedJobIds.push(selectedLabel); } }); onChange({ ...(selectedJobIds.length > 0 ? { jobIds: selectedJobIds } : {}), ...(selectedGroupIds.length > 0 ? { groupIds: selectedGroupIds } : {}), }); - }, - [jobIds, groupIds] + }) as Exclude['onChange'], undefined>, + [jobIds, groupIds, defaultOptions] ); useEffect(() => { + if (defaultOptions) return; fetchOptions(); }, []); @@ -99,15 +150,20 @@ export const JobSelectorControl: FC = ({ + label ?? ( + + ) } isInvalid={!!errors?.length} error={errors} > - singleSelection + singleSelection={!multiSelect} selectedOptions={selectedOptions} - options={options} + options={defaultOptions ?? options} onChange={onSelectionChange} fullWidth data-test-subj={'mlAnomalyAlertJobSelection'} diff --git a/x-pack/plugins/ml/public/alerting/jobs_health_rule/anomaly_detection_jobs_health_rule_trigger.tsx b/x-pack/plugins/ml/public/alerting/jobs_health_rule/anomaly_detection_jobs_health_rule_trigger.tsx new file mode 100644 index 0000000000000..7c75817e4029f --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/jobs_health_rule/anomaly_detection_jobs_health_rule_trigger.tsx @@ -0,0 +1,148 @@ +/* + * 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, { FC, useCallback, useMemo, useState } from 'react'; +import { EuiComboBoxOptionOption, EuiForm, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import useDebounce from 'react-use/lib/useDebounce'; +import { AlertTypeParamsExpressionProps } from '../../../../triggers_actions_ui/public'; +import { MlAnomalyDetectionJobsHealthRuleParams } from '../../../common/types/alerts'; +import { JobSelectorControl } from '../job_selector'; +import { jobsApiProvider } from '../../application/services/ml_api_service/jobs'; +import { HttpService } from '../../application/services/http_service'; +import { useMlKibana } from '../../application/contexts/kibana'; +import { TestsSelectionControl } from './tests_selection_control'; +import { isPopulatedObject } from '../../../common'; +import { ALL_JOBS_SELECTION } from '../../../common/constants/alerts'; + +export type MlAnomalyAlertTriggerProps = AlertTypeParamsExpressionProps; + +const AnomalyDetectionJobsHealthRuleTrigger: FC = ({ + alertParams, + setAlertParams, + errors, +}) => { + const { + services: { http }, + } = useMlKibana(); + const mlHttpService = useMemo(() => new HttpService(http), [http]); + const adJobsApiService = useMemo(() => jobsApiProvider(mlHttpService), [mlHttpService]); + const [excludeJobsOptions, setExcludeJobsOptions] = useState< + Array> + >([]); + + const includeJobsAndGroupIds: string[] = useMemo( + () => (Object.values(alertParams.includeJobs ?? {}) as string[][]).flat(), + [alertParams.includeJobs] + ); + + const excludeJobsAndGroupIds: string[] = useMemo( + () => (Object.values(alertParams.excludeJobs ?? {}) as string[][]).flat(), + [alertParams.excludeJobs] + ); + + const onAlertParamChange = useCallback( + (param: T) => ( + update: MlAnomalyDetectionJobsHealthRuleParams[T] + ) => { + setAlertParams(param, update); + }, + [] + ); + + const formErrors = Object.values(errors).flat(); + const isFormInvalid = formErrors.length > 0; + + useDebounce( + function updateExcludeJobsOptions() { + const areAllJobsSelected = alertParams.includeJobs?.jobIds?.[0] === ALL_JOBS_SELECTION; + + if (!areAllJobsSelected && !alertParams.includeJobs?.groupIds?.length) { + // It only makes sense to suggest excluded jobs options when at least one group or all jobs are selected + setExcludeJobsOptions([]); + return; + } + + adJobsApiService + .jobs(areAllJobsSelected ? [] : (alertParams.includeJobs.groupIds as string[])) + .then((jobs) => { + setExcludeJobsOptions([ + { + label: i18n.translate('xpack.ml.jobSelector.jobOptionsLabel', { + defaultMessage: 'Jobs', + }), + options: jobs.map((v) => ({ label: v.job_id })), + }, + ]); + }); + }, + 500, + [alertParams.includeJobs] + ); + + return ( + + + } + /> + + + + { + const callback = onAlertParamChange('excludeJobs'); + if (isPopulatedObject(update)) { + callback(update); + } else { + callback(null); + } + }, [])} + errors={Array.isArray(errors.excludeJobs) ? errors.excludeJobs : []} + multiSelect + label={ + + } + options={excludeJobsOptions} + /> + + + + + + ); +}; + +// Default export is required for React.lazy loading + +// eslint-disable-next-line import/no-default-export +export default AnomalyDetectionJobsHealthRuleTrigger; diff --git a/x-pack/plugins/ml/public/alerting/jobs_health_rule/index.ts b/x-pack/plugins/ml/public/alerting/jobs_health_rule/index.ts new file mode 100644 index 0000000000000..f26b38a1370ec --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/jobs_health_rule/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { registerJobsHealthAlertingRule } from './register_jobs_health_alerting_rule'; diff --git a/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts b/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts new file mode 100644 index 0000000000000..ef20b51df2600 --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/jobs_health_rule/register_jobs_health_alerting_rule.ts @@ -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 { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import { TriggersAndActionsUIPublicPluginSetup } from '../../../../triggers_actions_ui/public'; +import { PluginSetupContract as AlertingSetup } from '../../../../alerting/public'; +import { ML_ALERT_TYPES } from '../../../common/constants/alerts'; +import { MlAnomalyDetectionJobsHealthRuleParams } from '../../../common/types/alerts'; + +export function registerJobsHealthAlertingRule( + triggersActionsUi: TriggersAndActionsUIPublicPluginSetup, + alerting?: AlertingSetup +) { + triggersActionsUi.alertTypeRegistry.register({ + id: ML_ALERT_TYPES.AD_JOBS_HEALTH, + description: i18n.translate('xpack.ml.alertTypes.jobsHealthAlertingRule.description', { + defaultMessage: 'Alert when anomaly detection jobs experience operational issues.', + }), + iconClass: 'bell', + documentationUrl(docLinks) { + return docLinks.links.ml.alertingRules; + }, + alertParamsExpression: lazy(() => import('./anomaly_detection_jobs_health_rule_trigger')), + validate: (alertParams: MlAnomalyDetectionJobsHealthRuleParams) => { + const validationResult = { + errors: { + includeJobs: new Array(), + testsConfig: new Array(), + } as Record, + }; + + if (!alertParams.includeJobs?.jobIds?.length && !alertParams.includeJobs?.groupIds?.length) { + validationResult.errors.includeJobs.push( + i18n.translate('xpack.ml.alertTypes.jobsHealthAlertingRule.includeJobs.errorMessage', { + defaultMessage: 'Job selection is required', + }) + ); + } + + if ( + alertParams.testsConfig && + Object.values(alertParams.testsConfig).every((v) => v?.enabled === false) + ) { + validationResult.errors.testsConfig.push( + i18n.translate('xpack.ml.alertTypes.jobsHealthAlertingRule.testsConfig.errorMessage', { + defaultMessage: 'At least one health check must be enabled.', + }) + ); + } + + return validationResult; + }, + requiresAppContext: false, + defaultActionMessage: i18n.translate( + 'xpack.ml.alertTypes.jobsHealthAlertingRule.defaultActionMessage', + { + defaultMessage: `Anomaly detection jobs health check result: +\\{\\{context.message\\}\\} +- Job IDs: \\{\\{context.jobIds\\}\\} +`, + } + ), + }); +} diff --git a/x-pack/plugins/ml/public/alerting/jobs_health_rule/tests_selection_control.tsx b/x-pack/plugins/ml/public/alerting/jobs_health_rule/tests_selection_control.tsx new file mode 100644 index 0000000000000..8c033fe141222 --- /dev/null +++ b/x-pack/plugins/ml/public/alerting/jobs_health_rule/tests_selection_control.tsx @@ -0,0 +1,125 @@ +/* + * 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, { FC, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFormFieldset, EuiFormRow, EuiSpacer, EuiSwitch } from '@elastic/eui'; +import { JobsHealthRuleTestsConfig } from '../../../common/types/alerts'; +import { getResultJobsHealthRuleConfig } from '../../../common/util/alerts'; +import { HEALTH_CHECK_NAMES } from '../../../common/constants/alerts'; + +interface TestsSelectionControlProps { + config: JobsHealthRuleTestsConfig; + onChange: (update: JobsHealthRuleTestsConfig) => void; + errors?: string[]; +} + +export const TestsSelectionControl: FC = ({ + config, + onChange, + errors, +}) => { + const uiConfig = getResultJobsHealthRuleConfig(config); + + const updateCallback = useCallback( + (update: Partial>) => { + onChange({ + ...(config ?? {}), + ...update, + }); + }, + [onChange, config] + ); + + return ( + + + + + + + + {false && ( + <> + + } + onChange={updateCallback.bind(null, { mml: { enabled: !uiConfig.mml.enabled } })} + checked={uiConfig.mml.enabled} + /> + + + + + } + onChange={updateCallback.bind(null, { + delayedData: { enabled: !uiConfig.delayedData.enabled }, + })} + checked={uiConfig.delayedData.enabled} + /> + + + + + } + onChange={updateCallback.bind(null, { + behindRealtime: { enabled: !uiConfig.behindRealtime.enabled }, + })} + checked={uiConfig.behindRealtime.enabled} + /> + + + + + } + onChange={updateCallback.bind(null, { + errorMessages: { enabled: !uiConfig.errorMessages.enabled }, + })} + checked={uiConfig.errorMessages.enabled} + /> + + + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts b/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts index b1640ab7aba7d..99ba61f3d9154 100644 --- a/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts +++ b/x-pack/plugins/ml/public/alerting/register_ml_alerts.ts @@ -14,6 +14,7 @@ import type { PluginSetupContract as AlertingSetup } from '../../../alerting/pub import { PLUGIN_ID } from '../../common/constants/app'; import { formatExplorerUrl } from '../locator/formatters/anomaly_detection'; import { validateLookbackInterval, validateTopNBucket } from './validators'; +import { registerJobsHealthAlertingRule } from './jobs_health_rule'; export function registerMlAlerts( triggersActionsUi: TriggersAndActionsUIPublicPluginSetup, @@ -26,7 +27,7 @@ export function registerMlAlerts( }), iconClass: 'bell', documentationUrl(docLinks) { - return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/machine-learning/${docLinks.DOC_LINK_VERSION}/ml-configuring-alerts.html`; + return docLinks.links.ml.alertingRules; }, alertParamsExpression: lazy(() => import('./ml_anomaly_alert_trigger')), validate: (alertParams: MlAnomalyDetectionAlertParams) => { @@ -137,6 +138,8 @@ export function registerMlAlerts( ), }); + registerJobsHealthAlertingRule(triggersActionsUi, alerting); + if (alerting) { registerNavigation(alerting); } diff --git a/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts b/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts index e7d3ef97a301b..e4c1e0fe53f01 100644 --- a/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts +++ b/x-pack/plugins/ml/server/lib/alerts/alerting_service.ts @@ -436,7 +436,7 @@ export function alertingServiceProvider(mlClient: MlClient, datafeedsService: Da const jobIds = jobsResponse.map((v) => v.job_id); - const dataFeeds = await datafeedsService.getDatafeedByJobId(jobIds); + const datafeeds = await datafeedsService.getDatafeedByJobId(jobIds); const maxBucketInSeconds = resolveMaxTimeInterval( jobsResponse.map((v) => v.analysis_config.bucket_span) @@ -448,7 +448,7 @@ export function alertingServiceProvider(mlClient: MlClient, datafeedsService: Da } const lookBackTimeInterval: string = - params.lookbackInterval ?? resolveLookbackInterval(jobsResponse, dataFeeds ?? []); + params.lookbackInterval ?? resolveLookbackInterval(jobsResponse, datafeeds ?? []); const topNBuckets: number = params.topNBuckets ?? getTopNBuckets(jobsResponse[0]); diff --git a/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts new file mode 100644 index 0000000000000..59213a7cf6ab1 --- /dev/null +++ b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.test.ts @@ -0,0 +1,180 @@ +/* + * 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 { JobsHealthService, jobsHealthServiceProvider } from './jobs_health_service'; +import type { DatafeedsService } from '../../models/job_service/datafeeds'; +import type { Logger } from 'kibana/server'; +import { MlClient } from '../ml_client'; +import { MlJob, MlJobStats } from '@elastic/elasticsearch/api/types'; + +describe('JobsHealthService', () => { + const mlClient = ({ + getJobs: jest.fn().mockImplementation(({ job_id: jobIds = [] }) => { + let jobs: MlJob[] = []; + + if (jobIds.some((v: string) => v === 'test_group')) { + jobs = [ + ({ + job_id: 'test_job_01', + } as unknown) as MlJob, + ({ + job_id: 'test_job_02', + } as unknown) as MlJob, + ({ + job_id: 'test_job_03', + } as unknown) as MlJob, + ]; + } + + if (jobIds[0]?.startsWith('test_job_')) { + jobs = [ + ({ + job_id: jobIds[0], + } as unknown) as MlJob, + ]; + } + + return Promise.resolve({ + body: { + jobs, + }, + }); + }), + getJobStats: jest.fn().mockImplementation(({ job_id: jobIdsStr }) => { + const jobsIds = jobIdsStr.split(','); + return Promise.resolve({ + body: { + jobs: jobsIds.map((j: string) => { + return { + job_id: j, + state: j === 'test_job_02' ? 'opened' : 'closed', + }; + }) as MlJobStats, + }, + }); + }), + getDatafeedStats: jest.fn().mockImplementation(({ datafeed_id: datafeedIdsStr }) => { + const datafeedIds = datafeedIdsStr.split(','); + return Promise.resolve({ + body: { + datafeeds: datafeedIds.map((d: string) => { + return { + datafeed_id: d, + state: d === 'test_datafeed_02' ? 'stopped' : 'started', + timing_stats: { + job_id: d.replace('datafeed', 'job'), + }, + }; + }) as MlJobStats, + }, + }); + }), + } as unknown) as jest.Mocked; + + const datafeedsService = ({ + getDatafeedByJobId: jest.fn().mockImplementation((jobIds: string[]) => { + return Promise.resolve( + jobIds.map((j) => { + return { + datafeed_id: j.replace('job', 'datafeed'), + }; + }) + ); + }), + } as unknown) as jest.Mocked; + + const logger = ({ + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + } as unknown) as jest.Mocked; + + const jobHealthService: JobsHealthService = jobsHealthServiceProvider( + mlClient, + datafeedsService, + logger + ); + + beforeEach(() => {}); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('returns empty results when no jobs provided', async () => { + // act + const executionResult = await jobHealthService.getTestsResults('testRule', { + testsConfig: null, + includeJobs: { + jobIds: ['*'], + groupIds: [], + }, + excludeJobs: null, + }); + expect(logger.warn).toHaveBeenCalledWith('Rule "testRule" does not have associated jobs.'); + expect(datafeedsService.getDatafeedByJobId).not.toHaveBeenCalled(); + expect(executionResult).toEqual([]); + }); + + test('returns empty results and does not perform datafeed check when test is disabled', async () => { + const executionResult = await jobHealthService.getTestsResults('testRule', { + testsConfig: { + datafeed: { + enabled: false, + }, + behindRealtime: null, + delayedData: null, + errorMessages: null, + mml: null, + }, + includeJobs: { + jobIds: ['test_job_01'], + groupIds: [], + }, + excludeJobs: null, + }); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith(`Performing health checks for job IDs: test_job_01`); + expect(datafeedsService.getDatafeedByJobId).not.toHaveBeenCalled(); + expect(executionResult).toEqual([]); + }); + + test('returns results based on provided selection', async () => { + const executionResult = await jobHealthService.getTestsResults('testRule_03', { + testsConfig: null, + includeJobs: { + jobIds: [], + groupIds: ['test_group'], + }, + excludeJobs: { + jobIds: ['test_job_03'], + groupIds: [], + }, + }); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + `Performing health checks for job IDs: test_job_01, test_job_02` + ); + expect(datafeedsService.getDatafeedByJobId).toHaveBeenCalledWith([ + 'test_job_01', + 'test_job_02', + ]); + expect(mlClient.getJobStats).toHaveBeenCalledWith({ job_id: 'test_job_01,test_job_02' }); + expect(mlClient.getDatafeedStats).toHaveBeenCalledWith({ + datafeed_id: 'test_datafeed_01,test_datafeed_02', + }); + expect(executionResult).toEqual([ + { + name: 'Datafeed is not started', + context: { + jobIds: ['test_job_02'], + message: 'Datafeed is not started for the following jobs:', + }, + }, + ]); + }); +}); diff --git a/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts new file mode 100644 index 0000000000000..db4907decc3f0 --- /dev/null +++ b/x-pack/plugins/ml/server/lib/alerts/jobs_health_service.ts @@ -0,0 +1,185 @@ +/* + * 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 { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import { i18n } from '@kbn/i18n'; +import { Logger } from 'kibana/server'; +import { MlJobState } from '@elastic/elasticsearch/api/types'; +import { MlClient } from '../ml_client'; +import { + AnomalyDetectionJobsHealthRuleParams, + JobSelection, +} from '../../routes/schemas/alerting_schema'; +import { datafeedsProvider, DatafeedsService } from '../../models/job_service/datafeeds'; +import { ALL_JOBS_SELECTION, HEALTH_CHECK_NAMES } from '../../../common/constants/alerts'; +import { DatafeedStats } from '../../../common/types/anomaly_detection_jobs'; +import { GetGuards } from '../../shared_services/shared_services'; +import { AnomalyDetectionJobsHealthAlertContext } from './register_jobs_monitoring_rule_type'; +import { getResultJobsHealthRuleConfig } from '../../../common/util/alerts'; + +interface TestResult { + name: string; + context: AnomalyDetectionJobsHealthAlertContext; +} + +type TestsResults = TestResult[]; + +type NotStartedDatafeedResponse = Array; + +export function jobsHealthServiceProvider( + mlClient: MlClient, + datafeedsService: DatafeedsService, + logger: Logger +) { + /** + * Extracts result list of job ids based on included and excluded selection of jobs and groups. + * @param includeJobs + * @param excludeJobs + */ + const getResultJobIds = async (includeJobs: JobSelection, excludeJobs?: JobSelection | null) => { + const jobAndGroupIds = [...(includeJobs.jobIds ?? []), ...(includeJobs.groupIds ?? [])]; + + const includeAllJobs = jobAndGroupIds.some((id) => id === ALL_JOBS_SELECTION); + + // Extract jobs from group ids and make sure provided jobs assigned to a current space + const jobsResponse = ( + await mlClient.getJobs({ + ...(includeAllJobs ? {} : { job_id: jobAndGroupIds }), + }) + ).body.jobs; + + let resultJobIds = jobsResponse.map((v) => v.job_id); + + if (excludeJobs && (!!excludeJobs.jobIds.length || !!excludeJobs?.groupIds.length)) { + const excludedJobAndGroupIds = [ + ...(excludeJobs?.jobIds ?? []), + ...(excludeJobs?.groupIds ?? []), + ]; + const excludedJobsResponse = ( + await mlClient.getJobs({ + job_id: excludedJobAndGroupIds, + }) + ).body.jobs; + + const excludedJobsIds: Set = new Set(excludedJobsResponse.map((v) => v.job_id)); + + resultJobIds = resultJobIds.filter((v) => !excludedJobsIds.has(v)); + } + + return resultJobIds; + }; + + return { + /** + * Gets not started datafeeds for opened jobs. + * @param jobIds + */ + async getNotStartedDatafeeds(jobIds: string[]): Promise { + const datafeeds = await datafeedsService.getDatafeedByJobId(jobIds); + + if (datafeeds) { + const { + body: { jobs: jobsStats }, + } = await mlClient.getJobStats({ job_id: jobIds.join(',') }); + + const { + body: { datafeeds: datafeedsStats }, + } = await mlClient.getDatafeedStats({ + datafeed_id: datafeeds.map((d) => d.datafeed_id).join(','), + }); + + // match datafeed stats with the job ids + return (datafeedsStats as DatafeedStats[]) + .map((datafeedStats) => { + const jobId = datafeedStats.timing_stats.job_id; + const jobState = + jobsStats.find((jobStats) => jobStats.job_id === jobId)?.state ?? 'failed'; + return { + ...datafeedStats, + job_id: jobId, + job_state: jobState, + }; + }) + .filter((datafeedStat) => { + // Find opened jobs with not started datafeeds + return datafeedStat.job_state === 'opened' && datafeedStat.state !== 'started'; + }); + } + }, + /** + * Retrieves report grouped by test. + */ + async getTestsResults( + ruleInstanceName: string, + { testsConfig, includeJobs, excludeJobs }: AnomalyDetectionJobsHealthRuleParams + ): Promise { + const config = getResultJobsHealthRuleConfig(testsConfig); + + const results: TestsResults = []; + + const jobIds = await getResultJobIds(includeJobs, excludeJobs); + + if (jobIds.length === 0) { + logger.warn(`Rule "${ruleInstanceName}" does not have associated jobs.`); + return results; + } + + logger.debug(`Performing health checks for job IDs: ${jobIds.join(', ')}`); + + if (config.datafeed.enabled) { + const response = await this.getNotStartedDatafeeds(jobIds); + if (response && response.length > 0) { + results.push({ + name: HEALTH_CHECK_NAMES.datafeed, + context: { + jobIds: [...new Set(response.map((v) => v.job_id))], + message: i18n.translate( + 'xpack.ml.alertTypes.jobsHealthAlertingRule.datafeedStateMessage', + { + defaultMessage: 'Datafeed is not started for the following jobs:', + } + ), + }, + }); + } + } + + return results; + }, + }; +} + +export type JobsHealthService = ReturnType; + +export function getJobsHealthServiceProvider(getGuards: GetGuards) { + return { + jobsHealthServiceProvider( + savedObjectsClient: SavedObjectsClientContract, + request: KibanaRequest, + logger: Logger + ) { + return { + getTestsResults: async ( + ...args: Parameters + ): ReturnType => { + return await getGuards(request, savedObjectsClient) + .isFullLicense() + .hasMlCapabilities(['canGetJobs']) + .ok(({ mlClient, scopedClient }) => + jobsHealthServiceProvider( + mlClient, + datafeedsProvider(scopedClient, mlClient), + logger + ).getTestsResults(...args) + ); + }, + }; + }, + }; +} + +export type JobsHealthServiceProvider = ReturnType; diff --git a/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts b/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts index 07bca8f3aae74..e30ea01b27cb5 100644 --- a/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts +++ b/x-pack/plugins/ml/server/lib/alerts/register_anomaly_detection_alert_type.ts @@ -7,11 +7,7 @@ import { i18n } from '@kbn/i18n'; import { KibanaRequest } from 'kibana/server'; -import { - ML_ALERT_TYPES, - ML_ALERT_TYPES_CONFIG, - AnomalyScoreMatchGroupId, -} from '../../../common/constants/alerts'; +import { ML_ALERT_TYPES } from '../../../common/constants/alerts'; import { PLUGIN_ID } from '../../../common/constants/app'; import { MINIMUM_FULL_LICENSE } from '../../../common/license'; import { @@ -21,13 +17,12 @@ import { import { RegisterAlertParams } from './register_ml_alerts'; import { InfluencerAnomalyAlertDoc, RecordAnomalyAlertDoc } from '../../../common/types/alerts'; import { + ActionGroup, AlertInstanceContext, AlertInstanceState, AlertTypeState, } from '../../../../alerting/common'; -const alertTypeConfig = ML_ALERT_TYPES_CONFIG[ML_ALERT_TYPES.ANOMALY_DETECTION]; - export type AnomalyDetectionAlertContext = { name: string; jobIds: string[]; @@ -40,6 +35,17 @@ export type AnomalyDetectionAlertContext = { anomalyExplorerUrl: string; } & AlertInstanceContext; +export const ANOMALY_SCORE_MATCH_GROUP_ID = 'anomaly_score_match'; + +export type AnomalyScoreMatchGroupId = typeof ANOMALY_SCORE_MATCH_GROUP_ID; + +export const THRESHOLD_MET_GROUP: ActionGroup = { + id: ANOMALY_SCORE_MATCH_GROUP_ID, + name: i18n.translate('xpack.ml.anomalyDetectionAlert.actionGroupName', { + defaultMessage: 'Anomaly score matched the condition', + }), +}; + export function registerAnomalyDetectionAlertType({ alerting, mlSharedServices, @@ -53,9 +59,11 @@ export function registerAnomalyDetectionAlertType({ AnomalyScoreMatchGroupId >({ id: ML_ALERT_TYPES.ANOMALY_DETECTION, - name: alertTypeConfig.name, - actionGroups: alertTypeConfig.actionGroups, - defaultActionGroupId: alertTypeConfig.defaultActionGroupId, + name: i18n.translate('xpack.ml.anomalyDetectionAlert.name', { + defaultMessage: 'Anomaly detection alert', + }), + actionGroups: [THRESHOLD_MET_GROUP], + defaultActionGroupId: ANOMALY_SCORE_MATCH_GROUP_ID, validate: { params: mlAnomalyDetectionAlertParams, }, @@ -76,7 +84,7 @@ export function registerAnomalyDetectionAlertType({ { name: 'jobIds', description: i18n.translate('xpack.ml.alertContext.jobIdsDescription', { - defaultMessage: 'List of job IDs that triggered the alert instance', + defaultMessage: 'List of job IDs that triggered the alert', }), }, { @@ -132,7 +140,7 @@ export function registerAnomalyDetectionAlertType({ if (executionResult) { const alertInstanceName = executionResult.name; const alertInstance = services.alertInstanceFactory(alertInstanceName); - alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, executionResult); + alertInstance.scheduleActions(ANOMALY_SCORE_MATCH_GROUP_ID, executionResult); } }, }); diff --git a/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts b/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts new file mode 100644 index 0000000000000..3547b44cc73e4 --- /dev/null +++ b/x-pack/plugins/ml/server/lib/alerts/register_jobs_monitoring_rule_type.ts @@ -0,0 +1,109 @@ +/* + * 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 { KibanaRequest } from 'kibana/server'; +import { ML_ALERT_TYPES } from '../../../common/constants/alerts'; +import { PLUGIN_ID } from '../../../common/constants/app'; +import { MINIMUM_FULL_LICENSE } from '../../../common/license'; +import { + anomalyDetectionJobsHealthRuleParams, + AnomalyDetectionJobsHealthRuleParams, +} from '../../routes/schemas/alerting_schema'; +import { RegisterAlertParams } from './register_ml_alerts'; +import { + ActionGroup, + AlertInstanceContext, + AlertInstanceState, + AlertTypeState, +} from '../../../../alerting/common'; + +export type AnomalyDetectionJobsHealthAlertContext = { + jobIds: string[]; + message: string; +} & AlertInstanceContext; + +export const ANOMALY_DETECTION_JOB_REALTIME_ISSUE = 'anomaly_detection_realtime_issue'; + +export type AnomalyDetectionJobRealtimeIssue = typeof ANOMALY_DETECTION_JOB_REALTIME_ISSUE; + +export const REALTIME_ISSUE_DETECTED: ActionGroup = { + id: ANOMALY_DETECTION_JOB_REALTIME_ISSUE, + name: i18n.translate('xpack.ml.jobsHealthAlertingRule.actionGroupName', { + defaultMessage: 'Real-time issue detected', + }), +}; + +export function registerJobsMonitoringRuleType({ + alerting, + mlServicesProviders, + logger, +}: RegisterAlertParams) { + alerting.registerType< + AnomalyDetectionJobsHealthRuleParams, + never, // Only use if defining useSavedObjectReferences hook + AlertTypeState, + AlertInstanceState, + AnomalyDetectionJobsHealthAlertContext, + AnomalyDetectionJobRealtimeIssue + >({ + id: ML_ALERT_TYPES.AD_JOBS_HEALTH, + name: i18n.translate('xpack.ml.jobsHealthAlertingRule.name', { + defaultMessage: 'Anomaly detection jobs health', + }), + actionGroups: [REALTIME_ISSUE_DETECTED], + defaultActionGroupId: ANOMALY_DETECTION_JOB_REALTIME_ISSUE, + validate: { + params: anomalyDetectionJobsHealthRuleParams, + }, + actionVariables: { + context: [ + { + name: 'jobIds', + description: i18n.translate( + 'xpack.ml.alertTypes.jobsHealthAlertingRule.alertContext.jobIdsDescription', + { + defaultMessage: 'List of job IDs that triggered the alert', + } + ), + }, + { + name: 'message', + description: i18n.translate( + 'xpack.ml.alertTypes.jobsHealthAlertingRule.alertContext.messageDescription', + { + defaultMessage: 'Alert info message', + } + ), + }, + ], + }, + producer: PLUGIN_ID, + minimumLicenseRequired: MINIMUM_FULL_LICENSE, + isExportable: true, + async executor({ services, params, alertId, state, previousStartedAt, startedAt, name }) { + const fakeRequest = {} as KibanaRequest; + const { getTestsResults } = mlServicesProviders.jobsHealthServiceProvider( + services.savedObjectsClient, + fakeRequest, + logger + ); + const executionResult = await getTestsResults(name, params); + + if (executionResult.length > 0) { + logger.info( + `Scheduling actions for tests: ${executionResult.map((v) => v.name).join(', ')}` + ); + + executionResult.forEach(({ name: alertInstanceName, context }) => { + const alertInstance = services.alertInstanceFactory(alertInstanceName); + alertInstance.scheduleActions(ANOMALY_DETECTION_JOB_REALTIME_ISSUE, context); + }); + } + }, + }); +} diff --git a/x-pack/plugins/ml/server/lib/alerts/register_ml_alerts.ts b/x-pack/plugins/ml/server/lib/alerts/register_ml_alerts.ts index 8368c606598f0..6f1e000c9a430 100644 --- a/x-pack/plugins/ml/server/lib/alerts/register_ml_alerts.ts +++ b/x-pack/plugins/ml/server/lib/alerts/register_ml_alerts.ts @@ -9,13 +9,17 @@ import { Logger } from 'kibana/server'; import { AlertingPlugin } from '../../../../alerting/server'; import { registerAnomalyDetectionAlertType } from './register_anomaly_detection_alert_type'; import { SharedServices } from '../../shared_services'; +import { registerJobsMonitoringRuleType } from './register_jobs_monitoring_rule_type'; +import { MlServicesProviders } from '../../shared_services/shared_services'; export interface RegisterAlertParams { alerting: AlertingPlugin['setup']; logger: Logger; mlSharedServices: SharedServices; + mlServicesProviders: MlServicesProviders; } export function registerMlAlerts(params: RegisterAlertParams) { registerAnomalyDetectionAlertType(params); + registerJobsMonitoringRuleType(params); } diff --git a/x-pack/plugins/ml/server/plugin.ts b/x-pack/plugins/ml/server/plugin.ts index 213be9421c41d..35f66e86b955a 100644 --- a/x-pack/plugins/ml/server/plugin.ts +++ b/x-pack/plugins/ml/server/plugin.ts @@ -196,7 +196,7 @@ export class MlServerPlugin initMlServerLog({ log: this.log }); - const sharedServices = createSharedServices( + const { internalServicesProviders, sharedServicesProviders } = createSharedServices( this.mlLicense, getSpaces, plugins.cloud, @@ -211,7 +211,8 @@ export class MlServerPlugin registerMlAlerts({ alerting: plugins.alerting, logger: this.log, - mlSharedServices: sharedServices, + mlSharedServices: sharedServicesProviders, + mlServicesProviders: internalServicesProviders, }); } @@ -219,7 +220,7 @@ export class MlServerPlugin registerCollector(plugins.usageCollection, this.kibanaIndexConfig.kibana.index); } - return { ...sharedServices }; + return sharedServicesProviders; } public start(coreStart: CoreStart): MlPluginStart { diff --git a/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts b/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts index df22ccfe20821..4e0f9a9aa7c92 100644 --- a/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/alerting_schema.ts @@ -10,22 +10,24 @@ import { i18n } from '@kbn/i18n'; import { ALERT_PREVIEW_SAMPLE_SIZE } from '../../../common/constants/alerts'; import { ANOMALY_RESULT_TYPE } from '../../../common/constants/anomalies'; -export const mlAnomalyDetectionAlertParams = schema.object({ - jobSelection: schema.object( - { - jobIds: schema.arrayOf(schema.string(), { defaultValue: [] }), - groupIds: schema.arrayOf(schema.string(), { defaultValue: [] }), +const jobsSelectionSchema = schema.object( + { + jobIds: schema.arrayOf(schema.string(), { defaultValue: [] }), + groupIds: schema.arrayOf(schema.string(), { defaultValue: [] }), + }, + { + validate: (v) => { + if (!v.jobIds?.length && !v.groupIds?.length) { + return i18n.translate('xpack.ml.alertTypes.anomalyDetection.jobSelection.errorMessage', { + defaultMessage: 'Job selection is required', + }); + } }, - { - validate: (v) => { - if (!v.jobIds?.length && !v.groupIds?.length) { - return i18n.translate('xpack.ml.alertTypes.anomalyDetection.jobSelection.errorMessage', { - defaultMessage: 'Job selection is required', - }); - } - }, - } - ), + } +); + +export const mlAnomalyDetectionAlertParams = schema.object({ + jobSelection: jobsSelectionSchema, /** Anomaly score threshold */ severity: schema.number({ min: 0, max: 100 }), /** Result type to alert upon */ @@ -58,3 +60,47 @@ export type MlAnomalyDetectionAlertParams = TypeOf; + +export const anomalyDetectionJobsHealthRuleParams = schema.object({ + includeJobs: jobsSelectionSchema, + excludeJobs: schema.nullable(jobsSelectionSchema), + testsConfig: schema.nullable( + schema.object({ + datafeed: schema.nullable( + schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }) + ), + mml: schema.nullable( + schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }) + ), + delayedData: schema.nullable( + schema.object({ + enabled: schema.boolean({ defaultValue: true }), + docsCount: schema.nullable(schema.number()), + timeInterval: schema.nullable(schema.string()), + }) + ), + behindRealtime: schema.nullable( + schema.object({ + enabled: schema.boolean({ defaultValue: true }), + timeInterval: schema.nullable(schema.string()), + }) + ), + errorMessages: schema.nullable( + schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }) + ), + }) + ), +}); + +export type AnomalyDetectionJobsHealthRuleParams = TypeOf< + typeof anomalyDetectionJobsHealthRuleParams +>; + +export type TestsConfig = AnomalyDetectionJobsHealthRuleParams['testsConfig']; +export type JobSelection = AnomalyDetectionJobsHealthRuleParams['includeJobs']; diff --git a/x-pack/plugins/ml/server/shared_services/shared_services.ts b/x-pack/plugins/ml/server/shared_services/shared_services.ts index caed3fd933298..3766a48b0537d 100644 --- a/x-pack/plugins/ml/server/shared_services/shared_services.ts +++ b/x-pack/plugins/ml/server/shared_services/shared_services.ts @@ -30,6 +30,10 @@ import { getAlertingServiceProvider, MlAlertingServiceProvider, } from './providers/alerting_service'; +import { + getJobsHealthServiceProvider, + JobsHealthServiceProvider, +} from '../lib/alerts/jobs_health_service'; export type SharedServices = JobServiceProvider & AnomalyDetectorsProvider & @@ -38,6 +42,8 @@ export type SharedServices = JobServiceProvider & ResultsServiceProvider & MlAlertingServiceProvider; +export type MlServicesProviders = JobsHealthServiceProvider; + interface Guards { isMinimumLicense(): Guards; isFullLicense(): Guards; @@ -71,7 +77,10 @@ export function createSharedServices( getClusterClient: () => IClusterClient | null, getInternalSavedObjectsClient: () => SavedObjectsClientContract | null, isMlReady: () => Promise -): SharedServices { +): { + sharedServicesProviders: SharedServices; + internalServicesProviders: MlServicesProviders; +} { const { isFullLicense, isMinimumLicense } = licenseChecks(mlLicense); function getGuards( request: KibanaRequest, @@ -118,12 +127,23 @@ export function createSharedServices( } return { - ...getJobServiceProvider(getGuards), - ...getAnomalyDetectorsProvider(getGuards), - ...getModulesProvider(getGuards), - ...getResultsServiceProvider(getGuards), - ...getMlSystemProvider(getGuards, mlLicense, getSpaces, cloud, resolveMlCapabilities), - ...getAlertingServiceProvider(getGuards), + /** + * Exposed providers for shared services used by other plugins + */ + sharedServicesProviders: { + ...getJobServiceProvider(getGuards), + ...getAnomalyDetectorsProvider(getGuards), + ...getModulesProvider(getGuards), + ...getResultsServiceProvider(getGuards), + ...getMlSystemProvider(getGuards, mlLicense, getSpaces, cloud, resolveMlCapabilities), + ...getAlertingServiceProvider(getGuards), + }, + /** + * Services providers for ML internal usage + */ + internalServicesProviders: { + ...getJobsHealthServiceProvider(getGuards), + }, }; } From 14ca699c4883cd1c9b75186aca0836d73d28f715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ece=20=C3=96zalp?= Date: Thu, 22 Jul 2021 11:52:03 -0400 Subject: [PATCH 16/45] [CTI] adds performance improvements to threat match event query (#106150) --- .../signals/build_events_query.test.ts | 59 ++++++++ .../signals/build_events_query.ts | 52 +++---- .../signals/search_after_bulk_create.ts | 8 +- .../signals/single_search_after.ts | 10 +- .../threat_mapping/create_threat_signal.ts | 3 + .../lib/detection_engine/signals/types.ts | 2 + .../detection_engine/signals/utils.test.ts | 44 +++--- .../lib/detection_engine/signals/utils.ts | 31 +++-- .../tests/create_threat_matching.ts | 130 +++++++++++------- 9 files changed, 224 insertions(+), 115 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts index 28cea9ea22b0d..e6e1212c46905 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts @@ -477,4 +477,63 @@ describe('create_signals', () => { }, }); }); + + test('if trackTotalHits is provided it should be included', () => { + const query = buildEventsSearchQuery({ + index: ['auditbeat-*'], + from: 'now-5m', + to: 'today', + filter: {}, + size: 100, + searchAfterSortIds: undefined, + timestampOverride: undefined, + trackTotalHits: false, + }); + expect(query.track_total_hits).toEqual(false); + }); + + test('if sortOrder is provided it should be included', () => { + const query = buildEventsSearchQuery({ + index: ['auditbeat-*'], + from: 'now-5m', + to: 'today', + filter: {}, + size: 100, + searchAfterSortIds: undefined, + timestampOverride: undefined, + sortOrder: 'desc', + trackTotalHits: false, + }); + expect(query.body.sort[0]).toEqual({ + '@timestamp': { + order: 'desc', + unmapped_type: 'date', + }, + }); + }); + + test('it respects sort order for timestampOverride', () => { + const query = buildEventsSearchQuery({ + index: ['auditbeat-*'], + from: 'now-5m', + to: 'today', + filter: {}, + size: 100, + searchAfterSortIds: undefined, + timestampOverride: 'event.ingested', + sortOrder: 'desc', + }); + expect(query.body.sort[0]).toEqual({ + 'event.ingested': { + order: 'desc', + unmapped_type: 'date', + }, + }); + expect(query.body.sort[1]).toEqual({ + '@timestamp': { + order: 'desc', + unmapped_type: 'date', + }, + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts index 7b27d22d9b387..8d10eda2a30c2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts @@ -6,10 +6,7 @@ */ import type { estypes } from '@elastic/elasticsearch'; import { isEmpty } from 'lodash'; -import { - SortOrderOrUndefined, - TimestampOverrideOrUndefined, -} from '../../../../common/detection_engine/schemas/common/schemas'; +import { TimestampOverrideOrUndefined } from '../../../../common/detection_engine/schemas/common/schemas'; interface BuildEventsSearchQuery { aggregations?: Record; @@ -18,9 +15,10 @@ interface BuildEventsSearchQuery { to: string; filter: estypes.QueryDslQueryContainer; size: number; - sortOrder?: SortOrderOrUndefined; + sortOrder?: estypes.SearchSortOrder; searchAfterSortIds: estypes.SearchSortResults | undefined; timestampOverride: TimestampOverrideOrUndefined; + trackTotalHits?: boolean; } export const buildEventsSearchQuery = ({ @@ -33,6 +31,7 @@ export const buildEventsSearchQuery = ({ searchAfterSortIds, sortOrder, timestampOverride, + trackTotalHits, }: BuildEventsSearchQuery) => { const defaultTimeFields = ['@timestamp']; const timestamps = @@ -97,11 +96,28 @@ export const buildEventsSearchQuery = ({ { bool: { filter: [{ bool: { should: [...rangeFilter], minimum_should_match: 1 } }] } }, ]; + const sort: estypes.SearchSort = []; + if (timestampOverride) { + sort.push({ + [timestampOverride]: { + order: sortOrder ?? 'asc', + unmapped_type: 'date', + }, + }); + } + sort.push({ + '@timestamp': { + order: sortOrder ?? 'asc', + unmapped_type: 'date', + }, + }); + const searchQuery = { allow_no_indices: true, index, size, ignore_unavailable: true, + track_total_hits: trackTotalHits, body: { query: { bool: { @@ -121,31 +137,7 @@ export const buildEventsSearchQuery = ({ ...docFields, ], ...(aggregations ? { aggregations } : {}), - sort: [ - ...(timestampOverride != null - ? [ - { - [timestampOverride]: { - order: sortOrder ?? 'asc', - unmapped_type: 'date', - }, - }, - { - '@timestamp': { - order: sortOrder ?? 'asc', - unmapped_type: 'date', - }, - }, - ] - : [ - { - '@timestamp': { - order: sortOrder ?? 'asc', - unmapped_type: 'date', - }, - }, - ]), - ], + sort, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts index eb4af0c38ce25..75cffb598d186 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.ts @@ -14,7 +14,7 @@ import { createSearchAfterReturnType, createSearchResultReturnType, createSearchAfterReturnTypeFromResponse, - createTotalHitsFromSearchResult, + getTotalHitsValue, mergeReturns, mergeSearchResults, getSafeSortIds, @@ -37,6 +37,8 @@ export const searchAfterAndBulkCreate = async ({ enrichment = identity, bulkCreate, wrapHits, + sortOrder, + trackTotalHits, }: SearchAfterAndBulkCreateParams): Promise => { const ruleParams = ruleSO.attributes.params; let toReturn = createSearchAfterReturnType(); @@ -75,6 +77,8 @@ export const searchAfterAndBulkCreate = async ({ filter, pageSize: Math.ceil(Math.min(tuple.maxSignals, pageSize)), timestampOverride: ruleParams.timestampOverride, + trackTotalHits, + sortOrder, }); mergedSearchResults = mergeSearchResults([mergedSearchResults, searchResult]); toReturn = mergeReturns([ @@ -101,7 +105,7 @@ export const searchAfterAndBulkCreate = async ({ } // determine if there are any candidate signals to be processed - const totalHits = createTotalHitsFromSearchResult({ searchResult: mergedSearchResults }); + const totalHits = getTotalHitsValue(mergedSearchResults.hits.total); logger.debug(buildRuleMessage(`totalHits: ${totalHits}`)); logger.debug( buildRuleMessage(`searchResult.hit.hits.length: ${mergedSearchResults.hits.hits.length}`) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts index 6436da40088b3..ff49fb5892f50 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts @@ -16,10 +16,7 @@ import type { SignalSearchResponse, SignalSource } from './types'; import { BuildRuleMessage } from './rule_messages'; import { buildEventsSearchQuery } from './build_events_query'; import { createErrorsFromShard, makeFloatString } from './utils'; -import { - SortOrderOrUndefined, - TimestampOverrideOrUndefined, -} from '../../../../common/detection_engine/schemas/common/schemas'; +import { TimestampOverrideOrUndefined } from '../../../../common/detection_engine/schemas/common/schemas'; interface SingleSearchAfterParams { aggregations?: Record; @@ -30,10 +27,11 @@ interface SingleSearchAfterParams { services: AlertServices; logger: Logger; pageSize: number; - sortOrder?: SortOrderOrUndefined; + sortOrder?: estypes.SearchSortOrder; filter: estypes.QueryDslQueryContainer; timestampOverride: TimestampOverrideOrUndefined; buildRuleMessage: BuildRuleMessage; + trackTotalHits?: boolean; } // utilize search_after for paging results into bulk. @@ -50,6 +48,7 @@ export const singleSearchAfter = async ({ sortOrder, timestampOverride, buildRuleMessage, + trackTotalHits, }: SingleSearchAfterParams): Promise<{ searchResult: SignalSearchResponse; searchDuration: string; @@ -66,6 +65,7 @@ export const singleSearchAfter = async ({ sortOrder, searchAfterSortIds, timestampOverride, + trackTotalHits, }); const start = performance.now(); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts index 806f5e47608e4..fb9881b519a16 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threat_mapping/create_threat_signal.ts @@ -86,7 +86,10 @@ export const createThreatSignal = async ({ enrichment: threatEnrichment, bulkCreate, wrapHits, + sortOrder: 'desc', + trackTotalHits: false, }); + logger.debug( buildRuleMessage( `${ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index 052270d491cda..dbc6848335893 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -300,6 +300,8 @@ export interface SearchAfterAndBulkCreateParams { enrichment?: SignalsEnrichment; bulkCreate: BulkCreate; wrapHits: WrapHits; + trackTotalHits?: boolean; + sortOrder?: estypes.SearchSortOrder; } export interface SearchAfterAndBulkCreateReturnType { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index 4d5ac05957a4b..72a6ff478ade3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -36,11 +36,12 @@ import { createSearchAfterReturnTypeFromResponse, createSearchAfterReturnType, mergeReturns, - createTotalHitsFromSearchResult, lastValidDate, calculateThresholdSignalUuid, buildChunkedOrFilter, getValidDateFromDoc, + calculateTotal, + getTotalHitsValue, } from './utils'; import { BulkResponseErrorAggregation, SearchAfterAndBulkCreateReturnType } from './types'; import { @@ -53,7 +54,6 @@ import { sampleDocSearchResultsWithSortId, sampleEmptyDocSearchResults, sampleDocSearchResultsNoSortIdNoHits, - repeatedSearchResultsWithSortId, sampleDocSearchResultsNoSortId, sampleDocNoSortId, } from './__mocks__/es_results'; @@ -1569,22 +1569,6 @@ describe('utils', () => { }); }); - describe('createTotalHitsFromSearchResult', () => { - test('it should return 0 for empty results', () => { - const result = createTotalHitsFromSearchResult({ - searchResult: sampleEmptyDocSearchResults(), - }); - expect(result).toEqual(0); - }); - - test('it should return 4 for 4 result sets', () => { - const result = createTotalHitsFromSearchResult({ - searchResult: repeatedSearchResultsWithSortId(4, 1, ['1', '2', '3', '4']), - }); - expect(result).toEqual(4); - }); - }); - describe('calculateThresholdSignalUuid', () => { it('should generate a uuid without key', () => { const startedAt = new Date('2020-12-17T16:27:00Z'); @@ -1620,4 +1604,28 @@ describe('utils', () => { expect(filter).toEqual('field.name: ("id-1" OR "id-2") OR field.name: ("id-3")'); }); }); + + describe('getTotalHitsValue', () => { + test('returns value if present as number', () => { + expect(getTotalHitsValue(sampleDocSearchResultsWithSortId().hits.total)).toBe(1); + }); + + test('returns value if present as value object', () => { + expect(getTotalHitsValue({ value: 1 })).toBe(1); + }); + + test('returns -1 if not present', () => { + expect(getTotalHitsValue(undefined)).toBe(-1); + }); + }); + + describe('calculateTotal', () => { + test('should add totalHits if both totalHits values are numbers', () => { + expect(calculateTotal(1, 2)).toBe(3); + }); + + test('should return -1 if totalHits is undefined', () => { + expect(calculateTotal(undefined, 2)).toBe(-1); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index 4dd434156288f..cb1bf9d774359 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -797,9 +797,7 @@ export const mergeSearchResults = (searchResults: SignalSearchResponse[]) => { }, aggregations: newAggregations, hits: { - total: - createTotalHitsFromSearchResult({ searchResult: prev }) + - createTotalHitsFromSearchResult({ searchResult: next }), + total: calculateTotal(prev.hits.total, next.hits.total), max_score: Math.max(newHits.max_score!, existingHits.max_score!), hits: [...existingHits.hits, ...newHits.hits], }, @@ -807,16 +805,23 @@ export const mergeSearchResults = (searchResults: SignalSearchResponse[]) => { }); }; -export const createTotalHitsFromSearchResult = ({ - searchResult, -}: { - searchResult: { hits: { total: number | { value: number } } }; -}): number => { - const totalHits = - typeof searchResult.hits.total === 'number' - ? searchResult.hits.total - : searchResult.hits.total.value; - return totalHits; +export const getTotalHitsValue = (totalHits: number | { value: number } | undefined): number => + typeof totalHits === 'undefined' + ? -1 + : typeof totalHits === 'number' + ? totalHits + : totalHits.value; + +export const calculateTotal = ( + prevTotal: number | { value: number } | undefined, + nextTotal: number | { value: number } | undefined +): number => { + const prevTotalHits = getTotalHitsValue(prevTotal); + const nextTotalHits = getTotalHitsValue(nextTotal); + if (prevTotalHits === -1 || nextTotalHits === -1) { + return -1; + } + return prevTotalHits + nextTotalHits; }; export const calculateThresholdSignalUuid = ( diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts index c64713575c130..dabf2858dfe0c 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_threat_matching.ts @@ -153,7 +153,7 @@ export default ({ getService }: FtrProviderContext) => { const signalsOpen = await getSignalsByIds(supertest, [id]); expect(signalsOpen.hits.hits.length).equal(10); const fullSource = signalsOpen.hits.hits.find( - (signal) => signal._source.signal.parents[0].id === 'UBXOBmkBR346wHgnLP8T' + (signal) => signal._source.signal.parents[0].id === '7yJ-B2kBR346wHgnhlMn' ); const fullSignal = fullSource!._source; // If this doesn't exist the test is going to fail anyway so using a bang operator here to get rid of ts error expect(fullSignal).eql({ @@ -165,6 +165,29 @@ export default ({ getService }: FtrProviderContext) => { type: 'auditbeat', version: '8.0.0', }, + auditd: { + data: { + hostname: '46.101.47.213', + op: 'PAM:bad_ident', + terminal: 'ssh', + }, + message_type: 'user_err', + result: 'fail', + sequence: 2267, + session: 'unset', + summary: { + actor: { + primary: 'unset', + secondary: 'root', + }, + how: '/usr/sbin/sshd', + object: { + primary: 'ssh', + secondary: '46.101.47.213', + type: 'user-session', + }, + }, + }, cloud: { instance: { id: '133551048', @@ -176,11 +199,10 @@ export default ({ getService }: FtrProviderContext) => { version: '1.0.0-beta2', }, event: { - action: 'boot', - dataset: 'login', + action: 'error', + category: 'user-login', + module: 'auditd', kind: 'signal', - module: 'system', - origin: '/var/log/wtmp', }, host: { architecture: 'x86_64', @@ -197,9 +219,25 @@ export default ({ getService }: FtrProviderContext) => { version: '18.04.2 LTS (Bionic Beaver)', }, }, - message: 'System boot', + network: { + direction: 'incoming', + }, + process: { + executable: '/usr/sbin/sshd', + pid: 32739, + }, service: { - type: 'system', + type: 'auditd', + }, + source: { + ip: '46.101.47.213', + }, + user: { + audit: { + id: 'unset', + }, + id: '0', + name: 'root', }, signal: { _meta: { @@ -207,33 +245,31 @@ export default ({ getService }: FtrProviderContext) => { }, ancestors: [ { - depth: 0, - id: 'UBXOBmkBR346wHgnLP8T', - index: 'auditbeat-8.0.0-2019.02.19-000001', + id: '7yJ-B2kBR346wHgnhlMn', type: 'event', + index: 'auditbeat-8.0.0-2019.02.19-000001', + depth: 0, }, ], depth: 1, original_event: { - action: 'boot', - dataset: 'login', - kind: 'event', - module: 'system', - origin: '/var/log/wtmp', + action: 'error', + category: 'user-login', + module: 'auditd', }, original_time: fullSignal.signal.original_time, parent: { - depth: 0, - id: 'UBXOBmkBR346wHgnLP8T', - index: 'auditbeat-8.0.0-2019.02.19-000001', + id: '7yJ-B2kBR346wHgnhlMn', type: 'event', + index: 'auditbeat-8.0.0-2019.02.19-000001', + depth: 0, }, parents: [ { - depth: 0, - id: 'UBXOBmkBR346wHgnLP8T', - index: 'auditbeat-8.0.0-2019.02.19-000001', + id: '7yJ-B2kBR346wHgnhlMn', type: 'event', + index: 'auditbeat-8.0.0-2019.02.19-000001', + depth: 0, }, ], rule: fullSignal.signal.rule, @@ -774,34 +810,28 @@ export default ({ getService }: FtrProviderContext) => { type: 'indicator', }, }, - ]); - - assertContains(threats[1].indicator, [ { - description: "domain should match the auditbeat hosts' data's source.ip", - domain: '159.89.119.67', - first_seen: '2021-01-26T11:09:04.000Z', + description: 'this should match auditbeat/hosts on both port and ip', + first_seen: '2021-01-26T11:06:03.000Z', + ip: '45.115.45.3', matched: { - atomic: '159.89.119.67', - id: '978783', + atomic: '45.115.45.3', + id: '978785', index: 'filebeat-8.0.0-2021.01.26-000001', - field: 'destination.ip', + field: 'source.ip', type: ENRICHMENT_TYPES.IndicatorMatchRule, }, + port: 57324, provider: 'geenensp', type: 'url', - url: { - full: 'http://159.89.119.67:59600/bin.sh', - scheme: 'http', - }, event: { category: 'threat', created: '2021-01-26T11:09:05.529Z', dataset: 'threatintel.abuseurl', - ingested: '2021-01-26T11:09:06.595350Z', + ingested: '2021-01-26T11:09:06.616763Z', kind: 'enrichment', module: 'threatintel', - reference: 'https://urlhaus.abuse.ch/url/978783/', + reference: 'https://urlhaus.abuse.ch/url/978782/', type: 'indicator', }, }, @@ -810,10 +840,10 @@ export default ({ getService }: FtrProviderContext) => { first_seen: '2021-01-26T11:06:03.000Z', ip: '45.115.45.3', matched: { - atomic: '45.115.45.3', + atomic: 57324, id: '978785', index: 'filebeat-8.0.0-2021.01.26-000001', - field: 'source.ip', + field: 'source.port', type: ENRICHMENT_TYPES.IndicatorMatchRule, }, port: 57324, @@ -830,28 +860,34 @@ export default ({ getService }: FtrProviderContext) => { type: 'indicator', }, }, + ]); + + assertContains(threats[1].indicator, [ { - description: 'this should match auditbeat/hosts on both port and ip', - first_seen: '2021-01-26T11:06:03.000Z', - ip: '45.115.45.3', + description: "domain should match the auditbeat hosts' data's source.ip", + domain: '159.89.119.67', + first_seen: '2021-01-26T11:09:04.000Z', matched: { - atomic: 57324, - id: '978785', + atomic: '159.89.119.67', + id: '978783', index: 'filebeat-8.0.0-2021.01.26-000001', - field: 'source.port', + field: 'destination.ip', type: ENRICHMENT_TYPES.IndicatorMatchRule, }, - port: 57324, provider: 'geenensp', type: 'url', + url: { + full: 'http://159.89.119.67:59600/bin.sh', + scheme: 'http', + }, event: { category: 'threat', created: '2021-01-26T11:09:05.529Z', dataset: 'threatintel.abuseurl', - ingested: '2021-01-26T11:09:06.616763Z', + ingested: '2021-01-26T11:09:06.595350Z', kind: 'enrichment', module: 'threatintel', - reference: 'https://urlhaus.abuse.ch/url/978782/', + reference: 'https://urlhaus.abuse.ch/url/978783/', type: 'indicator', }, }, From 9255c586fc2dfc5b36540c19bb5d9f58e21b6be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20G=C3=B3mez?= Date: Thu, 22 Jul 2021 18:49:36 +0200 Subject: [PATCH 17/45] [Fleet] Tighten up agent monitoring permissions (#105419) * Prepare code for multiple datasets * Add specific datasets * Add endpoint monitoring datasets * Add auditbeat and consolidate lists Co-authored-by: Jen Huang --- .../fleet/server/services/agent_policy.ts | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 8302983316316..6881e0872606d 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -63,6 +63,19 @@ import { appContextService } from './app_context'; const SAVED_OBJECT_TYPE = AGENT_POLICY_SAVED_OBJECT_TYPE; +const MONITORING_DATASETS = [ + 'elastic_agent', + 'elastic_agent.elastic_agent', + 'elastic_agent.apm_server', + 'elastic_agent.filebeat', + 'elastic_agent.fleet_server', + 'elastic_agent.metricbeat', + 'elastic_agent.osquerybeat', + 'elastic_agent.packetbeat', + 'elastic_agent.endpoint_security', + 'elastic_agent.auditbeat', +]; + class AgentPolicyService { private triggerAgentPolicyUpdatedEvent = async ( soClient: SavedObjectsClientContract, @@ -762,7 +775,7 @@ class AgentPolicyService { cluster: DEFAULT_PERMISSIONS.cluster, }; - // TODO fetch this from the elastic agent package + // TODO: fetch this from the elastic agent package const monitoringOutput = fullAgentPolicy.agent?.monitoring.use_output; const monitoringNamespace = fullAgentPolicy.agent?.monitoring.namespace; if ( @@ -771,12 +784,16 @@ class AgentPolicyService { monitoringOutput && fullAgentPolicy.outputs[monitoringOutput]?.type === 'elasticsearch' ) { - const names: string[] = []; + let names: string[] = []; if (fullAgentPolicy.agent.monitoring.logs) { - names.push(`logs-elastic_agent*-${monitoringNamespace}`); + names = names.concat( + MONITORING_DATASETS.map((dataset) => `logs-${dataset}-${monitoringNamespace}`) + ); } if (fullAgentPolicy.agent.monitoring.metrics) { - names.push(`metrics-elastic_agent*-${monitoringNamespace}`); + names = names.concat( + MONITORING_DATASETS.map((dataset) => `metrics-${dataset}-${monitoringNamespace}`) + ); } permissions._elastic_agent_checks.indices = [ From 1dd77f227e6ed3c445725ed5c8da6468c03a1ac1 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 22 Jul 2021 12:58:49 -0400 Subject: [PATCH 18/45] [Fleet] Fix flickering while opening add agent flyout with a policy (#106546) --- ...advanced_agent_authentication_settings.tsx | 134 +++++++++++------- 1 file changed, 83 insertions(+), 51 deletions(-) diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx index adc6ba44dbb18..178c1716d3355 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/advanced_agent_authentication_settings.tsx @@ -6,41 +6,30 @@ */ import type { FunctionComponent } from 'react'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiButtonEmpty, EuiButton, EuiCallOut, EuiSelect, EuiSpacer, EuiText } from '@elastic/eui'; import { SO_SEARCH_LIMIT } from '../../applications/fleet/constants'; -import type { GetEnrollmentAPIKeysResponse } from '../../applications/fleet/types'; +import type { + EnrollmentAPIKey, + GetEnrollmentAPIKeysResponse, +} from '../../applications/fleet/types'; import { sendGetEnrollmentAPIKeys, useStartServices, sendCreateEnrollmentAPIKey, } from '../../applications/fleet/hooks'; +import { Loading } from '../loading'; -interface Props { +const NoEnrollmentKeysCallout: React.FunctionComponent<{ agentPolicyId?: string; - selectedApiKeyId?: string; - initialAuthenticationSettingsOpen?: boolean; - onKeyChange: (key?: string) => void; -} - -export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ - agentPolicyId, - selectedApiKeyId, - initialAuthenticationSettingsOpen = false, - onKeyChange, -}) => { + onCreateEnrollmentApiKey: (key: EnrollmentAPIKey) => void; +}> = ({ agentPolicyId, onCreateEnrollmentApiKey }) => { const { notifications } = useStartServices(); - const [enrollmentAPIKeys, setEnrollmentAPIKeys] = useState( - [] - ); - const [isLoadingEnrollmentKey, setIsLoadingEnrollmentKey] = useState(false); - const [isAuthenticationSettingsOpen, setIsAuthenticationSettingsOpen] = useState( - initialAuthenticationSettingsOpen - ); + const [isLoadingEnrollmentKey, setIsLoadingEnrollmentKey] = useState(false); const onCreateEnrollmentTokenClick = async () => { setIsLoadingEnrollmentKey(true); if (agentPolicyId) { @@ -53,8 +42,7 @@ export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ if (!res.data?.item) { return; } - setEnrollmentAPIKeys([res.data.item]); - onKeyChange(res.data.item.id); + onCreateEnrollmentApiKey(res.data.item); notifications.toasts.addSuccess( i18n.translate('xpack.fleet.newEnrollmentKey.keyCreatedToasts', { defaultMessage: 'Enrollment token created', @@ -69,6 +57,69 @@ export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ } }; + return ( + +
+ +
+ + + + +
+ ); +}; + +interface Props { + agentPolicyId?: string; + selectedApiKeyId?: string; + initialAuthenticationSettingsOpen?: boolean; + onKeyChange: (key?: string) => void; +} + +export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ + agentPolicyId, + selectedApiKeyId, + initialAuthenticationSettingsOpen = false, + onKeyChange, +}) => { + const { notifications } = useStartServices(); + const [enrollmentAPIKeys, setEnrollmentAPIKeys] = useState( + [] + ); + + const [isAuthenticationSettingsOpen, setIsAuthenticationSettingsOpen] = useState( + initialAuthenticationSettingsOpen + ); + const [isLoadingEnrollmentApiKeys, setIsLoadingEnrollmentApiKeys] = useState(false); + + const onCreateEnrollmentApiKey = useCallback( + (key: EnrollmentAPIKey) => { + setEnrollmentAPIKeys([key]); + onKeyChange(key.id); + }, + [onKeyChange] + ); + useEffect( function useEnrollmentKeysForAgentPolicyEffect() { if (!agentPolicyId) { @@ -79,6 +130,7 @@ export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ async function fetchEnrollmentAPIKeys() { try { + setIsLoadingEnrollmentApiKeys(true); const res = await sendGetEnrollmentAPIKeys({ page: 1, perPage: SO_SEARCH_LIMIT, @@ -102,6 +154,8 @@ export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ notifications.toasts.addError(error, { title: 'Error', }); + } finally { + setIsLoadingEnrollmentApiKeys(false); } } fetchEnrollmentAPIKeys(); @@ -157,35 +211,13 @@ export const AdvancedAgentAuthenticationSettings: FunctionComponent = ({ onKeyChange(e.target.value); }} /> + ) : isLoadingEnrollmentApiKeys ? ( + ) : ( - -
- -
- - - - -
+ )} )} From 4ad517f996e696db0c698b71721465c36b44dc16 Mon Sep 17 00:00:00 2001 From: James Rodewig <40268737+jrodewig@users.noreply.github.com> Date: Thu, 22 Jul 2021 13:19:09 -0400 Subject: [PATCH 19/45] [DOCS] Update Watcher reference (#106565) Kibana Alerting is now the preferred method for alerting in Elastic. To avoid confusion, we should use "Watcher" and avoid terms like "Elasticsearch alerting." This updates a reference on the Alerting page. Relates to https://github.com/elastic/elasticsearch/pull/75220 --- docs/user/alerting/alerting-getting-started.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/alerting/alerting-getting-started.asciidoc b/docs/user/alerting/alerting-getting-started.asciidoc index 0b4104ec1f31b..b53788b518fa0 100644 --- a/docs/user/alerting/alerting-getting-started.asciidoc +++ b/docs/user/alerting/alerting-getting-started.asciidoc @@ -125,7 +125,7 @@ image::images/rule-concepts-summary.svg[Rules, connectors, alerts and actions wo [[alerting-concepts-differences]] == Differences from Watcher -{kib} alerting and <> are both used to detect conditions and can trigger actions in response, but they are completely independent alerting systems. +{kib} alerting and <> are both used to detect conditions and can trigger actions in response, but they are completely independent alerting systems. This section will clarify some of the important differences in the function and intent of the two systems. From f30cfd4f7a41b80fc024fd6a6216dc4b15bcb574 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Thu, 22 Jul 2021 13:20:55 -0400 Subject: [PATCH 20/45] [Security Solution][Flyout] Fix timeline action visibility and filter present on scroll (#106527) * fix timeline action visibility and filter present on scroll * remove unused class --- .../event_details/event_fields_browser.tsx | 37 +++++- .../table/use_action_cell_data_provider.ts | 108 +++++++++--------- 2 files changed, 87 insertions(+), 58 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx index 46b7bd5a60b8d..fa792cda46034 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx @@ -9,6 +9,7 @@ import { getOr, noop, sortBy } from 'lodash/fp'; import { EuiInMemoryTable } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; +import { rgba } from 'polished'; import styled from 'styled-components'; import { arrayIndexToAriaIndex, @@ -37,10 +38,38 @@ interface Props { timelineTabType: TimelineTabs | 'flyout'; } +const TableWrapper = styled.div` + display: flex; + flex: 1; + overflow: hidden; + > div { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; + > .euiFlexGroup:first-of-type { + flex: 0; + } + } +`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)` flex: 1; overflow: auto; + &::-webkit-scrollbar { + height: ${({ theme }) => theme.eui.euiScrollBar}; + width: ${({ theme }) => theme.eui.euiScrollBar}; + } + &::-webkit-scrollbar-thumb { + background-clip: content-box; + background-color: ${({ theme }) => rgba(theme.eui.euiColorDarkShade, 0.5)}; + border: ${({ theme }) => theme.eui.euiScrollBarCorner} solid transparent; + } + &::-webkit-scrollbar-corner, + &::-webkit-scrollbar-track { + background-color: transparent; + } .eventFieldsTable__fieldIcon { padding-top: ${({ theme }) => parseFloat(theme.eui.euiSizeXS) * 1.5}px; @@ -102,10 +131,6 @@ const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)` color: ${({ theme }) => theme.eui.euiColorFullShade}; vertical-align: top; } - - .kbnDocViewer__warning { - margin-right: ${({ theme }) => theme.eui.euiSizeS}; - } `; /** @@ -248,7 +273,7 @@ export const EventFieldsBrowser = React.memo( }, [focusSearchInput]); return ( -
+ ( search={search} sorting={false} /> -
+ ); } ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts b/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts index fbe9767759d28..24f660e3315c5 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/table/use_action_cell_data_provider.ts @@ -5,6 +5,8 @@ * 2.0. */ +/* eslint-disable complexity */ + import { escapeDataProviderId } from '@kbn/securitysolution-t-grid'; import { isArray, isEmpty, isString } from 'lodash/fp'; import { @@ -54,61 +56,63 @@ export const useActionCellDataProvider = ({ const stringifiedValues: string[] = []; const arrayValues = Array.isArray(values) ? values : [values]; - const idList: string[] = arrayValues - .map((value, index) => { - // if (fieldFromBrowserField) return ''; - let id = null; - let valueAsString: string = isString(value) ? value : `${values}`; - const appendedUniqueId = `${contextId}-${eventId}-${field}-${index}-${value}-${eventId}-${field}-${value}`; - if (isObjectArray || fieldType === GEO_FIELD_TYPE || [MESSAGE_FIELD_NAME].includes(field)) { - stringifiedValues.push(valueAsString); - return ''; - } else if (fieldType === IP_FIELD_TYPE) { - id = `formatted-ip-data-provider-${contextId}-${field}-${value}-${eventId}`; - if (isString(value) && !isEmpty(value)) { - try { - const addresses = JSON.parse(value); - if (isArray(addresses)) { - valueAsString = addresses.join(','); - } - } catch (_) { - // Default to keeping the existing string value + const idList: string[] = arrayValues.reduce((memo, value, index) => { + let id = null; + let valueAsString: string = isString(value) ? value : `${values}`; + if (fieldFromBrowserField == null) { + stringifiedValues.push(valueAsString); + return memo; + } + const appendedUniqueId = `${contextId}-${eventId}-${field}-${index}-${value}-${eventId}-${field}-${value}`; + if (isObjectArray || fieldType === GEO_FIELD_TYPE || [MESSAGE_FIELD_NAME].includes(field)) { + stringifiedValues.push(valueAsString); + return memo; + } else if (fieldType === IP_FIELD_TYPE) { + id = `formatted-ip-data-provider-${contextId}-${field}-${value}-${eventId}`; + if (isString(value) && !isEmpty(value)) { + try { + const addresses = JSON.parse(value); + if (isArray(addresses)) { + valueAsString = addresses.join(','); } + } catch (_) { + // Default to keeping the existing string value } - } else if (PORT_NAMES.some((portName) => field === portName)) { - id = `port-default-draggable-${appendedUniqueId}`; - } else if (field === EVENT_DURATION_FIELD_NAME) { - id = `duration-default-draggable-${appendedUniqueId}`; - } else if (field === HOST_NAME_FIELD_NAME) { - id = `event-details-value-default-draggable-${appendedUniqueId}`; - } else if (fieldFormat === BYTES_FORMAT) { - id = `bytes-default-draggable-${appendedUniqueId}`; - } else if (field === SIGNAL_RULE_NAME_FIELD_NAME) { - id = `event-details-value-default-draggable-${appendedUniqueId}-${linkValue}`; - } else if (field === EVENT_MODULE_FIELD_NAME) { - id = `event-details-value-default-draggable-${appendedUniqueId}-${value}`; - } else if (field === SIGNAL_STATUS_FIELD_NAME) { - id = `alert-details-value-default-draggable-${appendedUniqueId}`; - } else if (field === AGENT_STATUS_FIELD_NAME) { - const valueToUse = typeof value === 'string' ? value : ''; - id = `event-details-value-default-draggable-${appendedUniqueId}`; - valueAsString = valueToUse; - } else if ( - [ - RULE_REFERENCE_FIELD_NAME, - REFERENCE_URL_FIELD_NAME, - EVENT_URL_FIELD_NAME, - INDICATOR_REFERENCE, - ].includes(field) - ) { - id = `event-details-value-default-draggable-${appendedUniqueId}-${value}`; - } else { - id = `event-details-value-default-draggable-${appendedUniqueId}`; } - stringifiedValues.push(valueAsString); - return escapeDataProviderId(id); - }) - .filter((id) => id !== ''); + } else if (PORT_NAMES.some((portName) => field === portName)) { + id = `port-default-draggable-${appendedUniqueId}`; + } else if (field === EVENT_DURATION_FIELD_NAME) { + id = `duration-default-draggable-${appendedUniqueId}`; + } else if (field === HOST_NAME_FIELD_NAME) { + id = `event-details-value-default-draggable-${appendedUniqueId}`; + } else if (fieldFormat === BYTES_FORMAT) { + id = `bytes-default-draggable-${appendedUniqueId}`; + } else if (field === SIGNAL_RULE_NAME_FIELD_NAME) { + id = `event-details-value-default-draggable-${appendedUniqueId}-${linkValue}`; + } else if (field === EVENT_MODULE_FIELD_NAME) { + id = `event-details-value-default-draggable-${appendedUniqueId}-${value}`; + } else if (field === SIGNAL_STATUS_FIELD_NAME) { + id = `alert-details-value-default-draggable-${appendedUniqueId}`; + } else if (field === AGENT_STATUS_FIELD_NAME) { + const valueToUse = typeof value === 'string' ? value : ''; + id = `event-details-value-default-draggable-${appendedUniqueId}`; + valueAsString = valueToUse; + } else if ( + [ + RULE_REFERENCE_FIELD_NAME, + REFERENCE_URL_FIELD_NAME, + EVENT_URL_FIELD_NAME, + INDICATOR_REFERENCE, + ].includes(field) + ) { + id = `event-details-value-default-draggable-${appendedUniqueId}-${value}`; + } else { + id = `event-details-value-default-draggable-${appendedUniqueId}`; + } + stringifiedValues.push(valueAsString); + memo.push(escapeDataProviderId(id)); + return memo; + }, [] as string[]); return { idList, From dd142c460f040571dee8ffb5541609b2a6fede2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Thu, 22 Jul 2021 13:39:24 -0400 Subject: [PATCH 21/45] Fix doc on ok rule status (#106545) --- docs/user/alerting/create-and-manage-rules.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/alerting/create-and-manage-rules.asciidoc b/docs/user/alerting/create-and-manage-rules.asciidoc index 2dd9a41205121..23e79911fd5ee 100644 --- a/docs/user/alerting/create-and-manage-rules.asciidoc +++ b/docs/user/alerting/create-and-manage-rules.asciidoc @@ -158,7 +158,7 @@ image:images/bulk-mute-disable.png[The Manage rules button lets you mute/unmute, A rule can have one of the following statuses: `active`:: The conditions for the rule have been met, and the associated actions should be invoked. -`ok`:: The conditions for the rule were previously met, but no longer. Changed to `recovered` in the 7.14 release. +`ok`:: The conditions for the rule have not been met, and the associated actions are not invoked. `error`:: An error was encountered during rule execution. `pending`:: The rule has not yet executed. The rule was either just created, or enabled after being disabled. `unknown`:: A problem occurred when calculating the status. Most likely, something went wrong with the alerting code. From e952f9a135c4d6959b91116d2b035a9ecc7620cf Mon Sep 17 00:00:00 2001 From: Michael Dokolin Date: Thu, 22 Jul 2021 20:22:05 +0200 Subject: [PATCH 22/45] [Reporting] Fix the delete button in the reporting management dashboard (#106016) * Fix the delete button in the reporting management dashboard * Update delete button copy for consistency --- .../public/management/report_delete_button.tsx | 2 +- .../reporting/public/management/report_listing.tsx | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/reporting/public/management/report_delete_button.tsx b/x-pack/plugins/reporting/public/management/report_delete_button.tsx index 5200191184972..7009a653c1bf6 100644 --- a/x-pack/plugins/reporting/public/management/report_delete_button.tsx +++ b/x-pack/plugins/reporting/public/management/report_delete_button.tsx @@ -92,7 +92,7 @@ export class ReportDeleteButton extends PureComponent { {intl.formatMessage( { id: 'xpack.reporting.listing.table.deleteReportButton', - defaultMessage: `Delete ({num})`, + defaultMessage: `Delete {num, plural, one {report} other {reports} }`, }, { num: jobsToDelete.length } )} diff --git a/x-pack/plugins/reporting/public/management/report_listing.tsx b/x-pack/plugins/reporting/public/management/report_listing.tsx index 02fe3aeaef5a1..9ba0026999137 100644 --- a/x-pack/plugins/reporting/public/management/report_listing.tsx +++ b/x-pack/plugins/reporting/public/management/report_listing.tsx @@ -147,7 +147,7 @@ class ReportListingUi extends Component { - {this.renderTable()} +
{this.renderTable()}
@@ -489,6 +489,14 @@ class ReportListingUi extends Component { return ( + {this.state.selectedJobs.length > 0 && ( + + + {this.renderDeleteButton()} + + + + )} { onChange={this.onTableChange} data-test-subj="reportJobListing" /> - {this.state.selectedJobs.length > 0 ? this.renderDeleteButton() : null} ); } From cd667d06bcd97b4f9eaefb771db6d9901099c8fd Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Thu, 22 Jul 2021 12:44:54 -0600 Subject: [PATCH 23/45] [Security Solutions][Detection Engine] Creates an autocomplete package and moves duplicate code between lists and security_solution there (#105382) ## Summary Creates an autocomplete package from `lists` and removes duplicate code between `lists` and `security_solutions` * Consolidates different PR's where we were changing different parts of autocomplete in different ways. * Existing Cypress tests should cover any mistakes hopefully Manual Testing: * Ensure this bug does not crop up again https://github.com/elastic/kibana/pull/87004 * Make sure that the exception list autocomplete looks alright ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .i18nrc.json | 1 + .../monorepo-packages.asciidoc | 1 + package.json | 1 + packages/BUILD.bazel | 1 + .../BUILD.bazel | 125 ++++++ .../README.md | 0 .../babel.config.js | 19 + .../jest.config.js | 13 + .../package.json | 10 + .../react/package.json | 5 + .../src/autocomplete/index.mock.ts | 15 + .../src/check_empty_value/index.test.ts | 49 ++ .../src/check_empty_value/index.ts | 37 ++ .../src/field/index.test.tsx | 16 +- .../src/field/index.tsx | 23 +- .../src/field_value_exists/index.test.tsx | 7 +- .../src/field_value_exists/index.tsx | 5 +- .../src/field_value_lists/index.test.tsx | 29 +- .../src/field_value_lists/index.tsx | 20 +- .../src/field_value_match/index.test.tsx | 24 +- .../src/field_value_match/index.tsx | 55 +-- .../src/field_value_match_any/index.test.tsx | 27 +- .../src/field_value_match_any/index.tsx | 28 +- .../src/fields/index.mock.ts | 313 +++++++++++++ .../src/filter_field_to_list/index.test.ts | 79 ++++ .../src/filter_field_to_list/index.ts | 29 ++ .../index.test.tsx | 97 ++++ .../src/get_generic_combo_box_props/index.ts | 48 ++ .../src/get_operators/index.test.ts | 53 +++ .../src/get_operators/index.ts | 38 ++ .../src/hooks/index.ts | 8 + .../index.test.ts | 38 +- .../use_field_value_autocomplete/index.ts | 19 +- .../src/index.ts | 19 + .../src/list_schema/index.mock.ts | 51 +++ .../src/operator/index.test.tsx | 12 +- .../src/operator/index.tsx | 16 +- .../src/param_is_valid/index.test.ts | 102 +++++ .../src/param_is_valid/index.ts | 52 +++ .../src/translations/index.ts | 29 ++ .../src/type_match/index.test.ts | 59 +++ .../src/type_match/index.ts | 27 ++ .../tsconfig.browser.json | 23 + .../tsconfig.json | 16 + .../components/autocomplete/helpers.test.ts | 388 ---------------- .../components/autocomplete/helpers.ts | 183 -------- .../components/autocomplete/index.tsx | 13 - .../components/autocomplete/translations.ts | 28 -- .../components/autocomplete/types.ts | 14 - .../components/builder/entry_renderer.tsx | 14 +- .../components/autocomplete/field.test.tsx | 146 ------ .../common/components/autocomplete/field.tsx | 146 ------ .../autocomplete/field_value_match.test.tsx | 425 ------------------ .../autocomplete/field_value_match.tsx | 285 ------------ .../components/autocomplete/helpers.test.ts | 223 --------- .../common/components/autocomplete/helpers.ts | 119 ----- .../use_field_value_autocomplete.test.ts | 325 -------------- .../hooks/use_field_value_autocomplete.ts | 123 ----- .../common/components/autocomplete/readme.md | 122 ----- .../components/autocomplete/translations.ts | 34 -- .../common/components/autocomplete/types.ts | 14 - .../components/threat_match/entry_item.tsx | 2 +- .../rules/autocomplete_field/index.tsx | 2 +- .../rules/risk_score_mapping/index.tsx | 2 +- .../rules/severity_mapping/index.tsx | 10 +- .../translations/translations/ja-JP.json | 16 +- .../translations/translations/zh-CN.json | 12 +- yarn.lock | 4 + 68 files changed, 1524 insertions(+), 2765 deletions(-) create mode 100644 packages/kbn-securitysolution-autocomplete/BUILD.bazel rename {x-pack/plugins/lists/public/exceptions/components/autocomplete => packages/kbn-securitysolution-autocomplete}/README.md (100%) create mode 100644 packages/kbn-securitysolution-autocomplete/babel.config.js create mode 100644 packages/kbn-securitysolution-autocomplete/jest.config.js create mode 100644 packages/kbn-securitysolution-autocomplete/package.json create mode 100644 packages/kbn-securitysolution-autocomplete/react/package.json create mode 100644 packages/kbn-securitysolution-autocomplete/src/autocomplete/index.mock.ts create mode 100644 packages/kbn-securitysolution-autocomplete/src/check_empty_value/index.test.ts create mode 100644 packages/kbn-securitysolution-autocomplete/src/check_empty_value/index.ts rename x-pack/plugins/lists/public/exceptions/components/autocomplete/field.test.tsx => packages/kbn-securitysolution-autocomplete/src/field/index.test.tsx (92%) rename x-pack/plugins/lists/public/exceptions/components/autocomplete/field.tsx => packages/kbn-securitysolution-autocomplete/src/field/index.tsx (87%) rename x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.test.tsx => packages/kbn-securitysolution-autocomplete/src/field_value_exists/index.test.tsx (70%) rename x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.tsx => packages/kbn-securitysolution-autocomplete/src/field_value_exists/index.tsx (83%) rename x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.test.tsx => packages/kbn-securitysolution-autocomplete/src/field_value_lists/index.test.tsx (88%) rename x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.tsx => packages/kbn-securitysolution-autocomplete/src/field_value_lists/index.tsx (80%) rename x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.test.tsx => packages/kbn-securitysolution-autocomplete/src/field_value_match/index.test.tsx (95%) rename x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.tsx => packages/kbn-securitysolution-autocomplete/src/field_value_match/index.tsx (85%) rename x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.test.tsx => packages/kbn-securitysolution-autocomplete/src/field_value_match_any/index.test.tsx (91%) rename x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.tsx => packages/kbn-securitysolution-autocomplete/src/field_value_match_any/index.tsx (86%) create mode 100644 packages/kbn-securitysolution-autocomplete/src/fields/index.mock.ts create mode 100644 packages/kbn-securitysolution-autocomplete/src/filter_field_to_list/index.test.ts create mode 100644 packages/kbn-securitysolution-autocomplete/src/filter_field_to_list/index.ts create mode 100644 packages/kbn-securitysolution-autocomplete/src/get_generic_combo_box_props/index.test.tsx create mode 100644 packages/kbn-securitysolution-autocomplete/src/get_generic_combo_box_props/index.ts create mode 100644 packages/kbn-securitysolution-autocomplete/src/get_operators/index.test.ts create mode 100644 packages/kbn-securitysolution-autocomplete/src/get_operators/index.ts create mode 100644 packages/kbn-securitysolution-autocomplete/src/hooks/index.ts rename x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.test.ts => packages/kbn-securitysolution-autocomplete/src/hooks/use_field_value_autocomplete/index.test.ts (92%) rename x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.ts => packages/kbn-securitysolution-autocomplete/src/hooks/use_field_value_autocomplete/index.ts (81%) create mode 100644 packages/kbn-securitysolution-autocomplete/src/index.ts create mode 100644 packages/kbn-securitysolution-autocomplete/src/list_schema/index.mock.ts rename x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.test.tsx => packages/kbn-securitysolution-autocomplete/src/operator/index.test.tsx (95%) rename x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.tsx => packages/kbn-securitysolution-autocomplete/src/operator/index.tsx (80%) create mode 100644 packages/kbn-securitysolution-autocomplete/src/param_is_valid/index.test.ts create mode 100644 packages/kbn-securitysolution-autocomplete/src/param_is_valid/index.ts create mode 100644 packages/kbn-securitysolution-autocomplete/src/translations/index.ts create mode 100644 packages/kbn-securitysolution-autocomplete/src/type_match/index.test.ts create mode 100644 packages/kbn-securitysolution-autocomplete/src/type_match/index.ts create mode 100644 packages/kbn-securitysolution-autocomplete/tsconfig.browser.json create mode 100644 packages/kbn-securitysolution-autocomplete/tsconfig.json delete mode 100644 x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.test.ts delete mode 100644 x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.ts delete mode 100644 x-pack/plugins/lists/public/exceptions/components/autocomplete/index.tsx delete mode 100644 x-pack/plugins/lists/public/exceptions/components/autocomplete/translations.ts delete mode 100644 x-pack/plugins/lists/public/exceptions/components/autocomplete/types.ts delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/field.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.test.ts delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.ts delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/readme.md delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/translations.ts delete mode 100644 x-pack/plugins/security_solution/public/common/components/autocomplete/types.ts diff --git a/.i18nrc.json b/.i18nrc.json index 0ee1e55ed62c6..ad32edb67b83f 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -1,5 +1,6 @@ { "paths": { + "autocomplete": "packages/kbn-securitysolution-autocomplete/src", "console": "src/plugins/console", "core": "src/core", "discover": "src/plugins/discover", diff --git a/docs/developer/getting-started/monorepo-packages.asciidoc b/docs/developer/getting-started/monorepo-packages.asciidoc index b656405b173d8..0b635df68aca4 100644 --- a/docs/developer/getting-started/monorepo-packages.asciidoc +++ b/docs/developer/getting-started/monorepo-packages.asciidoc @@ -91,6 +91,7 @@ yarn kbn watch-bazel - @kbn/optimizer - @kbn/plugin-helpers - @kbn/rule-data-utils +- @kbn/securitysolution-autocomplete - @kbn/securitysolution-es-utils - @kbn/securitysolution-hook-utils - @kbn/securitysolution-io-ts-alerting-types diff --git a/package.json b/package.json index 9034638c689a4..f7856b9f92e74 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "@kbn/mapbox-gl": "link:bazel-bin/packages/kbn-mapbox-gl", "@kbn/monaco": "link:bazel-bin/packages/kbn-monaco", "@kbn/rule-data-utils": "link:bazel-bin/packages/kbn-rule-data-utils", + "@kbn/securitysolution-autocomplete": "link:bazel-bin/packages/kbn-securitysolution-autocomplete", "@kbn/securitysolution-es-utils": "link:bazel-bin/packages/kbn-securitysolution-es-utils", "@kbn/securitysolution-hook-utils": "link:bazel-bin/packages/kbn-securitysolution-hook-utils", "@kbn/securitysolution-io-ts-alerting-types": "link:bazel-bin/packages/kbn-securitysolution-io-ts-alerting-types", diff --git a/packages/BUILD.bazel b/packages/BUILD.bazel index 0719357b6df35..778a7c7a0f2d4 100644 --- a/packages/BUILD.bazel +++ b/packages/BUILD.bazel @@ -36,6 +36,7 @@ filegroup( "//packages/kbn-plugin-generator:build", "//packages/kbn-plugin-helpers:build", "//packages/kbn-rule-data-utils:build", + "//packages/kbn-securitysolution-autocomplete:build", "//packages/kbn-securitysolution-list-constants:build", "//packages/kbn-securitysolution-io-ts-types:build", "//packages/kbn-securitysolution-io-ts-alerting-types:build", diff --git a/packages/kbn-securitysolution-autocomplete/BUILD.bazel b/packages/kbn-securitysolution-autocomplete/BUILD.bazel new file mode 100644 index 0000000000000..8e403a215d81d --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/BUILD.bazel @@ -0,0 +1,125 @@ +load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project") +load("@build_bazel_rules_nodejs//:index.bzl", "js_library", "pkg_npm") + +PKG_BASE_NAME = "kbn-securitysolution-autocomplete" + +PKG_REQUIRE_NAME = "@kbn/securitysolution-autocomplete" + +SOURCE_FILES = glob( + [ + "src/**/*.ts", + "src/**/*.tsx" + ], + exclude = [ + "**/*.test.*", + "**/*.mock.*", + "**/*.mocks.*", + ], +) + +SRCS = SOURCE_FILES + +filegroup( + name = "srcs", + srcs = SRCS, +) + +NPM_MODULE_EXTRA_FILES = [ + "react/package.json", + "package.json", + "README.md", +] + +SRC_DEPS = [ + "//packages/kbn-babel-preset", + "//packages/kbn-dev-utils", + "//packages/kbn-i18n", + "//packages/kbn-securitysolution-io-ts-list-types", + "//packages/kbn-securitysolution-list-hooks", + "@npm//@babel/core", + "@npm//babel-loader", + "@npm//@elastic/eui", + "@npm//react", + "@npm//resize-observer-polyfill", + "@npm//rxjs", + "@npm//tslib", +] + +TYPES_DEPS = [ + "@npm//typescript", + "@npm//@types/jest", + "@npm//@types/node", + "@npm//@types/react", +] + +DEPS = SRC_DEPS + TYPES_DEPS + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = [ + "//:tsconfig.base.json", + ], +) + +ts_config( + name = "tsconfig_browser", + src = "tsconfig.browser.json", + deps = [ + "//:tsconfig.base.json", + "//:tsconfig.browser.json", + ], +) + +ts_project( + name = "tsc", + args = ["--pretty"], + srcs = SRCS, + deps = DEPS, + allow_js = True, + declaration = True, + declaration_dir = "target_types", + declaration_map = True, + incremental = True, + out_dir = "target_node", + root_dir = "src", + source_map = True, + tsconfig = ":tsconfig", +) + +ts_project( + name = "tsc_browser", + args = ['--pretty'], + srcs = SRCS, + deps = DEPS, + allow_js = True, + declaration = False, + incremental = True, + out_dir = "target_web", + source_map = True, + root_dir = "src", + tsconfig = ":tsconfig_browser", +) + +js_library( + name = PKG_BASE_NAME, + package_name = PKG_REQUIRE_NAME, + srcs = NPM_MODULE_EXTRA_FILES, + visibility = ["//visibility:public"], + deps = [":tsc", ":tsc_browser"] + DEPS, +) + +pkg_npm( + name = "npm_module", + deps = [ + ":%s" % PKG_BASE_NAME, + ] +) + +filegroup( + name = "build", + srcs = [ + ":npm_module", + ], + visibility = ["//visibility:public"], +) diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/README.md b/packages/kbn-securitysolution-autocomplete/README.md similarity index 100% rename from x-pack/plugins/lists/public/exceptions/components/autocomplete/README.md rename to packages/kbn-securitysolution-autocomplete/README.md diff --git a/packages/kbn-securitysolution-autocomplete/babel.config.js b/packages/kbn-securitysolution-autocomplete/babel.config.js new file mode 100644 index 0000000000000..b4a118df51af5 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/babel.config.js @@ -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 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 = { + env: { + web: { + presets: ['@kbn/babel-preset/webpack_preset'], + }, + node: { + presets: ['@kbn/babel-preset/node_preset'], + }, + }, + ignore: ['**/*.test.ts', '**/*.test.tsx'], +}; diff --git a/packages/kbn-securitysolution-autocomplete/jest.config.js b/packages/kbn-securitysolution-autocomplete/jest.config.js new file mode 100644 index 0000000000000..9b14447c98366 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/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: ['/packages/kbn-securitysolution-autocomplete'], +}; diff --git a/packages/kbn-securitysolution-autocomplete/package.json b/packages/kbn-securitysolution-autocomplete/package.json new file mode 100644 index 0000000000000..5cfd18b63256a --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/package.json @@ -0,0 +1,10 @@ +{ + "name": "@kbn/securitysolution-autocomplete", + "version": "1.0.0", + "description": "Security Solution auto complete", + "license": "SSPL-1.0 OR Elastic License 2.0", + "browser": "./target_web/index.js", + "main": "./target_node/index.js", + "types": "./target_types/index.d.ts", + "private": true +} diff --git a/packages/kbn-securitysolution-autocomplete/react/package.json b/packages/kbn-securitysolution-autocomplete/react/package.json new file mode 100644 index 0000000000000..c5f222b5843ac --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/react/package.json @@ -0,0 +1,5 @@ +{ + "browser": "../target_web/react", + "main": "../target_node/react", + "types": "../target_types/react/index.d.ts" +} diff --git a/packages/kbn-securitysolution-autocomplete/src/autocomplete/index.mock.ts b/packages/kbn-securitysolution-autocomplete/src/autocomplete/index.mock.ts new file mode 100644 index 0000000000000..444a033b4887b --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/autocomplete/index.mock.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. + */ + +// Copied from "src/plugins/data/public/mocks.ts" but without any type information +// TODO: Remove this in favor of the data/public/mocks if/when they become available, https://github.com/elastic/kibana/issues/100715 +export const autocompleteStartMock = { + getQuerySuggestions: jest.fn(), + getValueSuggestions: jest.fn(), + hasQuerySuggestions: jest.fn(), +}; diff --git a/packages/kbn-securitysolution-autocomplete/src/check_empty_value/index.test.ts b/packages/kbn-securitysolution-autocomplete/src/check_empty_value/index.test.ts new file mode 100644 index 0000000000000..c36184e5c5ba1 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/check_empty_value/index.test.ts @@ -0,0 +1,49 @@ +/* + * 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 { checkEmptyValue } from '.'; +import { getField } from '../fields/index.mock'; +import * as i18n from '../translations'; + +describe('check_empty_value', () => { + test('returns no errors if no field has been selected', () => { + const isValid = checkEmptyValue('', undefined, true, false); + + expect(isValid).toBeUndefined(); + }); + + test('returns error string if user has touched a required input and left empty', () => { + const isValid = checkEmptyValue(undefined, getField('@timestamp'), true, true); + + expect(isValid).toEqual(i18n.FIELD_REQUIRED_ERR); + }); + + test('returns no errors if required input is empty but user has not yet touched it', () => { + const isValid = checkEmptyValue(undefined, getField('@timestamp'), true, false); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if user has touched an input that is not required and left empty', () => { + const isValid = checkEmptyValue(undefined, getField('@timestamp'), false, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if user has touched an input that is not required and left empty string', () => { + const isValid = checkEmptyValue('', getField('@timestamp'), false, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns null if input value is not empty string or undefined', () => { + const isValid = checkEmptyValue('hellooo', getField('@timestamp'), false, true); + + expect(isValid).toBeNull(); + }); +}); diff --git a/packages/kbn-securitysolution-autocomplete/src/check_empty_value/index.ts b/packages/kbn-securitysolution-autocomplete/src/check_empty_value/index.ts new file mode 100644 index 0000000000000..894f233f73a5a --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/check_empty_value/index.ts @@ -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 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 * as i18n from '../translations'; + +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/105731 +// import { IFieldType } from '../../../../../../../src/plugins/data/common'; +type IFieldType = any; + +/** + * Determines if empty value is ok + */ +export const checkEmptyValue = ( + param: string | undefined, + field: IFieldType | undefined, + isRequired: boolean, + touched: boolean +): string | undefined | null => { + if (isRequired && touched && (param == null || param.trim() === '')) { + return i18n.FIELD_REQUIRED_ERR; + } + + if ( + field == null || + (isRequired && !touched) || + (!isRequired && (param == null || param === '')) + ) { + return undefined; + } + + return null; +}; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field.test.tsx b/packages/kbn-securitysolution-autocomplete/src/field/index.test.tsx similarity index 92% rename from x-pack/plugins/lists/public/exceptions/components/autocomplete/field.test.tsx rename to packages/kbn-securitysolution-autocomplete/src/field/index.test.tsx index 416852b469a79..08f55cef89b66 100644 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field.test.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field/index.test.tsx @@ -1,22 +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. + * 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 { mount } from 'enzyme'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; +import { FieldComponent } from '.'; +import { fields, getField } from '../fields/index.mock'; -import { - fields, - getField, -} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; - -import { FieldComponent } from './field'; - -describe('FieldComponent', () => { +describe('field', () => { test('it renders disabled if "isDisabled" is true', () => { const wrapper = mount( = ({ fieldInputWidth, fieldTypeFilter = [], diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.test.tsx b/packages/kbn-securitysolution-autocomplete/src/field_value_exists/index.test.tsx similarity index 70% rename from x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.test.tsx rename to packages/kbn-securitysolution-autocomplete/src/field_value_exists/index.test.tsx index b6300581f12dd..c4c07aff909e4 100644 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.test.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field_value_exists/index.test.tsx @@ -1,14 +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; 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 { mount } from 'enzyme'; -import { AutocompleteFieldExistsComponent } from './field_value_exists'; +import { AutocompleteFieldExistsComponent } from '.'; describe('AutocompleteFieldExistsComponent', () => { test('it renders field disabled', () => { diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.tsx b/packages/kbn-securitysolution-autocomplete/src/field_value_exists/index.tsx similarity index 83% rename from x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.tsx rename to packages/kbn-securitysolution-autocomplete/src/field_value_exists/index.tsx index ff70204e53483..37a16406e65a3 100644 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_exists.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field_value_exists/index.tsx @@ -1,8 +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; 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'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.test.tsx b/packages/kbn-securitysolution-autocomplete/src/field_value_lists/index.test.tsx similarity index 88% rename from x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.test.tsx rename to packages/kbn-securitysolution-autocomplete/src/field_value_lists/index.test.tsx index a338ce6a27d6c..6fcf8ddf74b03 100644 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.test.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field_value_lists/index.test.tsx @@ -1,8 +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; 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'; @@ -11,15 +12,20 @@ import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { waitFor } from '@testing-library/react'; import type { ListSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { getFoundListSchemaMock } from '../../../../../lists/common/schemas/response/found_list_schema.mock'; -import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; -import { DATE_NOW, IMMUTABLE, VERSION } from '../../../../../lists/common/constants.mock'; - -import { AutocompleteFieldListsComponent } from './field_value_lists'; - -const mockKibanaHttpService = coreMock.createStart().http; +import { getField } from '../fields/index.mock'; +import { AutocompleteFieldListsComponent } from '.'; +import { + getListResponseMock, + getFoundListSchemaMock, + DATE_NOW, + IMMUTABLE, + VERSION, +} from '../list_schema/index.mock'; + +// TODO: Once these mocks are available, use them instead of hand mocking, https://github.com/elastic/kibana/issues/100715 +// const mockKibanaHttpService = coreMock.createStart().http; +// import { coreMock } from '../../../../../../../src/core/public/mocks'; +const mockKibanaHttpService = jest.fn(); const mockStart = jest.fn(); const mockKeywordList: ListSchema = { @@ -35,7 +41,6 @@ jest.mock('@kbn/securitysolution-list-hooks', () => { return { ...originalModule, - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type useFindLists: () => ({ error: undefined, loading: false, diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.tsx b/packages/kbn-securitysolution-autocomplete/src/field_value_lists/index.tsx similarity index 80% rename from x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.tsx rename to packages/kbn-securitysolution-autocomplete/src/field_value_lists/index.tsx index 047f8ef33c8c0..4064ff11962bd 100644 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_lists.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field_value_lists/index.tsx @@ -1,20 +1,28 @@ /* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. + * 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, { useCallback, useEffect, useMemo, useState } from 'react'; import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; -import { HttpStart } from 'kibana/public'; import type { ListSchema } from '@kbn/securitysolution-io-ts-list-types'; import { useFindLists } from '@kbn/securitysolution-list-hooks'; -import { IFieldType } from '../../../../../../../src/plugins/data/common'; +import { filterFieldToList } from '../filter_field_to_list'; +import { getGenericComboBoxProps } from '../get_generic_combo_box_props'; -import { filterFieldToList, getGenericComboBoxProps } from './helpers'; -import * as i18n from './translations'; +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/105731 +// import { IFieldType } from '../../../../../../../src/plugins/data/common'; +type IFieldType = any; + +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/100715 +// import { HttpStart } from 'kibana/public'; +type HttpStart = any; + +import * as i18n from '../translations'; const SINGLE_SELECTION = { asPlainText: true }; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.test.tsx b/packages/kbn-securitysolution-autocomplete/src/field_value_match/index.test.tsx similarity index 95% rename from x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.test.tsx rename to packages/kbn-securitysolution-autocomplete/src/field_value_match/index.test.tsx index c1ffb008e8563..d695088245622 100644 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.test.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field_value_match/index.test.tsx @@ -1,27 +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. + * 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 { ReactWrapper, mount } from 'enzyme'; import { EuiComboBox, EuiComboBoxOptionOption, EuiSuperSelect } from '@elastic/eui'; import { act } from '@testing-library/react'; +import { AutocompleteFieldMatchComponent } from '.'; +import { useFieldValueAutocomplete } from '../hooks/use_field_value_autocomplete'; +import { fields, getField } from '../fields/index.mock'; +import { autocompleteStartMock } from '../autocomplete/index.mock'; -import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; -import { - fields, - getField, -} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; - -import { AutocompleteFieldMatchComponent } from './field_value_match'; -import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; - -jest.mock('./hooks/use_field_value_autocomplete'); - -const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); +jest.mock('../hooks/use_field_value_autocomplete'); describe('AutocompleteFieldMatchComponent', () => { let wrapper: ReactWrapper; @@ -299,7 +293,6 @@ describe('AutocompleteFieldMatchComponent', () => { selectedValue="" /> ); - expect( wrapper.find('[data-test-subj="valuesAutocompleteMatchBoolean"]').exists() ).toBeTruthy(); @@ -431,7 +424,6 @@ describe('AutocompleteFieldMatchComponent', () => { selectedValue="" /> ); - wrapper .find('[data-test-subj="valueAutocompleteFieldMatchNumber"] input') .at(0) diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.tsx b/packages/kbn-securitysolution-autocomplete/src/field_value_match/index.tsx similarity index 85% rename from x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.tsx rename to packages/kbn-securitysolution-autocomplete/src/field_value_match/index.tsx index 8dbe8f223ae5b..8199967489515 100644 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field_value_match/index.tsx @@ -1,28 +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. + * 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, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState, useEffect } from 'react'; import { - EuiComboBox, - EuiComboBoxOptionOption, - EuiFieldNumber, - EuiFormRow, EuiSuperSelect, + EuiFormRow, + EuiFieldNumber, + EuiComboBoxOptionOption, + EuiComboBox, } from '@elastic/eui'; import { uniq } from 'lodash'; + import { ListOperatorTypeEnum as OperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; -import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/100715 +// import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; +type AutocompleteStart = any; -import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; -import { getGenericComboBoxProps, paramIsValid } from './helpers'; -import { GetGenericComboBoxPropsReturn } from './types'; -import * as i18n from './translations'; +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/105731 +// import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +type IFieldType = any; +type IIndexPattern = any; + +import * as i18n from '../translations'; +import { useFieldValueAutocomplete } from '../hooks/use_field_value_autocomplete'; +import { + getGenericComboBoxProps, + GetGenericComboBoxPropsReturn, +} from '../get_generic_combo_box_props'; +import { paramIsValid } from '../param_is_valid'; const BOOLEAN_OPTIONS = [ { inputDisplay: 'true', value: 'true' }, @@ -47,11 +58,6 @@ interface AutocompleteFieldMatchProps { onError?: (arg: boolean) => void; } -/** - * There is a copy of this within: - * x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx - * TODO: This should be in its own packaged and not copied, https://github.com/elastic/kibana/issues/105378 - */ export const AutocompleteFieldMatchComponent: React.FC = ({ placeholder, rowLabel, @@ -189,11 +195,6 @@ export const AutocompleteFieldMatchComponent: React.FC (fieldInputWidth ? { width: `${fieldInputWidth}px` } : {}), - [fieldInputWidth] - ); - useEffect((): void => { setError(undefined); if (onError != null) { @@ -225,7 +226,7 @@ export const AutocompleteFieldMatchComponent: React.FC @@ -234,7 +235,7 @@ export const AutocompleteFieldMatchComponent: React.FC
@@ -289,7 +290,7 @@ export const AutocompleteFieldMatchComponent: React.FC diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.test.tsx b/packages/kbn-securitysolution-autocomplete/src/field_value_match_any/index.test.tsx similarity index 91% rename from x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.test.tsx rename to packages/kbn-securitysolution-autocomplete/src/field_value_match_any/index.test.tsx index 8aa1f18b695a0..a3ca97874908e 100644 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.test.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field_value_match_any/index.test.tsx @@ -1,8 +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; 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'; @@ -10,18 +11,18 @@ import { ReactWrapper, mount } from 'enzyme'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { act } from '@testing-library/react'; -import { - fields, - getField, -} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; +import { AutocompleteFieldMatchAnyComponent } from '.'; +import { getField, fields } from '../fields/index.mock'; +import { useFieldValueAutocomplete } from '../hooks/use_field_value_autocomplete'; +import { autocompleteStartMock } from '../autocomplete/index.mock'; -import { AutocompleteFieldMatchAnyComponent } from './field_value_match_any'; -import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; - -const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); - -jest.mock('./hooks/use_field_value_autocomplete'); +jest.mock('../hooks/use_field_value_autocomplete', () => { + const actual = jest.requireActual('../hooks/use_field_value_autocomplete'); + return { + ...actual, + useFieldValueAutocomplete: jest.fn(), + }; +}); describe('AutocompleteFieldMatchAnyComponent', () => { let wrapper: ReactWrapper; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.tsx b/packages/kbn-securitysolution-autocomplete/src/field_value_match_any/index.tsx similarity index 86% rename from x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.tsx rename to packages/kbn-securitysolution-autocomplete/src/field_value_match_any/index.tsx index e5a5e76f8cc5d..338c4baa8bc6f 100644 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match_any.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/field_value_match_any/index.tsx @@ -1,8 +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; 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, { useCallback, useMemo, useState } from 'react'; @@ -10,13 +11,22 @@ import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; import { uniq } from 'lodash'; import { ListOperatorTypeEnum as OperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; -import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; - -import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; -import { getGenericComboBoxProps, paramIsValid } from './helpers'; -import { GetGenericComboBoxPropsReturn } from './types'; -import * as i18n from './translations'; +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/100715 +// import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; +type AutocompleteStart = any; + +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/105731 +// import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +type IFieldType = any; +type IIndexPattern = any; + +import * as i18n from '../translations'; +import { + getGenericComboBoxProps, + GetGenericComboBoxPropsReturn, +} from '../get_generic_combo_box_props'; +import { useFieldValueAutocomplete } from '../hooks/use_field_value_autocomplete'; +import { paramIsValid } from '../param_is_valid'; interface AutocompleteFieldMatchAnyProps { placeholder: string; diff --git a/packages/kbn-securitysolution-autocomplete/src/fields/index.mock.ts b/packages/kbn-securitysolution-autocomplete/src/fields/index.mock.ts new file mode 100644 index 0000000000000..5938ed34547a1 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/fields/index.mock.ts @@ -0,0 +1,313 @@ +/* + * 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. + */ + +// Copied from "src/plugins/data/common/index_patterns/fields/fields.mocks.ts" +// but without types. +// TODO: This should move out once those mocks are directly useable or in their own package, https://github.com/elastic/kibana/issues/100715 + +export const fields = [ + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'ssl', + type: 'boolean', + esTypes: ['boolean'], + count: 20, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: '@timestamp', + type: 'date', + esTypes: ['date'], + count: 30, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'time', + type: 'date', + esTypes: ['date'], + count: 30, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: '@tags', + type: 'string', + esTypes: ['keyword'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'utc_time', + type: 'date', + esTypes: ['date'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'phpmemory', + type: 'number', + esTypes: ['integer'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'ip', + type: 'ip', + esTypes: ['ip'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'request_body', + type: 'attachment', + esTypes: ['attachment'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'point', + type: 'geo_point', + esTypes: ['geo_point'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'area', + type: 'geo_shape', + esTypes: ['geo_shape'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'hashed', + type: 'murmur3', + esTypes: ['murmur3'], + count: 0, + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + }, + { + name: 'geo.coordinates', + type: 'geo_point', + esTypes: ['geo_point'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'extension', + type: 'string', + esTypes: ['keyword'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'machine.os', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + name: 'machine.os.raw', + type: 'string', + esTypes: ['keyword'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + subType: { multi: { parent: 'machine.os' } }, + }, + { + name: 'geo.src', + type: 'string', + esTypes: ['keyword'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: '_id', + type: 'string', + esTypes: ['_id'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + name: '_type', + type: 'string', + esTypes: ['_type'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + name: '_source', + type: '_source', + esTypes: ['_source'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + name: 'non-filterable', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: false, + aggregatable: true, + readFromDocValues: false, + }, + { + name: 'non-sortable', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: false, + aggregatable: false, + readFromDocValues: false, + }, + { + name: 'custom_user_field', + type: 'conflict', + esTypes: ['long', 'text'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'script string', + type: 'string', + count: 0, + scripted: true, + script: "'i am a string'", + lang: 'expression', + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + name: 'script number', + type: 'number', + count: 0, + scripted: true, + script: '1234', + lang: 'expression', + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + name: 'script date', + type: 'date', + count: 0, + scripted: true, + script: '1234', + lang: 'painless', + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + name: 'script murmur3', + type: 'murmur3', + count: 0, + scripted: true, + script: '1234', + lang: 'expression', + searchable: true, + aggregatable: true, + readFromDocValues: false, + }, + { + name: 'nestedField.child', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + subType: { nested: { path: 'nestedField' } }, + }, + { + name: 'nestedField.nestedChild.doublyNestedChild', + type: 'string', + esTypes: ['text'], + count: 0, + scripted: false, + searchable: true, + aggregatable: false, + readFromDocValues: false, + subType: { nested: { path: 'nestedField.nestedChild' } }, + }, +]; + +export const getField = (name: string) => fields.find((field) => field.name === name); diff --git a/packages/kbn-securitysolution-autocomplete/src/filter_field_to_list/index.test.ts b/packages/kbn-securitysolution-autocomplete/src/filter_field_to_list/index.test.ts new file mode 100644 index 0000000000000..1022849ffda36 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/filter_field_to_list/index.test.ts @@ -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 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 { filterFieldToList } from '.'; + +import type { ListSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { getListResponseMock } from '../list_schema/index.mock'; + +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/105731 +// import { IFieldType } from '../../../../../../../src/plugins/data/common'; +type IFieldType = any; + +describe('#filterFieldToList', () => { + test('it returns empty array if given a undefined for field', () => { + const filter = filterFieldToList([], undefined); + expect(filter).toEqual([]); + }); + + test('it returns empty array if filed does not contain esTypes', () => { + const field: IFieldType = { name: 'some-name', type: 'some-type' }; + const filter = filterFieldToList([], field); + expect(filter).toEqual([]); + }); + + test('it returns single filtered list of ip_range -> ip', () => { + const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of ip -> ip', () => { + const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'ip' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of keyword -> keyword', () => { + const field: IFieldType = { esTypes: ['keyword'], name: 'some-name', type: 'keyword' }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'keyword' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns single filtered list of text -> text', () => { + const field: IFieldType = { esTypes: ['text'], name: 'some-name', type: 'text' }; + const listItem: ListSchema = { ...getListResponseMock(), type: 'text' }; + const filter = filterFieldToList([listItem], field); + const expected: ListSchema[] = [listItem]; + expect(filter).toEqual(expected); + }); + + test('it returns 2 filtered lists of ip_range -> ip', () => { + const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; + const listItem1: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const listItem2: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const filter = filterFieldToList([listItem1, listItem2], field); + const expected: ListSchema[] = [listItem1, listItem2]; + expect(filter).toEqual(expected); + }); + + test('it returns 1 filtered lists of ip_range -> ip if the 2nd is not compatible type', () => { + const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; + const listItem1: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; + const listItem2: ListSchema = { ...getListResponseMock(), type: 'text' }; + const filter = filterFieldToList([listItem1, listItem2], field); + const expected: ListSchema[] = [listItem1]; + expect(filter).toEqual(expected); + }); +}); diff --git a/packages/kbn-securitysolution-autocomplete/src/filter_field_to_list/index.ts b/packages/kbn-securitysolution-autocomplete/src/filter_field_to_list/index.ts new file mode 100644 index 0000000000000..b2e48c25f9b51 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/filter_field_to_list/index.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 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 { ListSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { typeMatch } from '../type_match'; + +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/105731 +// import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +type IFieldType = any; + +/** + * Given an array of lists and optionally a field this will return all + * the lists that match against the field based on the types from the field + * @param lists The lists to match against the field + * @param field The field to check against the list to see if they are compatible + */ +export const filterFieldToList = (lists: ListSchema[], field?: IFieldType): ListSchema[] => { + if (field != null) { + const { esTypes = [] } = field; + return lists.filter(({ type }) => esTypes.some((esType: string) => typeMatch(type, esType))); + } else { + return []; + } +}; diff --git a/packages/kbn-securitysolution-autocomplete/src/get_generic_combo_box_props/index.test.tsx b/packages/kbn-securitysolution-autocomplete/src/get_generic_combo_box_props/index.test.tsx new file mode 100644 index 0000000000000..63a94be1271a7 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/get_generic_combo_box_props/index.test.tsx @@ -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 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 { getGenericComboBoxProps } from '.'; + +describe('get_generic_combo_box_props', () => { + test('it returns empty arrays if "options" is empty array', () => { + const result = getGenericComboBoxProps({ + options: [], + selectedOptions: ['option1'], + getLabel: (t: string) => t, + }); + + expect(result).toEqual({ comboOptions: [], labels: [], selectedComboOptions: [] }); + }); + + test('it returns formatted props if "options" array is not empty', () => { + const result = getGenericComboBoxProps({ + options: ['option1', 'option2', 'option3'], + selectedOptions: [], + getLabel: (t: string) => t, + }); + + expect(result).toEqual({ + comboOptions: [ + { + label: 'option1', + }, + { + label: 'option2', + }, + { + label: 'option3', + }, + ], + labels: ['option1', 'option2', 'option3'], + selectedComboOptions: [], + }); + }); + + test('it does not return "selectedOptions" items that do not appear in "options"', () => { + const result = getGenericComboBoxProps({ + options: ['option1', 'option2', 'option3'], + selectedOptions: ['option4'], + getLabel: (t: string) => t, + }); + + expect(result).toEqual({ + comboOptions: [ + { + label: 'option1', + }, + { + label: 'option2', + }, + { + label: 'option3', + }, + ], + labels: ['option1', 'option2', 'option3'], + selectedComboOptions: [], + }); + }); + + test('it return "selectedOptions" items that do appear in "options"', () => { + const result = getGenericComboBoxProps({ + options: ['option1', 'option2', 'option3'], + selectedOptions: ['option2'], + getLabel: (t: string) => t, + }); + + expect(result).toEqual({ + comboOptions: [ + { + label: 'option1', + }, + { + label: 'option2', + }, + { + label: 'option3', + }, + ], + labels: ['option1', 'option2', 'option3'], + selectedComboOptions: [ + { + label: 'option2', + }, + ], + }); + }); +}); diff --git a/packages/kbn-securitysolution-autocomplete/src/get_generic_combo_box_props/index.ts b/packages/kbn-securitysolution-autocomplete/src/get_generic_combo_box_props/index.ts new file mode 100644 index 0000000000000..0fba3c39344b8 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/get_generic_combo_box_props/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 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 { EuiComboBoxOptionOption } from '@elastic/eui'; + +export interface GetGenericComboBoxPropsReturn { + comboOptions: EuiComboBoxOptionOption[]; + labels: string[]; + selectedComboOptions: EuiComboBoxOptionOption[]; +} + +/** + * Determines the options, selected values and option labels for EUI combo box + * @param options options user can select from + * @param selectedOptions user selection if any + * @param getLabel helper function to know which property to use for labels + */ +export const getGenericComboBoxProps = ({ + getLabel, + options, + selectedOptions, +}: { + getLabel: (value: T) => string; + options: T[]; + selectedOptions: T[]; +}): GetGenericComboBoxPropsReturn => { + const newLabels = options.map(getLabel); + const newComboOptions: EuiComboBoxOptionOption[] = newLabels.map((label) => ({ label })); + const newSelectedComboOptions = selectedOptions + .map(getLabel) + .filter((option) => { + return newLabels.indexOf(option) !== -1; + }) + .map((option) => { + return newComboOptions[newLabels.indexOf(option)]; + }); + + return { + comboOptions: newComboOptions, + labels: newLabels, + selectedComboOptions: newSelectedComboOptions, + }; +}; diff --git a/packages/kbn-securitysolution-autocomplete/src/get_operators/index.test.ts b/packages/kbn-securitysolution-autocomplete/src/get_operators/index.test.ts new file mode 100644 index 0000000000000..e473df104fa6a --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/get_operators/index.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { + doesNotExistOperator, + EXCEPTION_OPERATORS, + existsOperator, + isNotOperator, + isOperator, +} from '@kbn/securitysolution-list-utils'; +import { getOperators } from '.'; +import { getField } from '../fields/index.mock'; + +describe('#getOperators', () => { + test('it returns "isOperator" if passed in field is "undefined"', () => { + const operator = getOperators(undefined); + + expect(operator).toEqual([isOperator]); + }); + + test('it returns expected operators when field type is "boolean"', () => { + const operator = getOperators(getField('ssl')); + + expect(operator).toEqual([isOperator, isNotOperator, existsOperator, doesNotExistOperator]); + }); + + test('it returns "isOperator" when field type is "nested"', () => { + const operator = getOperators({ + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'nestedField', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { nested: { path: 'nestedField' } }, + type: 'nested', + }); + + expect(operator).toEqual([isOperator]); + }); + + test('it returns all operator types when field type is not null, boolean, or nested', () => { + const operator = getOperators(getField('machine.os.raw')); + + expect(operator).toEqual(EXCEPTION_OPERATORS); + }); +}); diff --git a/packages/kbn-securitysolution-autocomplete/src/get_operators/index.ts b/packages/kbn-securitysolution-autocomplete/src/get_operators/index.ts new file mode 100644 index 0000000000000..39d2779e2dc44 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/get_operators/index.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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. + */ + +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/105731 +// import { IFieldType } from '../../../../../../../src/plugins/data/common'; +type IFieldType = any; + +import { + EXCEPTION_OPERATORS, + OperatorOption, + doesNotExistOperator, + existsOperator, + isNotOperator, + isOperator, +} from '@kbn/securitysolution-list-utils'; + +/** + * Returns the appropriate operators given a field type + * + * @param field IFieldType selected field + * + */ +export const getOperators = (field: IFieldType | undefined): OperatorOption[] => { + if (field == null) { + return [isOperator]; + } else if (field.type === 'boolean') { + return [isOperator, isNotOperator, existsOperator, doesNotExistOperator]; + } else if (field.type === 'nested') { + return [isOperator]; + } else { + return EXCEPTION_OPERATORS; + } +}; diff --git a/packages/kbn-securitysolution-autocomplete/src/hooks/index.ts b/packages/kbn-securitysolution-autocomplete/src/hooks/index.ts new file mode 100644 index 0000000000000..cc5a37bfc46f0 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/hooks/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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 './use_field_value_autocomplete'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.test.ts b/packages/kbn-securitysolution-autocomplete/src/hooks/use_field_value_autocomplete/index.test.ts similarity index 92% rename from x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.test.ts rename to packages/kbn-securitysolution-autocomplete/src/hooks/use_field_value_autocomplete/index.test.ts index 0335ffa55d2a2..534daa021cf4a 100644 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.test.ts +++ b/packages/kbn-securitysolution-autocomplete/src/hooks/use_field_value_autocomplete/index.test.ts @@ -1,28 +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. + * 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 { act, renderHook } from '@testing-library/react-hooks'; import { ListOperatorTypeEnum as OperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { stubIndexPatternWithFields } from '../../../../../../../../src/plugins/data/common/index_patterns/index_pattern.stub'; -import { getField } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; - import { UseFieldValueAutocompleteProps, UseFieldValueAutocompleteReturn, useFieldValueAutocomplete, -} from './use_field_value_autocomplete'; - -const { autocomplete: autocompleteStartMock } = dataPluginMock.createStartContract(); - -jest.mock('../../../../../../../../src/plugins/kibana_react/public'); - -describe('useFieldValueAutocomplete', () => { +} from '.'; +import { getField } from '../../fields/index.mock'; +import { autocompleteStartMock } from '../../autocomplete/index.mock'; + +// Copied from "src/plugins/data/common/index_patterns/index_pattern.stub.ts" +// TODO: Remove this in favor of the above if/when it is ported, https://github.com/elastic/kibana/issues/100715 +export const stubIndexPatternWithFields = { + id: '1234', + title: 'logstash-*', + fields: [ + { + name: 'response', + type: 'number', + esTypes: ['integer'], + aggregatable: true, + filterable: true, + searchable: true, + }, + ], +}; + +describe('use_field_value_autocomplete', () => { const onErrorMock = jest.fn(); const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.ts b/packages/kbn-securitysolution-autocomplete/src/hooks/use_field_value_autocomplete/index.ts similarity index 81% rename from x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.ts rename to packages/kbn-securitysolution-autocomplete/src/hooks/use_field_value_autocomplete/index.ts index 63d3925d6d64d..b4dec1615e3ed 100644 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks/use_field_value_autocomplete.ts +++ b/packages/kbn-securitysolution-autocomplete/src/hooks/use_field_value_autocomplete/index.ts @@ -1,16 +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. + * 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 { useEffect, useRef, useState } from 'react'; import { debounce } from 'lodash'; import { ListOperatorTypeEnum as OperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { AutocompleteStart } from '../../../../../../../../src/plugins/data/public'; -import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/100715 +// import { AutocompleteStart } from '../../../../../../../../src/plugins/data/public'; +type AutocompleteStart = any; + +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/105731 +// import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +type IFieldType = any; +type IIndexPattern = any; interface FuncArgs { fieldSelected: IFieldType | undefined; @@ -33,10 +40,6 @@ export interface UseFieldValueAutocompleteProps { } /** * Hook for using the field value autocomplete service - * There is a copy within: - * x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.ts - * - * TODO: This should be in its own packaged and not copied, https://github.com/elastic/kibana/issues/105378 */ export const useFieldValueAutocomplete = ({ selectedField, diff --git a/packages/kbn-securitysolution-autocomplete/src/index.ts b/packages/kbn-securitysolution-autocomplete/src/index.ts new file mode 100644 index 0000000000000..5fcb3f954189a --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/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 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 './check_empty_value'; +export * from './field'; +export * from './field_value_exists'; +export * from './field_value_lists'; +export * from './field_value_match'; +export * from './field_value_match_any'; +export * from './filter_field_to_list'; +export * from './get_generic_combo_box_props'; +export * from './get_operators'; +export * from './hooks'; +export * from './operator'; +export * from './param_is_valid'; diff --git a/packages/kbn-securitysolution-autocomplete/src/list_schema/index.mock.ts b/packages/kbn-securitysolution-autocomplete/src/list_schema/index.mock.ts new file mode 100644 index 0000000000000..fb629ad2f946e --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/list_schema/index.mock.ts @@ -0,0 +1,51 @@ +/* + * 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 { FoundListSchema, ListSchema } from '@kbn/securitysolution-io-ts-list-types'; + +// TODO: Once this mock is available within packages, use it instead, https://github.com/elastic/kibana/issues/100715 +// import { getFoundListSchemaMock } from '../../../../../lists/common/schemas/response/found_list_schema.mock'; +export const getFoundListSchemaMock = (): FoundListSchema => ({ + cursor: '123', + data: [getListResponseMock()], + page: 1, + per_page: 1, + total: 1, +}); + +// TODO: Once these mocks are available from packages use it instead, https://github.com/elastic/kibana/issues/100715 +export const DATE_NOW = '2020-04-20T15:25:31.830Z'; +export const USER = 'some user'; +export const IMMUTABLE = false; +export const VERSION = 1; +export const DESCRIPTION = 'some description'; +export const TIE_BREAKER = '6a76b69d-80df-4ab2-8c3e-85f466b06a0e'; +export const LIST_ID = 'some-list-id'; +export const META = {}; +export const TYPE = 'ip'; +export const NAME = 'some name'; + +// TODO: Once this mock is available within packages, use it instead, https://github.com/elastic/kibana/issues/100715 +// import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; +export const getListResponseMock = (): ListSchema => ({ + _version: undefined, + created_at: DATE_NOW, + created_by: USER, + description: DESCRIPTION, + deserializer: undefined, + id: LIST_ID, + immutable: IMMUTABLE, + meta: META, + name: NAME, + serializer: undefined, + tie_breaker_id: TIE_BREAKER, + type: TYPE, + updated_at: DATE_NOW, + updated_by: USER, + version: VERSION, +}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.test.tsx b/packages/kbn-securitysolution-autocomplete/src/operator/index.test.tsx similarity index 95% rename from x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.test.tsx rename to packages/kbn-securitysolution-autocomplete/src/operator/index.test.tsx index dadde8800b67f..fed7007b49636 100644 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/operator.test.tsx +++ b/packages/kbn-securitysolution-autocomplete/src/operator/index.test.tsx @@ -1,8 +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; 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'; @@ -10,11 +11,10 @@ import { mount } from 'enzyme'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { isNotOperator, isOperator } from '@kbn/securitysolution-list-utils'; -import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; +import { OperatorComponent } from '.'; +import { getField } from '../fields/index.mock'; -import { OperatorComponent } from './operator'; - -describe('OperatorComponent', () => { +describe('operator', () => { test('it renders disabled if "isDisabled" is true', () => { const wrapper = mount( { + beforeEach(() => { + // Disable momentJS deprecation warning and it looks like it is not typed either so + // we have to disable the type as well and cannot extend it easily. + ((moment as unknown) as { + suppressDeprecationWarnings: boolean; + }).suppressDeprecationWarnings = true; + }); + + afterEach(() => { + // Re-enable momentJS deprecation warning and it looks like it is not typed either so + // we have to disable the type as well and cannot extend it easily. + ((moment as unknown) as { + suppressDeprecationWarnings: boolean; + }).suppressDeprecationWarnings = false; + }); + + test('returns no errors if no field has been selected', () => { + const isValid = paramIsValid('', undefined, true, false); + + expect(isValid).toBeUndefined(); + }); + + test('returns error string if user has touched a required input and left empty', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), true, true); + + expect(isValid).toEqual(i18n.FIELD_REQUIRED_ERR); + }); + + test('returns no errors if required input is empty but user has not yet touched it', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), true, false); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if user has touched an input that is not required and left empty', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), false, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if user has touched an input that is not required and left empty string', () => { + const isValid = paramIsValid('', getField('@timestamp'), false, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if field is of type date and value is valid', () => { + const isValid = paramIsValid('1994-11-05T08:15:30-05:00', getField('@timestamp'), false, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns errors if filed is of type date and value is not valid', () => { + const isValid = paramIsValid('1593478826', getField('@timestamp'), false, true); + + expect(isValid).toEqual(i18n.DATE_ERR); + }); + + test('returns no errors if field is of type number and value is an integer', () => { + const isValid = paramIsValid('4', getField('bytes'), true, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if field is of type number and value is a float', () => { + const isValid = paramIsValid('4.3', getField('bytes'), true, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns no errors if field is of type number and value is a long', () => { + const isValid = paramIsValid('-9223372036854775808', getField('bytes'), true, true); + + expect(isValid).toBeUndefined(); + }); + + test('returns errors if field is of type number and value is "hello"', () => { + const isValid = paramIsValid('hello', getField('bytes'), true, true); + + expect(isValid).toEqual(i18n.NUMBER_ERR); + }); + + test('returns errors if field is of type number and value is "123abc"', () => { + const isValid = paramIsValid('123abc', getField('bytes'), true, true); + + expect(isValid).toEqual(i18n.NUMBER_ERR); + }); +}); diff --git a/packages/kbn-securitysolution-autocomplete/src/param_is_valid/index.ts b/packages/kbn-securitysolution-autocomplete/src/param_is_valid/index.ts new file mode 100644 index 0000000000000..5b596b4b62408 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/param_is_valid/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 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 dateMath from '@elastic/datemath'; +import { checkEmptyValue } from '../check_empty_value'; + +// TODO: I have to use any here for now, but once this is available below, we should use the correct types, https://github.com/elastic/kibana/issues/105731 +// import { IFieldType } from '../../../../../../../src/plugins/data/common'; +type IFieldType = any; + +import * as i18n from '../translations'; + +/** + * Very basic validation for values + * @param param the value being checked + * @param field the selected field + * @param isRequired whether or not an empty value is allowed + * @param touched has field been touched by user + * @returns undefined if valid, string with error message if invalid + */ +export const paramIsValid = ( + param: string | undefined, + field: IFieldType | undefined, + isRequired: boolean, + touched: boolean +): string | undefined => { + if (field == null) { + return undefined; + } + + const emptyValueError = checkEmptyValue(param, field, isRequired, touched); + if (emptyValueError !== null) { + return emptyValueError; + } + + switch (field.type) { + case 'date': + const moment = dateMath.parse(param ?? ''); + const isDate = Boolean(moment && moment.isValid()); + return isDate ? undefined : i18n.DATE_ERR; + case 'number': + const isNum = param != null && param.trim() !== '' && !isNaN(+param); + return isNum ? undefined : i18n.NUMBER_ERR; + default: + return undefined; + } +}; diff --git a/packages/kbn-securitysolution-autocomplete/src/translations/index.ts b/packages/kbn-securitysolution-autocomplete/src/translations/index.ts new file mode 100644 index 0000000000000..35d6531be51bd --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/translations/index.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 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'; + +export const LOADING = i18n.translate('autocomplete.loadingDescription', { + defaultMessage: 'Loading...', +}); + +export const SELECT_FIELD_FIRST = i18n.translate('autocomplete.selectField', { + defaultMessage: 'Please select a field first...', +}); + +export const FIELD_REQUIRED_ERR = i18n.translate('autocomplete.fieldRequiredError', { + defaultMessage: 'Value cannot be empty', +}); + +export const NUMBER_ERR = i18n.translate('autocomplete.invalidNumberError', { + defaultMessage: 'Not a valid number', +}); + +export const DATE_ERR = i18n.translate('autocomplete.invalidDateError', { + defaultMessage: 'Not a valid date', +}); diff --git a/packages/kbn-securitysolution-autocomplete/src/type_match/index.test.ts b/packages/kbn-securitysolution-autocomplete/src/type_match/index.test.ts new file mode 100644 index 0000000000000..4694313720c79 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/type_match/index.test.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 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 { typeMatch } from '.'; + +describe('type_match', () => { + test('ip -> ip is true', () => { + expect(typeMatch('ip', 'ip')).toEqual(true); + }); + + test('keyword -> keyword is true', () => { + expect(typeMatch('keyword', 'keyword')).toEqual(true); + }); + + test('text -> text is true', () => { + expect(typeMatch('text', 'text')).toEqual(true); + }); + + test('ip_range -> ip is true', () => { + expect(typeMatch('ip_range', 'ip')).toEqual(true); + }); + + test('date_range -> date is true', () => { + expect(typeMatch('date_range', 'date')).toEqual(true); + }); + + test('double_range -> double is true', () => { + expect(typeMatch('double_range', 'double')).toEqual(true); + }); + + test('float_range -> float is true', () => { + expect(typeMatch('float_range', 'float')).toEqual(true); + }); + + test('integer_range -> integer is true', () => { + expect(typeMatch('integer_range', 'integer')).toEqual(true); + }); + + test('long_range -> long is true', () => { + expect(typeMatch('long_range', 'long')).toEqual(true); + }); + + test('ip -> date is false', () => { + expect(typeMatch('ip', 'date')).toEqual(false); + }); + + test('long -> float is false', () => { + expect(typeMatch('long', 'float')).toEqual(false); + }); + + test('integer -> long is false', () => { + expect(typeMatch('integer', 'long')).toEqual(false); + }); +}); diff --git a/packages/kbn-securitysolution-autocomplete/src/type_match/index.ts b/packages/kbn-securitysolution-autocomplete/src/type_match/index.ts new file mode 100644 index 0000000000000..d5476f3b32b49 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/src/type_match/index.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 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 type { Type } from '@kbn/securitysolution-io-ts-list-types'; + +/** + * Given an input list type and a string based ES type this will match + * if they're exact or if they are compatible with a range + * @param type The type to match against the esType + * @param esType The ES type to match with + */ +export const typeMatch = (type: Type, esType: string): boolean => { + return ( + type === esType || + (type === 'ip_range' && esType === 'ip') || + (type === 'date_range' && esType === 'date') || + (type === 'double_range' && esType === 'double') || + (type === 'float_range' && esType === 'float') || + (type === 'integer_range' && esType === 'integer') || + (type === 'long_range' && esType === 'long') + ); +}; diff --git a/packages/kbn-securitysolution-autocomplete/tsconfig.browser.json b/packages/kbn-securitysolution-autocomplete/tsconfig.browser.json new file mode 100644 index 0000000000000..bab7b18c59cfd --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/tsconfig.browser.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.browser.json", + "compilerOptions": { + "allowJs": true, + "incremental": true, + "outDir": "./target_web", + "declaration": false, + "isolatedModules": true, + "sourceMap": true, + "sourceRoot": "../../../../../packages/kbn-securitysolution-autocomplete/src", + "types": [ + "jest", + "node" + ], + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + ], + "exclude": [ + "**/__fixtures__/**/*" + ] +} diff --git a/packages/kbn-securitysolution-autocomplete/tsconfig.json b/packages/kbn-securitysolution-autocomplete/tsconfig.json new file mode 100644 index 0000000000000..bf402e93ffd69 --- /dev/null +++ b/packages/kbn-securitysolution-autocomplete/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "allowJs": true, + "incremental": true, + "declarationDir": "./target_types", + "outDir": "target_node", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "sourceRoot": "../../../../packages/kbn-securitysolution-autocomplete/src", + "rootDir": "src", + "types": ["jest", "node", "resize-observer-polyfill"] + }, + "include": ["src/**/*"] +} diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.test.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.test.ts deleted file mode 100644 index 21764c6f459d8..0000000000000 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import moment from 'moment'; -import type { ListSchema } from '@kbn/securitysolution-io-ts-list-types'; -import { - EXCEPTION_OPERATORS, - doesNotExistOperator, - existsOperator, - isNotOperator, - isOperator, -} from '@kbn/securitysolution-list-utils'; - -import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { IFieldType } from '../../../../../../../src/plugins/data/common'; -import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; - -import * as i18n from './translations'; -import { - checkEmptyValue, - filterFieldToList, - getGenericComboBoxProps, - getOperators, - paramIsValid, - typeMatch, -} from './helpers'; - -describe('helpers', () => { - // @ts-ignore - moment.suppressDeprecationWarnings = true; - describe('#getOperators', () => { - test('it returns "isOperator" if passed in field is "undefined"', () => { - const operator = getOperators(undefined); - - expect(operator).toEqual([isOperator]); - }); - - test('it returns expected operators when field type is "boolean"', () => { - const operator = getOperators(getField('ssl')); - - expect(operator).toEqual([isOperator, isNotOperator, existsOperator, doesNotExistOperator]); - }); - - test('it returns "isOperator" when field type is "nested"', () => { - const operator = getOperators({ - aggregatable: false, - count: 0, - esTypes: ['text'], - name: 'nestedField', - readFromDocValues: false, - scripted: false, - searchable: true, - subType: { nested: { path: 'nestedField' } }, - type: 'nested', - }); - - expect(operator).toEqual([isOperator]); - }); - - test('it returns all operator types when field type is not null, boolean, or nested', () => { - const operator = getOperators(getField('machine.os.raw')); - - expect(operator).toEqual(EXCEPTION_OPERATORS); - }); - }); - - describe('#checkEmptyValue', () => { - test('returns no errors if no field has been selected', () => { - const isValid = checkEmptyValue('', undefined, true, false); - - expect(isValid).toBeUndefined(); - }); - - test('returns error string if user has touched a required input and left empty', () => { - const isValid = checkEmptyValue(undefined, getField('@timestamp'), true, true); - - expect(isValid).toEqual(i18n.FIELD_REQUIRED_ERR); - }); - - test('returns no errors if required input is empty but user has not yet touched it', () => { - const isValid = checkEmptyValue(undefined, getField('@timestamp'), true, false); - - expect(isValid).toBeUndefined(); - }); - - test('returns no errors if user has touched an input that is not required and left empty', () => { - const isValid = checkEmptyValue(undefined, getField('@timestamp'), false, true); - - expect(isValid).toBeUndefined(); - }); - - test('returns no errors if user has touched an input that is not required and left empty string', () => { - const isValid = checkEmptyValue('', getField('@timestamp'), false, true); - - expect(isValid).toBeUndefined(); - }); - - test('returns null if input value is not empty string or undefined', () => { - const isValid = checkEmptyValue('hellooo', getField('@timestamp'), false, true); - - expect(isValid).toBeNull(); - }); - }); - - describe('#paramIsValid', () => { - test('returns no errors if no field has been selected', () => { - const isValid = paramIsValid('', undefined, true, false); - - expect(isValid).toBeUndefined(); - }); - - test('returns error string if user has touched a required input and left empty', () => { - const isValid = paramIsValid(undefined, getField('@timestamp'), true, true); - - expect(isValid).toEqual(i18n.FIELD_REQUIRED_ERR); - }); - - test('returns no errors if required input is empty but user has not yet touched it', () => { - const isValid = paramIsValid(undefined, getField('@timestamp'), true, false); - - expect(isValid).toBeUndefined(); - }); - - test('returns no errors if user has touched an input that is not required and left empty', () => { - const isValid = paramIsValid(undefined, getField('@timestamp'), false, true); - - expect(isValid).toBeUndefined(); - }); - - test('returns no errors if user has touched an input that is not required and left empty string', () => { - const isValid = paramIsValid('', getField('@timestamp'), false, true); - - expect(isValid).toBeUndefined(); - }); - - test('returns no errors if field is of type date and value is valid', () => { - const isValid = paramIsValid( - '1994-11-05T08:15:30-05:00', - getField('@timestamp'), - false, - true - ); - - expect(isValid).toBeUndefined(); - }); - - test('returns errors if filed is of type date and value is not valid', () => { - const isValid = paramIsValid('1593478826', getField('@timestamp'), false, true); - - expect(isValid).toEqual(i18n.DATE_ERR); - }); - - test('returns no errors if field is of type number and value is an integer', () => { - const isValid = paramIsValid('4', getField('bytes'), true, true); - - expect(isValid).toBeUndefined(); - }); - - test('returns no errors if field is of type number and value is a float', () => { - const isValid = paramIsValid('4.3', getField('bytes'), true, true); - - expect(isValid).toBeUndefined(); - }); - - test('returns no errors if field is of type number and value is a long', () => { - const isValid = paramIsValid('-9223372036854775808', getField('bytes'), true, true); - - expect(isValid).toBeUndefined(); - }); - - test('returns errors if field is of type number and value is "hello"', () => { - const isValid = paramIsValid('hello', getField('bytes'), true, true); - - expect(isValid).toEqual(i18n.NUMBER_ERR); - }); - - test('returns errors if field is of type number and value is "123abc"', () => { - const isValid = paramIsValid('123abc', getField('bytes'), true, true); - - expect(isValid).toEqual(i18n.NUMBER_ERR); - }); - }); - - describe('#getGenericComboBoxProps', () => { - test('it returns empty arrays if "options" is empty array', () => { - const result = getGenericComboBoxProps({ - getLabel: (t: string) => t, - options: [], - selectedOptions: ['option1'], - }); - - expect(result).toEqual({ comboOptions: [], labels: [], selectedComboOptions: [] }); - }); - - test('it returns formatted props if "options" array is not empty', () => { - const result = getGenericComboBoxProps({ - getLabel: (t: string) => t, - options: ['option1', 'option2', 'option3'], - selectedOptions: [], - }); - - expect(result).toEqual({ - comboOptions: [ - { - label: 'option1', - }, - { - label: 'option2', - }, - { - label: 'option3', - }, - ], - labels: ['option1', 'option2', 'option3'], - selectedComboOptions: [], - }); - }); - - test('it does not return "selectedOptions" items that do not appear in "options"', () => { - const result = getGenericComboBoxProps({ - getLabel: (t: string) => t, - options: ['option1', 'option2', 'option3'], - selectedOptions: ['option4'], - }); - - expect(result).toEqual({ - comboOptions: [ - { - label: 'option1', - }, - { - label: 'option2', - }, - { - label: 'option3', - }, - ], - labels: ['option1', 'option2', 'option3'], - selectedComboOptions: [], - }); - }); - - test('it return "selectedOptions" items that do appear in "options"', () => { - const result = getGenericComboBoxProps({ - getLabel: (t: string) => t, - options: ['option1', 'option2', 'option3'], - selectedOptions: ['option2'], - }); - - expect(result).toEqual({ - comboOptions: [ - { - label: 'option1', - }, - { - label: 'option2', - }, - { - label: 'option3', - }, - ], - labels: ['option1', 'option2', 'option3'], - selectedComboOptions: [ - { - label: 'option2', - }, - ], - }); - }); - }); - - describe('#typeMatch', () => { - test('ip -> ip is true', () => { - expect(typeMatch('ip', 'ip')).toEqual(true); - }); - - test('keyword -> keyword is true', () => { - expect(typeMatch('keyword', 'keyword')).toEqual(true); - }); - - test('text -> text is true', () => { - expect(typeMatch('text', 'text')).toEqual(true); - }); - - test('ip_range -> ip is true', () => { - expect(typeMatch('ip_range', 'ip')).toEqual(true); - }); - - test('date_range -> date is true', () => { - expect(typeMatch('date_range', 'date')).toEqual(true); - }); - - test('double_range -> double is true', () => { - expect(typeMatch('double_range', 'double')).toEqual(true); - }); - - test('float_range -> float is true', () => { - expect(typeMatch('float_range', 'float')).toEqual(true); - }); - - test('integer_range -> integer is true', () => { - expect(typeMatch('integer_range', 'integer')).toEqual(true); - }); - - test('long_range -> long is true', () => { - expect(typeMatch('long_range', 'long')).toEqual(true); - }); - - test('ip -> date is false', () => { - expect(typeMatch('ip', 'date')).toEqual(false); - }); - - test('long -> float is false', () => { - expect(typeMatch('long', 'float')).toEqual(false); - }); - - test('integer -> long is false', () => { - expect(typeMatch('integer', 'long')).toEqual(false); - }); - }); - - describe('#filterFieldToList', () => { - test('it returns empty array if given a undefined for field', () => { - const filter = filterFieldToList([], undefined); - expect(filter).toEqual([]); - }); - - test('it returns empty array if filed does not contain esTypes', () => { - const field: IFieldType = { name: 'some-name', type: 'some-type' }; - const filter = filterFieldToList([], field); - expect(filter).toEqual([]); - }); - - test('it returns single filtered list of ip_range -> ip', () => { - const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; - const listItem: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; - const filter = filterFieldToList([listItem], field); - const expected: ListSchema[] = [listItem]; - expect(filter).toEqual(expected); - }); - - test('it returns single filtered list of ip -> ip', () => { - const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; - const listItem: ListSchema = { ...getListResponseMock(), type: 'ip' }; - const filter = filterFieldToList([listItem], field); - const expected: ListSchema[] = [listItem]; - expect(filter).toEqual(expected); - }); - - test('it returns single filtered list of keyword -> keyword', () => { - const field: IFieldType = { esTypes: ['keyword'], name: 'some-name', type: 'keyword' }; - const listItem: ListSchema = { ...getListResponseMock(), type: 'keyword' }; - const filter = filterFieldToList([listItem], field); - const expected: ListSchema[] = [listItem]; - expect(filter).toEqual(expected); - }); - - test('it returns single filtered list of text -> text', () => { - const field: IFieldType = { esTypes: ['text'], name: 'some-name', type: 'text' }; - const listItem: ListSchema = { ...getListResponseMock(), type: 'text' }; - const filter = filterFieldToList([listItem], field); - const expected: ListSchema[] = [listItem]; - expect(filter).toEqual(expected); - }); - - test('it returns 2 filtered lists of ip_range -> ip', () => { - const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; - const listItem1: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; - const listItem2: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; - const filter = filterFieldToList([listItem1, listItem2], field); - const expected: ListSchema[] = [listItem1, listItem2]; - expect(filter).toEqual(expected); - }); - - test('it returns 1 filtered lists of ip_range -> ip if the 2nd is not compatible type', () => { - const field: IFieldType = { esTypes: ['ip'], name: 'some-name', type: 'ip' }; - const listItem1: ListSchema = { ...getListResponseMock(), type: 'ip_range' }; - const listItem2: ListSchema = { ...getListResponseMock(), type: 'text' }; - const filter = filterFieldToList([listItem1, listItem2], field); - const expected: ListSchema[] = [listItem1]; - expect(filter).toEqual(expected); - }); - }); -}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.ts deleted file mode 100644 index 975416e272227..0000000000000 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.ts +++ /dev/null @@ -1,183 +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 dateMath from '@elastic/datemath'; -import { EuiComboBoxOptionOption } from '@elastic/eui'; -import type { ListSchema, Type } from '@kbn/securitysolution-io-ts-list-types'; -import { - EXCEPTION_OPERATORS, - OperatorOption, - doesNotExistOperator, - existsOperator, - isNotOperator, - isOperator, -} from '@kbn/securitysolution-list-utils'; - -import { IFieldType } from '../../../../../../../src/plugins/data/common'; - -import { GetGenericComboBoxPropsReturn } from './types'; -import * as i18n from './translations'; - -/** - * Returns the appropriate operators given a field type - * - * @param field IFieldType selected field - * - */ -export const getOperators = (field: IFieldType | undefined): OperatorOption[] => { - if (field == null) { - return [isOperator]; - } else if (field.type === 'boolean') { - return [isOperator, isNotOperator, existsOperator, doesNotExistOperator]; - } else if (field.type === 'nested') { - return [isOperator]; - } else { - return EXCEPTION_OPERATORS; - } -}; - -/** - * Determines if empty value is ok - * - * @param param the value being checked - * @param field the selected field - * @param isRequired whether or not an empty value is allowed - * @param touched has field been touched by user - * @returns undefined if valid, string with error message if invalid, - * null if no checks matched - */ -export const checkEmptyValue = ( - param: string | undefined, - field: IFieldType | undefined, - isRequired: boolean, - touched: boolean -): string | undefined | null => { - if (isRequired && touched && (param == null || param.trim() === '')) { - return i18n.FIELD_REQUIRED_ERR; - } - - if ( - field == null || - (isRequired && !touched) || - (!isRequired && (param == null || param === '')) - ) { - return undefined; - } - - return null; -}; - -/** - * Very basic validation for values - * There is a copy within: - * x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts - * - * TODO: This should be in its own packaged and not copied, https://github.com/elastic/kibana/issues/105378 - * - * @param param the value being checked - * @param field the selected field - * @param isRequired whether or not an empty value is allowed - * @param touched has field been touched by user - * @returns undefined if valid, string with error message if invalid - */ -export const paramIsValid = ( - param: string | undefined, - field: IFieldType | undefined, - isRequired: boolean, - touched: boolean -): string | undefined => { - if (field == null) { - return undefined; - } - - const emptyValueError = checkEmptyValue(param, field, isRequired, touched); - if (emptyValueError !== null) { - return emptyValueError; - } - - switch (field.type) { - case 'date': - const moment = dateMath.parse(param ?? ''); - const isDate = Boolean(moment && moment.isValid()); - return isDate ? undefined : i18n.DATE_ERR; - case 'number': - const isNum = param != null && param.trim() !== '' && !isNaN(+param); - return isNum ? undefined : i18n.NUMBER_ERR; - default: - return undefined; - } -}; - -/** - * Determines the options, selected values and option labels for EUI combo box - * There is a copy within: - * x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts - * - * TODO: This should be in its own packaged and not copied, https://github.com/elastic/kibana/issues/105378 - * @param options options user can select from - * @param selectedOptions user selection if any - * @param getLabel helper function to know which property to use for labels - */ -export const getGenericComboBoxProps = ({ - getLabel, - options, - selectedOptions, -}: { - getLabel: (value: T) => string; - options: T[]; - selectedOptions: T[]; -}): GetGenericComboBoxPropsReturn => { - const newLabels = options.map(getLabel); - const newComboOptions: EuiComboBoxOptionOption[] = newLabels.map((label) => ({ label })); - const newSelectedComboOptions = selectedOptions - .map(getLabel) - .filter((option) => { - return newLabels.indexOf(option) !== -1; - }) - .map((option) => { - return newComboOptions[newLabels.indexOf(option)]; - }); - - return { - comboOptions: newComboOptions, - labels: newLabels, - selectedComboOptions: newSelectedComboOptions, - }; -}; - -/** - * Given an array of lists and optionally a field this will return all - * the lists that match against the field based on the types from the field - * @param lists The lists to match against the field - * @param field The field to check against the list to see if they are compatible - */ -export const filterFieldToList = (lists: ListSchema[], field?: IFieldType): ListSchema[] => { - if (field != null) { - const { esTypes = [] } = field; - return lists.filter(({ type }) => esTypes.some((esType) => typeMatch(type, esType))); - } else { - return []; - } -}; - -/** - * Given an input list type and a string based ES type this will match - * if they're exact or if they are compatible with a range - * @param type The type to match against the esType - * @param esType The ES type to match with - */ -export const typeMatch = (type: Type, esType: string): boolean => { - return ( - type === esType || - (type === 'ip_range' && esType === 'ip') || - (type === 'date_range' && esType === 'date') || - (type === 'double_range' && esType === 'double') || - (type === 'float_range' && esType === 'float') || - (type === 'integer_range' && esType === 'integer') || - (type === 'long_range' && esType === 'long') - ); -}; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/index.tsx b/x-pack/plugins/lists/public/exceptions/components/autocomplete/index.tsx deleted file mode 100644 index 1623683f25ed5..0000000000000 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/index.tsx +++ /dev/null @@ -1,13 +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. - */ - -export { AutocompleteFieldExistsComponent } from './field_value_exists'; -export { AutocompleteFieldListsComponent } from './field_value_lists'; -export { AutocompleteFieldMatchAnyComponent } from './field_value_match_any'; -export { AutocompleteFieldMatchComponent } from './field_value_match'; -export { FieldComponent } from './field'; -export { OperatorComponent } from './operator'; diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/translations.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/translations.ts deleted file mode 100644 index 065239246d329..0000000000000 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/translations.ts +++ /dev/null @@ -1,28 +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'; - -export const LOADING = i18n.translate('xpack.lists.autocomplete.loadingDescription', { - defaultMessage: 'Loading...', -}); - -export const SELECT_FIELD_FIRST = i18n.translate('xpack.lists.autocomplete.selectField', { - defaultMessage: 'Please select a field first...', -}); - -export const FIELD_REQUIRED_ERR = i18n.translate('xpack.lists.autocomplete.fieldRequiredError', { - defaultMessage: 'Value cannot be empty', -}); - -export const NUMBER_ERR = i18n.translate('xpack.lists.autocomplete.invalidNumberError', { - defaultMessage: 'Not a valid number', -}); - -export const DATE_ERR = i18n.translate('xpack.lists.autocomplete.invalidDateError', { - defaultMessage: 'Not a valid date', -}); diff --git a/x-pack/plugins/lists/public/exceptions/components/autocomplete/types.ts b/x-pack/plugins/lists/public/exceptions/components/autocomplete/types.ts deleted file mode 100644 index 07f1903fb70e1..0000000000000 --- a/x-pack/plugins/lists/public/exceptions/components/autocomplete/types.ts +++ /dev/null @@ -1,14 +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 { EuiComboBoxOptionOption } from '@elastic/eui'; - -export interface GetGenericComboBoxPropsReturn { - comboOptions: EuiComboBoxOptionOption[]; - labels: string[]; - selectedComboOptions: EuiComboBoxOptionOption[]; -} diff --git a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx index c54da89766d76..d7741b3fe0ff1 100644 --- a/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx +++ b/x-pack/plugins/lists/public/exceptions/components/builder/entry_renderer.tsx @@ -27,16 +27,18 @@ import { getFilteredIndexPatterns, getOperatorOptions, } from '@kbn/securitysolution-list-utils'; +import { + AutocompleteFieldExistsComponent, + AutocompleteFieldListsComponent, + AutocompleteFieldMatchAnyComponent, + AutocompleteFieldMatchComponent, + FieldComponent, + OperatorComponent, +} from '@kbn/securitysolution-autocomplete'; import { AutocompleteStart } from '../../../../../../../src/plugins/data/public'; import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; import { HttpStart } from '../../../../../../../src/core/public'; -import { FieldComponent } from '../autocomplete/field'; -import { OperatorComponent } from '../autocomplete/operator'; -import { AutocompleteFieldExistsComponent } from '../autocomplete/field_value_exists'; -import { AutocompleteFieldMatchComponent } from '../autocomplete/field_value_match'; -import { AutocompleteFieldMatchAnyComponent } from '../autocomplete/field_value_match_any'; -import { AutocompleteFieldListsComponent } from '../autocomplete/field_value_lists'; import { getEmptyValue } from '../../../common/empty_value'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx deleted file mode 100644 index 79e6fe5506b84..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field.test.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { mount } from 'enzyme'; -import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; - -import { - fields, - getField, -} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { FieldComponent } from './field'; - -describe('FieldComponent', () => { - test('it renders disabled if "isDisabled" is true', () => { - const wrapper = mount( - - ); - - expect( - wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] input`).prop('disabled') - ).toBeTruthy(); - }); - - test('it renders loading if "isLoading" is true', () => { - const wrapper = mount( - - ); - wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] button`).at(0).simulate('click'); - expect( - wrapper - .find(`EuiComboBoxOptionsList[data-test-subj="fieldAutocompleteComboBox-optionsList"]`) - .prop('isLoading') - ).toBeTruthy(); - }); - - test('it allows user to clear values if "isClearable" is true', () => { - const wrapper = mount( - - ); - - expect( - wrapper - .find(`[data-test-subj="comboBoxInput"]`) - .hasClass('euiComboBox__inputWrap-isClearable') - ).toBeTruthy(); - }); - - test('it correctly displays selected field', () => { - const wrapper = mount( - - ); - - expect( - wrapper.find(`[data-test-subj="fieldAutocompleteComboBox"] EuiComboBoxPill`).at(0).text() - ).toEqual('machine.os.raw'); - }); - - test('it invokes "onChange" when option selected', () => { - const mockOnChange = jest.fn(); - const wrapper = mount( - - ); - - ((wrapper.find(EuiComboBox).props() as unknown) as { - onChange: (a: EuiComboBoxOptionOption[]) => void; - }).onChange([{ label: 'machine.os' }]); - - expect(mockOnChange).toHaveBeenCalledWith([ - { - aggregatable: true, - count: 0, - esTypes: ['text'], - name: 'machine.os', - readFromDocValues: false, - scripted: false, - searchable: true, - type: 'string', - }, - ]); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field.tsx deleted file mode 100644 index a175a9b847c71..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field.tsx +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useState, useMemo, useCallback } from 'react'; -import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui'; - -import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; -import { getGenericComboBoxProps } from './helpers'; -import { GetGenericComboBoxPropsReturn } from './types'; - -interface OperatorProps { - placeholder: string; - selectedField: IFieldType | undefined; - indexPattern: IIndexPattern | undefined; - isLoading: boolean; - isDisabled: boolean; - isClearable: boolean; - fieldTypeFilter?: string[]; - fieldInputWidth?: number; - isRequired?: boolean; - onChange: (a: IFieldType[]) => void; -} - -/** - * There is a copy within: - * x-pack/plugins/lists/public/exceptions/components/autocomplete/field.tsx - * - * TODO: This should be in its own packaged and not copied, https://github.com/elastic/kibana/issues/105378 - * NOTE: This has deviated from the copy and will have to be reconciled. - */ -export const FieldComponent: React.FC = ({ - placeholder, - selectedField, - indexPattern, - isLoading = false, - isDisabled = false, - isClearable = false, - isRequired = false, - fieldTypeFilter = [], - fieldInputWidth, - onChange, -}): JSX.Element => { - const [touched, setIsTouched] = useState(false); - - const { availableFields, selectedFields } = useMemo( - () => getComboBoxFields(indexPattern, selectedField, fieldTypeFilter), - [indexPattern, selectedField, fieldTypeFilter] - ); - - const { comboOptions, labels, selectedComboOptions } = useMemo( - () => getComboBoxProps({ availableFields, selectedFields }), - [availableFields, selectedFields] - ); - - const handleValuesChange = useCallback( - (newOptions: EuiComboBoxOptionOption[]): void => { - const newValues: IFieldType[] = newOptions.map( - ({ label }) => availableFields[labels.indexOf(label)] - ); - onChange(newValues); - }, - [availableFields, labels, onChange] - ); - - const handleTouch = useCallback((): void => { - setIsTouched(true); - }, [setIsTouched]); - - return ( - - ); -}; - -FieldComponent.displayName = 'Field'; - -interface ComboBoxFields { - availableFields: IFieldType[]; - selectedFields: IFieldType[]; -} - -const getComboBoxFields = ( - indexPattern: IIndexPattern | undefined, - selectedField: IFieldType | undefined, - fieldTypeFilter: string[] -): ComboBoxFields => { - const existingFields = getExistingFields(indexPattern); - const selectedFields = getSelectedFields(selectedField); - const availableFields = getAvailableFields(existingFields, selectedFields, fieldTypeFilter); - - return { availableFields, selectedFields }; -}; - -const getComboBoxProps = (fields: ComboBoxFields): GetGenericComboBoxPropsReturn => { - const { availableFields, selectedFields } = fields; - - return getGenericComboBoxProps({ - options: availableFields, - selectedOptions: selectedFields, - getLabel: (field) => field.name, - }); -}; - -const getExistingFields = (indexPattern: IIndexPattern | undefined): IFieldType[] => { - return indexPattern != null ? indexPattern.fields : []; -}; - -const getSelectedFields = (selectedField: IFieldType | undefined): IFieldType[] => { - return selectedField ? [selectedField] : []; -}; - -const getAvailableFields = ( - existingFields: IFieldType[], - selectedFields: IFieldType[], - fieldTypeFilter: string[] -): IFieldType[] => { - const fieldsByName = new Map(); - - existingFields.forEach((f) => fieldsByName.set(f.name, f)); - selectedFields.forEach((f) => fieldsByName.set(f.name, f)); - - const uniqueFields = Array.from(fieldsByName.values()); - - if (fieldTypeFilter.length > 0) { - return uniqueFields.filter(({ type }) => fieldTypeFilter.includes(type)); - } - - return uniqueFields; -}; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx deleted file mode 100644 index 38d103fe65130..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.test.tsx +++ /dev/null @@ -1,425 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { mount, ReactWrapper } from 'enzyme'; -import { EuiSuperSelect, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { act } from '@testing-library/react'; - -import { - fields, - getField, -} from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { AutocompleteFieldMatchComponent } from './field_value_match'; -import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; - -jest.mock('./hooks/use_field_value_autocomplete'); - -describe('AutocompleteFieldMatchComponent', () => { - let wrapper: ReactWrapper; - - const getValueSuggestionsMock = jest - .fn() - .mockResolvedValue([false, true, ['value 3', 'value 4'], jest.fn()]); - - beforeEach(() => { - (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ - false, - true, - ['value 1', 'value 2'], - getValueSuggestionsMock, - ]); - }); - - afterEach(() => { - jest.clearAllMocks(); - wrapper.unmount(); - }); - - test('it renders row label if one passed in', () => { - wrapper = mount( - - ); - - expect( - wrapper.find('[data-test-subj="valuesAutocompleteMatchLabel"] label').at(0).text() - ).toEqual('Row Label'); - }); - - test('it renders disabled if "isDisabled" is true', () => { - wrapper = mount( - - ); - - expect( - wrapper.find('[data-test-subj="valuesAutocompleteMatch"] input').prop('disabled') - ).toBeTruthy(); - }); - - test('it renders loading if "isLoading" is true', () => { - wrapper = mount( - - ); - wrapper.find('[data-test-subj="valuesAutocompleteMatch"] button').at(0).simulate('click'); - expect( - wrapper - .find('EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteMatch-optionsList"]') - .prop('isLoading') - ).toBeTruthy(); - }); - - test('it allows user to clear values if "isClearable" is true', () => { - wrapper = mount( - - ); - - expect( - wrapper - .find('[data-test-subj="comboBoxInput"]') - .hasClass('euiComboBox__inputWrap-isClearable') - ).toBeTruthy(); - }); - - test('it correctly displays selected value', () => { - wrapper = mount( - - ); - - expect( - wrapper.find('[data-test-subj="valuesAutocompleteMatch"] EuiComboBoxPill').at(0).text() - ).toEqual('126.45.211.34'); - }); - - test('it invokes "onChange" when new value created', async () => { - const mockOnChange = jest.fn(); - wrapper = mount( - - ); - - ((wrapper.find(EuiComboBox).props() as unknown) as { - onCreateOption: (a: string) => void; - }).onCreateOption('126.45.211.34'); - - expect(mockOnChange).toHaveBeenCalledWith('126.45.211.34'); - }); - - test('it invokes "onChange" when new value selected', async () => { - const mockOnChange = jest.fn(); - wrapper = mount( - - ); - - ((wrapper.find(EuiComboBox).props() as unknown) as { - onChange: (a: EuiComboBoxOptionOption[]) => void; - }).onChange([{ label: 'value 1' }]); - - expect(mockOnChange).toHaveBeenCalledWith('value 1'); - }); - - test('it refreshes autocomplete with search query when new value searched', () => { - wrapper = mount( - - ); - act(() => { - ((wrapper.find(EuiComboBox).props() as unknown) as { - onSearchChange: (a: string) => void; - }).onSearchChange('value 1'); - }); - - expect(useFieldValueAutocomplete).toHaveBeenCalledWith({ - selectedField: getField('machine.os.raw'), - operatorType: 'match', - query: 'value 1', - fieldValue: '', - indexPattern: { - id: '1234', - title: 'logstash-*', - fields, - }, - }); - }); - - describe('boolean type', () => { - const valueSuggestionsMock = jest.fn().mockResolvedValue([false, false, [], jest.fn()]); - - beforeEach(() => { - (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ - false, - false, - [], - valueSuggestionsMock, - ]); - }); - - test('it displays only two options - "true" or "false"', () => { - wrapper = mount( - - ); - - expect( - wrapper.find('[data-test-subj="valuesAutocompleteMatchBoolean"]').exists() - ).toBeTruthy(); - expect( - wrapper.find('[data-test-subj="valuesAutocompleteMatchBoolean"]').at(0).prop('options') - ).toEqual([ - { - inputDisplay: 'true', - value: 'true', - }, - { - inputDisplay: 'false', - value: 'false', - }, - ]); - }); - - test('it invokes "onChange" with "true" when selected', () => { - const mockOnChange = jest.fn(); - wrapper = mount( - - ); - - ((wrapper.find(EuiSuperSelect).props() as unknown) as { - onChange: (a: string) => void; - }).onChange('true'); - - expect(mockOnChange).toHaveBeenCalledWith('true'); - }); - - test('it invokes "onChange" with "false" when selected', () => { - const mockOnChange = jest.fn(); - wrapper = mount( - - ); - - ((wrapper.find(EuiSuperSelect).props() as unknown) as { - onChange: (a: string) => void; - }).onChange('false'); - - expect(mockOnChange).toHaveBeenCalledWith('false'); - }); - }); - - describe('number type', () => { - const valueSuggestionsMock = jest.fn().mockResolvedValue([false, false, [], jest.fn()]); - - beforeEach(() => { - (useFieldValueAutocomplete as jest.Mock).mockReturnValue([ - false, - false, - [], - valueSuggestionsMock, - ]); - }); - - test('it number input when field type is number', () => { - wrapper = mount( - - ); - - expect( - wrapper.find('[data-test-subj="valueAutocompleteFieldMatchNumber"]').exists() - ).toBeTruthy(); - }); - - test('it invokes "onChange" with numeric value when inputted', () => { - const mockOnChange = jest.fn(); - wrapper = mount( - - ); - - wrapper - .find('[data-test-subj="valueAutocompleteFieldMatchNumber"] input') - .at(0) - .simulate('change', { target: { value: '8' } }); - - expect(mockOnChange).toHaveBeenCalledWith('8'); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx deleted file mode 100644 index 21d1d9b4b31aa..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx +++ /dev/null @@ -1,285 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useMemo, useState, useEffect } from 'react'; -import { - EuiSuperSelect, - EuiFormRow, - EuiFieldNumber, - EuiComboBoxOptionOption, - EuiComboBox, -} from '@elastic/eui'; -import { uniq } from 'lodash'; - -import { ListOperatorTypeEnum as OperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; -import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; -import { paramIsValid, getGenericComboBoxProps } from './helpers'; - -import { GetGenericComboBoxPropsReturn } from './types'; -import * as i18n from './translations'; - -interface AutocompleteFieldMatchProps { - placeholder: string; - selectedField: IFieldType | undefined; - selectedValue: string | undefined; - indexPattern: IIndexPattern | undefined; - isLoading: boolean; - isDisabled: boolean; - isClearable: boolean; - isRequired?: boolean; - fieldInputWidth?: number; - rowLabel?: string; - onChange: (arg: string) => void; - onError?: (arg: boolean) => void; -} - -/** - * There is a copy of this within: - * x-pack/plugins/lists/public/exceptions/components/autocomplete/field_value_match.tsx - * TODO: This should be in its own packaged and not copied, https://github.com/elastic/kibana/issues/105378 - */ -export const AutocompleteFieldMatchComponent: React.FC = ({ - placeholder, - rowLabel, - selectedField, - selectedValue, - indexPattern, - isLoading, - isDisabled = false, - isClearable = false, - isRequired = false, - fieldInputWidth, - onChange, - onError, -}): JSX.Element => { - const [searchQuery, setSearchQuery] = useState(''); - const [touched, setIsTouched] = useState(false); - const [error, setError] = useState(undefined); - const [isLoadingSuggestions, isSuggestingValues, suggestions] = useFieldValueAutocomplete({ - selectedField, - operatorType: OperatorTypeEnum.MATCH, - fieldValue: selectedValue, - query: searchQuery, - indexPattern, - }); - const getLabel = useCallback((option: string): string => option, []); - const optionsMemo = useMemo((): string[] => { - const valueAsStr = String(selectedValue); - return selectedValue != null && selectedValue.trim() !== '' - ? uniq([valueAsStr, ...suggestions]) - : suggestions; - }, [suggestions, selectedValue]); - const selectedOptionsMemo = useMemo((): string[] => { - const valueAsStr = String(selectedValue); - return selectedValue ? [valueAsStr] : []; - }, [selectedValue]); - - const handleError = useCallback( - (err: string | undefined): void => { - setError((existingErr): string | undefined => { - const oldErr = existingErr != null; - const newErr = err != null; - if (oldErr !== newErr && onError != null) { - onError(newErr); - } - - return err; - }); - }, - [setError, onError] - ); - - const { comboOptions, labels, selectedComboOptions } = useMemo( - (): GetGenericComboBoxPropsReturn => - getGenericComboBoxProps({ - options: optionsMemo, - selectedOptions: selectedOptionsMemo, - getLabel, - }), - [optionsMemo, selectedOptionsMemo, getLabel] - ); - - const handleValuesChange = useCallback( - (newOptions: EuiComboBoxOptionOption[]): void => { - const [newValue] = newOptions.map(({ label }) => optionsMemo[labels.indexOf(label)]); - handleError(undefined); - onChange(newValue ?? ''); - }, - [handleError, labels, onChange, optionsMemo] - ); - - const handleSearchChange = useCallback( - (searchVal: string): void => { - if (searchVal !== '' && selectedField != null) { - const err = paramIsValid(searchVal, selectedField, isRequired, touched); - handleError(err); - - setSearchQuery(searchVal); - } - }, - [handleError, isRequired, selectedField, touched] - ); - - const handleCreateOption = useCallback( - (option: string): boolean | undefined => { - const err = paramIsValid(option, selectedField, isRequired, touched); - handleError(err); - - if (err != null) { - // Explicitly reject the user's input - return false; - } else { - onChange(option); - } - }, - [isRequired, onChange, selectedField, touched, handleError] - ); - - const handleNonComboBoxInputChange = (event: React.ChangeEvent): void => { - const newValue = event.target.value; - onChange(newValue); - }; - - const handleBooleanInputChange = (newOption: string): void => { - onChange(newOption); - }; - - const setIsTouchedValue = useCallback((): void => { - setIsTouched(true); - - const err = paramIsValid(selectedValue, selectedField, isRequired, true); - handleError(err); - }, [setIsTouched, handleError, selectedValue, selectedField, isRequired]); - - const inputPlaceholder = useMemo((): string => { - if (isLoading || isLoadingSuggestions) { - return i18n.LOADING; - } else if (selectedField == null) { - return i18n.SELECT_FIELD_FIRST; - } else { - return placeholder; - } - }, [isLoading, selectedField, isLoadingSuggestions, placeholder]); - - const isLoadingState = useMemo((): boolean => isLoading || isLoadingSuggestions, [ - isLoading, - isLoadingSuggestions, - ]); - - useEffect((): void => { - setError(undefined); - if (onError != null) { - onError(false); - } - }, [selectedField, onError]); - - const defaultInput = useMemo((): JSX.Element => { - return ( - - - - ); - }, [ - comboOptions, - error, - fieldInputWidth, - handleCreateOption, - handleSearchChange, - handleValuesChange, - inputPlaceholder, - isClearable, - isDisabled, - isLoadingState, - rowLabel, - selectedComboOptions, - selectedField, - setIsTouchedValue, - ]); - - if (!isSuggestingValues && selectedField != null) { - switch (selectedField.type) { - case 'number': - return ( - - 0 - ? parseFloat(selectedValue) - : selectedValue ?? '' - } - onChange={handleNonComboBoxInputChange} - data-test-subj="valueAutocompleteFieldMatchNumber" - style={fieldInputWidth ? { width: `${fieldInputWidth}px` } : {}} - fullWidth - /> - - ); - case 'boolean': - return ( - - - - ); - default: - return defaultInput; - } - } else { - return defaultInput; - } -}; - -AutocompleteFieldMatchComponent.displayName = 'AutocompleteFieldMatch'; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts deleted file mode 100644 index 1618de245365d..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import moment from 'moment'; -import '../../../common/mock/match_media'; -import { getField } from '../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; - -import * as i18n from './translations'; -import { checkEmptyValue, paramIsValid, getGenericComboBoxProps } from './helpers'; - -describe('helpers', () => { - // @ts-ignore - moment.suppressDeprecationWarnings = true; - - describe('#checkEmptyValue', () => { - test('returns no errors if no field has been selected', () => { - const isValid = checkEmptyValue('', undefined, true, false); - - expect(isValid).toBeUndefined(); - }); - - test('returns error string if user has touched a required input and left empty', () => { - const isValid = checkEmptyValue(undefined, getField('@timestamp'), true, true); - - expect(isValid).toEqual(i18n.FIELD_REQUIRED_ERR); - }); - - test('returns no errors if required input is empty but user has not yet touched it', () => { - const isValid = checkEmptyValue(undefined, getField('@timestamp'), true, false); - - expect(isValid).toBeUndefined(); - }); - - test('returns no errors if user has touched an input that is not required and left empty', () => { - const isValid = checkEmptyValue(undefined, getField('@timestamp'), false, true); - - expect(isValid).toBeUndefined(); - }); - - test('returns no errors if user has touched an input that is not required and left empty string', () => { - const isValid = checkEmptyValue('', getField('@timestamp'), false, true); - - expect(isValid).toBeUndefined(); - }); - - test('returns null if input value is not empty string or undefined', () => { - const isValid = checkEmptyValue('hellooo', getField('@timestamp'), false, true); - - expect(isValid).toBeNull(); - }); - }); - - describe('#paramIsValid', () => { - test('returns no errors if no field has been selected', () => { - const isValid = paramIsValid('', undefined, true, false); - - expect(isValid).toBeUndefined(); - }); - - test('returns error string if user has touched a required input and left empty', () => { - const isValid = paramIsValid(undefined, getField('@timestamp'), true, true); - - expect(isValid).toEqual(i18n.FIELD_REQUIRED_ERR); - }); - - test('returns no errors if required input is empty but user has not yet touched it', () => { - const isValid = paramIsValid(undefined, getField('@timestamp'), true, false); - - expect(isValid).toBeUndefined(); - }); - - test('returns no errors if user has touched an input that is not required and left empty', () => { - const isValid = paramIsValid(undefined, getField('@timestamp'), false, true); - - expect(isValid).toBeUndefined(); - }); - - test('returns no errors if user has touched an input that is not required and left empty string', () => { - const isValid = paramIsValid('', getField('@timestamp'), false, true); - - expect(isValid).toBeUndefined(); - }); - - test('returns no errors if field is of type date and value is valid', () => { - const isValid = paramIsValid( - '1994-11-05T08:15:30-05:00', - getField('@timestamp'), - false, - true - ); - - expect(isValid).toBeUndefined(); - }); - - test('returns errors if filed is of type date and value is not valid', () => { - const isValid = paramIsValid('1593478826', getField('@timestamp'), false, true); - - expect(isValid).toEqual(i18n.DATE_ERR); - }); - - test('returns no errors if field is of type number and value is an integer', () => { - const isValid = paramIsValid('4', getField('bytes'), true, true); - - expect(isValid).toBeUndefined(); - }); - - test('returns no errors if field is of type number and value is a float', () => { - const isValid = paramIsValid('4.3', getField('bytes'), true, true); - - expect(isValid).toBeUndefined(); - }); - - test('returns no errors if field is of type number and value is a long', () => { - const isValid = paramIsValid('-9223372036854775808', getField('bytes'), true, true); - - expect(isValid).toBeUndefined(); - }); - - test('returns errors if field is of type number and value is "hello"', () => { - const isValid = paramIsValid('hello', getField('bytes'), true, true); - - expect(isValid).toEqual(i18n.NUMBER_ERR); - }); - - test('returns errors if field is of type number and value is "123abc"', () => { - const isValid = paramIsValid('123abc', getField('bytes'), true, true); - - expect(isValid).toEqual(i18n.NUMBER_ERR); - }); - }); - - describe('#getGenericComboBoxProps', () => { - test('it returns empty arrays if "options" is empty array', () => { - const result = getGenericComboBoxProps({ - options: [], - selectedOptions: ['option1'], - getLabel: (t: string) => t, - }); - - expect(result).toEqual({ comboOptions: [], labels: [], selectedComboOptions: [] }); - }); - - test('it returns formatted props if "options" array is not empty', () => { - const result = getGenericComboBoxProps({ - options: ['option1', 'option2', 'option3'], - selectedOptions: [], - getLabel: (t: string) => t, - }); - - expect(result).toEqual({ - comboOptions: [ - { - label: 'option1', - }, - { - label: 'option2', - }, - { - label: 'option3', - }, - ], - labels: ['option1', 'option2', 'option3'], - selectedComboOptions: [], - }); - }); - - test('it does not return "selectedOptions" items that do not appear in "options"', () => { - const result = getGenericComboBoxProps({ - options: ['option1', 'option2', 'option3'], - selectedOptions: ['option4'], - getLabel: (t: string) => t, - }); - - expect(result).toEqual({ - comboOptions: [ - { - label: 'option1', - }, - { - label: 'option2', - }, - { - label: 'option3', - }, - ], - labels: ['option1', 'option2', 'option3'], - selectedComboOptions: [], - }); - }); - - test('it return "selectedOptions" items that do appear in "options"', () => { - const result = getGenericComboBoxProps({ - options: ['option1', 'option2', 'option3'], - selectedOptions: ['option2'], - getLabel: (t: string) => t, - }); - - expect(result).toEqual({ - comboOptions: [ - { - label: 'option1', - }, - { - label: 'option2', - }, - { - label: 'option3', - }, - ], - labels: ['option1', 'option2', 'option3'], - selectedComboOptions: [ - { - label: 'option2', - }, - ], - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts deleted file mode 100644 index 890f1e6755834..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts +++ /dev/null @@ -1,119 +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 dateMath from '@elastic/datemath'; -import { EuiComboBoxOptionOption } from '@elastic/eui'; - -import { IFieldType } from '../../../../../../../src/plugins/data/common'; - -import { GetGenericComboBoxPropsReturn } from './types'; -import * as i18n from './translations'; - -/** - * Determines if empty value is ok - * There is a copy within: - * x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.ts - * - * TODO: This should be in its own packaged and not copied, https://github.com/elastic/kibana/issues/105378 - */ -export const checkEmptyValue = ( - param: string | undefined, - field: IFieldType | undefined, - isRequired: boolean, - touched: boolean -): string | undefined | null => { - if (isRequired && touched && (param == null || param.trim() === '')) { - return i18n.FIELD_REQUIRED_ERR; - } - - if ( - field == null || - (isRequired && !touched) || - (!isRequired && (param == null || param === '')) - ) { - return undefined; - } - - return null; -}; - -/** - * Very basic validation for values - * There is a copy within: - * x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.ts - * - * TODO: This should be in its own packaged and not copied, https://github.com/elastic/kibana/issues/105378 - * @param param the value being checked - * @param field the selected field - * @param isRequired whether or not an empty value is allowed - * @param touched has field been touched by user - * @returns undefined if valid, string with error message if invalid - */ -export const paramIsValid = ( - param: string | undefined, - field: IFieldType | undefined, - isRequired: boolean, - touched: boolean -): string | undefined => { - if (field == null) { - return undefined; - } - - const emptyValueError = checkEmptyValue(param, field, isRequired, touched); - if (emptyValueError !== null) { - return emptyValueError; - } - - switch (field.type) { - case 'date': - const moment = dateMath.parse(param ?? ''); - const isDate = Boolean(moment && moment.isValid()); - return isDate ? undefined : i18n.DATE_ERR; - case 'number': - const isNum = param != null && param.trim() !== '' && !isNaN(+param); - return isNum ? undefined : i18n.NUMBER_ERR; - default: - return undefined; - } -}; - -/** - * Determines the options, selected values and option labels for EUI combo box - * There is a copy within: - * x-pack/plugins/lists/public/exceptions/components/autocomplete/helpers.ts - * - * TODO: This should be in its own packaged and not copied, https://github.com/elastic/kibana/issues/105378 - * @param options options user can select from - * @param selectedOptions user selection if any - * @param getLabel helper function to know which property to use for labels - */ -export function getGenericComboBoxProps({ - options, - selectedOptions, - getLabel, -}: { - options: T[]; - selectedOptions: T[]; - getLabel: (value: T) => string; -}): GetGenericComboBoxPropsReturn { - const newLabels = options.map(getLabel); - const newComboOptions: EuiComboBoxOptionOption[] = newLabels.map((label) => ({ label })); - const newSelectedComboOptions = selectedOptions - .map(getLabel) - .filter((option) => { - return newLabels.indexOf(option) !== -1; - }) - .map((option) => { - return newComboOptions[newLabels.indexOf(option)]; - }); - - return { - comboOptions: newComboOptions, - labels: newLabels, - selectedComboOptions: newSelectedComboOptions, - }; -} diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.test.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.test.ts deleted file mode 100644 index e0bdbf2603dc3..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.test.ts +++ /dev/null @@ -1,325 +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 { act, renderHook } from '@testing-library/react-hooks'; - -import { - UseFieldValueAutocompleteProps, - UseFieldValueAutocompleteReturn, - useFieldValueAutocomplete, -} from './use_field_value_autocomplete'; -import { useKibana } from '../../../../common/lib/kibana'; -import { stubIndexPatternWithFields } from '../../../../../../../../src/plugins/data/common/index_patterns/index_pattern.stub'; -import { getField } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks'; -import { ListOperatorTypeEnum as OperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; - -jest.mock('../../../../common/lib/kibana'); - -describe('useFieldValueAutocomplete', () => { - const onErrorMock = jest.fn(); - const getValueSuggestionsMock = jest.fn().mockResolvedValue(['value 1', 'value 2']); - - beforeEach(() => { - (useKibana as jest.Mock).mockReturnValue({ - services: { - data: { - autocomplete: { - getValueSuggestions: getValueSuggestionsMock, - }, - }, - }, - }); - }); - - afterEach(() => { - onErrorMock.mockClear(); - getValueSuggestionsMock.mockClear(); - }); - - test('initializes hook', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - UseFieldValueAutocompleteProps, - UseFieldValueAutocompleteReturn - >(() => - useFieldValueAutocomplete({ - selectedField: undefined, - operatorType: OperatorTypeEnum.MATCH, - fieldValue: '', - indexPattern: undefined, - query: '', - }) - ); - await waitForNextUpdate(); - - expect(result.current).toEqual([false, true, [], result.current[3]]); - }); - }); - - test('does not call autocomplete service if "operatorType" is "exists"', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - UseFieldValueAutocompleteProps, - UseFieldValueAutocompleteReturn - >(() => - useFieldValueAutocomplete({ - selectedField: getField('machine.os'), - operatorType: OperatorTypeEnum.EXISTS, - fieldValue: '', - indexPattern: stubIndexPatternWithFields, - query: '', - }) - ); - await waitForNextUpdate(); - - const expectedResult: UseFieldValueAutocompleteReturn = [false, true, [], result.current[3]]; - - expect(getValueSuggestionsMock).not.toHaveBeenCalled(); - expect(result.current).toEqual(expectedResult); - }); - }); - - test('does not call autocomplete service if "selectedField" is undefined', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - UseFieldValueAutocompleteProps, - UseFieldValueAutocompleteReturn - >(() => - useFieldValueAutocomplete({ - selectedField: undefined, - operatorType: OperatorTypeEnum.EXISTS, - fieldValue: '', - indexPattern: stubIndexPatternWithFields, - query: '', - }) - ); - await waitForNextUpdate(); - - const expectedResult: UseFieldValueAutocompleteReturn = [false, true, [], result.current[3]]; - - expect(getValueSuggestionsMock).not.toHaveBeenCalled(); - expect(result.current).toEqual(expectedResult); - }); - }); - - test('does not call autocomplete service if "indexPattern" is undefined', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - UseFieldValueAutocompleteProps, - UseFieldValueAutocompleteReturn - >(() => - useFieldValueAutocomplete({ - selectedField: getField('machine.os'), - operatorType: OperatorTypeEnum.EXISTS, - fieldValue: '', - indexPattern: undefined, - query: '', - }) - ); - await waitForNextUpdate(); - - const expectedResult: UseFieldValueAutocompleteReturn = [false, true, [], result.current[3]]; - - expect(getValueSuggestionsMock).not.toHaveBeenCalled(); - expect(result.current).toEqual(expectedResult); - }); - }); - - test('it uses full path name for nested fields to fetch suggestions', async () => { - const suggestionsMock = jest.fn().mockResolvedValue([]); - - (useKibana as jest.Mock).mockReturnValue({ - services: { - data: { - autocomplete: { - getValueSuggestions: suggestionsMock, - }, - }, - }, - }); - await act(async () => { - const signal = new AbortController().signal; - const { waitForNextUpdate } = renderHook< - UseFieldValueAutocompleteProps, - UseFieldValueAutocompleteReturn - >(() => - useFieldValueAutocomplete({ - selectedField: { ...getField('nestedField.child'), name: 'child' }, - operatorType: OperatorTypeEnum.MATCH, - fieldValue: '', - indexPattern: stubIndexPatternWithFields, - query: '', - }) - ); - // Note: initial `waitForNextUpdate` is hook initialization - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(suggestionsMock).toHaveBeenCalledWith({ - field: { ...getField('nestedField.child'), name: 'nestedField.child' }, - indexPattern: { - fields: [ - { - aggregatable: true, - esTypes: ['integer'], - filterable: true, - name: 'response', - searchable: true, - type: 'number', - }, - ], - id: '1234', - title: 'logstash-*', - }, - query: '', - signal, - }); - }); - }); - - test('returns "isSuggestingValues" of false if field type is boolean', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - UseFieldValueAutocompleteProps, - UseFieldValueAutocompleteReturn - >(() => - useFieldValueAutocomplete({ - selectedField: getField('ssl'), - operatorType: OperatorTypeEnum.MATCH, - fieldValue: '', - indexPattern: stubIndexPatternWithFields, - query: '', - }) - ); - // Note: initial `waitForNextUpdate` is hook initialization - await waitForNextUpdate(); - await waitForNextUpdate(); - - const expectedResult: UseFieldValueAutocompleteReturn = [false, false, [], result.current[3]]; - - expect(getValueSuggestionsMock).not.toHaveBeenCalled(); - expect(result.current).toEqual(expectedResult); - }); - }); - - test('returns "isSuggestingValues" of false to note that autocomplete service is not in use if no autocomplete suggestions available', async () => { - const suggestionsMock = jest.fn().mockResolvedValue([]); - - (useKibana as jest.Mock).mockReturnValue({ - services: { - data: { - autocomplete: { - getValueSuggestions: suggestionsMock, - }, - }, - }, - }); - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - UseFieldValueAutocompleteProps, - UseFieldValueAutocompleteReturn - >(() => - useFieldValueAutocomplete({ - selectedField: getField('bytes'), - operatorType: OperatorTypeEnum.MATCH, - fieldValue: '', - indexPattern: stubIndexPatternWithFields, - query: '', - }) - ); - // Note: initial `waitForNextUpdate` is hook initialization - await waitForNextUpdate(); - await waitForNextUpdate(); - - const expectedResult: UseFieldValueAutocompleteReturn = [false, false, [], result.current[3]]; - - expect(suggestionsMock).toHaveBeenCalled(); - expect(result.current).toEqual(expectedResult); - }); - }); - - test('returns suggestions', async () => { - await act(async () => { - const signal = new AbortController().signal; - const { result, waitForNextUpdate } = renderHook< - UseFieldValueAutocompleteProps, - UseFieldValueAutocompleteReturn - >(() => - useFieldValueAutocomplete({ - selectedField: getField('@tags'), - operatorType: OperatorTypeEnum.MATCH, - fieldValue: '', - indexPattern: stubIndexPatternWithFields, - query: '', - }) - ); - // Note: initial `waitForNextUpdate` is hook initialization - await waitForNextUpdate(); - await waitForNextUpdate(); - - const expectedResult: UseFieldValueAutocompleteReturn = [ - false, - true, - ['value 1', 'value 2'], - result.current[3], - ]; - - expect(getValueSuggestionsMock).toHaveBeenCalledWith({ - field: getField('@tags'), - indexPattern: stubIndexPatternWithFields, - query: '', - signal, - }); - expect(result.current).toEqual(expectedResult); - }); - }); - - test('returns new suggestions on subsequent calls', async () => { - await act(async () => { - const { result, waitForNextUpdate } = renderHook< - UseFieldValueAutocompleteProps, - UseFieldValueAutocompleteReturn - >(() => - useFieldValueAutocomplete({ - selectedField: getField('@tags'), - operatorType: OperatorTypeEnum.MATCH, - fieldValue: '', - indexPattern: stubIndexPatternWithFields, - query: '', - }) - ); - // Note: initial `waitForNextUpdate` is hook initialization - await waitForNextUpdate(); - await waitForNextUpdate(); - - expect(result.current[3]).not.toBeNull(); - - // Added check for typescripts sake, if null, - // would not reach below logic as test would stop above - if (result.current[3] != null) { - result.current[3]({ - fieldSelected: getField('@tags'), - value: 'hello', - patterns: stubIndexPatternWithFields, - searchQuery: '', - }); - } - - await waitForNextUpdate(); - - const expectedResult: UseFieldValueAutocompleteReturn = [ - false, - true, - ['value 1', 'value 2'], - result.current[3], - ]; - - expect(getValueSuggestionsMock).toHaveBeenCalledTimes(2); - expect(result.current).toEqual(expectedResult); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.ts deleted file mode 100644 index 0fc4a663b7e11..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/hooks/use_field_value_autocomplete.ts +++ /dev/null @@ -1,123 +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 { useEffect, useState, useRef } from 'react'; -import { debounce } from 'lodash'; - -import { ListOperatorTypeEnum as OperatorTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; -import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; -import { useKibana } from '../../../../common/lib/kibana'; - -interface FuncArgs { - fieldSelected: IFieldType | undefined; - value: string | string[] | undefined; - searchQuery: string; - patterns: IIndexPattern | undefined; -} - -type Func = (args: FuncArgs) => void; - -export type UseFieldValueAutocompleteReturn = [boolean, boolean, string[], Func | null]; - -export interface UseFieldValueAutocompleteProps { - selectedField: IFieldType | undefined; - operatorType: OperatorTypeEnum; - fieldValue: string | string[] | undefined; - query: string; - indexPattern: IIndexPattern | undefined; -} - -/** - * Hook for using the field value autocomplete service - * There is a copy within: - * x-pack/plugins/lists/public/exceptions/components/autocomplete/hooks.ts - * - * TODO: This should be in its own packaged and not copied, https://github.com/elastic/kibana/issues/105378 - */ -export const useFieldValueAutocomplete = ({ - selectedField, - operatorType, - fieldValue, - query, - indexPattern, -}: UseFieldValueAutocompleteProps): UseFieldValueAutocompleteReturn => { - const { services } = useKibana(); - const [isLoading, setIsLoading] = useState(false); - const [isSuggestingValues, setIsSuggestingValues] = useState(true); - const [suggestions, setSuggestions] = useState([]); - const updateSuggestions = useRef(null); - - useEffect(() => { - let isSubscribed = true; - const abortCtrl = new AbortController(); - - const fetchSuggestions = debounce( - async ({ fieldSelected, value, searchQuery, patterns }: FuncArgs) => { - try { - if (isSubscribed) { - if (fieldSelected == null || patterns == null) { - return; - } - - if (fieldSelected.type === 'boolean') { - setIsSuggestingValues(false); - return; - } - - setIsLoading(true); - - const field = - fieldSelected.subType != null && fieldSelected.subType.nested != null - ? { - ...fieldSelected, - name: `${fieldSelected.subType.nested.path}.${fieldSelected.name}`, - } - : fieldSelected; - - const newSuggestions = await services.data.autocomplete.getValueSuggestions({ - indexPattern: patterns, - field, - query: searchQuery, - signal: abortCtrl.signal, - }); - - if (newSuggestions.length === 0) { - setIsSuggestingValues(false); - } - - setIsLoading(false); - setSuggestions([...newSuggestions]); - } - } catch (error) { - if (isSubscribed) { - setSuggestions([]); - setIsLoading(false); - } - } - }, - 500 - ); - - if (operatorType !== OperatorTypeEnum.EXISTS) { - fetchSuggestions({ - fieldSelected: selectedField, - value: fieldValue, - searchQuery: query, - patterns: indexPattern, - }); - } - - updateSuggestions.current = fetchSuggestions; - - return (): void => { - isSubscribed = false; - abortCtrl.abort(); - }; - }, [services.data.autocomplete, selectedField, operatorType, fieldValue, indexPattern, query]); - - return [isLoading, isSuggestingValues, suggestions, updateSuggestions.current]; -}; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/readme.md b/x-pack/plugins/security_solution/public/common/components/autocomplete/readme.md deleted file mode 100644 index 2bf1867c008d2..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/readme.md +++ /dev/null @@ -1,122 +0,0 @@ -# Autocomplete Fields - -Need an input that shows available index fields? Or an input that autocompletes based on a selected indexPattern field? Bingo! That's what these components are for. They are generalized enough so that they can be reused throughout and repurposed based on your needs. - -All three of the available components rely on Eui's combo box. - -## useFieldValueAutocomplete - -This hook uses the kibana `services.data.autocomplete.getValueSuggestions()` service to return possible autocomplete fields based on the passed in `indexPattern` and `selectedField`. - -## FieldComponent - -This component can be used to display available indexPattern fields. It requires an indexPattern to be passed in and will show an error state if value is not one of the available indexPattern fields. Users will be able to select only one option. - -The `onChange` handler is passed `IFieldType[]`. - -```js - -``` - -## OperatorComponent - -This component can be used to display available operators. If you want to pass in your own operators, you can use `operatorOptions` prop. If a `operatorOptions` is provided, those will be used and it will ignore any of the built in logic that determines which operators to show. The operators within `operatorOptions` will still need to be of type `OperatorOption`. - -If no `operatorOptions` is provided, then the following behavior is observed: - -- if `selectedField` type is `boolean`, only `is`, `is not`, `exists`, `does not exist` operators will show -- if `selectedField` type is `nested`, only `is` operator will show -- if not one of the above, all operators will show (see `operators.ts`) - -The `onChange` handler is passed `OperatorOption[]`. - -```js - -``` - -## AutocompleteFieldExistsComponent - -This field value component is used when the selected operator is `exists` or `does not exist`. When these operators are selected, they are equivalent to using a wildcard. The combo box will be displayed as disabled. - -```js - -``` - -## AutocompleteFieldListsComponent - -This component can be used to display available large value lists - when operator selected is `is in list` or `is not in list`. It relies on hooks from the `lists` plugin. Users can only select one list and an error is shown if value is not one of available lists. - -The `selectedValue` should be the `id` of the selected list. - -This component relies on `selectedField` to render available lists. The reason being that it relies on the `selectedField` type to determine which lists to show as each large value list has a type as well. So if a user selects a field of type `ip`, it will only display lists of type `ip`. - -The `onChange` handler is passed `ListSchema`. - -```js - -``` - -## AutocompleteFieldMatchComponent - -This component can be used to allow users to select one single value. It uses the autocomplete hook to display any autocomplete options based on the passed in `indexPattern`, but also allows a user to add their own value. - -It does some minor validation, assuring that field value is a date if `selectedField` type is `date`, a number if `selectedField` type is `number`, an ip if `selectedField` type is `ip`. - -The `onChange` handler is passed selected `string`. - -```js - -``` - -## AutocompleteFieldMatchAnyComponent - -This component can be used to allow users to select multiple values. It uses the autocomplete hook to display any autocomplete options based on the passed in `indexPattern`, but also allows a user to add their own values. - -It does some minor validation, assuring that field values are a date if `selectedField` type is `date`, numbers if `selectedField` type is `number`, ips if `selectedField` type is `ip`. - -The `onChange` handler is passed selected `string[]`. - -```js - -``` diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/translations.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/translations.ts deleted file mode 100644 index 084f4b0698aac..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/translations.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const LOADING = i18n.translate('xpack.securitySolution.autocomplete.loadingDescription', { - defaultMessage: 'Loading...', -}); - -export const SELECT_FIELD_FIRST = i18n.translate( - 'xpack.securitySolution.autocomplete.selectField', - { - defaultMessage: 'Please select a field first...', - } -); - -export const FIELD_REQUIRED_ERR = i18n.translate( - 'xpack.securitySolution.autocomplete.fieldRequiredError', - { - defaultMessage: 'Value cannot be empty', - } -); - -export const NUMBER_ERR = i18n.translate('xpack.securitySolution.autocomplete.invalidNumberError', { - defaultMessage: 'Not a valid number', -}); - -export const DATE_ERR = i18n.translate('xpack.securitySolution.autocomplete.invalidDateError', { - defaultMessage: 'Not a valid date', -}); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/types.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/types.ts deleted file mode 100644 index 07f1903fb70e1..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/types.ts +++ /dev/null @@ -1,14 +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 { EuiComboBoxOptionOption } from '@elastic/eui'; - -export interface GetGenericComboBoxPropsReturn { - comboOptions: EuiComboBoxOptionOption[]; - labels: string[]; - selectedComboOptions: EuiComboBoxOptionOption[]; -} diff --git a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx index 49cfd841b7f8a..49bd7824d6100 100644 --- a/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/threat_match/entry_item.tsx @@ -9,8 +9,8 @@ import React, { useCallback, useMemo } from 'react'; import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; +import { FieldComponent } from '@kbn/securitysolution-autocomplete'; import { IFieldType, IndexPattern } from '../../../../../../../src/plugins/data/common'; -import { FieldComponent } from '../autocomplete/field'; import { FormattedEntry, Entry } from './types'; import * as i18n from './translations'; import { getEntryOnFieldChange, getEntryOnThreatFieldChange } from './helpers'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/autocomplete_field/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/autocomplete_field/index.tsx index 51404f65dc7d4..16caed9086e61 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/autocomplete_field/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/autocomplete_field/index.tsx @@ -7,8 +7,8 @@ import React, { useCallback, useMemo } from 'react'; import { EuiFormRow } from '@elastic/eui'; +import { FieldComponent } from '@kbn/securitysolution-autocomplete'; import { FieldHook } from '../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; -import { FieldComponent } from '../../../../common/components/autocomplete/field'; import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/common'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/index.tsx index c02f7992a9b92..eef18a502c270 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/risk_score_mapping/index.tsx @@ -20,10 +20,10 @@ import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { noop } from 'lodash/fp'; import { RiskScoreMapping } from '@kbn/securitysolution-io-ts-alerting-types'; +import { FieldComponent } from '@kbn/securitysolution-autocomplete'; import * as i18n from './translations'; import { FieldHook } from '../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; import { AboutStepRiskScore } from '../../../pages/detection_engine/rules/types'; -import { FieldComponent } from '../../../../common/components/autocomplete/field'; import { IFieldType } from '../../../../../../../../src/plugins/data/common/index_patterns/fields'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/common/index_patterns'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/severity_mapping/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/severity_mapping/index.tsx index 8b8c9441e7eae..d4fbdc31fbcae 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/severity_mapping/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/severity_mapping/index.tsx @@ -24,6 +24,11 @@ import { SeverityMapping, SeverityMappingItem, } from '@kbn/securitysolution-io-ts-alerting-types'; +import { + FieldComponent, + AutocompleteFieldMatchComponent, +} from '@kbn/securitysolution-autocomplete'; + import * as i18n from './translations'; import { FieldHook } from '../../../../../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; import { SeverityOptionItem } from '../step_about_rule/data'; @@ -32,8 +37,7 @@ import { IFieldType, IIndexPattern, } from '../../../../../../../../src/plugins/data/common/index_patterns'; -import { FieldComponent } from '../../../../common/components/autocomplete/field'; -import { AutocompleteFieldMatchComponent } from '../../../../common/components/autocomplete/field_value_match'; +import { useKibana } from '../../../../common/lib/kibana'; const NestedContent = styled.div` margin-left: 24px; @@ -68,6 +72,7 @@ export const SeverityField = ({ isDisabled, options, }: SeverityFieldProps) => { + const { services } = useKibana(); const { value, isMappingChecked, mapping } = field.value; const { setValue } = field; @@ -254,6 +259,7 @@ export const SeverityField = ({ Date: Thu, 22 Jul 2021 12:54:40 -0600 Subject: [PATCH 24/45] [Security Solutions] Fixes exception lists to be able to filter on os type (#106494) ## Summary Fixes https://github.com/elastic/kibana/issues/102613, and targets `7.14.0` as a blocker/critical Previously we never fully finished the plumbing for using the `os_types` (operating system type) in the exception lists to be able to filter out values based on this type. With the endpoint exceptions now having specific selections for os_type we have to filter it with exceptions and basically make it work. Some caveats is that the endpoints utilize `host.os.name.casless` for filtering against os_type, while agents such as auditbeat, winlogbeat, etc... use `host.os.type`. Really `host.os.type` is the correct ECS field to use, but to retain compatibility with the current version of endpoint agents I support both in one query to where if either of these two matches, then that will trigger the exceptions. * Adds e2e tests * Enhances the e2e tooling to do endpoint exception testing with `os_types`. * Adds the logic to handle os_type * Updates the unit tests ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../src/build_exception_filter/index.ts | 80 +- .../build_exceptions_filter.test.ts | 171 ++-- .../exception_list_item_schema.mock.ts | 2 +- .../components/exceptions/helpers.test.tsx | 2 +- .../exception_item/exception_details.test.tsx | 10 +- .../exceptions/viewer/helpers.test.tsx | 17 +- .../view/event_filters_list_page.test.tsx | 1 - .../tests/create_endpoint_exceptions.ts | 862 ++++++++++++++++++ .../security_and_spaces/tests/index.ts | 1 + .../detection_engine_api_integration/utils.ts | 153 +++- .../es_archives/rule_exceptions/README.md | 3 +- .../rule_exceptions/agent/data.json | 79 ++ .../rule_exceptions/agent/mappings.json | 40 + .../endpoint_without_host_type/data.json | 79 ++ .../endpoint_without_host_type/mappings.json | 64 ++ 15 files changed, 1427 insertions(+), 137 deletions(-) create mode 100644 x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_endpoint_exceptions.ts create mode 100644 x-pack/test/functional/es_archives/rule_exceptions/agent/data.json create mode 100644 x-pack/test/functional/es_archives/rule_exceptions/agent/mappings.json create mode 100644 x-pack/test/functional/es_archives/rule_exceptions/endpoint_without_host_type/data.json create mode 100644 x-pack/test/functional/es_archives/rule_exceptions/endpoint_without_host_type/mappings.json diff --git a/packages/kbn-securitysolution-list-utils/src/build_exception_filter/index.ts b/packages/kbn-securitysolution-list-utils/src/build_exception_filter/index.ts index 080bd0a311d7e..72db4991a49a4 100644 --- a/packages/kbn-securitysolution-list-utils/src/build_exception_filter/index.ts +++ b/packages/kbn-securitysolution-list-utils/src/build_exception_filter/index.ts @@ -19,6 +19,7 @@ import { entriesMatch, entriesMatchAny, entriesNested, + OsTypeArray, } from '@kbn/securitysolution-io-ts-list-types'; import { hasLargeValueList } from '../has_large_value_list'; @@ -69,26 +70,87 @@ export const chunkExceptions = ( return chunk(chunkSize, exceptions); }; -export const buildExceptionItemFilter = ( - exceptionItem: ExceptionItemSansLargeValueLists -): BooleanFilter | NestedFilter => { - const { entries } = exceptionItem; +/** + * Transforms the os_type into a regular filter as if the user had created it + * from the fields for the next state of transforms which will create the elastic filters + * from it. + * + * Note: We use two types of fields, the "host.os.type" and "host.os.name.caseless" + * The endpoint/endgame agent has been using "host.os.name.caseless" as the same value as the ECS + * value of "host.os.type" where the auditbeat, winlogbeat, etc... (other agents) are all using + * "host.os.type". In order to be compatible with both, I create an "OR" between these two data types + * where if either has a match then we will exclude it as part of the match. This should also be + * forwards compatible for endpoints/endgame agents when/if they upgrade to using "host.os.type" + * rather than using "host.os.name.caseless" values. + * + * Also we create another "OR" from the osType names so that if there are multiples such as ['windows', 'linux'] + * this will exclude anything with either 'windows' or with 'linux' + * @param osTypes The os_type array from the REST interface that is an array such as ['windows', 'linux'] + * @param entries The entries to join the OR's with before the elastic filter change out + */ +export const transformOsType = ( + osTypes: OsTypeArray, + entries: NonListEntry[] +): NonListEntry[][] => { + const hostTypeTransformed = osTypes.map((osType) => { + return [ + { field: 'host.os.type', operator: 'included', type: 'match', value: osType }, + ...entries, + ]; + }); + const caseLessTransformed = osTypes.map((osType) => { + return [ + { field: 'host.os.name.caseless', operator: 'included', type: 'match', value: osType }, + ...entries, + ]; + }); + return [...hostTypeTransformed, ...caseLessTransformed]; +}; - if (entries.length === 1) { - return createInnerAndClauses(entries[0]); - } else { +/** + * This builds an exception item filter with the os type + * @param osTypes The os_type array from the REST interface that is an array such as ['windows', 'linux'] + * @param entries The entries to join the OR's with before the elastic filter change out + */ +export const buildExceptionItemFilterWithOsType = ( + osTypes: OsTypeArray, + entries: NonListEntry[] +): BooleanFilter[] => { + const entriesWithOsTypes = transformOsType(osTypes, entries); + return entriesWithOsTypes.map((entryWithOsType) => { return { bool: { - filter: entries.map((entry) => createInnerAndClauses(entry)), + filter: entryWithOsType.map((entry) => createInnerAndClauses(entry)), }, }; + }); +}; + +export const buildExceptionItemFilter = ( + exceptionItem: ExceptionItemSansLargeValueLists +): Array => { + const { entries, os_types: osTypes } = exceptionItem; + if (osTypes != null && osTypes.length > 0) { + return buildExceptionItemFilterWithOsType(osTypes, entries); + } else { + if (entries.length === 1) { + return [createInnerAndClauses(entries[0])]; + } else { + return [ + { + bool: { + filter: entries.map((entry) => createInnerAndClauses(entry)), + }, + }, + ]; + } } }; export const createOrClauses = ( exceptionItems: ExceptionItemSansLargeValueLists[] ): Array => { - return exceptionItems.map((exceptionItem) => buildExceptionItemFilter(exceptionItem)); + return exceptionItems.flatMap((exceptionItem) => buildExceptionItemFilter(exceptionItem)); }; export const buildExceptionFilter = ({ diff --git a/x-pack/plugins/lists/common/exceptions/build_exceptions_filter.test.ts b/x-pack/plugins/lists/common/exceptions/build_exceptions_filter.test.ts index 9a996f8f1ac46..feee231f232b0 100644 --- a/x-pack/plugins/lists/common/exceptions/build_exceptions_filter.test.ts +++ b/x-pack/plugins/lists/common/exceptions/build_exceptions_filter.test.ts @@ -611,114 +611,115 @@ describe('build_exceptions_filter', () => { getEntryExistsExcludedMock(), ], }); - - expect(exceptionItemFilter).toEqual({ - bool: { - filter: [ - { - nested: { - path: 'parent.field', - query: { - bool: { - filter: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'parent.field.host.name': 'some host name', + expect(exceptionItemFilter).toEqual([ + { + bool: { + filter: [ + { + nested: { + path: 'parent.field', + query: { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'parent.field.host.name': 'some host name', + }, }, - }, - ], + ], + }, }, - }, - { - bool: { - must_not: { - bool: { - minimum_should_match: 1, - should: [ - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'parent.field.host.name': 'some host name', + { + bool: { + must_not: { + bool: { + minimum_should_match: 1, + should: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'parent.field.host.name': 'some host name', + }, }, - }, - ], + ], + }, }, - }, - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'parent.field.host.name': 'some other host name', + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'parent.field.host.name': 'some other host name', + }, }, - }, - ], + ], + }, }, - }, - ], + ], + }, }, }, }, - }, - { - bool: { - minimum_should_match: 1, - should: [{ exists: { field: 'parent.field.host.name' } }], + { + bool: { + minimum_should_match: 1, + should: [{ exists: { field: 'parent.field.host.name' } }], + }, }, - }, - ], + ], + }, }, + score_mode: 'none', }, - score_mode: 'none', }, - }, - { - bool: { - minimum_should_match: 1, - should: [ - { - bool: { - minimum_should_match: 1, - should: [{ match_phrase: { 'host.name': 'some "host" name' } }], + { + bool: { + minimum_should_match: 1, + should: [ + { + bool: { + minimum_should_match: 1, + should: [{ match_phrase: { 'host.name': 'some "host" name' } }], + }, }, - }, - { + { + bool: { + minimum_should_match: 1, + should: [{ match_phrase: { 'host.name': 'some other host name' } }], + }, + }, + ], + }, + }, + { + bool: { + must_not: { bool: { minimum_should_match: 1, - should: [{ match_phrase: { 'host.name': 'some other host name' } }], + should: [{ match_phrase: { 'host.name': 'some host name' } }], }, }, - ], - }, - }, - { - bool: { - must_not: { - bool: { - minimum_should_match: 1, - should: [{ match_phrase: { 'host.name': 'some host name' } }], - }, }, }, - }, - { - bool: { - must_not: { - bool: { minimum_should_match: 1, should: [{ exists: { field: 'host.name' } }] }, + { + bool: { + must_not: { + bool: { minimum_should_match: 1, should: [{ exists: { field: 'host.name' } }] }, + }, }, }, - }, - ], + ], + }, }, - }); + ]); }); }); diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts index 22a176da222d6..d04080e8a56c0 100644 --- a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts @@ -39,7 +39,7 @@ export const getExceptionListItemSchemaMock = ( meta: META, name: NAME, namespace_type: NAMESPACE_TYPE, - os_types: ['linux'], + os_types: [], tags: ['user added string for a tag', 'malware'], tie_breaker_id: TIE_BREAKER, type: ITEM_TYPE, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index bf6a94c53b477..af8058e25adc6 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -185,7 +185,7 @@ describe('Exception helpers', () => { meta: {}, name: 'some name', namespace_type: 'single', - os_types: ['linux'], + os_types: [], tags: ['user added string for a tag', 'malware'], type: 'simple', }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx index e3e9ba1bfa132..1d5094021c3d3 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx @@ -157,7 +157,7 @@ describe('ExceptionDetails', () => { }); test('it renders the operating system if one is specified in the exception item', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); const wrapper = mount( { }); test('it renders the exception item creator', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); const wrapper = mount( { }); test('it renders the exception item creation timestamp', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); const wrapper = mount( { }); test('it renders the description if one is included on the exception item', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); const wrapper = mount( { }); test('it renders with Name and Modified info when showName and showModified props are true', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = getExceptionListItemSchemaMock({ os_types: ['linux'] }); exceptionItem.comments = []; const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx index 634c7975a13a9..d67f526fa9bdc 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx @@ -154,7 +154,7 @@ describe('Exception viewer helpers', () => { describe('#getDescriptionListContent', () => { test('it returns formatted description list with os if one is specified', () => { - const payload = getExceptionListItemSchemaMock(); + const payload = getExceptionListItemSchemaMock({ os_types: ['linux'] }); payload.description = ''; const result = getDescriptionListContent(payload); const expected: DescriptionListItem[] = [ @@ -176,7 +176,7 @@ describe('Exception viewer helpers', () => { }); test('it returns formatted description list with a description if one specified', () => { - const payload = getExceptionListItemSchemaMock(); + const payload = getExceptionListItemSchemaMock({ os_types: ['linux'] }); payload.description = 'Im a description'; const result = getDescriptionListContent(payload); const expected: DescriptionListItem[] = [ @@ -202,7 +202,7 @@ describe('Exception viewer helpers', () => { }); test('it returns just user and date created if no other fields specified', () => { - const payload = getExceptionListItemSchemaMock(); + const payload = getExceptionListItemSchemaMock({ os_types: ['linux'] }); payload.description = ''; const result = getDescriptionListContent(payload); const expected: DescriptionListItem[] = [ @@ -224,7 +224,10 @@ describe('Exception viewer helpers', () => { }); test('it returns Modified By/On info. when `includeModified` is true', () => { - const result = getDescriptionListContent(getExceptionListItemSchemaMock(), true); + const result = getDescriptionListContent( + getExceptionListItemSchemaMock({ os_types: ['linux'] }), + true + ); expect(result).toEqual([ { description: 'Linux', @@ -254,7 +257,11 @@ describe('Exception viewer helpers', () => { }); test('it returns Name when `includeName` is true', () => { - const result = getDescriptionListContent(getExceptionListItemSchemaMock(), false, true); + const result = getDescriptionListContent( + getExceptionListItemSchemaMock({ os_types: ['linux'] }), + false, + true + ); expect(result).toEqual([ { description: 'some name', diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx index d44ce7a136fdf..b974dfebd4eb1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.test.tsx @@ -115,7 +115,6 @@ describe('When on the Event Filters List Page', () => { expect(eventMeta).toEqual([ 'some name', - 'Linux', 'April 20th 2020 @ 11:25:31', 'some user', 'April 20th 2020 @ 11:25:31', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_endpoint_exceptions.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_endpoint_exceptions.ts new file mode 100644 index 0000000000000..4a50a146421f6 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_endpoint_exceptions.ts @@ -0,0 +1,862 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { + createListsIndex, + deleteAllExceptions, + deleteListsIndex, +} from '../../../lists_api_integration/utils'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { + createRule, + createRuleWithExceptionEntries, + createSignalsIndex, + deleteAllAlerts, + deleteSignalsIndex, + getRuleForSignalTesting, + getSignalsById, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, +} from '../../utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + describe('Rule exception operators for endpoints', () => { + beforeEach(async () => { + await createSignalsIndex(supertest); + await createListsIndex(supertest); + await esArchiver.load( + 'x-pack/test/functional/es_archives/rule_exceptions/endpoint_without_host_type' + ); + await esArchiver.load('x-pack/test/functional/es_archives/rule_exceptions/agent'); + }); + + afterEach(async () => { + await deleteSignalsIndex(supertest); + await deleteAllAlerts(supertest); + await deleteAllExceptions(es); + await deleteListsIndex(supertest); + await esArchiver.unload( + 'x-pack/test/functional/es_archives/rule_exceptions/endpoint_without_host_type' + ); + await esArchiver.unload('x-pack/test/functional/es_archives/rule_exceptions/agent'); + }); + + describe('no exceptions set', () => { + it('should find all the "hosts" from a "agent" index when no exceptions are set on the rule', async () => { + const rule = getRuleForSignalTesting(['agent']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host).sort(); + expect(hits).to.eql([ + { + os: { type: 'linux' }, + }, + { + os: { type: 'windows' }, + }, + { + os: { type: 'macos' }, + }, + { + os: { type: 'linux' }, + }, + ]); + }); + + it('should find all the "hosts" from a "endpoint_without_host_type" index when no exceptions are set on the rule', async () => { + const rule = getRuleForSignalTesting(['endpoint_without_host_type']); + const { id } = await createRule(supertest, rule); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host).sort(); + expect(hits).to.eql([ + { + os: { name: 'Linux' }, + }, + { + os: { name: 'Windows' }, + }, + { + os: { name: 'Macos' }, + }, + { + os: { name: 'Linux' }, + }, + ]); + }); + }); + + describe('operating system types (os_types)', () => { + describe('endpoints', () => { + it('should filter 1 operating system types (os_type) if it is set as part of an endpoint exception', async () => { + const rule = getRuleForSignalTesting(['endpoint_without_host_type']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['linux'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '1', + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { name: 'Windows' }, + }, + { + os: { name: 'Macos' }, + }, + { + os: { name: 'Linux' }, + }, + ]); + }); + + it('should filter 2 operating system types as an "OR" (os_type) if it is set as part of an endpoint exception', async () => { + const rule = getRuleForSignalTesting(['endpoint_without_host_type']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['linux', 'macos'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '1', + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { name: 'Windows' }, + }, + { + os: { name: 'Macos' }, + }, + { + os: { name: 'Linux' }, + }, + ]); + }); + + it('should filter multiple operating system types if it is set as part of an endpoint exception', async () => { + const rule = getRuleForSignalTesting(['endpoint_without_host_type']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['linux'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '1', + }, + ], + }, + { + osTypes: ['windows'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '2', + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { name: 'Macos' }, + }, + { + os: { name: 'Linux' }, + }, + ]); + }); + + it('should filter multiple operating system types (os_type) with multiple filter items for an endpoint', async () => { + const rule = getRuleForSignalTesting(['endpoint_without_host_type']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['macos', 'linux'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '1', + }, + ], + }, + { + osTypes: ['windows', 'linux', 'macos'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '2', + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { name: 'Macos' }, + }, + { + os: { name: 'Linux' }, + }, + ]); + }); + }); + + describe('agent', () => { + it('should filter 1 operating system types (os_type) if it is set as part of an endpoint exception', async () => { + const rule = getRuleForSignalTesting(['agent']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['linux'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '1', + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { type: 'windows' }, + }, + { + os: { type: 'macos' }, + }, + { + os: { type: 'linux' }, + }, + ]); + }); + + it('should filter 1 operating system type as an "OR" (os_type) if it is set as part of an endpoint exception', async () => { + const rule = getRuleForSignalTesting(['agent']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['linux', 'macos'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '1', + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { type: 'windows' }, + }, + { + os: { type: 'macos' }, + }, + { + os: { type: 'linux' }, + }, + ]); + }); + + it('should filter multiple operating system types if it is set as part of an endpoint exception', async () => { + const rule = getRuleForSignalTesting(['agent']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['linux'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '1', + }, + ], + }, + { + osTypes: ['windows'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '2', + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { type: 'macos' }, + }, + { + os: { type: 'linux' }, + }, + ]); + }); + + it('should filter multiple operating system types (os_type) with multiple filter items for an endpoint', async () => { + const rule = getRuleForSignalTesting(['agent']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['macos', 'linux'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '1', + }, + ], + }, + { + osTypes: ['windows', 'linux', 'macos'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '2', + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { type: 'macos' }, + }, + { + os: { type: 'linux' }, + }, + ]); + }); + }); + + describe('agent and endpoint', () => { + it('should filter 2 operating system types (os_type) if it is set as part of an endpoint exception', async () => { + const rule = getRuleForSignalTesting(['agent', 'endpoint_without_host_type']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['linux'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '1', + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 6, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { type: 'windows' }, + }, + { + os: { name: 'Windows' }, + }, + { + os: { type: 'macos' }, + }, + { + os: { name: 'Macos' }, + }, + { + os: { type: 'linux' }, + }, + { + os: { name: 'Linux' }, + }, + ]); + }); + + it('should filter 2 operating system types as an "OR" (os_type) if it is set as part of an endpoint exception', async () => { + const rule = getRuleForSignalTesting(['agent', 'endpoint_without_host_type']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['linux', 'macos'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '1', + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 6, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { type: 'windows' }, + }, + { + os: { name: 'Windows' }, + }, + { + os: { type: 'macos' }, + }, + { + os: { name: 'Macos' }, + }, + { + os: { type: 'linux' }, + }, + { + os: { name: 'Linux' }, + }, + ]); + }); + + it('should filter multiple operating system types if it is set as part of an endpoint exception', async () => { + const rule = getRuleForSignalTesting(['agent', 'endpoint_without_host_type']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['linux'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '1', + }, + ], + }, + { + osTypes: ['windows'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '2', + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { type: 'macos' }, + }, + { + os: { name: 'Macos' }, + }, + { + os: { type: 'linux' }, + }, + { + os: { name: 'Linux' }, + }, + ]); + }); + + it('should filter multiple operating system types (os_type) with multiple filter items for an endpoint', async () => { + const rule = getRuleForSignalTesting(['agent', 'endpoint_without_host_type']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['macos', 'linux'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '1', + }, + ], + }, + { + osTypes: ['windows', 'linux', 'macos'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match', + value: '2', + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { type: 'macos' }, + }, + { + os: { name: 'Macos' }, + }, + { + os: { type: 'linux' }, + }, + { + os: { name: 'Linux' }, + }, + ]); + }); + }); + }); + + describe('"is" operator', () => { + it('should filter 1 value set as an endpoint exception and 1 value set as a normal rule exception ', async () => { + const rule = getRuleForSignalTesting(['agent']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [ + [ + { + field: 'host.os.type', + operator: 'included', + type: 'match', + value: 'linux', + }, + ], + ], + [ + { + osTypes: undefined, // This "undefined" is not possible through the user interface but is possible in the REST API + entries: [ + { + field: 'host.os.type', + operator: 'included', + type: 'match', + value: 'windows', + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { type: 'macos' }, + }, + ]); + }); + + it('should filter 1 value set as an endpoint exception and 1 value set as a normal rule exception with os_type set', async () => { + const rule = getRuleForSignalTesting(['agent']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [ + [ + { + field: 'host.os.type', + operator: 'included', + type: 'match', + value: 'linux', + }, + ], + ], + [ + { + osTypes: ['windows'], + entries: [ + { + field: 'host.os.type', + operator: 'included', + type: 'match', + value: 'windows', + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 1, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { type: 'macos' }, + }, + ]); + }); + }); + + describe('"is one of" operator', () => { + it('should filter 1 single value if it is set as an exception and the os_type is set to only 1 value', async () => { + const rule = getRuleForSignalTesting(['agent']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['windows'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match_any', + value: ['1', '2'], + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 3, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { type: 'linux' }, + }, + { + os: { type: 'macos' }, + }, + { + os: { type: 'linux' }, + }, + ]); + }); + + it('should filter 2 values if it is set as an exception and the os_type is set to 2 values', async () => { + const rule = getRuleForSignalTesting(['agent']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['windows', 'linux'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match_any', + value: ['1', '2'], + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { type: 'macos' }, + }, + { + os: { type: 'linux' }, + }, + ]); + }); + + it('should filter 2 values if it is set as an exception and the os_type is set to undefined', async () => { + const rule = getRuleForSignalTesting(['agent']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: undefined, // This is only possible through the REST API + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match_any', + value: ['1', '2'], + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 2, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { type: 'macos' }, + }, + { + os: { type: 'linux' }, + }, + ]); + }); + + it('should filter no values if they are set as an exception but the os_type is set to something not within the documents', async () => { + const rule = getRuleForSignalTesting(['agent']); + const { id } = await createRuleWithExceptionEntries( + supertest, + rule, + [], + [ + { + osTypes: ['macos'], + entries: [ + { + field: 'event.code', + operator: 'included', + type: 'match_any', + value: ['1', '2'], + }, + ], + }, + ] + ); + await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); + const signalsOpen = await getSignalsById(supertest, id); + const hits = signalsOpen.hits.hits.map((hit) => hit._source.host); + expect(hits).to.eql([ + { + os: { type: 'linux' }, + }, + { + os: { type: 'windows' }, + }, + { + os: { type: 'macos' }, + }, + { + os: { type: 'linux' }, + }, + ]); + }); + }); + }); +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts index 3c5e04ee1f64e..44e6023bf366a 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/index.ts @@ -14,6 +14,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { this.tags('ciGroup11'); loadTestFile(require.resolve('./aliases')); + loadTestFile(require.resolve('./create_endpoint_exceptions')); loadTestFile(require.resolve('./add_actions')); loadTestFile(require.resolve('./update_actions')); loadTestFile(require.resolve('./add_prepackaged_rules')); diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index ac11dd31c15e8..f8989c685c82c 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -12,7 +12,11 @@ import { SuperTest } from 'supertest'; import supertestAsPromised from 'supertest-as-promised'; import { Context } from '@elastic/elasticsearch/lib/Transport'; import { SearchResponse } from 'elasticsearch'; -import type { NonEmptyEntriesArray } from '@kbn/securitysolution-io-ts-list-types'; +import type { + ListArray, + NonEmptyEntriesArray, + OsTypeArray, +} from '@kbn/securitysolution-io-ts-list-types'; import type { CreateExceptionListItemSchema, CreateExceptionListSchema, @@ -21,7 +25,6 @@ import type { } from '@kbn/securitysolution-io-ts-list-types'; import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; import { PrePackagedRulesAndTimelinesStatusSchema } from '../../plugins/security_solution/common/detection_engine/schemas/response'; -import { getCreateExceptionListDetectionSchemaMock } from '../../plugins/lists/common/schemas/request/create_exception_list_schema.mock'; import { CreateRulesSchema, UpdateRulesSchema, @@ -45,7 +48,6 @@ import { INTERNAL_IMMUTABLE_KEY, INTERNAL_RULE_ID_KEY, } from '../../plugins/security_solution/common/constants'; -import { getCreateExceptionListItemMinimalSchemaMockWithoutId } from '../../plugins/lists/common/schemas/request/create_exception_list_item_schema.mock'; /** * This will remove server generated properties such as date times, etc... @@ -1149,28 +1151,97 @@ export const installPrePackagedRules = async ( }; /** - * Convenience testing function where you can pass in just the entries and you will - * get a rule created with the entries added to an exception list and exception list item - * all auto-created at once. + * Convenience testing function where you can pass in just the endpoint entries and you will + * get a container created with the entries. + * @param supertest super test agent + * @param endpointEntries The endpoint entries to create the rule and exception list from + * @param osTypes The os types to optionally add or not to add to the container + */ +export const createContainerWithEndpointEntries = async ( + supertest: SuperTest, + endpointEntries: Array<{ + entries: NonEmptyEntriesArray; + osTypes: OsTypeArray | undefined; + }> +): Promise => { + // If not given any endpoint entries, return without any + if (endpointEntries.length === 0) { + return []; + } + + // create the endpoint exception list container + // eslint-disable-next-line @typescript-eslint/naming-convention + const { id, list_id, namespace_type, type } = await createExceptionList(supertest, { + description: 'endpoint description', + list_id: 'endpoint_list', + name: 'endpoint_list', + type: 'endpoint', + }); + + // Add the endpoint exception list container to the backend + await Promise.all( + endpointEntries.map((endpointEntry) => { + const exceptionListItem: CreateExceptionListItemSchema = { + description: 'endpoint description', + entries: endpointEntry.entries, + list_id: 'endpoint_list', + name: 'endpoint_list', + os_types: endpointEntry.osTypes, + type: 'simple', + }; + return createExceptionListItem(supertest, exceptionListItem); + }) + ); + + // To reduce the odds of in-determinism and/or bugs we ensure we have + // the same length of entries before continuing. + await waitFor(async () => { + const { body } = await supertest.get(`${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${list_id}`); + return body.data.length === endpointEntries.length; + }, `within createContainerWithEndpointEntries ${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${list_id}`); + + return [ + { + id, + list_id, + namespace_type, + type, + }, + ]; +}; + +/** + * Convenience testing function where you can pass in just the endpoint entries and you will + * get a container created with the entries. * @param supertest super test agent - * @param rule The rule to create and attach an exception list to * @param entries The entries to create the rule and exception list from + * @param osTypes The os types to optionally add or not to add to the container */ -export const createRuleWithExceptionEntries = async ( +export const createContainerWithEntries = async ( supertest: SuperTest, - rule: CreateRulesSchema, entries: NonEmptyEntriesArray[] -): Promise => { +): Promise => { + // If not given any endpoint entries, return without any + if (entries.length === 0) { + return []; + } + // Create the rule exception list container // eslint-disable-next-line @typescript-eslint/naming-convention - const { id, list_id, namespace_type, type } = await createExceptionList( - supertest, - getCreateExceptionListDetectionSchemaMock() - ); + const { id, list_id, namespace_type, type } = await createExceptionList(supertest, { + description: 'some description', + list_id: 'some-list-id', + name: 'some name', + type: 'detection', + }); + // Add the rule exception list container to the backend await Promise.all( entries.map((entry) => { const exceptionListItem: CreateExceptionListItemSchema = { - ...getCreateExceptionListItemMinimalSchemaMockWithoutId(), + description: 'some description', + list_id: 'some-list-id', + name: 'some name', + type: 'simple', entries: entry, }; return createExceptionListItem(supertest, exceptionListItem); @@ -1180,13 +1251,44 @@ export const createRuleWithExceptionEntries = async ( // To reduce the odds of in-determinism and/or bugs we ensure we have // the same length of entries before continuing. await waitFor(async () => { - const { body } = await supertest.get( - `${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${ - getCreateExceptionListDetectionSchemaMock().list_id - }` - ); + const { body } = await supertest.get(`${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${list_id}`); return body.data.length === entries.length; - }, `within createRuleWithExceptionEntries ${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${getCreateExceptionListDetectionSchemaMock().list_id}`); + }, `within createContainerWithEntries ${EXCEPTION_LIST_ITEM_URL}/_find?list_id=${list_id}`); + + return [ + { + id, + list_id, + namespace_type, + type, + }, + ]; +}; + +/** + * Convenience testing function where you can pass in just the entries and you will + * get a rule created with the entries added to an exception list and exception list item + * all auto-created at once. + * @param supertest super test agent + * @param rule The rule to create and attach an exception list to + * @param entries The entries to create the rule and exception list from + * @param endpointEntries The endpoint entries to create the rule and exception list from + * @param osTypes The os types to optionally add or not to add to the container + */ +export const createRuleWithExceptionEntries = async ( + supertest: SuperTest, + rule: CreateRulesSchema, + entries: NonEmptyEntriesArray[], + endpointEntries?: Array<{ + entries: NonEmptyEntriesArray; + osTypes: OsTypeArray | undefined; + }> +): Promise => { + const maybeExceptionList = await createContainerWithEntries(supertest, entries); + const maybeEndpointList = await createContainerWithEndpointEntries( + supertest, + endpointEntries ?? [] + ); // create the rule but don't run it immediately as running it immediately can cause // the rule to sometimes not filter correctly the first time with an exception list @@ -1195,14 +1297,7 @@ export const createRuleWithExceptionEntries = async ( const ruleWithException: CreateRulesSchema = { ...rule, enabled: false, - exceptions_list: [ - { - id, - list_id, - namespace_type, - type, - }, - ], + exceptions_list: [...maybeExceptionList, ...maybeEndpointList], }; const ruleResponse = await createRule(supertest, ruleWithException); await supertest diff --git a/x-pack/test/functional/es_archives/rule_exceptions/README.md b/x-pack/test/functional/es_archives/rule_exceptions/README.md index 1fbf4962d55fe..a7c5aebe8a7e2 100644 --- a/x-pack/test/functional/es_archives/rule_exceptions/README.md +++ b/x-pack/test/functional/es_archives/rule_exceptions/README.md @@ -1,7 +1,8 @@ Within this folder is input test data for tests such as: ```ts -security_and_spaces/tests/rule_exceptions.ts +security_and_spaces/tests/operator_data_types +security_and_spaces/tests/create_endpoint_exceptions.ts ``` where these are small ECS compliant input indexes that try to express tests that exercise different parts of diff --git a/x-pack/test/functional/es_archives/rule_exceptions/agent/data.json b/x-pack/test/functional/es_archives/rule_exceptions/agent/data.json new file mode 100644 index 0000000000000..e20e7de50cfeb --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/agent/data.json @@ -0,0 +1,79 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "agent", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "host": { + "os": { + "type": "linux" + } + }, + "event": { + "code": 1 + } + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "agent", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "host": { + "os": { + "type": "windows" + } + }, + "event": { + "code": 2 + } + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "agent", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "host": { + "os": { + "type": "macos" + } + }, + "event": { + "code": 3 + } + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "agent", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "host": { + "os": { + "type": "linux" + } + }, + "event": { + "code": 4 + } + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/agent/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/agent/mappings.json new file mode 100644 index 0000000000000..028e417ae34c0 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/agent/mappings.json @@ -0,0 +1,40 @@ +{ + "type": "index", + "value": { + "index": "agent", + "mappings": { + "dynamic": "strict", + "properties": { + "@timestamp": { + "type": "date" + }, + "host": { + "properties": { + "os": { + "properties": { + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "event": { + "properties": { + "code": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/endpoint_without_host_type/data.json b/x-pack/test/functional/es_archives/rule_exceptions/endpoint_without_host_type/data.json new file mode 100644 index 0000000000000..d0e69259a861f --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/endpoint_without_host_type/data.json @@ -0,0 +1,79 @@ +{ + "type": "doc", + "value": { + "id": "1", + "index": "endpoint_without_host_type", + "source": { + "@timestamp": "2020-10-28T05:00:53.000Z", + "host": { + "os": { + "name": "Linux" + } + }, + "event": { + "code": 1 + } + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "2", + "index": "endpoint_without_host_type", + "source": { + "@timestamp": "2020-10-28T05:01:53.000Z", + "host": { + "os": { + "name": "Windows" + } + }, + "event": { + "code": 2 + } + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "3", + "index": "endpoint_without_host_type", + "source": { + "@timestamp": "2020-10-28T05:02:53.000Z", + "host": { + "os": { + "name": "Macos" + } + }, + "event": { + "code": 3 + } + }, + "type": "_doc" + } +} + +{ + "type": "doc", + "value": { + "id": "4", + "index": "endpoint_without_host_type", + "source": { + "@timestamp": "2020-10-28T05:03:53.000Z", + "host": { + "os": { + "name": "Linux" + } + }, + "event": { + "code": 4 + } + }, + "type": "_doc" + } +} diff --git a/x-pack/test/functional/es_archives/rule_exceptions/endpoint_without_host_type/mappings.json b/x-pack/test/functional/es_archives/rule_exceptions/endpoint_without_host_type/mappings.json new file mode 100644 index 0000000000000..7775d5e20e305 --- /dev/null +++ b/x-pack/test/functional/es_archives/rule_exceptions/endpoint_without_host_type/mappings.json @@ -0,0 +1,64 @@ +{ + "type": "index", + "value": { + "index": "endpoint_without_host_type", + "mappings": { + "dynamic": "strict", + "properties": { + "@timestamp": { + "type": "date" + }, + "host": { + "properties": { + "os": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "caseless": { + "type": "keyword", + "ignore_above": 1024, + "normalizer": "lowercase" + }, + "text": { + "type": "text" + } + } + } + } + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "caseless": { + "type": "keyword", + "ignore_above": 1024, + "normalizer": "lowercase" + }, + "text": { + "type": "text" + } + } + } + } + }, + "event": { + "properties": { + "code": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "settings": { + "index": { + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} From a9d645410ed72e9e9f832926cf99f21a7b9a930e Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Thu, 22 Jul 2021 16:11:03 -0400 Subject: [PATCH 25/45] [Observability] adjust FieldValueSuggestions test to prevent flakiness (#106420) --- .../shared/field_value_suggestions/index.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.test.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.test.tsx index 1dd43d45c40e6..6671c43dd8c7b 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.test.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/index.test.tsx @@ -7,14 +7,13 @@ import React from 'react'; import { FieldValueSuggestions } from './index'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitForElementToBeRemoved } from '@testing-library/react'; import * as searchHook from '../../../hooks/use_es_search'; import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; jest.setTimeout(30000); -// flaky https://github.com/elastic/kibana/issues/105784 -describe.skip('FieldValueSuggestions', () => { +describe('FieldValueSuggestions', () => { jest.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(1500); jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(1500); @@ -109,6 +108,8 @@ describe.skip('FieldValueSuggestions', () => { expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(['US'], []); + await waitForElementToBeRemoved(() => screen.queryByText('Apply')); + rerender( Date: Fri, 23 Jul 2021 00:26:54 +0300 Subject: [PATCH 26/45] [i18n] Test EUI i18n tokens coverage (#106377) --- .../__snapshots__/i18n_service.test.tsx.snap | 8 +- src/core/public/i18n/i18n_eui_mapping.test.ts | 100 +++++++++ src/core/public/i18n/i18n_eui_mapping.tsx | 123 +++++------ .../translations/translations/ja-JP.json | 195 ++-------------- .../translations/translations/zh-CN.json | 208 ++---------------- 5 files changed, 194 insertions(+), 440 deletions(-) create mode 100644 src/core/public/i18n/i18n_eui_mapping.test.ts diff --git a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap index 16504cf97366e..95f5d1953b761 100644 --- a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap +++ b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap @@ -93,8 +93,8 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiDataGridSchema.booleanSortTextDesc": "True-False", "euiDataGridSchema.currencySortTextAsc": "Low-High", "euiDataGridSchema.currencySortTextDesc": "High-Low", - "euiDataGridSchema.dateSortTextAsc": "New-Old", - "euiDataGridSchema.dateSortTextDesc": "Old-New", + "euiDataGridSchema.dateSortTextAsc": "Old-New", + "euiDataGridSchema.dateSortTextDesc": "New-Old", "euiDataGridSchema.jsonSortTextAsc": "Small-Large", "euiDataGridSchema.jsonSortTextDesc": "Large-Small", "euiDataGridSchema.numberSortTextAsc": "Low-High", @@ -180,11 +180,11 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiSaturation.roleDescription": "HSV color mode saturation and value selection", "euiSaturation.screenReaderAnnouncement": "Use the arrow keys to navigate the square color gradient. The coordinates resulting from each key press will be used to calculate HSV color mode \\"saturation\\" and \\"value\\" numbers, in the range of 0 to 1. Left and right decrease and increase (respectively) the \\"saturation\\" value. Up and down decrease and increase (respectively) the \\"value\\" value.", "euiSelectable.loadingOptions": "Loading options", - "euiSelectable.noAvailableOptions": "There aren't any options available", + "euiSelectable.noAvailableOptions": "No options available", "euiSelectable.noMatchingOptions": [Function], "euiSelectable.placeholderName": "Filter options", "euiSelectableListItem.excludedOption": "Excluded option.", - "euiSelectableListItem.excludedOptionInstructions": "To deselect this option, press enter", + "euiSelectableListItem.excludedOptionInstructions": "To deselect this option, press enter.", "euiSelectableListItem.includedOption": "Included option.", "euiSelectableListItem.includedOptionInstructions": "To exclude this option, press enter.", "euiSelectableTemplateSitewide.loadingResults": "Loading results", diff --git a/src/core/public/i18n/i18n_eui_mapping.test.ts b/src/core/public/i18n/i18n_eui_mapping.test.ts new file mode 100644 index 0000000000000..1b80257266d4c --- /dev/null +++ b/src/core/public/i18n/i18n_eui_mapping.test.ts @@ -0,0 +1,100 @@ +/* + * 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. + */ + +jest.mock('@kbn/i18n'); + +import { i18n } from '@kbn/i18n'; + +import i18ntokens from '@elastic/eui/i18ntokens.json'; +import { getEuiContextMapping } from './i18n_eui_mapping'; + +/** Regexp to find {values} usage */ +const VALUES_REGEXP = /\{\w+\}/; + +describe('@elastic/eui i18n tokens', () => { + const i18nTranslateMock = jest + .fn() + .mockImplementation((id, { defaultMessage }) => defaultMessage); + i18n.translate = i18nTranslateMock; + + const euiContextMapping = getEuiContextMapping(); + + test('all tokens are mapped', () => { + // Extract the tokens from the EUI library: We need to uniq them because they might be duplicated + const euiTokensFromLib = [...new Set(i18ntokens.map(({ token }) => token))]; + const euiTokensFromMapping = Object.keys(euiContextMapping); + + expect(euiTokensFromMapping.sort()).toStrictEqual(euiTokensFromLib.sort()); + }); + + test('tokens that include {word} should be mapped to functions', () => { + const euiTokensFromLibWithValues = i18ntokens.filter(({ defString }) => + VALUES_REGEXP.test(defString) + ); + const euiTokensFromLib = [...new Set(euiTokensFromLibWithValues.map(({ token }) => token))]; + const euiTokensFromMapping = Object.entries(euiContextMapping) + .filter(([, value]) => typeof value === 'function') + .map(([key]) => key); + + expect(euiTokensFromMapping.sort()).toStrictEqual(euiTokensFromLib.sort()); + }); + + i18ntokens.forEach(({ token, defString }) => { + describe(`Token "${token}"`, () => { + let i18nTranslateCall: [ + string, + { defaultMessage: string; values?: object; description?: string } + ]; + + beforeAll(() => { + // If it's a function, call it, so we have the mock to register the call. + const entry = euiContextMapping[token as keyof typeof euiContextMapping]; + const translationOutput = typeof entry === 'function' ? entry({}) : entry; + + // If it's a string, it comes from i18n.translate call + if (typeof translationOutput === 'string') { + // find the call in the mocks + i18nTranslateCall = i18nTranslateMock.mock.calls.find( + ([kbnToken]) => kbnToken === `core.${token}` + ); + } else { + // Otherwise, it's a fn returning `FormattedMessage` component => read the props + const { id, defaultMessage, values } = translationOutput.props; + i18nTranslateCall = [id, { defaultMessage, values }]; + } + }); + + test('a translation should be registered as `core.{TOKEN}`', () => { + expect(i18nTranslateCall).not.toBeUndefined(); + }); + + test('defaultMessage is in sync with defString', () => { + // Clean up typical errors from the `@elastic/eui` extraction token tool + const normalizedDefString = defString + // Quoted words should use double-quotes + .replace(/\s'/g, ' "') + .replace(/'\s/g, '" ') + // Should not include break-lines + .replace(/\n/g, '') + // Should trim extra spaces + .replace(/\s{2,}/g, ' ') + .trim(); + + expect(i18nTranslateCall[1].defaultMessage).toBe(normalizedDefString); + }); + + test('values should match', () => { + const valuesFromEuiLib = defString.match(new RegExp(VALUES_REGEXP, 'g')) || []; + const receivedValuesInMock = Object.keys(i18nTranslateCall[1].values ?? {}).map( + (key) => `{${key}}` + ); + expect(receivedValuesInMock.sort()).toStrictEqual(valuesFromEuiLib.sort()); + }); + }); + }); +}); diff --git a/src/core/public/i18n/i18n_eui_mapping.tsx b/src/core/public/i18n/i18n_eui_mapping.tsx index b7fbf8f91cc4e..beed0deced35c 100644 --- a/src/core/public/i18n/i18n_eui_mapping.tsx +++ b/src/core/public/i18n/i18n_eui_mapping.tsx @@ -15,8 +15,8 @@ interface EuiValues { [key: string]: any; } -export const getEuiContextMapping = () => { - const euiContextMapping: EuiTokensObject = { +export const getEuiContextMapping = (): EuiTokensObject => { + return { 'euiAccordion.isLoading': i18n.translate('core.euiAccordion.isLoading', { defaultMessage: 'Loading', }), @@ -40,7 +40,7 @@ export const getEuiContextMapping = () => { page, pageCount, }: EuiValues) => - i18n.translate('core.euiBasicTable.tableDescriptionWithoutPagination', { + i18n.translate('core.euiBasicTable.tableAutoCaptionWithPagination', { defaultMessage: 'This table contains {itemCount} rows out of {totalItemCount} rows; Page {page} of {pageCount}.', values: { itemCount, totalItemCount, page, pageCount }, @@ -219,6 +219,9 @@ export const getEuiContextMapping = () => { description: 'Screen reader text to describe the composite behavior of the color stops component.', }), + 'euiColumnActions.hideColumn': i18n.translate('core.euiColumnActions.hideColumn', { + defaultMessage: 'Hide column', + }), 'euiColumnActions.sort': ({ schemaLabel }: EuiValues) => i18n.translate('core.euiColumnActions.sort', { defaultMessage: 'Sort {schemaLabel}', @@ -230,9 +233,6 @@ export const getEuiContextMapping = () => { 'euiColumnActions.moveRight': i18n.translate('core.euiColumnActions.moveRight', { defaultMessage: 'Move right', }), - 'euiColumnActions.hideColumn': i18n.translate('core.euiColumnActions.hideColumn', { - defaultMessage: 'Hide column', - }), 'euiColumnSelector.hideAll': i18n.translate('core.euiColumnSelector.hideAll', { defaultMessage: 'Hide all', }), @@ -369,12 +369,6 @@ export const getEuiContextMapping = () => { 'euiControlBar.screenReaderHeading': i18n.translate('core.euiControlBar.screenReaderHeading', { defaultMessage: 'Page level controls', }), - 'euiControlBar.customScreenReaderAnnouncement': ({ landmarkHeading }: EuiValues) => - i18n.translate('core.euiControlBar.customScreenReaderAnnouncement', { - defaultMessage: - 'There is a new region landmark called {landmarkHeading} with page level controls at the end of the document.', - values: { landmarkHeading }, - }), 'euiControlBar.screenReaderAnnouncement': i18n.translate( 'core.euiControlBar.screenReaderAnnouncement', { @@ -382,6 +376,12 @@ export const getEuiContextMapping = () => { 'There is a new region landmark with page level controls at the end of the document.', } ), + 'euiControlBar.customScreenReaderAnnouncement': ({ landmarkHeading }: EuiValues) => + i18n.translate('core.euiControlBar.customScreenReaderAnnouncement', { + defaultMessage: + 'There is a new region landmark called {landmarkHeading} with page level controls at the end of the document.', + values: { landmarkHeading }, + }), 'euiDataGrid.screenReaderNotice': i18n.translate('core.euiDataGrid.screenReaderNotice', { defaultMessage: 'Cell contains interactive content.', }), @@ -466,13 +466,13 @@ export const getEuiContextMapping = () => { } ), 'euiDataGridSchema.dateSortTextAsc': i18n.translate('core.euiDataGridSchema.dateSortTextAsc', { - defaultMessage: 'New-Old', + defaultMessage: 'Old-New', description: 'Ascending date label', }), 'euiDataGridSchema.dateSortTextDesc': i18n.translate( 'core.euiDataGridSchema.dateSortTextDesc', { - defaultMessage: 'Old-New', + defaultMessage: 'New-Old', description: 'Descending date label', } ), @@ -519,8 +519,8 @@ export const getEuiContextMapping = () => { }), 'euiFilterButton.filterBadge': ({ count, hasActiveFilters }: EuiValues) => i18n.translate('core.euiFilterButton.filterBadge', { - defaultMessage: '${count} ${filterCountLabel} filters', - values: { count, filterCountLabel: hasActiveFilters ? 'active' : 'available' }, + defaultMessage: '{count} {hasActiveFilters} filters', + values: { count, hasActiveFilters: hasActiveFilters ? 'active' : 'available' }, }), 'euiFlyout.closeAriaLabel': i18n.translate('core.euiFlyout.closeAriaLabel', { defaultMessage: 'Close this dialog', @@ -642,19 +642,19 @@ export const getEuiContextMapping = () => { 'euiModal.closeModal': i18n.translate('core.euiModal.closeModal', { defaultMessage: 'Closes this modal window', }), - 'euiNotificationEventMessages.accordionButtonText': ({ + 'euiNotificationEventMessages.accordionButtonText': ({ messagesLength }: EuiValues) => + i18n.translate('core.euiNotificationEventMessages.accordionButtonText', { + defaultMessage: '+ {messagesLength} more', + values: { messagesLength }, + }), + 'euiNotificationEventMessages.accordionAriaLabelButtonText': ({ messagesLength, eventName, }: EuiValues) => - i18n.translate('core.euiNotificationEventMessages.accordionButtonText', { + i18n.translate('core.euiNotificationEventMessages.accordionAriaLabelButtonText', { defaultMessage: '+ {messagesLength} messages for {eventName}', values: { messagesLength, eventName }, }), - 'euiNotificationEventMessages.accordionAriaLabelButtonText': ({ messagesLength }: EuiValues) => - i18n.translate('core.euiNotificationEventMessages.accordionAriaLabelButtonText', { - defaultMessage: '+ {messagesLength} more', - values: { messagesLength }, - }), 'euiNotificationEventMeta.contextMenuButton': ({ eventName }: EuiValues) => i18n.translate('core.euiNotificationEventMeta.contextMenuButton', { defaultMessage: 'Menu for {eventName}', @@ -682,25 +682,6 @@ export const getEuiContextMapping = () => { defaultMessage: 'Mark as unread', } ), - 'euiNotificationEventReadIcon.readAria': ({ eventName }: EuiValues) => - i18n.translate('core.euiNotificationEventReadIcon.readAria', { - defaultMessage: '{eventName} is read', - values: { eventName }, - }), - 'euiNotificationEventReadIcon.unreadAria': ({ eventName }: EuiValues) => - i18n.translate('core.euiNotificationEventReadIcon.unreadAria', { - defaultMessage: '{eventName} is unread', - values: { eventName }, - }), - 'euiNotificationEventReadIcon.read': i18n.translate('core.euiNotificationEventReadIcon.read', { - defaultMessage: 'Read', - }), - 'euiNotificationEventReadIcon.unread': i18n.translate( - 'core.euiNotificationEventReadIcon.unread', - { - defaultMessage: 'Unread', - } - ), 'euiNotificationEventMessages.accordionHideText': i18n.translate( 'core.euiNotificationEventMessages.accordionHideText', { @@ -712,13 +693,11 @@ export const getEuiContextMapping = () => { defaultMessage: 'Next page, {page}', values: { page }, }), - 'euiPagination.pageOfTotalCompressed': ({ page, total }: EuiValues) => ( - - ), + 'euiPagination.pageOfTotalCompressed': ({ page, total }: EuiValues) => + i18n.translate('core.euiPagination.pageOfTotalCompressed', { + defaultMessage: '{page} of {total}', + values: { page, total }, + }), 'euiPagination.previousPage': ({ page }: EuiValues) => i18n.translate('core.euiPagination.previousPage', { defaultMessage: 'Previous page, {page}', @@ -881,7 +860,7 @@ export const getEuiContextMapping = () => { description: 'Placeholder message while data is asynchronously loaded', }), 'euiSelectable.noAvailableOptions': i18n.translate('core.euiSelectable.noAvailableOptions', { - defaultMessage: "There aren't any options available", + defaultMessage: 'No options available', }), 'euiSelectable.noMatchingOptions': ({ searchValue }: EuiValues) => ( { 'euiSelectableListItem.excludedOptionInstructions': i18n.translate( 'core.euiSelectableListItem.excludedOptionInstructions', { - defaultMessage: 'To deselect this option, press enter', + defaultMessage: 'To deselect this option, press enter.', } ), 'euiSelectableTemplateSitewide.loadingResults': i18n.translate( @@ -1039,7 +1018,7 @@ export const getEuiContextMapping = () => { 'euiSuperSelect.screenReaderAnnouncement': ({ optionsCount }: EuiValues) => i18n.translate('core.euiSuperSelect.screenReaderAnnouncement', { defaultMessage: - 'You are in a form selector of {optionsCount} items and must select a single option. Use the Up and Down keys to navigate or Escape to close.', + 'You are in a form selector of {optionsCount} items and must select a single option. Use the up and down keys to navigate or escape to close.', values: { optionsCount }, }), 'euiSuperSelectControl.selectAnOption': ({ selectedValue }: EuiValues) => @@ -1086,6 +1065,7 @@ export const getEuiContextMapping = () => { i18n.translate('core.euiTableHeaderCell.titleTextWithDesc', { defaultMessage: '{innerText}; {description}', values: { innerText, description }, + description: 'Displayed in a cell in the header of the table to describe the field', }), 'euiTablePagination.rowsPerPage': i18n.translate('core.euiTablePagination.rowsPerPage', { defaultMessage: 'Rows per page', @@ -1111,6 +1091,15 @@ export const getEuiContextMapping = () => { defaultMessage: 'Notification', description: 'ARIA label on an element containing a notification', }), + 'euiTourStep.endTour': i18n.translate('core.euiTourStep.endTour', { + defaultMessage: 'End tour', + }), + 'euiTourStep.skipTour': i18n.translate('core.euiTourStep.skipTour', { + defaultMessage: 'Skip tour', + }), + 'euiTourStep.closeTour': i18n.translate('core.euiTourStep.closeTour', { + defaultMessage: 'Close tour', + }), 'euiTourStepIndicator.isActive': i18n.translate('core.euiTourStepIndicator.isActive', { defaultMessage: 'active', description: 'Text for an active tour step', @@ -1123,15 +1112,6 @@ export const getEuiContextMapping = () => { defaultMessage: 'incomplete', description: 'Text for an incomplete tour step', }), - 'euiTourStep.endTour': i18n.translate('core.euiTourStep.endTour', { - defaultMessage: 'End tour', - }), - 'euiTourStep.skipTour': i18n.translate('core.euiTourStep.skipTour', { - defaultMessage: 'Skip tour', - }), - 'euiTourStep.closeTour': i18n.translate('core.euiTourStep.closeTour', { - defaultMessage: 'Close tour', - }), 'euiTourStepIndicator.ariaLabel': ({ status, number }: EuiValues) => i18n.translate('core.euiTourStepIndicator.ariaLabel', { defaultMessage: 'Step {number} {status}', @@ -1149,7 +1129,24 @@ export const getEuiContextMapping = () => { defaultMessage: 'You can quickly navigate this list using arrow keys.', } ), + 'euiNotificationEventReadIcon.read': i18n.translate('core.euiNotificationEventReadIcon.read', { + defaultMessage: 'Read', + }), + 'euiNotificationEventReadIcon.readAria': ({ eventName }: EuiValues) => + i18n.translate('core.euiNotificationEventReadIcon.readAria', { + defaultMessage: '{eventName} is read', + values: { eventName }, + }), + 'euiNotificationEventReadIcon.unread': i18n.translate( + 'core.euiNotificationEventReadIcon.unread', + { + defaultMessage: 'Unread', + } + ), + 'euiNotificationEventReadIcon.unreadAria': ({ eventName }: EuiValues) => + i18n.translate('core.euiNotificationEventReadIcon.unreadAria', { + defaultMessage: '{eventName} is unread', + values: { eventName }, + }), }; - - return euiContextMapping; }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2d37576bae1fd..d406dd9b688d0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -250,7 +250,6 @@ "core.euiBasicTable.selectThisRow": "この行を選択", "core.euiBasicTable.tableAutoCaptionWithoutPagination": "この表には{itemCount}行あります。", "core.euiBasicTable.tableCaptionWithPagination": "{tableCaption}; {page}/{pageCount}ページ。", - "core.euiBasicTable.tableDescriptionWithoutPagination": "この表には{totalItemCount}行中{itemCount}行あります; {page}/{pageCount}ページ。", "core.euiBasicTable.tablePagination": "前の表のページネーション: {tableCaption}", "core.euiBasicTable.tableSimpleAutoCaptionWithPagination": "この表には{itemCount}行あります; {page}/{pageCount}ページ。", "core.euiBottomBar.customScreenReaderAnnouncement": "ドキュメントの最後には、新しいリージョンランドマーク{landmarkHeading}とページレベルのコントロールがあります。", @@ -338,7 +337,6 @@ "core.euiFieldPassword.showPassword": "プレーンテキストとしてパスワードを表示します。注記:パスワードは画面上に見えるように表示されます。", "core.euiFilePicker.clearSelectedFiles": "選択したファイルを消去", "core.euiFilePicker.filesSelected": "選択されたファイル", - "core.euiFilterButton.filterBadge": "${count} ${filterCountLabel} 個のフィルター", "core.euiFlyout.closeAriaLabel": "このダイアログを閉じる", "core.euiForm.addressFormErrors": "ハイライトされたエラーを修正してください。", "core.euiFormControlLayoutClearButton.label": "インプットを消去", @@ -363,8 +361,6 @@ "core.euiMarkdownEditorToolbar.editor": "エディター", "core.euiMarkdownEditorToolbar.previewMarkdown": "プレビュー", "core.euiModal.closeModal": "このモーダルウィンドウを閉じます", - "core.euiNotificationEventMessages.accordionAriaLabelButtonText": "+ {messagesLength}以上", - "core.euiNotificationEventMessages.accordionButtonText": "+ {eventName}の{messagesLength}メスえーいj", "core.euiNotificationEventMessages.accordionHideText": "非表示", "core.euiNotificationEventMeta.contextMenuButton": "{eventName}のメニュー", "core.euiNotificationEventReadButton.markAsRead": "既読に設定", @@ -7460,184 +7456,19 @@ "xpack.canvas.workpadTemplates.table.descriptionColumnTitle": "説明", "xpack.canvas.workpadTemplates.table.nameColumnTitle": "テンプレート名", "xpack.canvas.workpadTemplates.table.tagsColumnTitle": "タグ", - "expressionShape.functions.shape.args.borderHelpText": "図形の外郭の {SVG} カラーです。", - "expressionShape.functions.shape.args.borderWidthHelpText": "境界の太さです。", - "expressionShape.functions.shape.args.fillHelpText": "図形を塗りつぶす {SVG} カラーです。", - "expressionShape.functions.shape.args.maintainAspectHelpText": "図形の元の横縦比を維持しますか?", - "expressionShape.functions.shape.args.shapeHelpText": "図形を選択します。", - "expressionShape.functions.shapeHelpText": "図形を作成します。", - "expressionShape.renderer.shape.displayName": "形状", - "expressionShape.renderer.shape.helpDescription": "基本的な図形をレンダリングします", - "xpack.cases.addConnector.title": "コネクターの追加", - "xpack.cases.allCases.actions": "アクション", - "xpack.cases.allCases.comments": "コメント", - "xpack.cases.allCases.noTagsAvailable": "利用可能なタグがありません", - "xpack.cases.caseTable.addNewCase": "新規ケースの追加", - "xpack.cases.caseTable.bulkActions": "一斉アクション", - "xpack.cases.caseTable.bulkActions.closeSelectedTitle": "選択した項目を閉じる", - "xpack.cases.caseTable.bulkActions.deleteSelectedTitle": "選択した項目を削除", - "xpack.cases.caseTable.bulkActions.markInProgressTitle": "実行中に設定", - "xpack.cases.caseTable.bulkActions.openSelectedTitle": "選択した項目を開く", - "xpack.cases.caseTable.caseDetailsLinkAria": "クリックすると、タイトル{detailName}のケースを表示します", - "xpack.cases.caseTable.changeStatus": "ステータスの変更", - "xpack.cases.caseTable.closed": "終了", - "xpack.cases.caseTable.closedCases": "終了したケース", - "xpack.cases.caseTable.delete": "削除", - "xpack.cases.caseTable.incidentSystem": "インシデント管理システム", - "xpack.cases.caseTable.inProgressCases": "進行中のケース", - "xpack.cases.caseTable.noCases.body": "表示するケースがありません。新しいケースを作成するか、または上記のフィルター設定を変更してください。", - "xpack.cases.caseTable.noCases.readonly.body": "表示するケースがありません。上のフィルター設定を変更してください。", - "xpack.cases.caseTable.noCases.title": "ケースなし", - "xpack.cases.caseTable.notPushed": "プッシュされません", - "xpack.cases.caseTable.openCases": "ケースを開く", - "xpack.cases.caseTable.pushLinkAria": "クリックすると、{ thirdPartyName }でインシデントを表示します。", - "xpack.cases.caseTable.refreshTitle": "更新", - "xpack.cases.caseTable.requiresUpdate": " 更新が必要", - "xpack.cases.caseTable.searchAriaLabel": "ケースの検索", - "xpack.cases.caseTable.searchPlaceholder": "例:ケース名", - "xpack.cases.caseTable.selectedCasesTitle": "{totalRules} {totalRules, plural, other {ケース}} を選択しました", - "xpack.cases.caseTable.snIncident": "外部インシデント", - "xpack.cases.caseTable.status": "ステータス", - "xpack.cases.caseTable.upToDate": " は最新です", - "xpack.cases.caseView.actionLabel.addDescription": "説明を追加しました", - "xpack.cases.caseView.actionLabel.addedField": "追加しました", - "xpack.cases.caseView.actionLabel.changededField": "変更しました", - "xpack.cases.caseView.actionLabel.editedField": "編集しました", - "xpack.cases.caseView.actionLabel.on": "日付", - "xpack.cases.caseView.actionLabel.pushedNewIncident": "新しいインシデントとしてプッシュしました", - "xpack.cases.caseView.actionLabel.removedField": "削除しました", - "xpack.cases.caseView.actionLabel.removedThirdParty": "外部のインシデント管理システムを削除しました", - "xpack.cases.caseView.actionLabel.selectedThirdParty": "インシデント管理システムとして{ thirdParty }を選択しました", - "xpack.cases.caseView.actionLabel.updateIncident": "インシデントを更新しました", - "xpack.cases.caseView.actionLabel.viewIncident": "{incidentNumber}を表示", - "xpack.cases.caseView.alertCommentLabelTitle": "アラートを追加しました", - "xpack.cases.caseView.alreadyPushedToExternalService": "すでに{ externalService }インシデントにプッシュしました", - "xpack.cases.caseView.appropiateLicense": "適切なライセンス", - "xpack.cases.caseView.backLabel": "ケースに戻る", - "xpack.cases.caseView.cancel": "キャンセル", - "xpack.cases.caseView.case": "ケース", - "xpack.cases.caseView.caseClosed": "ケースを閉じました", - "xpack.cases.caseView.caseInProgress": "進行中のケース", - "xpack.cases.caseView.caseName": "ケース名", - "xpack.cases.caseView.caseOpened": "ケースを開きました", - "xpack.cases.caseView.caseRefresh": "ケースを更新", - "xpack.cases.caseView.closeCase": "ケースを閉じる", - "xpack.cases.caseView.closedOn": "終了日", - "xpack.cases.caseView.cloudDeploymentLink": "クラウド展開", - "xpack.cases.caseView.comment": "コメント", - "xpack.cases.caseView.comment.addComment": "コメントを追加", - "xpack.cases.caseView.comment.addCommentHelpText": "新しいコメントを追加...", - "xpack.cases.caseView.commentFieldRequiredError": "コメントが必要です。", - "xpack.cases.caseView.connectors": "外部インシデント管理システム", - "xpack.cases.caseView.copyCommentLinkAria": "参照リンクをコピー", - "xpack.cases.caseView.create": "新規ケースを作成", - "xpack.cases.caseView.createCase": "ケースを作成", - "xpack.cases.caseView.description": "説明", - "xpack.cases.caseView.description.save": "保存", - "xpack.cases.caseView.doesNotExist.button": "ケースに戻る", - "xpack.cases.caseView.doesNotExist.description": "ID {caseId} のケースが見つかりませんでした。一般的には、これはケースが削除されたか、IDが正しくないことを意味します。", - "xpack.cases.caseView.doesNotExist.title": "このケースは存在しません", - "xpack.cases.caseView.edit": "編集", - "xpack.cases.caseView.edit.comment": "コメントを編集", - "xpack.cases.caseView.edit.description": "説明を編集", - "xpack.cases.caseView.edit.quote": "お客様の声", - "xpack.cases.caseView.editActionsLinkAria": "クリックすると、すべてのアクションを表示します", - "xpack.cases.caseView.editTagsLinkAria": "クリックすると、タグを編集します", - "xpack.cases.caseView.emailBody": "ケースリファレンス:{caseUrl}", - "xpack.cases.caseView.emailSubject": "セキュリティケース - {caseTitle}", - "xpack.cases.caseView.errorsPushServiceCallOutTitle": "外部コネクターを選択", - "xpack.cases.caseView.fieldChanged": "変更されたコネクターフィールド", - "xpack.cases.caseView.fieldRequiredError": "必須フィールド", - "xpack.cases.caseView.generatedAlertCommentLabelTitle": "から追加されました", - "xpack.cases.caseView.isolatedHost": "分離されたホスト", - "xpack.cases.caseView.lockedIncidentDesc": "更新は必要ありません", - "xpack.cases.caseView.lockedIncidentTitle": "{ thirdParty }インシデントは最新です", - "xpack.cases.caseView.lockedIncidentTitleNone": "外部インシデントは最新です", - "xpack.cases.caseView.markedCaseAs": "ケースを設定", - "xpack.cases.caseView.markInProgress": "実行中に設定", - "xpack.cases.caseView.moveToCommentAria": "参照されたコメントをハイライト", - "xpack.cases.caseView.name": "名前", - "xpack.cases.caseView.noReportersAvailable": "利用可能なレポートがありません。", - "xpack.cases.caseView.noTags": "現在、このケースにタグは割り当てられていません。", - "xpack.cases.caseView.openCase": "ケースを開く", - "xpack.cases.caseView.openedOn": "開始日", - "xpack.cases.caseView.optional": "オプション", - "xpack.cases.caseView.particpantsLabel": "参加者", - "xpack.cases.caseView.pushNamedIncident": "{ thirdParty }インシデントとしてプッシュ", - "xpack.cases.caseView.pushThirdPartyIncident": "外部インシデントとしてプッシュ", - "xpack.cases.caseView.pushToService.configureConnector": "外部システムでケースを開いて更新するには、このケースの外部インシデント管理システムを選択する必要があります。", - "xpack.cases.caseView.pushToServiceDisableBecauseCaseClosedDescription": "終了したケースは外部システムに送信できません。外部システムでケースを開始または更新したい場合にはケースを再開します。", - "xpack.cases.caseView.pushToServiceDisableBecauseCaseClosedTitle": "ケースを再開する", - "xpack.cases.caseView.pushToServiceDisableByConfigDescription": "kibana.ymlファイルは、特定のコネクターのみを許可するように構成されています。外部システムでケースを開けるようにするには、xpack.actions.enabledActiontypes設定に.[actionTypeId] (例:.servicenow | .jira) を追加します。詳細は{link}をご覧ください。", - "xpack.cases.caseView.pushToServiceDisableByConfigTitle": "Kibanaの構成ファイルで外部サービスを有効にする", - "xpack.cases.caseView.pushToServiceDisableByInvalidConnector": "外部サービスに更新を送信するために使用されるコネクターが削除されました。外部システムでケースを更新するには、別のコネクターを選択するか、新しいコネクターを作成してください。", - "xpack.cases.caseView.pushToServiceDisableByLicenseDescription": "{appropriateLicense}があるか、{cloud}を使用しているか、無償試用版をテストしているときには、外部システムでケースを開くことができます。", - "xpack.cases.caseView.pushToServiceDisableByLicenseTitle": "適切なライセンスにアップグレード", - "xpack.cases.caseView.releasedHost": "リリースされたホスト", - "xpack.cases.caseView.reopenCase": "ケースを再開", - "xpack.cases.caseView.reporterLabel": "報告者", - "xpack.cases.caseView.requiredUpdateToExternalService": "{ externalService }インシデントの更新が必要です", - "xpack.cases.caseView.sendEmalLinkAria": "クリックすると、{user}に電子メールを送信します", - "xpack.cases.caseView.showAlertTooltip": "アラートの詳細を表示", - "xpack.cases.caseView.statusLabel": "ステータス", - "xpack.cases.caseView.syncAlertsLabel": "アラートの同期", - "xpack.cases.caseView.tags": "タグ", - "xpack.cases.caseView.to": "に", - "xpack.cases.caseView.unknown": "不明", - "xpack.cases.caseView.unknownRule.label": "不明なルール", - "xpack.cases.caseView.updateNamedIncident": "{ thirdParty }インシデントを更新", - "xpack.cases.caseView.updateThirdPartyIncident": "外部インシデントを更新", - "xpack.cases.common.alertAddedToCase": "ケースに追加しました", - "xpack.cases.common.alertLabel": "アラート", - "xpack.cases.common.alertsLabel": "アラート", - "xpack.cases.common.allCases.caseModal.title": "ケースを選択", - "xpack.cases.common.allCases.table.selectableMessageCollections": "ケースとサブケースを選択することはできません", - "xpack.cases.common.noConnector": "コネクターを選択していません", - "xpack.cases.components.connectors.cases.actionTypeTitle": "ケース", - "xpack.cases.components.connectors.cases.addNewCaseOption": "新規ケースの追加", - "xpack.cases.components.connectors.cases.callOutMsg": "ケースには複数のサブケースを追加して、生成されたアラートをグループ化できます。サブケースではこのような生成されたアラートのステータスをより高い粒度で制御でき、1つのケースに関連付けられるアラートが多くなりすぎないようにします。", - "xpack.cases.components.connectors.cases.callOutTitle": "生成されたアラートはサブケースに関連付けられます", - "xpack.cases.components.connectors.cases.caseRequired": "ケースの選択が必要です。", - "xpack.cases.components.connectors.cases.casesDropdownRowLabel": "サブケースを許可するケース", - "xpack.cases.components.connectors.cases.createCaseLabel": "ケースを作成", - "xpack.cases.components.connectors.cases.optionAddToExistingCase": "既存のケースに追加", - "xpack.cases.components.connectors.cases.selectMessageText": "ケースを作成または更新します。", - "xpack.cases.components.create.syncAlertHelpText": "このオプションを有効にすると、このケースのアラートのステータスをケースステータスと同期します。", - "xpack.cases.configure.readPermissionsErrorDescription": "コネクターを表示するアクセス権がありません。このケースに関連付けら他コネクターを表示する場合は、Kibana管理者に連絡してください。", - "xpack.cases.configure.successSaveToast": "保存された外部接続設定", - "xpack.cases.configureCases.addNewConnector": "新しいコネクターを追加", - "xpack.cases.configureCases.cancelButton": "キャンセル", - "xpack.cases.configureCases.caseClosureOptionsDesc": "ケースの終了方法を定義します。自動終了のためには、外部のインシデント管理システムへの接続を確立する必要があります。", - "xpack.cases.configureCases.caseClosureOptionsLabel": "ケース終了オプション", - "xpack.cases.configureCases.caseClosureOptionsManual": "ケースを手動で終了する", - "xpack.cases.configureCases.caseClosureOptionsNewIncident": "新しいインシデントを外部システムにプッシュするときにケースを自動的に終了する", - "xpack.cases.configureCases.caseClosureOptionsSubCases": "サブケースの自動終了はサポートされていません。", - "xpack.cases.configureCases.caseClosureOptionsTitle": "ケースの終了", - "xpack.cases.configureCases.commentMapping": "コメント", - "xpack.cases.configureCases.fieldMappingDesc": "データを{ thirdPartyName }にプッシュするときに、ケースフィールドを{ thirdPartyName }フィールドにマッピングします。フィールドマッピングでは、{ thirdPartyName } への接続を確立する必要があります。", - "xpack.cases.configureCases.fieldMappingDescErr": "{ thirdPartyName }のマッピングを取得できませんでした。", - "xpack.cases.configureCases.fieldMappingEditAppend": "末尾に追加", - "xpack.cases.configureCases.fieldMappingFirstCol": "Kibanaケースフィールド", - "xpack.cases.configureCases.fieldMappingSecondCol": "{ thirdPartyName } フィールド", - "xpack.cases.configureCases.fieldMappingThirdCol": "編集時と更新時", - "xpack.cases.configureCases.fieldMappingTitle": "{ thirdPartyName } フィールドマッピング", - "xpack.cases.configureCases.headerTitle": "ケースを構成", - "xpack.cases.configureCases.incidentManagementSystemDesc": "ケースを外部のインシデント管理システムに接続します。その後にサードパーティシステムでケースデータをインシデントとしてプッシュできます。", - "xpack.cases.configureCases.incidentManagementSystemLabel": "インシデント管理システム", - "xpack.cases.configureCases.incidentManagementSystemTitle": "外部インシデント管理システム", - "xpack.cases.configureCases.requiredMappings": "1 つ以上のケースフィールドを次の { connectorName } フィールドにマッピングする必要があります:{ fields }", - "xpack.cases.configureCases.saveAndCloseButton": "保存して閉じる", - "xpack.cases.configureCases.saveButton": "保存", - "xpack.cases.configureCases.updateConnector": "フィールドマッピングを更新", - "xpack.cases.configureCases.updateSelectedConnector": "{ connectorName }を更新", - "xpack.cases.configureCases.warningMessage": "選択したコネクターが削除されました。別のコネクターを選択するか、新しいコネクターを作成してください。", - "xpack.cases.configureCases.warningTitle": "警告", - "xpack.cases.configureCasesButton": "外部接続を編集", - "xpack.cases.confirmDeleteCase.confirmQuestion": "{quantity, plural, =1 {このケース} other {これらのケース}}を削除すると、関連するすべてのケースデータが完全に削除され、外部インシデント管理システムにデータをプッシュできなくなります。続行していいですか?", - "xpack.cases.confirmDeleteCase.deleteTitle": "「{caseTitle}」を削除", - "xpack.cases.confirmDeleteCase.selectedCases": "\"{quantity, plural, =1 {{title}} other {選択した{quantity}個のケース}}\"を削除", - "xpack.cases.connecors.get.missingCaseConnectorErrorMessage": "オブジェクトタイプ「{id}」は登録されていません。", - "xpack.cases.connecors.register.duplicateCaseConnectorErrorMessage": "オブジェクトタイプ「{id}」はすでに登録されています。", + "expressionError.errorComponent.description": "表現が失敗し次のメッセージが返されました:", + "expressionError.errorComponent.title": "おっと!表現が失敗しました", + "expressionError.renderer.debug.displayName": "デバッグ", + "expressionError.renderer.debug.helpDescription": "デバッグアウトプットをフォーマットされた {JSON} としてレンダリングします", + "expressionError.renderer.error.displayName": "エラー情報", + "expressionError.renderer.error.helpDescription": "エラーデータをユーザーにわかるようにレンダリングします", + "expressionRevealImage.functions.revealImage.args.emptyImageHelpText": "表示される背景画像です。画像アセットは「{BASE64}」データ {URL} として提供するか、部分式で渡します。", + "expressionRevealImage.functions.revealImage.args.imageHelpText": "表示する画像です。画像アセットは{BASE64}データ{URL}として提供するか、部分式で渡します。", + "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": " ({date}に{user}が追加) ", "xpack.cases.connectors.cases.externalIncidentCreated": " ({date}に{user}が作成) ", "xpack.cases.connectors.cases.externalIncidentDefault": " ({date}に{user}が作成) ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e7975c969e0da..dbe67290bbe3a 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -253,7 +253,6 @@ "core.euiBasicTable.selectThisRow": "选择此行", "core.euiBasicTable.tableAutoCaptionWithoutPagination": "此表包含 {itemCount} 行。", "core.euiBasicTable.tableCaptionWithPagination": "{tableCaption};第 {page} 页,共 {pageCount} 页。", - "core.euiBasicTable.tableDescriptionWithoutPagination": "此表包含 {itemCount} 行,共有 {totalItemCount} 行;第 {page} 页,共 {pageCount} 页。", "core.euiBasicTable.tablePagination": "前表分页:{tableCaption}", "core.euiBasicTable.tableSimpleAutoCaptionWithPagination": "此表包含 {itemCount} 行;第 {page} 页,共 {pageCount} 页。", "core.euiBottomBar.customScreenReaderAnnouncement": "有称作 {landmarkHeading} 且页面级别控件位于文档结尾的新地区地标。", @@ -341,7 +340,6 @@ "core.euiFieldPassword.showPassword": "将密码显示为纯文本。注意:这会将您的密码暴露在屏幕上。", "core.euiFilePicker.clearSelectedFiles": "清除选定的文件", "core.euiFilePicker.filesSelected": "个文件已选择", - "core.euiFilterButton.filterBadge": "{count} 个 {filterCountLabel} 筛选", "core.euiFlyout.closeAriaLabel": "关闭此对话框", "core.euiForm.addressFormErrors": "请解决突出显示的错误。", "core.euiFormControlLayoutClearButton.label": "清除输入", @@ -366,8 +364,6 @@ "core.euiMarkdownEditorToolbar.editor": "编辑器", "core.euiMarkdownEditorToolbar.previewMarkdown": "预览", "core.euiModal.closeModal": "关闭此模式窗口", - "core.euiNotificationEventMessages.accordionAriaLabelButtonText": "+ 另外 {messagesLength} 条", - "core.euiNotificationEventMessages.accordionButtonText": "+ {eventName} 的 {messagesLength} 条消息", "core.euiNotificationEventMessages.accordionHideText": "隐藏", "core.euiNotificationEventMeta.contextMenuButton": "{eventName} 的菜单", "core.euiNotificationEventReadButton.markAsRead": "标记为已读", @@ -7509,193 +7505,23 @@ "xpack.canvas.workpadTemplates.table.descriptionColumnTitle": "描述", "xpack.canvas.workpadTemplates.table.nameColumnTitle": "模板名称", "xpack.canvas.workpadTemplates.table.tagsColumnTitle": "标签", - "expressionShape.functions.shape.args.borderHelpText": "形状轮廓边框的 {SVG} 颜色。", - "expressionShape.functions.shape.args.borderWidthHelpText": "边框的粗细。", - "expressionShape.functions.shape.args.fillHelpText": "填充形状的 {SVG} 颜色。", - "expressionShape.functions.shape.args.maintainAspectHelpText": "维持形状的原始纵横比?", - "expressionShape.functions.shape.args.shapeHelpText": "选取形状。", - "expressionShape.functions.shapeHelpText": "创建形状。", - "expressionShape.renderer.shape.displayName": "形状", - "expressionShape.renderer.shape.helpDescription": "呈现基本形状", - "xpack.cases.addConnector.title": "添加连接器", - "xpack.cases.allCases.actions": "操作", - "xpack.cases.allCases.comments": "注释", - "xpack.cases.allCases.noTagsAvailable": "没有可用标记", - "xpack.cases.caseTable.addNewCase": "添加新案例", - "xpack.cases.caseTable.bulkActions": "批处理操作", - "xpack.cases.caseTable.bulkActions.closeSelectedTitle": "关闭所选", - "xpack.cases.caseTable.bulkActions.deleteSelectedTitle": "删除所选", - "xpack.cases.caseTable.bulkActions.markInProgressTitle": "标记为进行中", - "xpack.cases.caseTable.bulkActions.openSelectedTitle": "打开所选", - "xpack.cases.caseTable.caseDetailsLinkAria": "单击以访问标题为 {detailName} 的案例", - "xpack.cases.caseTable.changeStatus": "更改状态", - "xpack.cases.caseTable.closed": "已关闭", - "xpack.cases.caseTable.closedCases": "已关闭案例", - "xpack.cases.caseTable.delete": "删除", - "xpack.cases.caseTable.incidentSystem": "事件管理系统", - "xpack.cases.caseTable.inProgressCases": "进行中的案例", - "xpack.cases.caseTable.noCases.body": "没有可显示的案例。请创建新案例或在上面更改您的筛选设置。", - "xpack.cases.caseTable.noCases.readonly.body": "没有可显示的案例。请在上面更改您的筛选设置。", - "xpack.cases.caseTable.noCases.title": "无案例", - "xpack.cases.caseTable.notPushed": "未推送", - "xpack.cases.caseTable.openCases": "未结案例", - "xpack.cases.caseTable.pushLinkAria": "单击可在 { thirdPartyName } 上查看该事件。", - "xpack.cases.caseTable.refreshTitle": "刷新", - "xpack.cases.caseTable.requiresUpdate": " 需要更新", - "xpack.cases.caseTable.searchAriaLabel": "搜索案例", - "xpack.cases.caseTable.searchPlaceholder": "例如案例名", - "xpack.cases.caseTable.selectedCasesTitle": "已选择 {totalRules} 个{totalRules, plural, other {案例}}", - "xpack.cases.caseTable.showingCasesTitle": "正在显示 {totalRules} 个{totalRules, plural, other {案例}}", - "xpack.cases.caseTable.snIncident": "外部事件", - "xpack.cases.caseTable.status": "状态", - "xpack.cases.caseTable.unit": "{totalCount, plural, other {案例}}", - "xpack.cases.caseTable.upToDate": " 是最新的", - "xpack.cases.caseView.actionLabel.addDescription": "添加了描述", - "xpack.cases.caseView.actionLabel.addedField": "添加了", - "xpack.cases.caseView.actionLabel.changededField": "更改了", - "xpack.cases.caseView.actionLabel.editedField": "编辑了", - "xpack.cases.caseView.actionLabel.on": "在", - "xpack.cases.caseView.actionLabel.pushedNewIncident": "已推送为新事件", - "xpack.cases.caseView.actionLabel.removedField": "移除了", - "xpack.cases.caseView.actionLabel.removedThirdParty": "已移除外部事件管理系统", - "xpack.cases.caseView.actionLabel.selectedThirdParty": "已选择 { thirdParty } 作为事件管理系统", - "xpack.cases.caseView.actionLabel.updateIncident": "更新了事件", - "xpack.cases.caseView.actionLabel.viewIncident": "查看 {incidentNumber}", - "xpack.cases.caseView.alertCommentLabelTitle": "添加了告警,从", - "xpack.cases.caseView.alreadyPushedToExternalService": "已推送到 { externalService } 事件", - "xpack.cases.caseView.appropiateLicense": "适当的许可证", - "xpack.cases.caseView.backLabel": "返回到案例", - "xpack.cases.caseView.cancel": "取消", - "xpack.cases.caseView.case": "案例", - "xpack.cases.caseView.caseClosed": "案例已关闭", - "xpack.cases.caseView.caseInProgress": "案例进行中", - "xpack.cases.caseView.caseName": "案例名称", - "xpack.cases.caseView.caseOpened": "案例已打开", - "xpack.cases.caseView.caseRefresh": "刷新案例", - "xpack.cases.caseView.closeCase": "关闭案例", - "xpack.cases.caseView.closedOn": "关闭日期", - "xpack.cases.caseView.cloudDeploymentLink": "云部署", - "xpack.cases.caseView.comment": "注释", - "xpack.cases.caseView.comment.addComment": "添加注释", - "xpack.cases.caseView.comment.addCommentHelpText": "添加新注释......", - "xpack.cases.caseView.commentFieldRequiredError": "注释必填。", - "xpack.cases.caseView.connectors": "外部事件管理系统", - "xpack.cases.caseView.copyCommentLinkAria": "复制参考链接", - "xpack.cases.caseView.create": "创建新案例", - "xpack.cases.caseView.createCase": "创建案例", - "xpack.cases.caseView.description": "描述", - "xpack.cases.caseView.description.save": "保存", - "xpack.cases.caseView.doesNotExist.button": "返回到案例", - "xpack.cases.caseView.doesNotExist.description": "找不到 ID 为 {caseId} 的案例。这很可能意味着案例已删除或 ID 不正确。", - "xpack.cases.caseView.doesNotExist.title": "此案例不存在", - "xpack.cases.caseView.edit": "编辑", - "xpack.cases.caseView.edit.comment": "编辑注释", - "xpack.cases.caseView.edit.description": "编辑描述", - "xpack.cases.caseView.edit.quote": "引述", - "xpack.cases.caseView.editActionsLinkAria": "单击可查看所有操作", - "xpack.cases.caseView.editTagsLinkAria": "单击可编辑标签", - "xpack.cases.caseView.emailBody": "案例参考:{caseUrl}", - "xpack.cases.caseView.emailSubject": "Security 案例 - {caseTitle}", - "xpack.cases.caseView.errorsPushServiceCallOutTitle": "选择外部连接器", - "xpack.cases.caseView.fieldChanged": "已更改连接器字段", - "xpack.cases.caseView.fieldRequiredError": "必填字段", - "xpack.cases.caseView.generatedAlertCommentLabelTitle": "添加自", - "xpack.cases.caseView.generatedAlertCountCommentLabelTitle": "{totalCount} 个{totalCount, plural, other {告警}}", - "xpack.cases.caseView.isolatedHost": "已隔离主机", - "xpack.cases.caseView.lockedIncidentDesc": "不需要任何更新", - "xpack.cases.caseView.lockedIncidentTitle": "{ thirdParty } 事件是最新的", - "xpack.cases.caseView.lockedIncidentTitleNone": "外部事件是最新的", - "xpack.cases.caseView.markedCaseAs": "将案例标记为", - "xpack.cases.caseView.markInProgress": "标记为进行中", - "xpack.cases.caseView.moveToCommentAria": "高亮显示引用的注释", - "xpack.cases.caseView.name": "名称", - "xpack.cases.caseView.noReportersAvailable": "没有报告者。", - "xpack.cases.caseView.noTags": "当前没有为此案例分配标签。", - "xpack.cases.caseView.openCase": "创建案例", - "xpack.cases.caseView.openedOn": "打开时间", - "xpack.cases.caseView.optional": "可选", - "xpack.cases.caseView.otherEndpoints": " 以及{endpoints, plural, other {其他}} {endpoints} 个", - "xpack.cases.caseView.particpantsLabel": "参与者", - "xpack.cases.caseView.pushNamedIncident": "推送为 { thirdParty } 事件", - "xpack.cases.caseView.pushThirdPartyIncident": "推送为外部事件", - "xpack.cases.caseView.pushToService.configureConnector": "要在外部系统中打开和更新案例,必须为此案例选择外部事件管理系统。", - "xpack.cases.caseView.pushToServiceDisableBecauseCaseClosedDescription": "关闭的案例无法发送到外部系统。如果希望在外部系统中打开或更新案例,请重新打开案例。", - "xpack.cases.caseView.pushToServiceDisableBecauseCaseClosedTitle": "重新打开案例", - "xpack.cases.caseView.pushToServiceDisableByConfigDescription": "kibana.yml 文件已配置为仅允许特定连接器。要在外部系统中打开案例,请将 .[actionTypeId](例如:.servicenow | .jira)添加到 xpack.actions.enabledActiontypes 设置。有关更多信息,请参阅{link}。", - "xpack.cases.caseView.pushToServiceDisableByConfigTitle": "在 Kibana 配置文件中启用外部服务", - "xpack.cases.caseView.pushToServiceDisableByInvalidConnector": "用于将更新发送到外部服务的连接器已删除。要在外部系统中更新案例,请选择不同的连接器或创建新的连接器。", - "xpack.cases.caseView.pushToServiceDisableByLicenseDescription": "有{appropriateLicense}、正使用{cloud}或正在免费试用时,可在外部系统中创建案例。", - "xpack.cases.caseView.pushToServiceDisableByLicenseTitle": "升级适当的许可", - "xpack.cases.caseView.releasedHost": "已释放主机", - "xpack.cases.caseView.reopenCase": "重新打开案例", - "xpack.cases.caseView.reporterLabel": "报告者", - "xpack.cases.caseView.requiredUpdateToExternalService": "需要更新 { externalService } 事件", - "xpack.cases.caseView.sendEmalLinkAria": "单击可向 {user} 发送电子邮件", - "xpack.cases.caseView.showAlertTooltip": "显示告警详情", - "xpack.cases.caseView.statusLabel": "状态", - "xpack.cases.caseView.syncAlertsLabel": "同步告警", - "xpack.cases.caseView.tags": "标签", - "xpack.cases.caseView.to": "到", - "xpack.cases.caseView.unknown": "未知", - "xpack.cases.caseView.unknownRule.label": "未知规则", - "xpack.cases.caseView.updateNamedIncident": "更新 { thirdParty } 事件", - "xpack.cases.caseView.updateThirdPartyIncident": "更新外部事件", - "xpack.cases.common.alertAddedToCase": "已添加到案例", - "xpack.cases.common.alertLabel": "告警", - "xpack.cases.common.alertsLabel": "告警", - "xpack.cases.common.allCases.caseModal.title": "选择案例", - "xpack.cases.common.allCases.table.selectableMessageCollections": "无法选择具有子案例的案例", - "xpack.cases.common.noConnector": "未选择任何连接器", - "xpack.cases.components.connectors.cases.actionTypeTitle": "案例", - "xpack.cases.components.connectors.cases.addNewCaseOption": "添加新案例", - "xpack.cases.components.connectors.cases.callOutMsg": "案例可以包含多个子案例以允许分组生成的告警。子案例将为这些已生成告警的状态提供更精细的控制,从而防止在一个案例上附加过多的告警。", - "xpack.cases.components.connectors.cases.callOutTitle": "已生成告警将附加到子案例", - "xpack.cases.components.connectors.cases.caseRequired": "必须选择策略。", - "xpack.cases.components.connectors.cases.casesDropdownRowLabel": "允许有子案例的案例", - "xpack.cases.components.connectors.cases.createCaseLabel": "创建案例", - "xpack.cases.components.connectors.cases.optionAddToExistingCase": "添加到现有案例", - "xpack.cases.components.connectors.cases.selectMessageText": "创建或更新案例。", - "xpack.cases.components.create.syncAlertHelpText": "启用此选项将使本案例中的告警状态与案例状态同步。", - "xpack.cases.configure.readPermissionsErrorDescription": "您无权查看连接器。如果要查看与此案例关联的连接器,请联系Kibana 管理员。", - "xpack.cases.configure.successSaveToast": "已保存外部连接设置", - "xpack.cases.configureCases.addNewConnector": "添加新连接器", - "xpack.cases.configureCases.cancelButton": "取消", - "xpack.cases.configureCases.caseClosureOptionsDesc": "定义如何关闭案例。要自动关闭,需要与外部事件管理系统建立连接。", - "xpack.cases.configureCases.caseClosureOptionsLabel": "案例关闭选项", - "xpack.cases.configureCases.caseClosureOptionsManual": "手动关闭案例", - "xpack.cases.configureCases.caseClosureOptionsNewIncident": "将新事件推送到外部系统时自动关闭案例", - "xpack.cases.configureCases.caseClosureOptionsSubCases": "不支持自动关闭子案例。", - "xpack.cases.configureCases.caseClosureOptionsTitle": "案例关闭", - "xpack.cases.configureCases.commentMapping": "注释", - "xpack.cases.configureCases.fieldMappingDesc": "将数据推送到 { thirdPartyName } 时,将案例字段映射到 { thirdPartyName } 字段。字段映射需要与 { thirdPartyName } 建立连接。", - "xpack.cases.configureCases.fieldMappingDescErr": "无法检索 { thirdPartyName } 的映射。", - "xpack.cases.configureCases.fieldMappingEditAppend": "追加", - "xpack.cases.configureCases.fieldMappingFirstCol": "Kibana 案例字段", - "xpack.cases.configureCases.fieldMappingSecondCol": "{ thirdPartyName } 字段", - "xpack.cases.configureCases.fieldMappingThirdCol": "编辑和更新时", - "xpack.cases.configureCases.fieldMappingTitle": "{ thirdPartyName } 字段映射", - "xpack.cases.configureCases.headerTitle": "配置案例", - "xpack.cases.configureCases.incidentManagementSystemDesc": "将您的案例连接到外部事件管理系统。然后,您便可以将案例数据推送为第三方系统中的事件。", - "xpack.cases.configureCases.incidentManagementSystemLabel": "事件管理系统", - "xpack.cases.configureCases.incidentManagementSystemTitle": "外部事件管理系统", - "xpack.cases.configureCases.requiredMappings": "至少有一个案例字段需要映射到以下所需的 { connectorName } 字段:{ fields }", - "xpack.cases.configureCases.saveAndCloseButton": "保存并关闭", - "xpack.cases.configureCases.saveButton": "保存", - "xpack.cases.configureCases.updateConnector": "更新字段映射", - "xpack.cases.configureCases.updateSelectedConnector": "更新 { connectorName }", - "xpack.cases.configureCases.warningMessage": "选定的连接器已删除。选择不同的连接器或创建新的连接器。", - "xpack.cases.configureCases.warningTitle": "警告", - "xpack.cases.configureCasesButton": "编辑外部连接", - "xpack.cases.confirmDeleteCase.confirmQuestion": "删除{quantity, plural, =1 {此案例} other {这些案例}}即会永久移除所有相关案例数据,而且您将无法再将数据推送到外部事件管理系统。是否确定要继续?", - "xpack.cases.confirmDeleteCase.deleteCase": "删除{quantity, plural, other {案例}}", - "xpack.cases.confirmDeleteCase.deleteTitle": "删除“{caseTitle}”", - "xpack.cases.confirmDeleteCase.selectedCases": "删除“{quantity, plural, =1 {{title}} other {选定的 {quantity} 个案例}}”", - "xpack.cases.connecors.get.missingCaseConnectorErrorMessage": "对象类型“{id}”未注册。", - "xpack.cases.connecors.register.duplicateCaseConnectorErrorMessage": "已注册对象类型“{id}”。", - "xpack.cases.connectors.cases.externalIncidentAdded": "(由 {user} 于 {date}添加)", - "xpack.cases.connectors.cases.externalIncidentCreated": "(由 {user} 于 {date}创建)", - "xpack.cases.connectors.cases.externalIncidentDefault": "(由 {user} 于 {date}创建)", - "xpack.cases.connectors.cases.externalIncidentUpdated": "(由 {user} 于 {date}更新)", + "expressionError.errorComponent.description": "表达式失败,并显示消息:", + "expressionError.errorComponent.title": "哎哟!表达式失败", + "expressionError.renderer.debug.displayName": "故障排查", + "expressionError.renderer.debug.helpDescription": "将故障排查输出呈现为带格式的 {JSON}", + "expressionError.renderer.error.displayName": "错误信息", + "expressionError.renderer.error.helpDescription": "以用户友好的方式呈现错误数据", + "expressionRevealImage.functions.revealImage.args.emptyImageHelpText": "要显示的可选背景图像。以 `{BASE64}` 数据 {URL} 的形式提供图像资产或传入子表达式。", + "expressionRevealImage.functions.revealImage.args.imageHelpText": "要显示的图像。以 {BASE64} 数据 {URL} 的形式提供图像资产或传入子表达式。", + "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}创建) ", + "xpack.cases.connectors.cases.externalIncidentUpdated": " (由 {user} 于 {date}更新) ", "xpack.cases.connectors.cases.title": "案例", "xpack.cases.connectors.jira.issueTypesSelectFieldLabel": "问题类型", "xpack.cases.connectors.jira.parentIssueSearchLabel": "父问题", From 02e8e7f55c0586e3f68b25650cbd7e6431483bab Mon Sep 17 00:00:00 2001 From: John Schulz Date: Thu, 22 Jul 2021 17:44:33 -0400 Subject: [PATCH 27/45] [Fleet] OpenAPI add specs for all missing routes from #79574 (#106594) ## Summary Add OpenAPI specs for all the missing routes mentioned in https://github.com/elastic/kibana/issues/79574 - [x] `/settings` [commit](https://github.com/elastic/kibana/commit/da25a6091d09ff3c6969eb7d0c74c9abc1d6f904) & [PR docs](http://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/jfsiii/kibana/79574-missing-from-openapi/x-pack/plugins/fleet/common/openapi/bundled.json#operation/get-settings) - [x] `/outputs` https://github.com/elastic/kibana/commit/ef1434587199e3a5f57f34bfa36d8d0faff11f74 & https://github.com/elastic/kibana/commit/0c1be7eae0e38587edf2783d2e50390ecdd44835 [PR docs start here](http://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/jfsiii/kibana/79574-missing-from-openapi/x-pack/plugins/fleet/common/openapi/bundled.json#operation/get-output) - [x] `/epm/packages/{pkgName}/stats` [commit](https://github.com/elastic/kibana/commit/7d984482874fc6d69b2bbf75b0f64d478ac90f7b) & [PR docs](http://redocly.github.io/redoc/?url=https://raw.githubusercontent.com/jfsiii/kibana/79574-missing-from-openapi/x-pack/plugins/fleet/common/openapi/bundled.json#operation/get-package-stats) ### Checklist - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials --- .../plugins/fleet/common/openapi/bundled.json | 297 ++++++++++++++++++ .../plugins/fleet/common/openapi/bundled.yaml | 189 +++++++++++ .../schemas/fleet_settings_response.yaml | 7 + .../openapi/components/schemas/output.yaml | 29 ++ .../schemas/package_usage_stats.yaml | 7 + .../openapi/components/schemas/settings.yaml | 16 + .../fleet/common/openapi/entrypoint.yaml | 8 + .../paths/epm@packages@{pkg_name}@stats.yaml | 24 ++ .../fleet/common/openapi/paths/outputs.yaml | 22 ++ .../openapi/paths/outputs@{output_id}.yaml | 53 ++++ .../fleet/common/openapi/paths/settings.yaml | 22 ++ 11 files changed, 674 insertions(+) create mode 100644 x-pack/plugins/fleet/common/openapi/components/schemas/fleet_settings_response.yaml create mode 100644 x-pack/plugins/fleet/common/openapi/components/schemas/output.yaml create mode 100644 x-pack/plugins/fleet/common/openapi/components/schemas/package_usage_stats.yaml create mode 100644 x-pack/plugins/fleet/common/openapi/components/schemas/settings.yaml create mode 100644 x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@stats.yaml create mode 100644 x-pack/plugins/fleet/common/openapi/paths/outputs.yaml create mode 100644 x-pack/plugins/fleet/common/openapi/paths/outputs@{output_id}.yaml create mode 100644 x-pack/plugins/fleet/common/openapi/paths/settings.yaml diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index f16bb8ac9a436..4dc67a6771531 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -59,6 +59,42 @@ ] } }, + "/settings": { + "get": { + "summary": "Settings", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/fleet_settings_response" + } + } + } + } + }, + "operationId": "get-settings" + }, + "post": { + "summary": "Settings - Update", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/fleet_settings_response" + } + } + } + } + }, + "operationId": "update-settings" + } + }, "/epm/categories": { "get": { "summary": "Package categories", @@ -1643,6 +1679,173 @@ } ] } + }, + "/outputs": { + "get": { + "summary": "Outputs", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/output" + } + }, + "total": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "perPage": { + "type": "integer" + } + } + } + } + } + } + }, + "operationId": "get-outputs" + } + }, + "/outputs/{outputId}": { + "get": { + "summary": "Output - Info", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/output" + } + }, + "required": [ + "item" + ] + } + } + } + } + }, + "operationId": "get-output" + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "outputId", + "in": "path", + "required": true + } + ], + "put": { + "summary": "Output - Update", + "operationId": "update-output", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hosts": { + "type": "string" + }, + "ca_sha256": { + "type": "string" + }, + "config": { + "type": "object" + }, + "config_yaml": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/output" + } + }, + "required": [ + "item" + ] + } + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/kbn_xsrf" + } + ] + } + }, + "/epm/packages/{pkgName}/stats": { + "get": { + "summary": "Get stats for a package", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "response": { + "$ref": "#/components/schemas/package_usage_stats" + } + }, + "required": [ + "response" + ] + } + } + } + } + }, + "operationId": "get-package-stats", + "security": [ + { + "basicAuth": [] + } + ] + }, + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "pkgName", + "in": "path", + "required": true + } + ] } }, "components": { @@ -1733,6 +1936,43 @@ "nonFatalErrors" ] }, + "settings": { + "title": "Settings", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "has_seen_add_data_notice": { + "type": "boolean" + }, + "has_seen_fleet_migration_notice": { + "type": "boolean" + }, + "fleet_server_hosts": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "fleet_server_hosts", + "id" + ] + }, + "fleet_settings_response": { + "title": "Fleet settings response", + "type": "object", + "properties": { + "item": { + "$ref": "#/components/schemas/settings" + } + }, + "required": [ + "item" + ] + }, "search_result": { "title": "Search result", "type": "object", @@ -2389,6 +2629,63 @@ "$ref": "#/components/schemas/new_package_policy" } ] + }, + "output": { + "title": "Output", + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "elasticsearch" + ] + }, + "hosts": { + "type": "array", + "items": { + "type": "string" + } + }, + "ca_sha256": { + "type": "string" + }, + "api_key": { + "type": "string" + }, + "config": { + "type": "object" + }, + "config_yaml": { + "type": "string" + } + }, + "required": [ + "id", + "is_default", + "name", + "type" + ] + }, + "package_usage_stats": { + "title": "Package usage stats", + "type": "object", + "properties": { + "agent_policy_count": { + "type": "integer" + } + }, + "required": [ + "agent_policy_count" + ] } } }, diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index ceefd3337925e..f2a12c0edb8a6 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -36,6 +36,29 @@ paths: operationId: setup parameters: - $ref: '#/components/parameters/kbn_xsrf' + /settings: + get: + summary: Settings + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/fleet_settings_response' + operationId: get-settings + post: + summary: Settings - Update + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/fleet_settings_response' + operationId: update-settings /epm/categories: get: summary: Package categories @@ -996,6 +1019,108 @@ paths: - sucess parameters: - $ref: '#/components/parameters/kbn_xsrf' + /outputs: + get: + summary: Outputs + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/output' + total: + type: integer + page: + type: integer + perPage: + type: integer + operationId: get-outputs + '/outputs/{outputId}': + get: + summary: Output - Info + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/output' + required: + - item + operationId: get-output + parameters: + - schema: + type: string + name: outputId + in: path + required: true + put: + summary: Output - Update + operationId: update-output + requestBody: + content: + application/json: + schema: + type: object + properties: + hosts: + type: string + ca_sha256: + type: string + config: + type: object + config_yaml: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: '#/components/schemas/output' + required: + - item + parameters: + - $ref: '#/components/parameters/kbn_xsrf' + '/epm/packages/{pkgName}/stats': + get: + summary: Get stats for a package + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + response: + $ref: '#/components/schemas/package_usage_stats' + required: + - response + operationId: get-package-stats + security: + - basicAuth: [] + parameters: + - schema: + type: string + name: pkgName + in: path + required: true components: securitySchemes: basicAuth: @@ -1061,6 +1186,31 @@ components: required: - isInitialized - nonFatalErrors + settings: + title: Settings + type: object + properties: + id: + type: string + has_seen_add_data_notice: + type: boolean + has_seen_fleet_migration_notice: + type: boolean + fleet_server_hosts: + type: array + items: + type: string + required: + - fleet_server_hosts + - id + fleet_settings_response: + title: Fleet settings response + type: object + properties: + item: + $ref: '#/components/schemas/settings' + required: + - item search_result: title: Search result type: object @@ -1502,5 +1652,44 @@ components: version: type: string - $ref: '#/components/schemas/new_package_policy' + output: + title: Output + type: object + properties: + id: + type: string + is_default: + type: boolean + name: + type: string + type: + type: string + enum: + - elasticsearch + hosts: + type: array + items: + type: string + ca_sha256: + type: string + api_key: + type: string + config: + type: object + config_yaml: + type: string + required: + - id + - is_default + - name + - type + package_usage_stats: + title: Package usage stats + type: object + properties: + agent_policy_count: + type: integer + required: + - agent_policy_count security: - basicAuth: [] diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/fleet_settings_response.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/fleet_settings_response.yaml new file mode 100644 index 0000000000000..bb25cb54e599f --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/fleet_settings_response.yaml @@ -0,0 +1,7 @@ +title: Fleet settings response +type: object +properties: + item: + $ref: ./settings.yaml +required: + - item diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/output.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/output.yaml new file mode 100644 index 0000000000000..b4e060ca0c151 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/output.yaml @@ -0,0 +1,29 @@ +title: Output +type: object +properties: + id: + type: string + is_default: + type: boolean + name: + type: string + type: + type: string + enum: ['elasticsearch'] + hosts: + type: array + items: + type: string + ca_sha256: + type: string + api_key: + type: string + config: + type: object + config_yaml: + type: string +required: + - id + - is_default + - name + - type diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/package_usage_stats.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/package_usage_stats.yaml new file mode 100644 index 0000000000000..55977e2141a63 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/package_usage_stats.yaml @@ -0,0 +1,7 @@ +title: Package usage stats +type: object +properties: + agent_policy_count: + type: integer +required: + - agent_policy_count diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/settings.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/settings.yaml new file mode 100644 index 0000000000000..952683400b230 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/settings.yaml @@ -0,0 +1,16 @@ +title: Settings +type: object +properties: + id: + type: string + has_seen_add_data_notice: + type: boolean + has_seen_fleet_migration_notice: + type: boolean + fleet_server_hosts: + type: array + items: + type: string +required: + - fleet_server_hosts + - id diff --git a/x-pack/plugins/fleet/common/openapi/entrypoint.yaml b/x-pack/plugins/fleet/common/openapi/entrypoint.yaml index 0cf197e27ab82..ad8ef1408ae6b 100644 --- a/x-pack/plugins/fleet/common/openapi/entrypoint.yaml +++ b/x-pack/plugins/fleet/common/openapi/entrypoint.yaml @@ -16,6 +16,8 @@ paths: # plugin-wide endpoint(s) /setup: $ref: paths/setup.yaml + /settings: + $ref: paths/settings.yaml # EPM / integrations endpoints /epm/categories: $ref: paths/epm@categories.yaml @@ -62,6 +64,12 @@ paths: $ref: paths/package_policies@delete.yaml '/package_policies/{packagePolicyId}': $ref: 'paths/package_policies@{package_policy_id}.yaml' + /outputs: + $ref: paths/outputs.yaml + /outputs/{outputId}: + $ref: paths/outputs@{output_id}.yaml + '/epm/packages/{pkgName}/stats': + $ref: 'paths/epm@packages@{pkg_name}@stats.yaml' components: securitySchemes: basicAuth: diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@stats.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@stats.yaml new file mode 100644 index 0000000000000..9e9a4a57516dc --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@stats.yaml @@ -0,0 +1,24 @@ +get: + summary: Get stats for a package + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + response: + $ref: ../components/schemas/package_usage_stats.yaml + required: + - response + operationId: get-package-stats + security: + - basicAuth: [] +parameters: + - schema: + type: string + name: pkgName + in: path + required: true diff --git a/x-pack/plugins/fleet/common/openapi/paths/outputs.yaml b/x-pack/plugins/fleet/common/openapi/paths/outputs.yaml new file mode 100644 index 0000000000000..94fe7c16e520d --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/paths/outputs.yaml @@ -0,0 +1,22 @@ +get: + summary: Outputs + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: + $ref: ../components/schemas/output.yaml + total: + type: integer + page: + type: integer + perPage: + type: integer + operationId: get-outputs diff --git a/x-pack/plugins/fleet/common/openapi/paths/outputs@{output_id}.yaml b/x-pack/plugins/fleet/common/openapi/paths/outputs@{output_id}.yaml new file mode 100644 index 0000000000000..2f8f5e76ebaff --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/paths/outputs@{output_id}.yaml @@ -0,0 +1,53 @@ +get: + summary: Output - Info + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: ../components/schemas/output.yaml + required: + - item + operationId: get-output +parameters: + - schema: + type: string + name: outputId + in: path + required: true +put: + summary: Output - Update + operationId: update-output + requestBody: + content: + application/json: + schema: + type: object + properties: + hosts: + type: string + ca_sha256: + type: string + config: + type: object + config_yaml: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + item: + $ref: ../components/schemas/output.yaml + required: + - item + parameters: + - $ref: ../components/headers/kbn_xsrf.yaml diff --git a/x-pack/plugins/fleet/common/openapi/paths/settings.yaml b/x-pack/plugins/fleet/common/openapi/paths/settings.yaml new file mode 100644 index 0000000000000..b23fbc698e423 --- /dev/null +++ b/x-pack/plugins/fleet/common/openapi/paths/settings.yaml @@ -0,0 +1,22 @@ +get: + summary: Settings + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: ../components/schemas/fleet_settings_response.yaml + operationId: get-settings +post: + summary: Settings - Update + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: ../components/schemas/fleet_settings_response.yaml + operationId: update-settings From 62b81093f5f19f0ee3240e6301bd84f03480d982 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Thu, 22 Jul 2021 18:10:54 -0400 Subject: [PATCH 28/45] [Uptime] [User experience] prevent search input focus loss (#106601) * user experience - preserve focus on search input when clicked * dismiss popover on escape and input blur * adjust types --- .../URLSearch/SelectableUrlList.test.tsx | 98 +++++++++++++++++-- .../URLFilter/URLSearch/SelectableUrlList.tsx | 19 ++-- .../URLFilter/URLSearch/index.tsx | 2 +- 3 files changed, 102 insertions(+), 17 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.test.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.test.tsx index f1bad1cf28544..330cb56b2bbb1 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.test.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.test.tsx @@ -5,24 +5,49 @@ * 2.0. */ -import React from 'react'; +import React, { useState } from 'react'; +import { + fireEvent, + waitFor, + waitForElementToBeRemoved, + screen, +} from '@testing-library/react'; import { createMemoryHistory } from 'history'; import * as fetcherHook from '../../../../../hooks/use_fetcher'; import { SelectableUrlList } from './SelectableUrlList'; import { render } from '../../utils/test_helper'; +import { I18LABELS } from '../../translations'; describe('SelectableUrlList', () => { - it('it uses search term value from url', () => { - jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ - data: {}, - status: fetcherHook.FETCH_STATUS.SUCCESS, - refetch: jest.fn(), - }); + jest.spyOn(fetcherHook, 'useFetcher').mockReturnValue({ + data: {}, + status: fetcherHook.FETCH_STATUS.SUCCESS, + refetch: jest.fn(), + }); - const customHistory = createMemoryHistory({ - initialEntries: ['/?searchTerm=blog'], - }); + const customHistory = createMemoryHistory({ + initialEntries: ['/?searchTerm=blog'], + }); + + function WrappedComponent() { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + return ( + + ); + } + it('it uses search term value from url', () => { const { getByDisplayValue } = render( { ); expect(getByDisplayValue('blog')).toBeInTheDocument(); }); + + it('maintains focus on search input field', () => { + const { getByLabelText } = render( + , + { customHistory } + ); + + const input = getByLabelText(I18LABELS.filterByUrl); + fireEvent.click(input); + + expect(document.activeElement).toBe(input); + }); + + it('hides popover on escape', async () => { + const { + getByText, + getByLabelText, + queryByText, + } = render(, { customHistory }); + + const input = getByLabelText(I18LABELS.filterByUrl); + fireEvent.click(input); + + // wait for title of popover to be present + await waitFor(() => { + expect(getByText(I18LABELS.getSearchResultsLabel(0))).toBeInTheDocument(); + screen.debug(); + }); + + // escape key + fireEvent.keyDown(input, { + key: 'Escape', + code: 'Escape', + keyCode: 27, + charCode: 27, + }); + + // wait for title of popover to be removed + await waitForElementToBeRemoved(() => + queryByText(I18LABELS.getSearchResultsLabel(0)) + ); + }); }); diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx index 48e0adeeb57df..37fc1eb5b240a 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/URLFilter/URLSearch/SelectableUrlList.tsx @@ -72,7 +72,7 @@ interface Props { searchValue: string; popoverIsOpen: boolean; initialValue?: string; - setPopoverIsOpen: React.Dispatch>; + setPopoverIsOpen: React.Dispatch>; } export function SelectableUrlList({ @@ -93,6 +93,8 @@ export function SelectableUrlList({ const titleRef = useRef(null); + const formattedOptions = formatOptions(data.items ?? []); + const onEnterKey = (evt: KeyboardEvent) => { if (evt.key.toLowerCase() === 'enter') { onTermChange(); @@ -104,11 +106,11 @@ export function SelectableUrlList({ } }; - // @ts-ignore - not sure, why it's not working - useEvent('keydown', onEnterKey, searchRef); - const onInputClick = (e: React.MouseEvent) => { setPopoverIsOpen(true); + if (searchRef) { + searchRef.focus(); + } }; const onSearchInput = (e: React.FormEvent) => { @@ -116,8 +118,6 @@ export function SelectableUrlList({ setPopoverIsOpen(true); }; - const formattedOptions = formatOptions(data.items ?? []); - const closePopover = () => { setPopoverIsOpen(false); if (searchRef) { @@ -125,6 +125,11 @@ export function SelectableUrlList({ } }; + // @ts-ignore - not sure, why it's not working + useEvent('keydown', onEnterKey, searchRef); + useEvent('escape', () => setPopoverIsOpen(false), searchRef); + useEvent('blur', () => setPopoverIsOpen(false), searchRef); + useEffect(() => { if (searchRef && initialValue) { searchRef.value = initialValue; @@ -189,6 +194,7 @@ export function SelectableUrlList({ onInput: onSearchInput, inputRef: setSearchRef, placeholder: I18LABELS.filterByUrl, + 'aria-label': I18LABELS.filterByUrl, }} listProps={{ rowHeight: 68, @@ -210,6 +216,7 @@ export function SelectableUrlList({ closePopover={closePopover} style={{ minWidth: 400 }} anchorPosition="downLeft" + ownFocus={false} >
(); + const [popoverIsOpen, setPopoverIsOpen] = useState(false); const [searchValue, setSearchValue] = useState(searchTerm ?? ''); From 75d4250fd49a94ccfa028cecaecf6b23de4ccb32 Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Thu, 22 Jul 2021 15:37:25 -0700 Subject: [PATCH 29/45] [Metrics UI] Create functional tests for Metrics Explorer (#105869) * [Metrics UI] Create saved view tests for Metrics Explorer * Adding basic functionality tests * Adding missing metric test * Adding chart customizations * Fixing import * Fixing es archive path * fixing home page tests for saved objects to match metrics explorer * Update x-pack/test/functional/apps/infra/home_page.ts Co-authored-by: Sandra Gonzales Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Sandra Gonzales --- .../components/saved_views/create_modal.tsx | 2 +- .../saved_views/toolbar_control.tsx | 9 +- .../saved_views/view_list_modal.tsx | 6 +- .../metrics_explorer/components/chart.tsx | 4 +- .../components/chart_options.tsx | 10 +- .../metrics_explorer/components/group_by.tsx | 1 + .../metrics_explorer/components/metrics.tsx | 1 + .../components/no_metrics.tsx | 1 + .../test/functional/apps/infra/constants.ts | 2 + .../test/functional/apps/infra/home_page.ts | 36 ++++- x-pack/test/functional/apps/infra/index.ts | 1 + .../functional/apps/infra/metrics_explorer.ts | 123 ++++++++++++++++++ x-pack/test/functional/page_objects/index.ts | 6 +- .../page_objects/infra_metric_explorer.ts | 39 ------ .../page_objects/infra_metrics_explorer.ts | 60 +++++++++ .../page_objects/infra_saved_views.ts | 90 +++++++++++++ 16 files changed, 341 insertions(+), 50 deletions(-) create mode 100644 x-pack/test/functional/apps/infra/metrics_explorer.ts delete mode 100644 x-pack/test/functional/page_objects/infra_metric_explorer.ts create mode 100644 x-pack/test/functional/page_objects/infra_metrics_explorer.ts create mode 100644 x-pack/test/functional/page_objects/infra_saved_views.ts diff --git a/x-pack/plugins/infra/public/components/saved_views/create_modal.tsx b/x-pack/plugins/infra/public/components/saved_views/create_modal.tsx index 654cba0721bb8..9aae695395614 100644 --- a/x-pack/plugins/infra/public/components/saved_views/create_modal.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/create_modal.tsx @@ -39,7 +39,7 @@ export const SavedViewCreateModal = ({ close, save, isInvalid }: Props) => { }, [includeTime, save, viewName]); return ( - + (props: Props) { <> @@ -163,8 +164,8 @@ export function SavedViewsToolbarControls(props: Props) { id="xpack.infra.savedView.currentView" /> - - + + {currentView ? currentView.name : i18n.translate('xpack.infra.savedView.unknownView', { @@ -182,6 +183,7 @@ export function SavedViewsToolbarControls(props: Props) { > (props: Props) { /> (props: Props) { /> (props: Props) { /> + ( <> {search} -
{list}
+
+ {list} +
)} diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx index 35265f0a462cf..c178532c13b92 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart.tsx @@ -97,7 +97,7 @@ export const MetricsExplorerChart = ({ : dataDomain; return ( -
+
{options.groupBy ? ( @@ -133,7 +133,7 @@ export const MetricsExplorerChart = ({ )} -
+
{metrics.length && series.rows.length > 0 ? ( {metrics.map((metric, id) => ( diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_options.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_options.tsx index a239d607a17d2..aa051bc3ff442 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_options.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_options.tsx @@ -39,7 +39,12 @@ export const MetricsExplorerChartOptions = ({ chartOptions, onChange }: Props) = }, []); const button = ( - + return ( { return ( diff --git a/x-pack/test/functional/apps/infra/constants.ts b/x-pack/test/functional/apps/infra/constants.ts index 72d291158d6dd..56f74599f6a96 100644 --- a/x-pack/test/functional/apps/infra/constants.ts +++ b/x-pack/test/functional/apps/infra/constants.ts @@ -22,6 +22,8 @@ export const DATES = { hosts: { withData: '10/17/2018 7:58:03 PM', withoutData: '10/09/2018 10:00:00 PM', + min: '2018-10-17T19:42:21.208Z', + max: '2018-10-17T19:58:03.952Z', }, stream: { startWithData: '2018-10-17T19:42:22.000Z', diff --git a/x-pack/test/functional/apps/infra/home_page.ts b/x-pack/test/functional/apps/infra/home_page.ts index 3e2d356edc69f..612e67cb36647 100644 --- a/x-pack/test/functional/apps/infra/home_page.ts +++ b/x-pack/test/functional/apps/infra/home_page.ts @@ -13,7 +13,7 @@ const DATE_WITHOUT_DATA = DATES.metricsAndLogs.hosts.withoutData; export default ({ getPageObjects, getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); - const pageObjects = getPageObjects(['common', 'infraHome']); + const pageObjects = getPageObjects(['common', 'infraHome', 'infraSavedViews']); describe('Home page', function () { this.tags('includeFirefox'); @@ -55,5 +55,39 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.infraHome.getNoMetricsDataPrompt(); }); }); + + describe('Saved Views', () => { + before(() => esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs')); + after(() => esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs')); + + describe('Inventory Test save functionality', () => { + it('should have save and load controls', async () => { + await pageObjects.common.navigateToApp('infraOps'); + await pageObjects.infraHome.goToTime(DATE_WITH_DATA); + await pageObjects.infraSavedViews.getSavedViewsButton(); + await pageObjects.infraSavedViews.ensureViewIsLoaded('Default view'); + }); + + it('should open popover', async () => { + await pageObjects.infraSavedViews.clickSavedViewsButton(); + await pageObjects.infraSavedViews.closeSavedViewsPopover(); + }); + + it('should create new saved view and load it', async () => { + await pageObjects.infraSavedViews.clickSavedViewsButton(); + await pageObjects.infraSavedViews.clickSaveNewViewButton(); + await pageObjects.infraSavedViews.getCreateSavedViewModal(); + await pageObjects.infraSavedViews.createNewSavedView('view1'); + await pageObjects.infraSavedViews.ensureViewIsLoaded('view1'); + }); + + it('should new views should be listed in the load views list', async () => { + await pageObjects.infraSavedViews.clickSavedViewsButton(); + await pageObjects.infraSavedViews.clickLoadViewButton(); + await pageObjects.infraSavedViews.ensureViewIsLoadable('view1'); + await pageObjects.infraSavedViews.closeSavedViewsLoadModal(); + }); + }); + }); }); }; diff --git a/x-pack/test/functional/apps/infra/index.ts b/x-pack/test/functional/apps/infra/index.ts index 8cdcf6b619b26..15f92b8f37fd4 100644 --- a/x-pack/test/functional/apps/infra/index.ts +++ b/x-pack/test/functional/apps/infra/index.ts @@ -15,6 +15,7 @@ export default ({ loadTestFile }: FtrProviderContext) => { loadTestFile(require.resolve('./home_page')); loadTestFile(require.resolve('./metrics_source_configuration')); loadTestFile(require.resolve('./metrics_anomalies')); + loadTestFile(require.resolve('./metrics_explorer')); }); describe('Logs UI', function () { loadTestFile(require.resolve('./log_entry_categories_tab')); diff --git a/x-pack/test/functional/apps/infra/metrics_explorer.ts b/x-pack/test/functional/apps/infra/metrics_explorer.ts new file mode 100644 index 0000000000000..6b1873b7b5e39 --- /dev/null +++ b/x-pack/test/functional/apps/infra/metrics_explorer.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import moment from 'moment'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { DATES } from './constants'; + +const START_DATE = moment.utc(DATES.metricsAndLogs.hosts.min); +const END_DATE = moment.utc(DATES.metricsAndLogs.hosts.max); +const timepickerFormat = 'MMM D, YYYY @ HH:mm:ss.SSS'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const pageObjects = getPageObjects([ + 'common', + 'infraHome', + 'infraMetricsExplorer', + 'timePicker', + 'infraSavedViews', + ]); + + describe('Metrics Explorer', function () { + this.tags('includeFirefox'); + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); + }); + + describe('Basic Functionality', () => { + before(async () => { + esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'); + await pageObjects.common.navigateToApp('infraOps'); + await pageObjects.infraHome.goToMetricExplorer(); + await pageObjects.timePicker.setAbsoluteRange( + START_DATE.format(timepickerFormat), + END_DATE.format(timepickerFormat) + ); + }); + after(() => esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs')); + + it('should have three metrics by default', async () => { + const metrics = await pageObjects.infraMetricsExplorer.getMetrics(); + expect(metrics.length).to.equal(3); + }); + + it('should remove metrics one by one', async () => { + await pageObjects.infraMetricsExplorer.removeMetric('system.cpu.total.norm.pct'); + await pageObjects.infraMetricsExplorer.removeMetric('kubernetes.pod.cpu.usage.node.pct'); + await pageObjects.infraMetricsExplorer.removeMetric('docker.cpu.total.pct'); + const metrics = await pageObjects.infraMetricsExplorer.getMetrics(); + expect(metrics.length).to.equal(0); + }); + + it('should display "Missing Metric" message for zero metrics', async () => { + await pageObjects.infraMetricsExplorer.getMissingMetricMessage(); + }); + + it('should add a metric', async () => { + await pageObjects.infraMetricsExplorer.addMetric('system.cpu.user.pct'); + const metrics = await pageObjects.infraMetricsExplorer.getMetrics(); + expect(metrics.length).to.equal(1); + }); + + it('should set "graph per" to "host.name"', async () => { + await pageObjects.infraMetricsExplorer.setGroupBy('host.name'); + }); + + it('should display multple charts', async () => { + const charts = await pageObjects.infraMetricsExplorer.getCharts(); + expect(charts.length).to.equal(6); + }); + + it('should render as area chart by default', async () => { + const charts = await pageObjects.infraMetricsExplorer.getCharts(); + const chartType = await pageObjects.infraMetricsExplorer.getChartType(charts[0]); + expect(chartType).to.equal('area chart'); + }); + + it('should change to bar chart', async () => { + await pageObjects.infraMetricsExplorer.switchChartType('bar'); + const charts = await pageObjects.infraMetricsExplorer.getCharts(); + const chartType = await pageObjects.infraMetricsExplorer.getChartType(charts[0]); + expect(chartType).to.equal('bar chart'); + }); + }); + + describe('Saved Views', () => { + before(() => esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs')); + after(() => esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs')); + describe('save functionality', () => { + it('should have saved views component', async () => { + await pageObjects.common.navigateToApp('infraOps'); + await pageObjects.infraHome.goToMetricExplorer(); + await pageObjects.infraSavedViews.getSavedViewsButton(); + await pageObjects.infraSavedViews.ensureViewIsLoaded('Default view'); + }); + + it('should open popover', async () => { + await pageObjects.infraSavedViews.clickSavedViewsButton(); + await pageObjects.infraSavedViews.closeSavedViewsPopover(); + }); + + it('should create new saved view and load it', async () => { + await pageObjects.infraSavedViews.clickSavedViewsButton(); + await pageObjects.infraSavedViews.clickSaveNewViewButton(); + await pageObjects.infraSavedViews.getCreateSavedViewModal(); + await pageObjects.infraSavedViews.createNewSavedView('view1'); + await pageObjects.infraSavedViews.ensureViewIsLoaded('view1'); + }); + + it('should new views should be listed in the load views list', async () => { + await pageObjects.infraSavedViews.clickSavedViewsButton(); + await pageObjects.infraSavedViews.clickLoadViewButton(); + await pageObjects.infraSavedViews.ensureViewIsLoadable('view1'); + await pageObjects.infraSavedViews.closeSavedViewsLoadModal(); + }); + }); + }); + }); +}; diff --git a/x-pack/test/functional/page_objects/index.ts b/x-pack/test/functional/page_objects/index.ts index 5c3d9b680fc41..7d2991692b127 100644 --- a/x-pack/test/functional/page_objects/index.ts +++ b/x-pack/test/functional/page_objects/index.ts @@ -34,7 +34,8 @@ import { CrossClusterReplicationPageProvider } from './cross_cluster_replication import { RemoteClustersPageProvider } from './remote_clusters_page'; import { CopySavedObjectsToSpacePageProvider } from './copy_saved_objects_to_space_page'; import { LensPageProvider } from './lens_page'; -import { InfraMetricExplorerProvider } from './infra_metric_explorer'; +import { InfraMetricsExplorerProvider } from './infra_metrics_explorer'; +import { InfraSavedViewsProvider } from './infra_saved_views'; import { RoleMappingsPageProvider } from './role_mappings_page'; import { SpaceSelectorPageObject } from './space_selector_page'; import { IngestPipelinesPageProvider } from './ingest_pipelines_page'; @@ -59,8 +60,9 @@ export const pageObjects = { reporting: ReportingPageObject, spaceSelector: SpaceSelectorPageObject, infraHome: InfraHomePageProvider, - infraMetricExplorer: InfraMetricExplorerProvider, + infraMetricsExplorer: InfraMetricsExplorerProvider, infraLogs: InfraLogsPageProvider, + infraSavedViews: InfraSavedViewsProvider, maps: GisPageObject, statusPage: StatusPageObject, upgradeAssistant: UpgradeAssistantPageObject, diff --git a/x-pack/test/functional/page_objects/infra_metric_explorer.ts b/x-pack/test/functional/page_objects/infra_metric_explorer.ts deleted file mode 100644 index 1bc5befc4e9d0..0000000000000 --- a/x-pack/test/functional/page_objects/infra_metric_explorer.ts +++ /dev/null @@ -1,39 +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 { FtrProviderContext } from '../ftr_provider_context'; - -export function InfraMetricExplorerProvider({ getService }: FtrProviderContext) { - const testSubjects = getService('testSubjects'); - - return { - async getSaveViewButton() { - return await testSubjects.find('openSaveViewModal'); - }, - - async getLoadViewsButton() { - return await testSubjects.find('loadViews'); - }, - - async openSaveViewsFlyout() { - return await testSubjects.click('loadViews'); - }, - - async closeSavedViewFlyout() { - return await testSubjects.click('cancelSavedViewModal'); - }, - - async openCreateSaveViewModal() { - return await testSubjects.click('openSaveViewModal'); - }, - - async openEnterViewNameAndSave() { - await testSubjects.setValue('savedViewViweName', 'View1'); - await testSubjects.click('createSavedViewButton'); - }, - }; -} diff --git a/x-pack/test/functional/page_objects/infra_metrics_explorer.ts b/x-pack/test/functional/page_objects/infra_metrics_explorer.ts new file mode 100644 index 0000000000000..5334125687dfe --- /dev/null +++ b/x-pack/test/functional/page_objects/infra_metrics_explorer.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { WebElementWrapper } from 'test/functional/services/lib/web_element_wrapper'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export function InfraMetricsExplorerProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const comboBox = getService('comboBox'); + + return { + async getMetrics() { + const subject = await testSubjects.find('metricsExplorer-metrics'); + return await subject.findAllByCssSelector('span.euiBadge'); + }, + + async removeMetric(value: string) { + const subject = await testSubjects.find('metricsExplorer-metrics'); + const button = await subject.findByCssSelector(`span.euiBadge[value="${value}"] button`); + return await button.click(); + }, + + async addMetric(value: string) { + const subject = await testSubjects.find('metricsExplorer-metrics'); + return await comboBox.setElement(subject, value); + }, + + async setGroupBy(value: string) { + const subject = await testSubjects.find('metricsExplorer-groupBy'); + return await comboBox.setElement(subject, value); + }, + + async getCharts() { + return await testSubjects.findAll('metricsExplorer-chart'); + }, + + async getMissingMetricMessage() { + return await testSubjects.find('metricsExplorer-missingMetricMessage'); + }, + + async getChartType(chart: WebElementWrapper) { + const figure = await chart.findByCssSelector('figure'); + const descId = await figure.getAttribute('aria-describedby'); + const descElement = await chart.findByCssSelector(`dd[id="${descId}"]`); + return await descElement.getAttribute('textContent'); + }, + + async switchChartType(type: string) { + const customizeButton = await testSubjects.find('metricsExplorer-customize'); + await customizeButton.click(); + const chartRadio = await testSubjects.find(`metricsExplorer-chartRadio-${type}`); + const radioInput = await chartRadio.findByCssSelector(`label[for="${type}"]`); + return await radioInput.click(); + }, + }; +} diff --git a/x-pack/test/functional/page_objects/infra_saved_views.ts b/x-pack/test/functional/page_objects/infra_saved_views.ts new file mode 100644 index 0000000000000..31b528d4ec153 --- /dev/null +++ b/x-pack/test/functional/page_objects/infra_saved_views.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 expect from '@kbn/expect'; +import { Key } from 'selenium-webdriver'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export function InfraSavedViewsProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + + return { + async getSavedViewsButton() { + return await testSubjects.find('savedViews-openPopover'); + }, + + async clickSavedViewsButton() { + return await testSubjects.click('savedViews-openPopover'); + }, + + async getSavedViewsPopoer() { + return await testSubjects.find('savedViews-popover'); + }, + + async closeSavedViewsPopover() { + await testSubjects.find('savedViews-popover'); + return await browser.pressKeys([Key.ESCAPE]); + }, + + async getLoadViewButton() { + return await testSubjects.find('savedViews-loadView'); + }, + + async clickLoadViewButton() { + return await testSubjects.click('savedViews-loadView'); + }, + + async getManageViewsButton() { + return await testSubjects.find('savedViews-manageViews'); + }, + + async clickManageViewsButton() { + return await testSubjects.click('savedViews-manageViews'); + }, + + async getUpdateViewButton() { + return await testSubjects.find('savedViews-updateView'); + }, + + async clickUpdateViewButton() { + return await testSubjects.click('savedViews-updateView'); + }, + + async getSaveNewViewButton() { + return await testSubjects.find('savedViews-saveNewView'); + }, + + async clickSaveNewViewButton() { + return await testSubjects.click('savedViews-saveNewView'); + }, + + async getCreateSavedViewModal() { + return await testSubjects.find('savedViews-createModal'); + }, + + async createNewSavedView(name: string) { + await testSubjects.setValue('savedViewViweName', name); + await testSubjects.click('createSavedViewButton'); + await testSubjects.missingOrFail('savedViews-createModal'); + }, + + async ensureViewIsLoaded(name: string) { + const subject = await testSubjects.find('savedViews-currentViewName'); + expect(await subject.getVisibleText()).to.be(name); + }, + + async ensureViewIsLoadable(name: string) { + const subjects = await testSubjects.getVisibleTextAll('savedViews-loadList'); + expect(subjects.some((s) => s.split('\n').includes(name))).to.be(true); + }, + + async closeSavedViewsLoadModal() { + return await testSubjects.click('cancelSavedViewModal'); + }, + }; +} From ec71c7c971cafaace921366d3d4867866922446c Mon Sep 17 00:00:00 2001 From: debadair Date: Thu, 22 Jul 2021 16:20:13 -0700 Subject: [PATCH 30/45] [DOCS] Removed obsolete UA info. (#106620) --- .../upgrade-assistant/index.asciidoc | 64 ++++--------------- 1 file changed, 13 insertions(+), 51 deletions(-) diff --git a/docs/management/upgrade-assistant/index.asciidoc b/docs/management/upgrade-assistant/index.asciidoc index 209b55faf4f56..dbff211cef372 100644 --- a/docs/management/upgrade-assistant/index.asciidoc +++ b/docs/management/upgrade-assistant/index.asciidoc @@ -2,60 +2,22 @@ [[upgrade-assistant]] == Upgrade Assistant -The Upgrade Assistant helps you prepare for your upgrade to the next major {es} version. -For example, if you are using 6.8, the Upgrade Assistant helps you to upgrade to 7.0. -To access the assistant, open the main menu, then click *Stack Management > Upgrade Assistant*. +The Upgrade Assistant helps you prepare for your upgrade +to the next major version of the Elastic Stack. +To access the assistant, open the main menu and go to *Stack Management > Upgrade Assistant*. -The assistant identifies the deprecated settings in your cluster and indices -and guides you through the process of resolving issues, including reindexing. +The assistant identifies deprecated settings in your configuration, +enables you to see if you are using deprecated features, +and guides you through the process of resolving issues. -Before you upgrade, make sure that you are using the latest released minor -version of {es} to see the most up-to-date deprecation issues. -For example, if you want to upgrade to 7.0, make sure that you are using 6.8. +If you have indices that were created prior to 7.0, +you can use the assistant to reindex them so they can be accessed from 8.0. -[float] +IMPORTANT: To see the most up-to-date deprecation information before +upgrading to 8.0, upgrade to the latest 7.n release. + +[discrete] === Required permissions The `manage` cluster privilege is required to access the *Upgrade assistant*. -Additional privileges may be needed to perform certain actions. - -To add the privilege, open the main menu, then click *Stack Management > Roles*. - -[float] -=== Reindexing - -The *Indices* page lists the indices that are incompatible with the next -major version of {es}. You can initiate a reindex to resolve the issues. - -[role="screenshot"] -image::images/management-upgrade-assistant-9.0.png[] - -For a preview of how the data will change during the reindex, select the -index name. A warning appears if the index requires destructive changes. -Back up your index, then proceed with the reindex by accepting each breaking change. - -You can follow the progress as the Upgrade Assistant makes the index read-only, -creates a new index, reindexes the documents, and creates an alias that points -from the old index to the new one. - -If the reindexing fails or is cancelled, the changes are rolled back, the -new index is deleted, and the original index becomes writable. An error -message explains the reason for the failure. - -You can reindex multiple indices at a time, but keep an eye on the -{es} metrics, including CPU usage, memory pressure, and disk usage. If a -metric is so high it affects query performance, cancel the reindex and -continue by reindexing fewer indices at a time. - -Additional considerations: - -* If you use {alert-features}, when you reindex the internal indices -(`.watches`), the {watcher} process pauses and no alerts are triggered. - -* If you use {ml-features}, when you reindex the internal indices (`.ml-state`), -the {ml} jobs pause and models are not trained or updated. - -* If you use {security-features}, before you reindex the internal indices -(`.security*`), it is a good idea to create a temporary superuser account in the -`file` realm. For more information, see -{ref}/configuring-file-realm.html[Configuring a file realm]. +Additional privileges may be needed to perform certain actions. \ No newline at end of file From bda98e70f29e00f1a61b7a859c48d70bfa67d5f9 Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Thu, 22 Jul 2021 18:21:14 -0700 Subject: [PATCH 31/45] [Metrics UI] Ensure alert dropdown closes properly (#106343) --- .../components/metrics_alert_dropdown.tsx | 38 +++++---- .../test/functional/apps/infra/home_page.ts | 81 ++++++++++++------- .../page_objects/infra_home_page.ts | 32 ++++++++ 3 files changed, 110 insertions(+), 41 deletions(-) diff --git a/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx b/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx index 1f2998db4b43f..5cbd1909054af 100644 --- a/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx +++ b/x-pack/plugins/infra/public/alerting/common/components/metrics_alert_dropdown.tsx @@ -32,6 +32,13 @@ export const MetricsAlertDropdown = () => { const closeFlyout = useCallback(() => setVisibleFlyoutType(null), [setVisibleFlyoutType]); + const closePopover = useCallback(() => { + setPopoverOpen(false); + }, [setPopoverOpen]); + + const togglePopover = useCallback(() => { + setPopoverOpen(!popoverOpen); + }, [setPopoverOpen, popoverOpen]); const infrastructureAlertsPanel = useMemo( () => ({ id: 1, @@ -40,14 +47,18 @@ export const MetricsAlertDropdown = () => { }), items: [ { + 'data-test-subj': 'inventory-alerts-create-rule', name: i18n.translate('xpack.infra.alerting.createInventoryRuleButton', { defaultMessage: 'Create inventory rule', }), - onClick: () => setVisibleFlyoutType('inventory'), + onClick: () => { + closePopover(); + setVisibleFlyoutType('inventory'); + }, }, ], }), - [setVisibleFlyoutType] + [setVisibleFlyoutType, closePopover] ); const metricsAlertsPanel = useMemo( @@ -58,14 +69,18 @@ export const MetricsAlertDropdown = () => { }), items: [ { + 'data-test-subj': 'metrics-threshold-alerts-create-rule', name: i18n.translate('xpack.infra.alerting.createThresholdRuleButton', { defaultMessage: 'Create threshold rule', }), - onClick: () => setVisibleFlyoutType('threshold'), + onClick: () => { + closePopover(); + setVisibleFlyoutType('threshold'); + }, }, ], }), - [setVisibleFlyoutType] + [setVisibleFlyoutType, closePopover] ); const manageAlertsLinkProps = useLinkProps({ @@ -89,12 +104,14 @@ export const MetricsAlertDropdown = () => { canCreateAlerts ? [ { + 'data-test-subj': 'inventory-alerts-menu-option', name: i18n.translate('xpack.infra.alerting.infrastructureDropdownMenu', { defaultMessage: 'Infrastructure', }), panel: 1, }, { + 'data-test-subj': 'metrics-threshold-alerts-menu-option', name: i18n.translate('xpack.infra.alerting.metricsDropdownMenu', { defaultMessage: 'Metrics', }), @@ -120,14 +137,6 @@ export const MetricsAlertDropdown = () => { [infrastructureAlertsPanel, metricsAlertsPanel, firstPanelMenuItems, canCreateAlerts] ); - const closePopover = useCallback(() => { - setPopoverOpen(false); - }, [setPopoverOpen]); - - const openPopover = useCallback(() => { - setPopoverOpen(true); - }, [setPopoverOpen]); - return ( <> { color="text" iconSide={'right'} iconType={'arrowDown'} - onClick={openPopover} + onClick={togglePopover} + data-test-subj="infrastructure-alerts-and-rules" > { isOpen={popoverOpen} closePopover={closePopover} > - + diff --git a/x-pack/test/functional/apps/infra/home_page.ts b/x-pack/test/functional/apps/infra/home_page.ts index 612e67cb36647..275990bada71c 100644 --- a/x-pack/test/functional/apps/infra/home_page.ts +++ b/x-pack/test/functional/apps/infra/home_page.ts @@ -56,37 +56,64 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); + describe('alerts flyouts', () => { + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs'); + await pageObjects.common.navigateToApp('infraOps'); + await pageObjects.infraHome.waitForLoading(); + await pageObjects.infraHome.goToTime(DATE_WITH_DATA); + }); + after( + async () => + await esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs') + ); + + it('should open and close inventory alert flyout', async () => { + await pageObjects.infraHome.openInventoryAlertFlyout(); + await pageObjects.infraHome.closeAlertFlyout(); + }); + + it('should open and close inventory alert flyout', async () => { + await pageObjects.infraHome.openMetricsThresholdAlertFlyout(); + await pageObjects.infraHome.closeAlertFlyout(); + }); + + it('should open and close alerts popover using button', async () => { + await pageObjects.infraHome.clickAlertsAndRules(); + await pageObjects.infraHome.ensurePopoverOpened(); + await pageObjects.infraHome.clickAlertsAndRules(); + await pageObjects.infraHome.ensurePopoverClosed(); + }); + }); + describe('Saved Views', () => { before(() => esArchiver.load('x-pack/test/functional/es_archives/infra/metrics_and_logs')); after(() => esArchiver.unload('x-pack/test/functional/es_archives/infra/metrics_and_logs')); + it('should have save and load controls', async () => { + await pageObjects.common.navigateToApp('infraOps'); + await pageObjects.infraHome.goToTime(DATE_WITH_DATA); + await pageObjects.infraSavedViews.getSavedViewsButton(); + await pageObjects.infraSavedViews.ensureViewIsLoaded('Default view'); + }); + + it('should open popover', async () => { + await pageObjects.infraSavedViews.clickSavedViewsButton(); + await pageObjects.infraSavedViews.closeSavedViewsPopover(); + }); + + it('should create new saved view and load it', async () => { + await pageObjects.infraSavedViews.clickSavedViewsButton(); + await pageObjects.infraSavedViews.clickSaveNewViewButton(); + await pageObjects.infraSavedViews.getCreateSavedViewModal(); + await pageObjects.infraSavedViews.createNewSavedView('view1'); + await pageObjects.infraSavedViews.ensureViewIsLoaded('view1'); + }); - describe('Inventory Test save functionality', () => { - it('should have save and load controls', async () => { - await pageObjects.common.navigateToApp('infraOps'); - await pageObjects.infraHome.goToTime(DATE_WITH_DATA); - await pageObjects.infraSavedViews.getSavedViewsButton(); - await pageObjects.infraSavedViews.ensureViewIsLoaded('Default view'); - }); - - it('should open popover', async () => { - await pageObjects.infraSavedViews.clickSavedViewsButton(); - await pageObjects.infraSavedViews.closeSavedViewsPopover(); - }); - - it('should create new saved view and load it', async () => { - await pageObjects.infraSavedViews.clickSavedViewsButton(); - await pageObjects.infraSavedViews.clickSaveNewViewButton(); - await pageObjects.infraSavedViews.getCreateSavedViewModal(); - await pageObjects.infraSavedViews.createNewSavedView('view1'); - await pageObjects.infraSavedViews.ensureViewIsLoaded('view1'); - }); - - it('should new views should be listed in the load views list', async () => { - await pageObjects.infraSavedViews.clickSavedViewsButton(); - await pageObjects.infraSavedViews.clickLoadViewButton(); - await pageObjects.infraSavedViews.ensureViewIsLoadable('view1'); - await pageObjects.infraSavedViews.closeSavedViewsLoadModal(); - }); + it('should new views should be listed in the load views list', async () => { + await pageObjects.infraSavedViews.clickSavedViewsButton(); + await pageObjects.infraSavedViews.clickLoadViewButton(); + await pageObjects.infraSavedViews.ensureViewIsLoadable('view1'); + await pageObjects.infraSavedViews.closeSavedViewsLoadModal(); }); }); }); diff --git a/x-pack/test/functional/page_objects/infra_home_page.ts b/x-pack/test/functional/page_objects/infra_home_page.ts index fe4a296bae0b7..09941c129c819 100644 --- a/x-pack/test/functional/page_objects/infra_home_page.ts +++ b/x-pack/test/functional/page_objects/infra_home_page.ts @@ -195,5 +195,37 @@ export function InfraHomePageProvider({ getService, getPageObjects }: FtrProvide await thresholdInput.clearValueWithKeyboard({ charByChar: true }); await thresholdInput.type([threshold]); }, + + async clickAlertsAndRules() { + await testSubjects.click('infrastructure-alerts-and-rules'); + }, + + async ensurePopoverOpened() { + await testSubjects.existOrFail('metrics-alert-menu'); + }, + + async ensurePopoverClosed() { + await testSubjects.missingOrFail('metrics-alert-menu'); + }, + + async openInventoryAlertFlyout() { + await testSubjects.click('infrastructure-alerts-and-rules'); + await testSubjects.click('inventory-alerts-menu-option'); + await testSubjects.click('inventory-alerts-create-rule'); + await testSubjects.missingOrFail('inventory-alerts-create-rule'); + await testSubjects.find('euiFlyoutCloseButton'); + }, + + async openMetricsThresholdAlertFlyout() { + await testSubjects.click('infrastructure-alerts-and-rules'); + await testSubjects.click('metrics-threshold-alerts-menu-option'); + await testSubjects.click('metrics-threshold-alerts-create-rule'); + await testSubjects.missingOrFail('metrics-threshold-alerts-create-rule'); + await testSubjects.find('euiFlyoutCloseButton'); + }, + + async closeAlertFlyout() { + await testSubjects.click('euiFlyoutCloseButton'); + }, }; } From a8da74df560da6ace190f646fb3b538480bc10f0 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Thu, 22 Jul 2021 21:58:22 -0400 Subject: [PATCH 32/45] [Uptime] monitor list layout fix (#106159) * update monitor list component to hide downtown history and accordion toggle in medium and large screens * force page headings with flex content to wrap * adjust types * adjust variable naming for uptime monitor list Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__snapshots__/monitor_list.test.tsx.snap | 6 +- .../monitor_list/monitor_list.test.tsx | 67 +++++ .../overview/monitor_list/monitor_list.tsx | 229 ++++++++++-------- x-pack/plugins/uptime/public/routes.tsx | 11 +- 4 files changed, 212 insertions(+), 101 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap index a4fcb141d454b..e6e7250dd5533 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/__snapshots__/monitor_list.test.tsx.snap @@ -1052,7 +1052,7 @@ exports[`MonitorList component renders the monitor list 1`] = ` scope="col" >
{ @@ -112,6 +114,10 @@ describe('MonitorList component', () => { beforeAll(() => { mockMoment(); + global.innerWidth = 1200; + + // Trigger the window resize event. + global.dispatchEvent(new Event('resize')); }); const getMonitorList = (timestamp?: string): MonitorSummariesResult => { @@ -275,6 +281,67 @@ describe('MonitorList component', () => { }); }); + describe('responsive behavior', () => { + describe('xl screens', () => { + beforeAll(() => { + global.innerWidth = 1200; + + // Trigger the window resize event. + global.dispatchEvent(new Event('resize')); + }); + + it('shows ping histogram and expand button on xl and xxl screens', async () => { + const list = getMonitorList(moment().subtract(5, 'minute').toISOString()); + const { getByTestId, getByText } = render( + + ); + + await waitFor(() => { + expect( + getByTestId( + `xpack.uptime.monitorList.${list.summaries[0].monitor_id}.expandMonitorDetail` + ) + ).toBeInTheDocument(); + expect(getByText('Downtime history')).toBeInTheDocument(); + }); + }); + }); + + describe('large and medium screens', () => { + it('hides ping histogram and expand button on extra large screens', async () => { + global.innerWidth = 1199; + + // Trigger the window resize event. + global.dispatchEvent(new Event('resize')); + + const { queryByTestId, queryByText } = render( + + ); + + await waitFor(() => { + expect( + queryByTestId('xpack.uptime.monitorList.always-down.expandMonitorDetail') + ).not.toBeInTheDocument(); + expect(queryByText('Downtime history')).not.toBeInTheDocument(); + }); + }); + }); + }); + describe('noItemsMessage', () => { it('returns loading message while loading', () => { expect(noItemsMessage(true)).toEqual(`Loading...`); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx index 0b707588acfb5..37803dff88ce9 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx @@ -4,7 +4,9 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import React, { useState } from 'react'; +import useWindowSize from 'react-use/lib/useWindowSize'; +import useDebounce from 'react-use/lib/useDebounce'; import { EuiButtonIcon, EuiBasicTable, @@ -13,8 +15,8 @@ import { EuiLink, EuiPanel, EuiSpacer, + getBreakpoint, } from '@elastic/eui'; -import React, { useState } from 'react'; import { HistogramPoint, X509Expiry } from '../../../../common/runtime_types'; import { MonitorSummary } from '../../../../common/runtime_types'; import { MonitorListStatusColumn } from './columns/monitor_status_column'; @@ -51,15 +53,33 @@ export const MonitorListComponent: ({ pageSize, setPageSize, }: Props) => any = ({ filters, monitorList: { list, error, loading }, pageSize, setPageSize }) => { - const [drawerIds, updateDrawerIds] = useState([]); + const [expandedDrawerIds, updateExpandedDrawerIds] = useState([]); + const { width } = useWindowSize(); + const [hideExtraColumns, setHideExtraColumns] = useState(false); + + useDebounce( + () => { + setHideExtraColumns(['m', 'l'].includes(getBreakpoint(width) ?? '')); + }, + 50, + [width] + ); const items = list.summaries ?? []; const nextPagePagination = list.nextPagePagination ?? ''; const prevPagePagination = list.prevPagePagination ?? ''; + const toggleDrawer = (id: string) => { + if (expandedDrawerIds.includes(id)) { + updateExpandedDrawerIds(expandedDrawerIds.filter((p) => p !== id)); + } else { + updateExpandedDrawerIds([...expandedDrawerIds, id]); + } + }; + const getExpandedRowMap = () => { - return drawerIds.reduce((map: ExpandedRowMap, id: string) => { + return expandedDrawerIds.reduce((map: ExpandedRowMap, id: string) => { return { ...map, [id]: ( @@ -72,104 +92,113 @@ export const MonitorListComponent: ({ }; const columns = [ - { - align: 'left' as const, - field: 'state.summary.status', - name: labels.STATUS_COLUMN_LABEL, - mobileOptions: { - fullWidth: true, + ...[ + { + align: 'left' as const, + field: 'state.summary.status', + name: labels.STATUS_COLUMN_LABEL, + mobileOptions: { + fullWidth: true, + }, + render: (status: string, { state: { timestamp, summaryPings } }: MonitorSummary) => { + return ( + + ); + }, }, - render: (status: string, { state: { timestamp, summaryPings } }: MonitorSummary) => { - return ( - - ); + { + align: 'left' as const, + field: 'state.monitor.name', + name: labels.NAME_COLUMN_LABEL, + mobileOptions: { + fullWidth: true, + }, + render: (_name: string, summary: MonitorSummary) => , + sortable: true, }, - }, - { - align: 'left' as const, - field: 'state.monitor.name', - name: labels.NAME_COLUMN_LABEL, - mobileOptions: { - fullWidth: true, + { + align: 'left' as const, + field: 'state.url.full', + name: URL_LABEL, + width: '30%', + render: (url: string) => ( + + {url} + + ), }, - render: (name: string, summary: MonitorSummary) => , - sortable: true, - }, - { - align: 'left' as const, - field: 'state.url.full', - name: URL_LABEL, - width: '30%', - render: (url: string) => ( - - {url} - - ), - }, - { - align: 'left' as const, - field: 'state.monitor.name', - name: TAGS_LABEL, - width: '12%', - render: (_name: string, summary: MonitorSummary) => , - }, - { - align: 'left' as const, - field: 'state.tls.server.x509', - name: labels.TLS_COLUMN_LABEL, - render: (x509: X509Expiry) => , - }, - { - align: 'center' as const, - field: 'histogram.points', - name: labels.HISTORY_COLUMN_LABEL, - mobileOptions: { - show: false, + { + align: 'left' as const, + field: 'state.monitor.name', + name: TAGS_LABEL, + width: '12%', + render: (_name: string, summary: MonitorSummary) => , }, - render: (histogramSeries: HistogramPoint[] | null, summary: MonitorSummary) => ( - - ), - }, - { - align: 'center' as const, - field: '', - name: STATUS_ALERT_COLUMN, - width: '100px', - render: (item: MonitorSummary) => ( - - ), - }, - { - align: 'right' as const, - field: 'monitor_id', - name: '', - sortable: true, - isExpander: true, - width: '40px', - render: (id: string) => { - return ( - { - if (drawerIds.includes(id)) { - updateDrawerIds(drawerIds.filter((p) => p !== id)); - } else { - updateDrawerIds([...drawerIds, id]); - } - }} + { + align: 'left' as const, + field: 'state.tls.server.x509', + name: labels.TLS_COLUMN_LABEL, + render: (x509: X509Expiry) => , + }, + ], + ...(!hideExtraColumns + ? [ + { + align: 'left' as const, + field: 'histogram.points', + name: labels.HISTORY_COLUMN_LABEL, + mobileOptions: { + show: false, + }, + render: (histogramSeries: HistogramPoint[] | null, summary: MonitorSummary) => ( + + ), + }, + ] + : []), + ...[ + { + align: 'center' as const, + field: '', + name: STATUS_ALERT_COLUMN, + width: '100px', + render: (item: MonitorSummary) => ( + - ); + ), }, - }, + ], + ...(!hideExtraColumns + ? [ + { + align: 'right' as const, + field: 'monitor_id', + name: '', + sortable: true, + isExpander: true, + width: '40px', + render: (id: string) => { + return ( + toggleDrawer(id)} + /> + ); + }, + }, + ] + : []), ]; return ( @@ -188,6 +217,14 @@ export const MonitorListComponent: ({ noItemsMessage={noItemsMessage(loading, filters)} columns={columns} tableLayout={'auto'} + rowProps={ + hideExtraColumns + ? ({ monitor_id: monitorId }) => ({ + onClick: () => toggleDrawer(monitorId), + 'aria-label': labels.getExpandDrawerLabel(monitorId), + }) + : undefined + } /> diff --git a/x-pack/plugins/uptime/public/routes.tsx b/x-pack/plugins/uptime/public/routes.tsx index 2b0cc4dc5e5c2..e19f4bd5f93c1 100644 --- a/x-pack/plugins/uptime/public/routes.tsx +++ b/x-pack/plugins/uptime/public/routes.tsx @@ -6,6 +6,7 @@ */ import React, { FC, useEffect } from 'react'; +import styled from 'styled-components'; import { Route, Switch } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -144,6 +145,12 @@ export const PageRouter: FC = () => { } = useKibana(); const PageTemplateComponent = observability.navigation.PageTemplate; + const StyledPageTemplateComponent = styled(PageTemplateComponent)` + .euiPageHeaderContent > .euiFlexGroup { + flex-wrap: wrap; + } + `; + return ( {Routes.map( @@ -153,9 +160,9 @@ export const PageRouter: FC = () => { {pageHeader ? ( - + - + ) : ( )} From 3e4b64b779c518e94eb339cb821f9cc2a65566f0 Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Fri, 23 Jul 2021 11:29:06 +0300 Subject: [PATCH 33/45] [Canvas] Expression repeat image (#104255) * Repeat Image plugin added. --- .i18nrc.json | 1 + docs/developer/plugin-list.asciidoc | 4 + packages/kbn-optimizer/limits.yml | 3 +- src/dev/storybook/aliases.ts | 1 + .../public/components/error/error.tsx | 12 +- .../.storybook/main.js | 10 ++ src/plugins/expression_repeat_image/README.md | 9 ++ .../common/constants.ts | 14 +++ .../common/expression_functions/index.ts | 13 ++ .../repeat_image_function.test.ts | 35 +++--- .../repeat_image_function.ts | 115 ++++++++++++++++++ .../expression_repeat_image/common/index.ts | 11 ++ .../common/types/expression_functions.ts | 30 +++++ .../common/types/expression_renderers.ts | 21 ++++ .../common/types/index.ts | 9 ++ .../expression_repeat_image/jest.config.js | 13 ++ .../expression_repeat_image/kibana.json | 10 ++ .../public/components/index.ts | 9 ++ .../components/repeat_image_component.tsx | 99 +++++++++++++++ .../repeat_image.stories.storyshot | 0 .../repeat_image_renderer.stories.tsx | 11 +- .../public/expression_renderers/index.ts | 13 ++ .../repeat_image_renderer.tsx | 58 +++++++++ .../expression_repeat_image/public/index.ts | 17 +++ .../expression_repeat_image/public/plugin.ts | 41 +++++++ .../expression_repeat_image/server/index.ts | 15 +++ .../expression_repeat_image/server/plugin.ts | 39 ++++++ .../expression_repeat_image/tsconfig.json | 21 ++++ .../functions/common/index.ts | 2 - .../functions/common/repeat_image.ts | 84 ------------- .../canvas_plugin_src/renderers/core.ts | 15 +-- .../canvas_plugin_src/renderers/external.ts | 12 +- .../renderers/repeat_image.ts | 79 ------------ .../canvas_plugin_src/uis/arguments/shape.js | 26 ++-- x-pack/plugins/canvas/i18n/errors.ts | 10 -- .../i18n/functions/dict/repeat_image.ts | 47 ------- .../canvas/i18n/functions/function_help.ts | 2 - x-pack/plugins/canvas/i18n/renderers.ts | 10 -- x-pack/plugins/canvas/kibana.json | 3 +- .../datasource_preview/datasource_preview.js | 3 +- .../render_with_fn/render_with_fn.tsx | 17 +-- .../components/shape_picker/shape_picker.tsx | 2 +- .../shape_preview/shape_preview.tsx | 3 +- .../shareable_runtime/supported_renderers.js | 4 +- x-pack/plugins/canvas/tsconfig.json | 1 + .../translations/translations/ja-JP.json | 16 +-- .../translations/translations/zh-CN.json | 20 +-- 47 files changed, 667 insertions(+), 323 deletions(-) create mode 100644 src/plugins/expression_repeat_image/.storybook/main.js create mode 100755 src/plugins/expression_repeat_image/README.md create mode 100644 src/plugins/expression_repeat_image/common/constants.ts create mode 100644 src/plugins/expression_repeat_image/common/expression_functions/index.ts rename x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.test.js => src/plugins/expression_repeat_image/common/expression_functions/repeat_image_function.test.ts (62%) create mode 100644 src/plugins/expression_repeat_image/common/expression_functions/repeat_image_function.ts create mode 100755 src/plugins/expression_repeat_image/common/index.ts create mode 100644 src/plugins/expression_repeat_image/common/types/expression_functions.ts create mode 100644 src/plugins/expression_repeat_image/common/types/expression_renderers.ts create mode 100644 src/plugins/expression_repeat_image/common/types/index.ts create mode 100644 src/plugins/expression_repeat_image/jest.config.js create mode 100755 src/plugins/expression_repeat_image/kibana.json create mode 100644 src/plugins/expression_repeat_image/public/components/index.ts create mode 100644 src/plugins/expression_repeat_image/public/components/repeat_image_component.tsx rename {x-pack/plugins/canvas/canvas_plugin_src/renderers => src/plugins/expression_repeat_image/public/expression_renderers}/__stories__/__snapshots__/repeat_image.stories.storyshot (100%) rename x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/repeat_image.stories.tsx => src/plugins/expression_repeat_image/public/expression_renderers/__stories__/repeat_image_renderer.stories.tsx (69%) create mode 100644 src/plugins/expression_repeat_image/public/expression_renderers/index.ts create mode 100644 src/plugins/expression_repeat_image/public/expression_renderers/repeat_image_renderer.tsx create mode 100755 src/plugins/expression_repeat_image/public/index.ts create mode 100755 src/plugins/expression_repeat_image/public/plugin.ts create mode 100755 src/plugins/expression_repeat_image/server/index.ts create mode 100755 src/plugins/expression_repeat_image/server/plugin.ts create mode 100644 src/plugins/expression_repeat_image/tsconfig.json delete mode 100644 x-pack/plugins/canvas/canvas_plugin_src/functions/common/repeat_image.ts delete mode 100644 x-pack/plugins/canvas/canvas_plugin_src/renderers/repeat_image.ts delete mode 100644 x-pack/plugins/canvas/i18n/functions/dict/repeat_image.ts 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/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/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/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/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}创建) ", From c0ceb06f4b8f67a3ceff1ccf8b8b48b3866f5c0f Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Fri, 23 Jul 2021 09:49:55 -0500 Subject: [PATCH 34/45] [Security Solution] UEBA Spacetime Project (#104973) Merging with known issues documented here: https://github.com/elastic/kibana/issues/106648 --- .../security_solution/common/constants.ts | 24 +- .../common/experimental_features.ts | 5 +- .../security_solution/index.ts | 29 +++ .../security_solution/ueba/common/index.ts | 52 ++++ .../ueba/host_rules/index.ts | 48 ++++ .../ueba/host_tactics/index.ts | 52 ++++ .../security_solution/ueba/index.ts | 19 ++ .../ueba/risk_score/index.ts | 47 ++++ .../ueba/user_rules/index.ts | 78 ++++++ .../common/types/timeline/index.ts | 2 + .../public/app/deep_links/index.test.ts | 38 ++- .../public/app/deep_links/index.ts | 36 ++- .../public/app/home/home_navigations.ts | 8 + .../security_solution/public/app/index.tsx | 4 +- .../public/app/translations.ts | 4 + .../security_solution/public/app/types.ts | 8 +- .../common/components/header_page/index.tsx | 4 +- .../components/link_to/redirect_to_ueba.tsx | 24 ++ .../public/common/components/links/index.tsx | 40 ++++ .../navigation/breadcrumbs/index.ts | 24 ++ .../navigation/tab_navigation/index.tsx | 3 +- .../common/components/navigation/types.ts | 26 +- .../index.test.tsx | 14 ++ .../index.tsx | 13 +- .../use_navigation_items.tsx | 4 +- .../components/paginated_table/index.tsx | 19 +- .../common/components/url_state/constants.ts | 7 +- .../common/components/url_state/types.ts | 12 +- .../common/containers/sourcerer/index.tsx | 15 +- .../common/hooks/use_experimental_features.ts | 5 +- .../mock/endpoint/app_context_render.tsx | 2 +- .../public/common/mock/global_state.ts | 42 +++- .../public/common/mock/utils.ts | 2 + .../public/common/store/app/model.ts | 2 +- .../public/common/store/app/reducer.ts | 7 + .../public/common/store/reducer.ts | 2 + .../public/common/store/types.ts | 2 + .../public/common/utils/route/types.ts | 11 +- .../timeline_actions/alert_context_menu.tsx | 14 +- .../detection_engine/rules/details/index.tsx | 20 +- .../security_solution/public/helpers.ts | 4 +- .../public/lazy_sub_plugins.tsx | 2 + .../security_solution/public/plugin.tsx | 38 ++- .../plugins/security_solution/public/types.ts | 5 + .../components/host_rules_table/columns.tsx | 145 +++++++++++ .../components/host_rules_table/index.tsx | 173 ++++++++++++++ .../host_rules_table/translations.ts | 33 +++ .../components/host_tactics_table/columns.tsx | 153 ++++++++++++ .../components/host_tactics_table/index.tsx | 161 +++++++++++++ .../host_tactics_table/translations.ts | 33 +++ .../components/risk_score_table/columns.tsx | 79 ++++++ .../components/risk_score_table/index.tsx | 157 ++++++++++++ .../risk_score_table/translations.ts | 29 +++ .../public/ueba/components/translations.ts | 18 ++ .../public/ueba/components/utils.ts | 20 ++ .../ueba/containers/host_rules/index.tsx | 220 +++++++++++++++++ .../containers/host_rules/translations.ts | 22 ++ .../ueba/containers/host_tactics/index.tsx | 225 ++++++++++++++++++ .../containers/host_tactics/translations.ts | 22 ++ .../ueba/containers/risk_score/index.tsx | 216 +++++++++++++++++ .../containers/risk_score/translations.ts | 22 ++ .../ueba/containers/user_rules/index.tsx | 209 ++++++++++++++++ .../containers/user_rules/translations.ts | 22 ++ .../security_solution/public/ueba/index.ts | 30 +++ .../ueba/pages/details/details_tabs.tsx | 95 ++++++++ .../public/ueba/pages/details/helpers.ts | 50 ++++ .../public/ueba/pages/details/index.tsx | 150 ++++++++++++ .../public/ueba/pages/details/nav_tabs.tsx | 37 +++ .../public/ueba/pages/details/types.ts | 65 +++++ .../public/ueba/pages/details/utils.ts | 71 ++++++ .../public/ueba/pages/display.tsx | 14 ++ .../public/ueba/pages/index.tsx | 65 +++++ .../public/ueba/pages/nav_tabs.tsx | 22 ++ .../navigation/host_rules_query_tab_body.tsx | 64 +++++ .../host_tactics_query_tab_body.tsx | 63 +++++ .../public/ueba/pages/navigation/index.ts | 11 + .../navigation/risk_score_query_tab_body.tsx | 52 ++++ .../public/ueba/pages/navigation/types.ts | 39 +++ .../navigation/user_rules_query_tab_body.tsx | 70 ++++++ .../public/ueba/pages/translations.ts | 27 +++ .../public/ueba/pages/types.ts | 29 +++ .../public/ueba/pages/ueba.tsx | 184 ++++++++++++++ .../public/ueba/pages/ueba_tabs.tsx | 82 +++++++ .../security_solution/public/ueba/routes.tsx | 26 ++ .../public/ueba/store/actions.ts | 35 +++ .../public/ueba/store/helpers.ts | 45 ++++ .../public/ueba/store/index.ts | 22 ++ .../public/ueba/store/model.ts | 78 ++++++ .../public/ueba/store/reducer.ts | 136 +++++++++++ .../public/ueba/store/selectors.ts | 27 +++ .../signals/executors/eql.test.ts | 2 + .../detection_engine/signals/executors/eql.ts | 10 +- .../signals/executors/query.ts | 10 +- .../signals/executors/threat_match.ts | 10 +- .../signals/executors/threshold.test.ts | 2 + .../signals/executors/threshold.ts | 10 +- .../signals/get_input_output_index.test.ts | 64 ++++- .../signals/get_input_output_index.ts | 33 ++- .../signals/signal_rule_alert_type.test.ts | 2 + .../signals/signal_rule_alert_type.ts | 14 +- .../security_solution/server/plugin.ts | 1 + .../factory/hosts/details/index.test.tsx | 1 + .../security_solution/factory/index.ts | 2 + .../factory/ueba/host_rules/helpers.ts | 24 ++ .../factory/ueba/host_rules/index.ts | 59 +++++ .../ueba/host_rules/query.host_rules.dsl.ts | 86 +++++++ .../factory/ueba/host_tactics/helpers.ts | 47 ++++ .../factory/ueba/host_tactics/index.ts | 59 +++++ .../host_tactics/query.host_tactics.dsl.ts | 90 +++++++ .../security_solution/factory/ueba/index.ts | 23 ++ .../factory/ueba/risk_score/helpers.ts | 23 ++ .../factory/ueba/risk_score/index.ts | 59 +++++ .../ueba/risk_score/query.risk_score.dsl.ts | 71 ++++++ .../factory/ueba/user_rules/helpers.ts | 19 ++ .../factory/ueba/user_rules/index.ts | 67 ++++++ .../ueba/user_rules/query.user_rules.dsl.ts | 97 ++++++++ .../security_solution/server/ui_settings.ts | 5 +- .../timeline/events/last_event_time/index.ts | 1 + .../timelines/common/types/timeline/index.ts | 2 + .../timelines/public/store/t_grid/types.ts | 1 + .../query.events_last_event_time.dsl.ts | 1 + 121 files changed, 5143 insertions(+), 117 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/common/index.ts create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_rules/index.ts create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_tactics/index.ts create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/index.ts create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/risk_score/index.ts create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/user_rules/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_ueba.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/components/host_rules_table/columns.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/components/host_rules_table/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/components/host_rules_table/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/columns.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/components/risk_score_table/columns.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/components/risk_score_table/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/components/risk_score_table/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/components/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/components/utils.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/host_rules/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/host_rules/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/host_tactics/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/host_tactics/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/risk_score/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/risk_score/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/user_rules/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/user_rules/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/index.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/details/details_tabs.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/details/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/details/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/details/nav_tabs.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/details/types.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/details/utils.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/display.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/nav_tabs.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/navigation/host_rules_query_tab_body.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/navigation/host_tactics_query_tab_body.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/navigation/index.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/navigation/risk_score_query_tab_body.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/navigation/types.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/navigation/user_rules_query_tab_body.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/types.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/ueba.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/ueba_tabs.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/routes.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/store/actions.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/store/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/store/index.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/store/model.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/store/reducer.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/store/selectors.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/helpers.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/query.host_rules.dsl.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/helpers.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/query.host_tactics.dsl.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/helpers.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/query.risk_score.dsl.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/helpers.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/query.user_rules.dsl.ts 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], From e45d25dde00ab5c1882522a78502ddf7f77c7d7f Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Sun, 25 Jul 2021 15:59:24 +0200 Subject: [PATCH 35/45] Fix vis. search filter being overridden in dashboard (#106399) * fix vis search filter context being overridden in dashboard * Revert "fix vis search filter context being overridden in dashboard" This reverts commit ead7ef6ed34d9a3acbfe4f7bdeae1063fc7ce8c0. * updated filtering order in kibana context * use buildFilter Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../search/expressions/kibana_context.test.ts | 264 ++++++++++++++++++ .../search/expressions/kibana_context.ts | 2 +- 2 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 src/plugins/data/common/search/expressions/kibana_context.test.ts 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, }; }, From f7a308859fab15cd72622900f70bfb9ed9cd8b95 Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Sun, 25 Jul 2021 10:53:17 -0400 Subject: [PATCH 36/45] [App Search] New Add Domain Flyout for Crawler (#106102) * Added /api/app_search/engines/{name}/crawler/domains to Crawler routes * New AddDomainLogic * New AddDomainFormSubmitButton component * New AddDomainFormErrors component * New AddDomainForm component * New AddDomainFlyout component * Add AddDomainFlyout to CrawlerOverview * Use exact path for CrawlerOverview in CrawlerRouter * Clean-up AddDomainFlyout * Clean-up AddDomainForm * Clean-up AddDomainFormSubmitButton * Extract getErrorsFromHttpResponse from flashAPIErrors * Clean-up AddDomainLogic * Remove unused imports Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../add_domain/add_domain_flyout.test.tsx | 69 ++++ .../add_domain/add_domain_flyout.tsx | 98 ++++++ .../add_domain/add_domain_form.test.tsx | 122 +++++++ .../components/add_domain/add_domain_form.tsx | 105 ++++++ .../add_domain_form_errors.test.tsx | 41 +++ .../add_domain/add_domain_form_errors.tsx | 40 +++ .../add_domain_form_submit_button.test.tsx | 59 ++++ .../add_domain_form_submit_button.tsx | 30 ++ .../add_domain/add_domain_logic.test.ts | 300 ++++++++++++++++++ .../components/add_domain/add_domain_logic.ts | 166 ++++++++++ .../components/add_domain/utils.test.ts | 23 ++ .../crawler/components/add_domain/utils.ts | 21 ++ .../crawler/crawler_overview.test.tsx | 3 +- .../components/crawler/crawler_overview.tsx | 20 ++ .../components/crawler/crawler_router.tsx | 4 +- .../flash_messages/handle_api_errors.test.ts | 25 +- .../flash_messages/handle_api_errors.ts | 21 +- .../server/routes/app_search/crawler.test.ts | 56 ++++ .../server/routes/app_search/crawler.ts | 25 ++ 19 files changed, 1217 insertions(+), 11 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.ts 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}', From a1134e1bcaa37353a2aacf8098ae7e3f518b1a04 Mon Sep 17 00:00:00 2001 From: Mat Schaffer Date: Mon, 26 Jul 2021 13:52:51 +0900 Subject: [PATCH 37/45] Note full cli arg for es full featured snapshot (#103045) --- docs/developer/advanced/running-elasticsearch.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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** From 6a50aff2545167b176a31383ef491bc33e1c6409 Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Mon, 26 Jul 2021 09:09:37 +0300 Subject: [PATCH 38/45] [Canvas] Register `expression_functions` in `{expression}/public/plugin.ts`. (#106636) * Registered `revealImageFunction` in `public/plugin`. * Registered `shapeFunction` in `public/plugin`. --- src/plugins/expression_reveal_image/public/plugin.ts | 2 ++ src/plugins/expression_shape/public/plugin.ts | 2 ++ 2 files changed, 4 insertions(+) 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); } From 1d9ec12b5b39f6363959520cdd86d008d31847f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Mon, 26 Jul 2021 09:41:07 +0200 Subject: [PATCH 39/45] [Security solution] [Endpoint] Unify subtitle text in flyout and modal for event filters (#106562) * Unify subtitle text in flyout and modal for event filters * Change variable name and make it more consistent with trusted apps showing subtitle only when adding event filters * Remove old unused keys Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../view/components/form/index.tsx | 18 ++++++++---------- .../view/components/form/translations.ts | 7 ------- .../view/event_filters_list_page.tsx | 7 ++----- .../pages/event_filters/view/translations.ts | 6 ++++++ .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 6 files changed, 16 insertions(+), 24 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx index db5c42241a0cc..29723a5fd3cf8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/index.tsx @@ -31,16 +31,10 @@ import { ExceptionBuilder } from '../../../../../../shared_imports'; import { useEventFiltersSelector } from '../../hooks'; import { getFormEntryStateMutable, getHasNameError, getNewComment } from '../../../store/selector'; -import { - FORM_DESCRIPTION, - NAME_LABEL, - NAME_ERROR, - NAME_PLACEHOLDER, - OS_LABEL, - RULE_NAME, -} from './translations'; +import { NAME_LABEL, NAME_ERROR, NAME_PLACEHOLDER, OS_LABEL, RULE_NAME } from './translations'; import { OS_TITLES } from '../../../../../common/translations'; import { ENDPOINT_EVENT_FILTERS_LIST_ID, EVENT_FILTER_LIST_TYPE } from '../../../constants'; +import { ABOUT_EVENT_FILTERS } from '../../translations'; const OPERATING_SYSTEMS: readonly OperatingSystem[] = [ OperatingSystem.MAC, @@ -205,8 +199,12 @@ export const EventFiltersForm: React.FC = memo( return !isIndexPatternLoading && exception ? ( - {FORM_DESCRIPTION} - + {!exception || !exception.item_id ? ( + + {ABOUT_EVENT_FILTERS} + + + ) : null} {nameInputMemo} {allowSelectOs ? ( diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts index 7391251a936e6..bfb828699118e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/components/form/translations.ts @@ -7,13 +7,6 @@ import { i18n } from '@kbn/i18n'; -export const FORM_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.eventFilter.modal.description', - { - defaultMessage: "Events are filtered when the rule's conditions are met:", - } -); - export const NAME_PLACEHOLDER = i18n.translate( 'xpack.securitySolution.eventFilter.form.name.placeholder', { diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx index 2d608bdc6e157..95f3e856a6ff6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/view/event_filters_list_page.tsx @@ -44,6 +44,7 @@ import { EventFilterDeleteModal } from './components/event_filter_delete_modal'; import { SearchBar } from '../../../components/search_bar'; import { BackToExternalAppButton } from '../../../components/back_to_external_app_button'; +import { ABOUT_EVENT_FILTERS } from './translations'; type EventListPaginatedContent = PaginatedContentProps< Immutable, @@ -195,11 +196,7 @@ export const EventFiltersListPage = memo(() => { defaultMessage="Event Filters" /> } - subtitle={i18n.translate('xpack.securitySolution.eventFilters.aboutInfo', { - defaultMessage: - 'Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch. Event ' + - 'filters are processed by the Endpoint Security integration, and are applied to hosts running this integration on their agents.', - })} + subtitle={ABOUT_EVENT_FILTERS} actions={ doesDataExist && ( { values: { error: getError.message }, }); }; + +export const ABOUT_EVENT_FILTERS = i18n.translate('xpack.securitySolution.eventFilters.aboutInfo', { + defaultMessage: + 'Add an event filter to exclude high volume or unwanted events from being written to Elasticsearch. Event ' + + 'filters are processed by the Endpoint Security integration, and are applied to hosts running this integration on their agents.', +}); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 5a09667e2a327..7e26464ad1955 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -21816,7 +21816,6 @@ "xpack.securitySolution.eventFilter.form.updateSuccessToastTitle": "\"{name}\"が正常に更新されました", "xpack.securitySolution.eventFilter.modal.actions.cancel": "キャンセル", "xpack.securitySolution.eventFilter.modal.actions.confirm": "エンドポイントイベントフィルターを追加", - "xpack.securitySolution.eventFilter.modal.description": "ルールの条件が満たされたときにイベントがフィルタリングされます。", "xpack.securitySolution.eventFilter.modal.subtitle": "Endpoint Security", "xpack.securitySolution.eventFilter.modal.title": "エンドポイントイベントフィルターを追加", "xpack.securitySolution.eventFilter.search.placeholder": "次のフィールドで検索:名前、コメント、値", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index de212d601660d..b1c6bdea9bfef 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -22133,7 +22133,6 @@ "xpack.securitySolution.eventFilter.form.updateSuccessToastTitle": "“{name}”已成功更新。", "xpack.securitySolution.eventFilter.modal.actions.cancel": "取消", "xpack.securitySolution.eventFilter.modal.actions.confirm": "添加终端事件筛选", - "xpack.securitySolution.eventFilter.modal.description": "满足规则的条件时将筛选事件:", "xpack.securitySolution.eventFilter.modal.subtitle": "Endpoint Security", "xpack.securitySolution.eventFilter.modal.title": "添加终端事件筛选", "xpack.securitySolution.eventFilter.search.placeholder": "搜索下面的字段:name、comments、value", From 293a6d6c420dfe6758f29e3b518019a0ef4493a6 Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Mon, 26 Jul 2021 11:33:37 +0300 Subject: [PATCH 40/45] [Canvas] `Datasource/index` refactor. (#106643) * Refactored from `recompose` to `hooks`. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/components/datasource/index.js | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/canvas/public/components/datasource/index.js b/x-pack/plugins/canvas/public/components/datasource/index.js index 91f34f4a127ec..5a0cbf6d05bd9 100644 --- a/x-pack/plugins/canvas/public/components/datasource/index.js +++ b/x-pack/plugins/canvas/public/components/datasource/index.js @@ -5,9 +5,9 @@ * 2.0. */ +import React, { useState, useCallback } from 'react'; import { PropTypes } from 'prop-types'; import { connect } from 'react-redux'; -import { withState, withHandlers, compose } from 'recompose'; import { get } from 'lodash'; import { datasourceRegistry } from '../../expression_types'; import { getServerFunctions } from '../../state/selectors/app'; @@ -15,6 +15,36 @@ import { getSelectedElement, getSelectedPage } from '../../state/selectors/workp import { setArgumentAtIndex, setAstAtIndex, flushContext } from '../../state/actions/elements'; import { Datasource as Component } from './datasource'; +const DatasourceComponent = (props) => { + const { args, datasource } = props; + const [stateArgs, updateArgs] = useState(args); + const [selecting, setSelecting] = useState(false); + const [previewing, setPreviewing] = useState(false); + const [isInvalid, setInvalid] = useState(false); + const [stateDatasource, selectDatasource] = useState(datasource); + + const resetArgs = useCallback(() => { + updateArgs(args); + }, [updateArgs, args]); + + return ( + + ); +}; + const mapStateToProps = (state) => ({ element: getSelectedElement(state), pageId: getSelectedPage(state), @@ -82,17 +112,11 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { }; }; -export const Datasource = compose( - connect(mapStateToProps, mapDispatchToProps, mergeProps), - withState('stateArgs', 'updateArgs', ({ args }) => args), - withState('selecting', 'setSelecting', false), - withState('previewing', 'setPreviewing', false), - withState('isInvalid', 'setInvalid', false), - withState('stateDatasource', 'selectDatasource', ({ datasource }) => datasource), - withHandlers({ - resetArgs: ({ updateArgs, args }) => () => updateArgs(args), - }) -)(Component); +export const Datasource = connect( + mapStateToProps, + mapDispatchToProps, + mergeProps +)(DatasourceComponent); Datasource.propTypes = { done: PropTypes.func, From 89b8dc6095138b0dfe971f0f70198b6408a8b05d Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Mon, 26 Jul 2021 11:34:38 +0300 Subject: [PATCH 41/45] `Datasource` refactored from `recompose` to `hooks`. (#106640) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/components/datasource/datasource.js | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource.js b/x-pack/plugins/canvas/public/components/datasource/datasource.js index c2aa9b7f5c5ce..acda812792c45 100644 --- a/x-pack/plugins/canvas/public/components/datasource/datasource.js +++ b/x-pack/plugins/canvas/public/components/datasource/datasource.js @@ -5,20 +5,17 @@ * 2.0. */ -import { compose, branch, renderComponent } from 'recompose'; +import React from 'react'; import PropTypes from 'prop-types'; import { NoDatasource } from './no_datasource'; import { DatasourceComponent } from './datasource_component'; -const branches = [ - // rendered when there is no datasource in the expression - branch( - ({ datasource, stateDatasource }) => !datasource || !stateDatasource, - renderComponent(NoDatasource) - ), -]; +export const Datasource = (props) => { + const { datasource, stateDatasource } = props; + if (!datasource || !stateDatasource) return ; -export const Datasource = compose(...branches)(DatasourceComponent); + return ; +}; Datasource.propTypes = { args: PropTypes.object, From 3027999435a69a70ced29c8dcff5f51dd29004c6 Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Mon, 26 Jul 2021 11:48:45 +0300 Subject: [PATCH 42/45] [Canvas] Expression image (#104318) * Added `expression_image` plugin. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .i18nrc.json | 1 + docs/developer/plugin-list.asciidoc | 4 + packages/kbn-optimizer/limits.yml | 1 + src/dev/storybook/aliases.ts | 1 + .../expression_image/.storybook/main.js | 10 ++ src/plugins/expression_image/README.md | 9 ++ .../expression_image/__fixtures__/index.ts | 9 ++ .../expression_image/common/constants.ts | 14 +++ .../image_function.test.ts | 42 ++++---- .../expression_functions/image_function.ts | 96 +++++++++++++++++++ .../common/expression_functions/index.ts | 13 +++ src/plugins/expression_image/common/index.ts | 10 ++ .../common/types/expression_functions.ts | 32 +++++++ .../common/types/expression_renderers.ts | 21 ++++ .../expression_image/common/types/index.ts | 9 ++ src/plugins/expression_image/jest.config.js | 13 +++ src/plugins/expression_image/kibana.json | 9 ++ .../__snapshots__/image.stories.storyshot | 0 .../__stories__/image_renderer.stories.tsx | 31 ++++++ .../expression_renderers/image_renderer.tsx | 53 ++++++++++ .../public/expression_renderers/index.ts | 13 +++ src/plugins/expression_image/public/index.ts | 17 ++++ src/plugins/expression_image/public/plugin.ts | 35 +++++++ src/plugins/expression_image/server/index.ts | 15 +++ src/plugins/expression_image/server/plugin.ts | 33 +++++++ src/plugins/expression_image/tsconfig.json | 22 +++++ .../functions/common/image.ts | 75 --------------- .../functions/common/index.ts | 2 - .../renderers/__stories__/image.stories.tsx | 29 ------ .../canvas_plugin_src/renderers/core.ts | 3 +- .../canvas_plugin_src/renderers/external.ts | 2 + .../canvas_plugin_src/renderers/image.tsx | 41 -------- .../canvas/i18n/functions/dict/image.ts | 64 ------------- .../canvas/i18n/functions/function_errors.ts | 2 - .../canvas/i18n/functions/function_help.ts | 2 - x-pack/plugins/canvas/i18n/renderers.ts | 10 -- x-pack/plugins/canvas/kibana.json | 1 + .../__stories__/rendered_element.stories.tsx | 4 +- .../components/rendered_element.tsx | 5 +- .../shareable_runtime/supported_renderers.js | 2 +- x-pack/plugins/canvas/tsconfig.json | 1 + .../translations/translations/ja-JP.json | 14 +-- .../translations/translations/zh-CN.json | 14 +-- 43 files changed, 522 insertions(+), 262 deletions(-) create mode 100644 src/plugins/expression_image/.storybook/main.js create mode 100755 src/plugins/expression_image/README.md create mode 100644 src/plugins/expression_image/__fixtures__/index.ts create mode 100644 src/plugins/expression_image/common/constants.ts rename x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.test.js => src/plugins/expression_image/common/expression_functions/image_function.test.ts (59%) create mode 100644 src/plugins/expression_image/common/expression_functions/image_function.ts create mode 100644 src/plugins/expression_image/common/expression_functions/index.ts create mode 100755 src/plugins/expression_image/common/index.ts create mode 100644 src/plugins/expression_image/common/types/expression_functions.ts create mode 100644 src/plugins/expression_image/common/types/expression_renderers.ts create mode 100644 src/plugins/expression_image/common/types/index.ts create mode 100644 src/plugins/expression_image/jest.config.js create mode 100755 src/plugins/expression_image/kibana.json rename {x-pack/plugins/canvas/canvas_plugin_src/renderers => src/plugins/expression_image/public/expression_renderers}/__stories__/__snapshots__/image.stories.storyshot (100%) create mode 100644 src/plugins/expression_image/public/expression_renderers/__stories__/image_renderer.stories.tsx create mode 100644 src/plugins/expression_image/public/expression_renderers/image_renderer.tsx create mode 100644 src/plugins/expression_image/public/expression_renderers/index.ts create mode 100755 src/plugins/expression_image/public/index.ts create mode 100755 src/plugins/expression_image/public/plugin.ts create mode 100755 src/plugins/expression_image/server/index.ts create mode 100755 src/plugins/expression_image/server/plugin.ts create mode 100644 src/plugins/expression_image/tsconfig.json delete mode 100644 x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.ts delete mode 100644 x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/image.stories.tsx delete mode 100644 x-pack/plugins/canvas/canvas_plugin_src/renderers/image.tsx delete mode 100644 x-pack/plugins/canvas/i18n/functions/dict/image.ts diff --git a/.i18nrc.json b/.i18nrc.json index bdfe444bb99b5..36b3777fd1f5a 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", + "expressionImage": "src/plugins/expression_image", "expressionRepeatImage": "src/plugins/expression_repeat_image", "expressionRevealImage": "src/plugins/expression_reveal_image", "expressionShape": "src/plugins/expression_shape", diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index 77f16a9d69d46..d2b5f54a59383 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_image/README.md[expressionImage] +|Expression Image plugin adds an image renderer to the expression plugin. The renderer will display the given 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. diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 0bf15d236bc9c..ef6ff52a6c8b8 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -114,5 +114,6 @@ pageLoadAssetSize: cases: 144442 expressionError: 22127 expressionRepeatImage: 22341 + expressionImage: 19288 expressionShape: 30033 userSetup: 18532 diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 7aca25d2013d2..b27ff86631725 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_image: 'src/plugins/expression_image/.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', diff --git a/src/plugins/expression_image/.storybook/main.js b/src/plugins/expression_image/.storybook/main.js new file mode 100644 index 0000000000000..742239e638b8a --- /dev/null +++ b/src/plugins/expression_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_image/README.md b/src/plugins/expression_image/README.md new file mode 100755 index 0000000000000..b02c9fd39b3d2 --- /dev/null +++ b/src/plugins/expression_image/README.md @@ -0,0 +1,9 @@ +# expressionRevealImage + +Expression Image plugin adds an `image` renderer to the expression plugin. The renderer will display the given image. + +--- + +## 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_image/__fixtures__/index.ts b/src/plugins/expression_image/__fixtures__/index.ts new file mode 100644 index 0000000000000..279e8d87446bc --- /dev/null +++ b/src/plugins/expression_image/__fixtures__/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 { imageFunction } from '../common/expression_functions'; diff --git a/src/plugins/expression_image/common/constants.ts b/src/plugins/expression_image/common/constants.ts new file mode 100644 index 0000000000000..0ac8bec2e1f7d --- /dev/null +++ b/src/plugins/expression_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 = 'expressionImage'; +export const PLUGIN_NAME = 'expressionImage'; + +export const CONTEXT = '_context_'; +export const BASE64 = '`base64`'; +export const URL = 'URL'; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.test.js b/src/plugins/expression_image/common/expression_functions/image_function.test.ts similarity index 59% rename from x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.test.js rename to src/plugins/expression_image/common/expression_functions/image_function.test.ts index 862560e5643d7..7deaeb90b411b 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.test.js +++ b/src/plugins/expression_image/common/expression_functions/image_function.test.ts @@ -1,72 +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. + * 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 expect from '@kbn/expect'; +import { ExecutionContext } from 'src/plugins/expressions'; import { + functionWrapper, getElasticLogo, getElasticOutline, - functionWrapper, -} from '../../../../../../src/plugins/presentation_util/common/lib'; -import { image } from './image'; +} from '../../../presentation_util/common/lib'; +import { imageFunction as image } from './image_function'; -// TODO: the test was not running and is not up to date describe('image', () => { const fn = functionWrapper(image); - let elasticLogo; - let elasticOutline; + let elasticLogo: string; + let elasticOutline: string; + beforeEach(async () => { - elasticLogo = (await getElasticLogo()).elasticLogo; - elasticOutline = (await getElasticOutline()).elasticOutline; + elasticLogo = (await getElasticLogo())?.elasticLogo; + elasticOutline = (await getElasticOutline())?.elasticOutline; }); it('returns an image object using a dataUrl', async () => { - const result = await fn(null, { dataurl: elasticOutline, mode: 'cover' }); + const result = await fn( + null, + { dataurl: elasticOutline, mode: 'cover' }, + {} as ExecutionContext + ); expect(result).to.have.property('type', 'image'); }); describe('args', () => { describe('dataurl', () => { it('sets the source of the image using dataurl', async () => { - const result = await fn(null, { dataurl: elasticOutline }); + const result = await fn(null, { dataurl: elasticOutline }, {} as ExecutionContext); expect(result).to.have.property('dataurl', elasticOutline); }); it.skip('sets the source of the image using url', async () => { // This is skipped because functionWrapper doesn't use the actual // interpreter and doesn't resolve aliases - const result = await fn(null, { url: elasticOutline }); + const result = await fn(null, { url: elasticOutline }, {} as ExecutionContext); expect(result).to.have.property('dataurl', elasticOutline); }); it('defaults to the elasticLogo if not provided', async () => { - const result = await fn(null); + const result = await fn(null, {}, {} as ExecutionContext); expect(result).to.have.property('dataurl', elasticLogo); }); }); describe('sets the mode', () => { it('to contain', async () => { - const result = await fn(null, { mode: 'contain' }); + const result = await fn(null, { mode: 'contain' }, {} as ExecutionContext); expect(result).to.have.property('mode', 'contain'); }); it('to cover', async () => { - const result = await fn(null, { mode: 'cover' }); + const result = await fn(null, { mode: 'cover' }, {} as ExecutionContext); expect(result).to.have.property('mode', 'cover'); }); it('to stretch', async () => { - const result = await fn(null, { mode: 'stretch' }); + const result = await fn(null, { mode: 'stretch' }, {} as ExecutionContext); expect(result).to.have.property('mode', '100% 100%'); }); it("defaults to 'contain' if not provided", async () => { - const result = await fn(null); + const result = await fn(null, {}, {} as ExecutionContext); expect(result).to.have.property('mode', 'contain'); }); }); diff --git a/src/plugins/expression_image/common/expression_functions/image_function.ts b/src/plugins/expression_image/common/expression_functions/image_function.ts new file mode 100644 index 0000000000000..8394681e1b10b --- /dev/null +++ b/src/plugins/expression_image/common/expression_functions/image_function.ts @@ -0,0 +1,96 @@ +/* + * 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 { getElasticLogo, resolveWithMissingImage } from '../../../presentation_util/common/lib'; +import { BASE64, URL } from '../constants'; +import { ExpressionImageFunction, ImageMode } from '../types'; + +export const strings = { + help: i18n.translate('expressionImage.functions.imageHelpText', { + defaultMessage: + 'Displays an image. Provide an image asset as a {BASE64} data {URL}, or pass in a sub-expression.', + values: { + BASE64, + URL, + }, + }), + args: { + dataurl: i18n.translate('expressionImage.functions.image.args.dataurlHelpText', { + defaultMessage: 'The {https} {URL} or {BASE64} data {URL} of an image.', + values: { + BASE64, + https: 'HTTP(S)', + URL, + }, + }), + mode: i18n.translate('expressionImage.functions.image.args.modeHelpText', { + defaultMessage: + '{contain} shows the entire image, scaled to fit. ' + + '{cover} fills the container with the image, cropping from the sides or bottom as needed. ' + + '{stretch} resizes the height and width of the image to 100% of the container.', + values: { + contain: `\`"${ImageMode.CONTAIN}"\``, + cover: `\`"${ImageMode.COVER}"\``, + stretch: `\`"${ImageMode.STRETCH}"\``, + }, + }), + }, +}; + +const errors = { + invalidImageMode: () => + i18n.translate('expressionImage.functions.image.invalidImageModeErrorMessage', { + defaultMessage: '"mode" must be "{contain}", "{cover}", or "{stretch}"', + values: { + contain: ImageMode.CONTAIN, + cover: ImageMode.COVER, + stretch: ImageMode.STRETCH, + }, + }), +}; + +export const imageFunction: ExpressionImageFunction = () => { + const { help, args: argHelp } = strings; + + return { + name: 'image', + aliases: [], + type: 'image', + inputTypes: ['null'], + help, + args: { + dataurl: { + // This was accepting dataurl, but there was no facility in fn for checking type and handling a dataurl type. + types: ['string', 'null'], + help: argHelp.dataurl, + aliases: ['_', 'url'], + default: null, + }, + mode: { + types: ['string'], + help: argHelp.mode, + default: 'contain', + options: Object.values(ImageMode), + }, + }, + fn: async (input, { dataurl, mode }) => { + if (!mode || !Object.values(ImageMode).includes(mode)) { + throw new Error(errors.invalidImageMode()); + } + + const modeStyle = mode === 'stretch' ? '100% 100%' : mode; + const { elasticLogo } = await getElasticLogo(); + return { + type: 'image', + mode: modeStyle, + dataurl: resolveWithMissingImage(dataurl, elasticLogo) as string, + }; + }, + }; +}; diff --git a/src/plugins/expression_image/common/expression_functions/index.ts b/src/plugins/expression_image/common/expression_functions/index.ts new file mode 100644 index 0000000000000..5274069d3d17e --- /dev/null +++ b/src/plugins/expression_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 { imageFunction } from './image_function'; + +export const functions = [imageFunction]; + +export { imageFunction }; diff --git a/src/plugins/expression_image/common/index.ts b/src/plugins/expression_image/common/index.ts new file mode 100755 index 0000000000000..f251b9cf01cb3 --- /dev/null +++ b/src/plugins/expression_image/common/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 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'; diff --git a/src/plugins/expression_image/common/types/expression_functions.ts b/src/plugins/expression_image/common/types/expression_functions.ts new file mode 100644 index 0000000000000..5ee9ed93cc87b --- /dev/null +++ b/src/plugins/expression_image/common/types/expression_functions.ts @@ -0,0 +1,32 @@ +/* + * 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 } from '../../../expressions'; + +export enum ImageMode { + CONTAIN = 'contain', + COVER = 'cover', + STRETCH = 'stretch', +} + +interface Arguments { + dataurl: string | null; + mode: ImageMode | null; +} + +export interface Return { + type: 'image'; + mode: string; + dataurl: string; +} + +export type ExpressionImageFunction = () => ExpressionFunctionDefinition< + 'image', + null, + Arguments, + Promise +>; diff --git a/src/plugins/expression_image/common/types/expression_renderers.ts b/src/plugins/expression_image/common/types/expression_renderers.ts new file mode 100644 index 0000000000000..c4ff7a7f18ae9 --- /dev/null +++ b/src/plugins/expression_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. + */ + +import { ImageMode } from './expression_functions'; + +export type OriginString = 'bottom' | 'left' | 'top' | 'right'; + +export interface ImageRendererConfig { + dataurl: string | null; + mode: ImageMode | null; +} + +export interface NodeDimensions { + width: number; + height: number; +} diff --git a/src/plugins/expression_image/common/types/index.ts b/src/plugins/expression_image/common/types/index.ts new file mode 100644 index 0000000000000..ec934e7affe88 --- /dev/null +++ b/src/plugins/expression_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_image/jest.config.js b/src/plugins/expression_image/jest.config.js new file mode 100644 index 0000000000000..3d5bc9f184c6a --- /dev/null +++ b/src/plugins/expression_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_image'], +}; diff --git a/src/plugins/expression_image/kibana.json b/src/plugins/expression_image/kibana.json new file mode 100755 index 0000000000000..13b4e989b8f70 --- /dev/null +++ b/src/plugins/expression_image/kibana.json @@ -0,0 +1,9 @@ +{ + "id": "expressionImage", + "version": "1.0.0", + "kibanaVersion": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["expressions", "presentationUtil"], + "optionalPlugins": [] +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/__snapshots__/image.stories.storyshot b/src/plugins/expression_image/public/expression_renderers/__stories__/__snapshots__/image.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/__snapshots__/image.stories.storyshot rename to src/plugins/expression_image/public/expression_renderers/__stories__/__snapshots__/image.stories.storyshot diff --git a/src/plugins/expression_image/public/expression_renderers/__stories__/image_renderer.stories.tsx b/src/plugins/expression_image/public/expression_renderers/__stories__/image_renderer.stories.tsx new file mode 100644 index 0000000000000..d75aa1a4263eb --- /dev/null +++ b/src/plugins/expression_image/public/expression_renderers/__stories__/image_renderer.stories.tsx @@ -0,0 +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 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 { Render, waitFor } from '../../../../presentation_util/public/__stories__'; +import { imageRenderer } from '../image_renderer'; +import { getElasticLogo } from '../../../../../../src/plugins/presentation_util/common/lib'; +import { ImageMode } from '../../../common'; + +const Renderer = ({ elasticLogo }: { elasticLogo: string }) => { + const config = { + dataurl: elasticLogo, + mode: ImageMode.COVER, + }; + + return ; +}; + +storiesOf('renderers/image', module).add( + 'default', + (_, props) => { + return ; + }, + { decorators: [waitFor(getElasticLogo())] } +); diff --git a/src/plugins/expression_image/public/expression_renderers/image_renderer.tsx b/src/plugins/expression_image/public/expression_renderers/image_renderer.tsx new file mode 100644 index 0000000000000..3d542a9978a83 --- /dev/null +++ b/src/plugins/expression_image/public/expression_renderers/image_renderer.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { render, unmountComponentAtNode } from 'react-dom'; +import { ExpressionRenderDefinition, IInterpreterRenderHandlers } from 'src/plugins/expressions'; +import { i18n } from '@kbn/i18n'; +import { getElasticLogo, isValidUrl } from '../../../presentation_util/public'; +import { ImageRendererConfig } from '../../common/types'; + +const strings = { + getDisplayName: () => + i18n.translate('expressionImage.renderer.image.displayName', { + defaultMessage: 'Image', + }), + getHelpDescription: () => + i18n.translate('expressionImage.renderer.image.helpDescription', { + defaultMessage: 'Render an image', + }), +}; + +export const imageRenderer = (): ExpressionRenderDefinition => ({ + name: 'image', + displayName: strings.getDisplayName(), + help: strings.getHelpDescription(), + reuseDomNode: true, + render: async ( + domNode: HTMLElement, + config: ImageRendererConfig, + handlers: IInterpreterRenderHandlers + ) => { + const { elasticLogo } = await getElasticLogo(); + const dataurl = isValidUrl(config.dataurl ?? '') ? config.dataurl : elasticLogo; + + const style = { + height: '100%', + backgroundImage: `url(${dataurl})`, + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center center', + backgroundSize: config.mode as string, + }; + + handlers.onDestroy(() => { + unmountComponentAtNode(domNode); + }); + + render(
, domNode, () => handlers.done()); + }, +}); diff --git a/src/plugins/expression_image/public/expression_renderers/index.ts b/src/plugins/expression_image/public/expression_renderers/index.ts new file mode 100644 index 0000000000000..96c274f05a7a9 --- /dev/null +++ b/src/plugins/expression_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 { imageRenderer } from './image_renderer'; + +export const renderers = [imageRenderer]; + +export { imageRenderer }; diff --git a/src/plugins/expression_image/public/index.ts b/src/plugins/expression_image/public/index.ts new file mode 100755 index 0000000000000..522418640bd1f --- /dev/null +++ b/src/plugins/expression_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 { ExpressionImagePlugin } from './plugin'; + +export type { ExpressionImagePluginSetup, ExpressionImagePluginStart } from './plugin'; + +export function plugin() { + return new ExpressionImagePlugin(); +} + +export * from './expression_renderers'; diff --git a/src/plugins/expression_image/public/plugin.ts b/src/plugins/expression_image/public/plugin.ts new file mode 100755 index 0000000000000..44feea4121637 --- /dev/null +++ b/src/plugins/expression_image/public/plugin.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 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 { imageRenderer } from './expression_renderers'; +import { imageFunction } from '../common/expression_functions'; + +interface SetupDeps { + expressions: ExpressionsSetup; +} + +interface StartDeps { + expression: ExpressionsStart; +} + +export type ExpressionImagePluginSetup = void; +export type ExpressionImagePluginStart = void; + +export class ExpressionImagePlugin + implements Plugin { + public setup(core: CoreSetup, { expressions }: SetupDeps): ExpressionImagePluginSetup { + expressions.registerFunction(imageFunction); + expressions.registerRenderer(imageRenderer); + } + + public start(core: CoreStart): ExpressionImagePluginStart {} + + public stop() {} +} diff --git a/src/plugins/expression_image/server/index.ts b/src/plugins/expression_image/server/index.ts new file mode 100755 index 0000000000000..a4c6ee888d086 --- /dev/null +++ b/src/plugins/expression_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 { ExpressionImagePlugin } from './plugin'; + +export type { ExpressionImagePluginSetup, ExpressionImagePluginStart } from './plugin'; + +export function plugin() { + return new ExpressionImagePlugin(); +} diff --git a/src/plugins/expression_image/server/plugin.ts b/src/plugins/expression_image/server/plugin.ts new file mode 100755 index 0000000000000..d3259d45107e5 --- /dev/null +++ b/src/plugins/expression_image/server/plugin.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 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 { imageFunction } from '../common/expression_functions'; + +interface SetupDeps { + expressions: ExpressionsServerSetup; +} + +interface StartDeps { + expression: ExpressionsServerStart; +} + +export type ExpressionImagePluginSetup = void; +export type ExpressionImagePluginStart = void; + +export class ExpressionImagePlugin + implements Plugin { + public setup(core: CoreSetup, { expressions }: SetupDeps): ExpressionImagePluginSetup { + expressions.registerFunction(imageFunction); + } + + public start(core: CoreStart): ExpressionImagePluginStart {} + + public stop() {} +} diff --git a/src/plugins/expression_image/tsconfig.json b/src/plugins/expression_image/tsconfig.json new file mode 100644 index 0000000000000..5fab51496c97e --- /dev/null +++ b/src/plugins/expression_image/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "isolatedModules": true + }, + "include": [ + "common/**/*", + "public/**/*", + "server/**/*", + "__fixtures__/**/*", + ], + "references": [ + { "path": "../../core/tsconfig.json" }, + { "path": "../presentation_util/tsconfig.json" }, + { "path": "../expressions/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.ts b/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.ts deleted file mode 100644 index e661a15cea3ae..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/functions/common/image.ts +++ /dev/null @@ -1,75 +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 { getFunctionHelp, getFunctionErrors } from '../../../i18n'; - -import { - getElasticLogo, - resolveWithMissingImage, -} from '../../../../../../src/plugins/presentation_util/common/lib'; - -export enum ImageMode { - CONTAIN = 'contain', - COVER = 'cover', - STRETCH = 'stretch', -} - -interface Arguments { - dataurl: string | null; - mode: ImageMode | null; -} - -export interface Return { - type: 'image'; - mode: string; - dataurl: string; -} - -export function image(): ExpressionFunctionDefinition<'image', null, Arguments, Promise> { - const { help, args: argHelp } = getFunctionHelp().image; - const errors = getFunctionErrors().image; - return { - name: 'image', - aliases: [], - type: 'image', - inputTypes: ['null'], - help, - args: { - dataurl: { - // This was accepting dataurl, but there was no facility in fn for checking type and handling a dataurl type. - types: ['string', 'null'], - help: argHelp.dataurl, - aliases: ['_', 'url'], - default: null, - }, - mode: { - types: ['string'], - help: argHelp.mode, - default: 'contain', - options: Object.values(ImageMode), - }, - }, - fn: async (input, { dataurl, mode }) => { - if (!mode || !Object.values(ImageMode).includes(mode)) { - throw errors.invalidImageMode(); - } - const { elasticLogo } = await getElasticLogo(); - - if (dataurl === null) { - dataurl = elasticLogo; - } - - const modeStyle = mode === 'stretch' ? '100% 100%' : mode; - return { - type: 'image', - mode: modeStyle, - dataurl: resolveWithMissingImage(dataurl, elasticLogo) as string, - }; - }, - }; -} 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 6ab7abac985cc..3de3275ebb231 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 @@ -29,7 +29,6 @@ import { gt } from './gt'; import { gte } from './gte'; import { head } from './head'; import { ifFn } from './if'; -import { image } from './image'; import { joinRows } from './join_rows'; import { lt } from './lt'; import { lte } from './lte'; @@ -79,7 +78,6 @@ export const functions = [ gte, head, ifFn, - image, lt, lte, joinRows, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/image.stories.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/image.stories.tsx deleted file mode 100644 index a8ac14768bfa5..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/image.stories.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { storiesOf } from '@storybook/react'; -import { image } from '../image'; -import { getElasticLogo } from '../../../../../../src/plugins/presentation_util/common/lib'; -import { waitFor } from '../../../../../../src/plugins/presentation_util/public/__stories__'; -import { Render } from './render'; - -const Renderer = ({ elasticLogo }: { elasticLogo: string }) => { - const config = { - type: 'image' as 'image', - mode: 'cover', - dataurl: elasticLogo, - }; - - return ; -}; - -storiesOf('renderers/image', module).add( - 'default', - (_, props) => , - { decorators: [waitFor(getElasticLogo())] } -); 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 8eabae4c661d2..80ca5e68860b4 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/core.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/core.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { image } from './image'; import { markdown } from './markdown'; import { metric } from './metric'; import { pie } from './pie'; @@ -14,6 +13,6 @@ import { progress } from './progress'; import { text } from './text'; import { table } from './table'; -export const renderFunctions = [image, markdown, metric, pie, plot, progress, table, text]; +export const renderFunctions = [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 0c824fb3dd25e..eab3b88b0fe26 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/external.ts +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/external.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { imageRenderer } from '../../../../../src/plugins/expression_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'; @@ -14,6 +15,7 @@ export const renderFunctions = [ revealImageRenderer, debugRenderer, errorRenderer, + imageRenderer, shapeRenderer, repeatImageRenderer, ]; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/image.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/image.tsx deleted file mode 100644 index 78e3ecb7a4c95..0000000000000 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/image.tsx +++ /dev/null @@ -1,41 +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 ReactDOM from 'react-dom'; -import React from 'react'; -import { - getElasticLogo, - isValidUrl, -} from '../../../../../src/plugins/presentation_util/common/lib'; -import { Return as Arguments } from '../functions/common/image'; -import { RendererStrings } from '../../i18n'; -import { RendererFactory } from '../../types'; - -const { image: strings } = RendererStrings; - -export const image: RendererFactory = () => ({ - name: 'image', - displayName: strings.getDisplayName(), - help: strings.getHelpDescription(), - reuseDomNode: true, - render: async (domNode, config, handlers) => { - const { elasticLogo } = await getElasticLogo(); - const dataurl = isValidUrl(config.dataurl) ? config.dataurl : elasticLogo; - - const style = { - height: '100%', - backgroundImage: `url(${dataurl})`, - backgroundRepeat: 'no-repeat', - backgroundPosition: 'center center', - backgroundSize: config.mode, - }; - - ReactDOM.render(
, domNode, () => handlers.done()); - - handlers.onDestroy(() => ReactDOM.unmountComponentAtNode(domNode)); - }, -}); diff --git a/x-pack/plugins/canvas/i18n/functions/dict/image.ts b/x-pack/plugins/canvas/i18n/functions/dict/image.ts deleted file mode 100644 index b619d550f9efd..0000000000000 --- a/x-pack/plugins/canvas/i18n/functions/dict/image.ts +++ /dev/null @@ -1,64 +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 { image } from '../../../canvas_plugin_src/functions/common/image'; -import { FunctionHelp } from '../function_help'; -import { FunctionFactory } from '../../../types'; -import { - URL, - BASE64, - IMAGE_MODE_CONTAIN, - IMAGE_MODE_COVER, - IMAGE_MODE_STRETCH, -} from '../../constants'; - -export const help: FunctionHelp> = { - help: i18n.translate('xpack.canvas.functions.imageHelpText', { - defaultMessage: - 'Displays an image. Provide an image asset as a {BASE64} data {URL}, or pass in a sub-expression.', - values: { - BASE64, - URL, - }, - }), - args: { - dataurl: i18n.translate('xpack.canvas.functions.image.args.dataurlHelpText', { - defaultMessage: 'The {https} {URL} or {BASE64} data {URL} of an image.', - values: { - BASE64, - https: 'HTTP(S)', - URL, - }, - }), - mode: i18n.translate('xpack.canvas.functions.image.args.modeHelpText', { - defaultMessage: - '{contain} shows the entire image, scaled to fit. ' + - '{cover} fills the container with the image, cropping from the sides or bottom as needed. ' + - '{stretch} resizes the height and width of the image to 100% of the container.', - values: { - contain: `\`"${IMAGE_MODE_CONTAIN}"\``, - cover: `\`"${IMAGE_MODE_COVER}"\``, - stretch: `\`"${IMAGE_MODE_STRETCH}"\``, - }, - }), - }, -}; - -export const errors = { - invalidImageMode: () => - new Error( - i18n.translate('xpack.canvas.functions.image.invalidImageModeErrorMessage', { - defaultMessage: '"mode" must be "{contain}", "{cover}", or "{stretch}"', - values: { - contain: IMAGE_MODE_CONTAIN, - cover: IMAGE_MODE_COVER, - stretch: IMAGE_MODE_STRETCH, - }, - }) - ), -}; diff --git a/x-pack/plugins/canvas/i18n/functions/function_errors.ts b/x-pack/plugins/canvas/i18n/functions/function_errors.ts index a01cb09a38347..1e515ece63569 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_errors.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_errors.ts @@ -14,7 +14,6 @@ import { errors as csv } from './dict/csv'; import { errors as date } from './dict/date'; import { errors as demodata } from './dict/demodata'; import { errors as getCell } from './dict/get_cell'; -import { errors as image } from './dict/image'; import { errors as joinRows } from './dict/join_rows'; import { errors as ply } from './dict/ply'; import { errors as pointseries } from './dict/pointseries'; @@ -32,7 +31,6 @@ export const getFunctionErrors = () => ({ date, demodata, getCell, - image, joinRows, ply, pointseries, diff --git a/x-pack/plugins/canvas/i18n/functions/function_help.ts b/x-pack/plugins/canvas/i18n/functions/function_help.ts index 0ca2c01718b49..bb5681264efc9 100644 --- a/x-pack/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/plugins/canvas/i18n/functions/function_help.ts @@ -40,7 +40,6 @@ import { help as gt } from './dict/gt'; import { help as gte } from './dict/gte'; import { help as head } from './dict/head'; import { help as ifFn } from './dict/if'; -import { help as image } from './dict/image'; import { help as joinRows } from './dict/join_rows'; import { help as location } from './dict/location'; import { help as lt } from './dict/lt'; @@ -199,7 +198,6 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ head, if: ifFn, joinRows, - image, location, lt, lte, diff --git a/x-pack/plugins/canvas/i18n/renderers.ts b/x-pack/plugins/canvas/i18n/renderers.ts index 80f1a5aecc89e..faa43fe03817e 100644 --- a/x-pack/plugins/canvas/i18n/renderers.ts +++ b/x-pack/plugins/canvas/i18n/renderers.ts @@ -55,16 +55,6 @@ export const RendererStrings = { defaultMessage: 'Renders an embeddable Saved Object from other parts of Kibana', }), }, - image: { - getDisplayName: () => - i18n.translate('xpack.canvas.renderer.image.displayName', { - defaultMessage: 'Image', - }), - getHelpDescription: () => - i18n.translate('xpack.canvas.renderer.image.helpDescription', { - defaultMessage: 'Render an image', - }), - }, markdown: { getDisplayName: () => i18n.translate('xpack.canvas.renderer.markdown.displayName', { diff --git a/x-pack/plugins/canvas/kibana.json b/x-pack/plugins/canvas/kibana.json index 1692d90884a62..acede1c9c2aa8 100644 --- a/x-pack/plugins/canvas/kibana.json +++ b/x-pack/plugins/canvas/kibana.json @@ -11,6 +11,7 @@ "data", "embeddable", "expressionError", + "expressionImage", "expressionRepeatImage", "expressionRevealImage", "expressionShape", diff --git a/x-pack/plugins/canvas/shareable_runtime/components/__stories__/rendered_element.stories.tsx b/x-pack/plugins/canvas/shareable_runtime/components/__stories__/rendered_element.stories.tsx index db74dd7514ee9..ee609f42f1cf9 100644 --- a/x-pack/plugins/canvas/shareable_runtime/components/__stories__/rendered_element.stories.tsx +++ b/x-pack/plugins/canvas/shareable_runtime/components/__stories__/rendered_element.stories.tsx @@ -9,7 +9,7 @@ import { storiesOf } from '@storybook/react'; import React from 'react'; import { ExampleContext } from '../../test/context_example'; -import { image } from '../../../canvas_plugin_src/renderers/image'; +import { imageFunction } from '../../../../../../src/plugins/expression_image/__fixtures__'; import { sharedWorkpads } from '../../test'; import { RenderedElement, RenderedElementComponent } from '../rendered_element'; @@ -30,7 +30,7 @@ storiesOf('shareables/RenderedElement', module) ; } /** @@ -64,7 +65,7 @@ export class RenderedElementComponent extends PureComponent { try { fn.render(this.ref.current, value.value, createHandlers()); - } catch (e) { + } catch (e: any) { // eslint-disable-next-line no-console console.log(as, e.message); } diff --git a/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js b/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js index d5f0a2196814e..9b86ebddbd9b9 100644 --- a/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js +++ b/x-pack/plugins/canvas/shareable_runtime/supported_renderers.js @@ -5,7 +5,6 @@ * 2.0. */ -import { image } from '../canvas_plugin_src/renderers/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'; @@ -13,6 +12,7 @@ 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 { imageRenderer as image } from '../../../../src/plugins/expression_image/public'; import { errorRenderer as error, debugRenderer as debug, diff --git a/x-pack/plugins/canvas/tsconfig.json b/x-pack/plugins/canvas/tsconfig.json index 6181df5abe464..6d57aee565c89 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_image/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" }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7e26464ad1955..81b6f83654647 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6703,10 +6703,10 @@ "xpack.canvas.functions.if.args.elseHelpText": "条件が {BOOLEAN_FALSE} の場合の戻り値です。指定されておらず、条件が満たされていない場合は、元の {CONTEXT} が戻されます。", "xpack.canvas.functions.if.args.thenHelpText": "条件が {BOOLEAN_TRUE} の場合の戻り値です。指定されておらず、条件が満たされている場合は、元の {CONTEXT} が戻されます。", "xpack.canvas.functions.ifHelpText": "条件付きロジックを実行します。", - "xpack.canvas.functions.image.args.dataurlHelpText": "画像の {https} {URL} または {BASE64} データ {URL} です。", - "xpack.canvas.functions.image.args.modeHelpText": "{contain} はサイズに合わせて拡大・縮小して画像全体を表示し、{cover} はコンテナーを画像で埋め、必要に応じて両端や下をクロップします。{stretch} は画像の高さと幅をコンテナーの 100% になるよう変更します。", - "xpack.canvas.functions.image.invalidImageModeErrorMessage": "「mode」は「{contain}」、「{cover}」、または「{stretch}」でなければなりません", - "xpack.canvas.functions.imageHelpText": "画像を表示します。画像アセットは{BASE64}データ{URL}として提供するか、部分式で渡します。", + "expressionImage.functions.image.args.dataurlHelpText": "画像の {https} {URL} または {BASE64} データ {URL} です。", + "expressionImage.functions.image.args.modeHelpText": "{contain} はサイズに合わせて拡大・縮小して画像全体を表示し、{cover} はコンテナーを画像で埋め、必要に応じて両端や下をクロップします。{stretch} は画像の高さと幅をコンテナーの 100% になるよう変更します。", + "expressionImage.functions.image.invalidImageModeErrorMessage": "「mode」は「{contain}」、「{cover}」、または「{stretch}」でなければなりません", + "expressionImage.functions.imageHelpText": "画像を表示します。画像アセットは{BASE64}データ{URL}として提供するか、部分式で渡します。", "xpack.canvas.functions.joinRows.args.columnHelpText": "値を抽出する列またはフィールド。", "xpack.canvas.functions.joinRows.args.distinctHelpText": "一意の値のみを抽出しますか?", "xpack.canvas.functions.joinRows.args.quoteHelpText": "各抽出された値を囲む引用符文字。", @@ -6959,8 +6959,8 @@ "xpack.canvas.renderer.dropdownFilter.matchAllOptionLabel": "すべて", "xpack.canvas.renderer.embeddable.displayName": "埋め込み可能", "xpack.canvas.renderer.embeddable.helpDescription": "Kibana の他の部分から埋め込み可能な保存済みオブジェクトをレンダリングします", - "xpack.canvas.renderer.image.displayName": "画像", - "xpack.canvas.renderer.image.helpDescription": "画像をレンダリングします", + "expressionImage.renderer.image.displayName": "画像", + "expressionImage.renderer.image.helpDescription": "画像をレンダリングします", "xpack.canvas.renderer.markdown.displayName": "マークダウン", "xpack.canvas.renderer.markdown.helpDescription": "{MARKDOWN} インプットを使用して {HTML} を表示", "xpack.canvas.renderer.metric.displayName": "メトリック", @@ -6973,6 +6973,8 @@ "xpack.canvas.renderer.progress.helpDescription": "エレメントのパーセンテージを示す進捗インジケーターをレンダリングします", "expressionRepeatImage.renderer.repeatImage.displayName": "画像の繰り返し", "expressionRepeatImage.renderer.repeatImage.helpDescription": "画像を指定回数繰り返し表示します", + "expressionShape.renderer.shape.displayName": "形状", + "expressionShape.renderer.shape.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 b1c6bdea9bfef..81c28d517c4f0 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6744,10 +6744,10 @@ "xpack.canvas.functions.if.args.elseHelpText": "条件为 {BOOLEAN_FALSE} 时的返回值。未指定且条件未满足时,将返回原始 {CONTEXT}。", "xpack.canvas.functions.if.args.thenHelpText": "条件为 {BOOLEAN_TRUE} 时的返回值。未指定且条件满足时,将返回原始 {CONTEXT}。", "xpack.canvas.functions.ifHelpText": "执行条件逻辑。", - "xpack.canvas.functions.image.args.dataurlHelpText": "图像的 {https} {URL} 或 {BASE64} 数据 {URL}。", - "xpack.canvas.functions.image.args.modeHelpText": "{contain} 将显示整个图像,图像缩放至适合大小。{cover} 将使用该图像填充容器,根据需要在两边或底部裁剪图像。{stretch} 将图像的高和宽调整为容器的 100%。", - "xpack.canvas.functions.image.invalidImageModeErrorMessage": "“mode”必须为“{contain}”、“{cover}”或“{stretch}”", - "xpack.canvas.functions.imageHelpText": "显示图像。以 {BASE64} 数据 {URL} 的形式提供图像资产或传入子表达式。", + "expressionImage.functions.image.args.dataurlHelpText": "图像的 {https} {URL} 或 {BASE64} 数据 {URL}。", + "expressionImage.functions.image.args.modeHelpText": "{contain} 将显示整个图像,图像缩放至适合大小。{cover} 将使用该图像填充容器,根据需要在两边或底部裁剪图像。{stretch} 将图像的高和宽调整为容器的 100%。", + "expressionImage.functions.image.invalidImageModeErrorMessage": "“mode”必须为“{contain}”、“{cover}”或“{stretch}”", + "expressionImage.functions.imageHelpText": "显示图像。以 {BASE64} 数据 {URL} 的形式提供图像资产或传入子表达式。", "xpack.canvas.functions.joinRows.args.columnHelpText": "从其中提取值的列或字段。", "xpack.canvas.functions.joinRows.args.distinctHelpText": "仅提取唯一值?", "xpack.canvas.functions.joinRows.args.quoteHelpText": "要将每个提取的值引起来的引号字符。", @@ -7000,8 +7000,8 @@ "xpack.canvas.renderer.dropdownFilter.matchAllOptionLabel": "任意", "xpack.canvas.renderer.embeddable.displayName": "可嵌入", "xpack.canvas.renderer.embeddable.helpDescription": "从 Kibana 的其他部分呈现可嵌入的已保存对象", - "xpack.canvas.renderer.image.displayName": "图像", - "xpack.canvas.renderer.image.helpDescription": "呈现图像", + "expressionImage.renderer.image.displayName": "图像", + "expressionImage.renderer.image.helpDescription": "呈现图像", "xpack.canvas.renderer.markdown.displayName": "Markdown", "xpack.canvas.renderer.markdown.helpDescription": "使用 {MARKDOWN} 输入呈现 {HTML}", "xpack.canvas.renderer.metric.displayName": "指标", @@ -7016,6 +7016,8 @@ "expressionRepeatImage.renderer.repeatImage.helpDescription": "重复图像给定次数", "expressionRevealImage.renderer.revealImage.displayName": "图像显示", "expressionRevealImage.renderer.revealImage.helpDescription": "显示一定百分比的图像,以制作定制的仪表样式图表", + "expressionShape.renderer.shape.displayName": "形状", + "expressionShape.renderer.shape.helpDescription": "呈现基本形状", "xpack.canvas.renderer.table.displayName": "数据表", "xpack.canvas.renderer.table.helpDescription": "将表格数据呈现为 {HTML}", "xpack.canvas.renderer.text.displayName": "纯文本", From 7c064ec31e1dfb922c14fc2613614dc417695d03 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Mon, 26 Jul 2021 12:24:34 +0300 Subject: [PATCH 43/45] [Vega, TSVB, Timeline] fix send data request twice when opening visualizations. (#106398) Closes: #106398 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../visualizations/public/embeddable/visualize_embeddable.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index c82036449d173..9a0adf265e5a2 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -136,6 +136,9 @@ export class VisualizeEmbeddable this.deps = deps; this.timefilter = timefilter; this.syncColors = this.input.syncColors; + this.searchSessionId = this.input.searchSessionId; + this.query = this.input.query; + this.vis = vis; this.vis.uiState.on('change', this.uiStateChangeHandler); this.vis.uiState.on('reload', this.reload); @@ -149,7 +152,7 @@ export class VisualizeEmbeddable } this.subscriptions.push( - this.getUpdated$().subscribe((value) => { + this.getInput$().subscribe(() => { const isDirty = this.handleChanges(); if (isDirty && this.handler) { From 91aa2bb8ec6e980bf40e31bc99fbecd93521a79a Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Mon, 26 Jul 2021 11:32:00 +0200 Subject: [PATCH 44/45] [Lens] Truncate field dropdown in the middle (#106285) * [Lens] Truncate field dropdown in the middle * implementation * aligning width of the elements, calculating width in canvas, serving edgecases like selected element, tests update * revert selectedField as it doesn't solve all cases * code review * cr Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../dimension_panel/field_select.tsx | 140 +++++++++++------- .../dimension_panel/truncated_label.test.tsx | 78 ++++++++++ .../dimension_panel/truncated_label.tsx | 86 +++++++++++ 3 files changed, 251 insertions(+), 53 deletions(-) create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/truncated_label.test.tsx create mode 100644 x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/truncated_label.tsx diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx index db0a42047a1b8..54eb3d48efccf 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/field_select.tsx @@ -7,8 +7,9 @@ import './field_select.scss'; import { partition } from 'lodash'; -import React, { useMemo } from 'react'; +import React, { useMemo, useRef } from 'react'; import { i18n } from '@kbn/i18n'; +import useEffectOnce from 'react-use/lib/useEffectOnce'; import { EuiComboBox, EuiFlexGroup, @@ -17,7 +18,6 @@ import { EuiComboBoxProps, } from '@elastic/eui'; import classNames from 'classnames'; -import { EuiHighlight } from '@elastic/eui'; import { OperationType } from '../indexpattern'; import { LensFieldIcon } from '../lens_field_icon'; import { DataType } from '../../types'; @@ -25,7 +25,7 @@ import { OperationSupportMatrix } from './operation_support'; import { IndexPattern, IndexPatternPrivateState } from '../types'; import { trackUiEvent } from '../../lens_ui_telemetry'; import { fieldExists } from '../pure_helpers'; - +import { TruncatedLabel } from './truncated_label'; export interface FieldChoice { type: 'field'; field: string; @@ -45,6 +45,10 @@ export interface FieldSelectProps extends EuiComboBoxProps(null); + const [labelProps, setLabelProps] = React.useState<{ + width: number; + font: string; + }>({ + width: DEFAULT_COMBOBOX_WIDTH - COMBOBOX_PADDINGS, + font: DEFAULT_FONT, + }); - return ( - { + if (comboBoxRef.current) { + const current = { + ...labelProps, + width: comboBoxRef.current?.clientWidth - COMBOBOX_PADDINGS, + }; + if (shouldRecomputeAll) { + current.font = window.getComputedStyle(comboBoxRef.current).font; } - singleSelection={{ asPlainText: true }} - onChange={(choices) => { - if (choices.length === 0) { - onDeleteColumn?.(); - return; - } + setLabelProps(current); + } + }; - const choice = (choices[0].value as unknown) as FieldChoice; + useEffectOnce(() => { + if (comboBoxRef.current) { + computeStyles(undefined, true); + } + window.addEventListener('resize', computeStyles); + }); - if (choice.field !== selectedField) { - trackUiEvent('indexpattern_dimension_field_changed'); - onChoose(choice); + return ( +
+ { - return ( - - - - - - {option.label} - - - ); - }} - {...rest} - /> + singleSelection={{ asPlainText: true }} + onChange={(choices) => { + if (choices.length === 0) { + onDeleteColumn?.(); + return; + } + + const choice = (choices[0].value as unknown) as FieldChoice; + + if (choice.field !== selectedField) { + trackUiEvent('indexpattern_dimension_field_changed'); + onChoose(choice); + } + }} + renderOption={(option, searchValue) => { + return ( + + + + + + + + + ); + }} + {...rest} + /> +
); } diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/truncated_label.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/truncated_label.test.tsx new file mode 100644 index 0000000000000..b558afddd689e --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/truncated_label.test.tsx @@ -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 React from 'react'; +import { mount } from 'enzyme'; +import 'jest-canvas-mock'; +import { TruncatedLabel } from './truncated_label'; + +describe('truncated_label', () => { + const defaultProps = { + font: '14px Inter', + // jest-canvas-mock mocks measureText as the number of string characters, thats why the width is so low + width: 30, + search: '', + label: 'example_field', + }; + it('displays passed label if shorter than passed labelLength', () => { + const wrapper = mount(); + expect(wrapper.text()).toEqual('example_field'); + }); + it('middle truncates label', () => { + const wrapper = mount( + + ); + expect(wrapper.text()).toEqual('example_….subcategory.subfield'); + }); + describe('with search value passed', () => { + it('constructs truncated label when searching for the string of index = 0', () => { + const wrapper = mount( + + ); + expect(wrapper.text()).toEqual('example_space.example_field.s…'); + expect(wrapper.find('mark').text()).toEqual('example_space'); + }); + it('constructs truncated label when searching for the string in the middle', () => { + const wrapper = mount( + + ); + expect(wrapper.text()).toEqual('…ample_field.subcategory.subf…'); + expect(wrapper.find('mark').text()).toEqual('ample_field'); + }); + it('constructs truncated label when searching for the string at the end of the label', () => { + const wrapper = mount( + + ); + expect(wrapper.text()).toEqual('…le_field.subcategory.subfield'); + expect(wrapper.find('mark').text()).toEqual('subf'); + }); + + it('constructs truncated label when searching for the string longer than the truncated width and highlights the whole content', () => { + const wrapper = mount( + + ); + expect(wrapper.text()).toEqual('…ample_space.example_field.su…'); + expect(wrapper.find('mark').text()).toEqual('…ample_space.example_field.su…'); + }); + }); +}); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/truncated_label.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/truncated_label.tsx new file mode 100644 index 0000000000000..47b1313a74c4e --- /dev/null +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/truncated_label.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo } from 'react'; +import { EuiMark } from '@elastic/eui'; +import { EuiHighlight } from '@elastic/eui'; + +const createContext = () => + document.createElement('canvas').getContext('2d') as CanvasRenderingContext2D; + +// extracted from getTextWidth for performance +const context = createContext(); + +const getTextWidth = (text: string, font: string) => { + const ctx = context ?? createContext(); + ctx.font = font; + const metrics = ctx.measureText(text); + return metrics.width; +}; + +const truncateLabel = ( + width: number, + font: string, + label: string, + approximateLength: number, + labelFn: (label: string, length: number) => string +) => { + let output = labelFn(label, approximateLength); + while (getTextWidth(output, font) > width) { + approximateLength = approximateLength - 1; + output = labelFn(label, approximateLength); + } + return output; +}; + +export const TruncatedLabel = React.memo(function TruncatedLabel({ + label, + width, + search, + font, +}: { + label: string; + search: string; + width: number; + font: string; +}) { + const textWidth = useMemo(() => getTextWidth(label, font), [label, font]); + + if (textWidth < width) { + return {label}; + } + + const searchPosition = label.indexOf(search); + const approximateLen = Math.round((width * label.length) / textWidth); + const separator = `…`; + let separatorsLength = separator.length; + let labelFn; + + if (!search || searchPosition === -1) { + labelFn = (text: string, length: number) => + `${text.substr(0, 8)}${separator}${text.substr(text.length - (length - 8))}`; + } else if (searchPosition === 0) { + // search phrase at the beginning + labelFn = (text: string, length: number) => `${text.substr(0, length)}${separator}`; + } else if (approximateLen > label.length - searchPosition) { + // search phrase close to the end or at the end + labelFn = (text: string, length: number) => `${separator}${text.substr(text.length - length)}`; + } else { + // search phrase is in the middle + labelFn = (text: string, length: number) => + `${separator}${text.substr(searchPosition, length)}${separator}`; + separatorsLength = 2 * separator.length; + } + + const outputLabel = truncateLabel(width, font, label, approximateLen, labelFn); + + return search.length < outputLabel.length - separatorsLength ? ( + {outputLabel} + ) : ( + {outputLabel} + ); +}); From 80876620f94d4e17ce69e6ac1f578cc484b8c89b Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Mon, 26 Jul 2021 12:32:24 +0300 Subject: [PATCH 45/45] [TSVB] fix Default index pattern is requested while it shouldnt (#106119) * [TSVB] fix Default index pattern is requested while it shouldnt * update index_patterns_utils.ts * fix wrong behaviour on no index found Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/index_patterns_utils.test.ts | 2 +- .../common/index_patterns_utils.ts | 32 ++++--- .../application/components/annotation_row.tsx | 27 +++--- .../application/components/index_pattern.js | 44 +++++----- .../lib/index_pattern_select/index.ts | 2 +- .../index_pattern_select.tsx | 25 +++--- .../lib/index_pattern_select/types.ts | 5 +- .../components/query_bar_wrapper.tsx | 12 +-- .../application/components/vis_editor.tsx | 84 +++++++++---------- .../contexts/default_index_context.ts | 12 --- .../public/application/lib/fetch_fields.ts | 19 ++--- 11 files changed, 122 insertions(+), 142 deletions(-) delete mode 100644 src/plugins/vis_type_timeseries/public/application/contexts/default_index_context.ts diff --git a/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts b/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts index a601da234e078..f8dd206f8f4d5 100644 --- a/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts +++ b/src/plugins/vis_type_timeseries/common/index_patterns_utils.test.ts @@ -44,7 +44,7 @@ describe('extractIndexPatterns', () => { }); test('should return index patterns', () => { - expect(extractIndexPatternValues(panel, null)).toEqual([ + expect(extractIndexPatternValues(panel, undefined)).toEqual([ '*', 'example-1-*', 'example-2-*', diff --git a/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts b/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts index 1224fd33daee3..1a8c277efbf7c 100644 --- a/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts +++ b/src/plugins/vis_type_timeseries/common/index_patterns_utils.ts @@ -8,7 +8,7 @@ import { uniq } from 'lodash'; import type { Panel, IndexPatternValue, FetchedIndexPattern } from '../common/types'; -import { IIndexPattern, IndexPatternsService } from '../../data/common'; +import { IndexPatternsService } from '../../data/common'; export const isStringTypeIndexPattern = ( indexPatternValue: IndexPatternValue @@ -17,31 +17,27 @@ export const isStringTypeIndexPattern = ( export const getIndexPatternKey = (indexPatternValue: IndexPatternValue) => isStringTypeIndexPattern(indexPatternValue) ? indexPatternValue : indexPatternValue?.id ?? ''; -export const extractIndexPatternValues = (panel: Panel, defaultIndex: IIndexPattern | null) => { +export const extractIndexPatternValues = (panel: Panel, defaultIndexId?: string) => { const patterns: IndexPatternValue[] = []; - if (panel.index_pattern) { - patterns.push(panel.index_pattern); - } + const addIndex = (value?: IndexPatternValue) => { + if (value) { + patterns.push(value); + } else if (defaultIndexId) { + patterns.push({ id: defaultIndexId }); + } + }; + + addIndex(panel.index_pattern); panel.series.forEach((series) => { - const indexPattern = series.series_index_pattern; - if (indexPattern && series.override_index_pattern) { - patterns.push(indexPattern); + if (series.override_index_pattern) { + addIndex(series.series_index_pattern); } }); if (panel.annotations) { - panel.annotations.forEach((item) => { - const indexPattern = item.index_pattern; - if (indexPattern) { - patterns.push(indexPattern); - } - }); - } - - if (patterns.length === 0 && defaultIndex?.id) { - patterns.push({ id: defaultIndex.id }); + panel.annotations.forEach((item) => addIndex(item.index_pattern)); } return uniq(patterns).sort(); diff --git a/src/plugins/vis_type_timeseries/public/application/components/annotation_row.tsx b/src/plugins/vis_type_timeseries/public/application/components/annotation_row.tsx index 715cf4d6709da..1dfb96b419d49 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/annotation_row.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/annotation_row.tsx @@ -26,7 +26,7 @@ import { KBN_FIELD_TYPES, Query } from '../../../../../plugins/data/public'; import { AddDeleteButtons } from './add_delete_buttons'; import { ColorPicker } from './color_picker'; import { FieldSelect } from './aggs/field_select'; -import { IndexPatternSelect } from './lib/index_pattern_select'; +import { IndexPatternSelect, IndexPatternSelectProps } from './lib/index_pattern_select'; import { QueryBarWrapper } from './query_bar_wrapper'; import { YesNo } from './yes_no'; import { fetchIndexPattern } from '../../../common/index_patterns_utils'; @@ -35,7 +35,7 @@ import { getDefaultQueryLanguage } from './lib/get_default_query_language'; // @ts-expect-error not typed yet import { IconSelect } from './icon_select/icon_select'; -import type { Annotation, FetchedIndexPattern, IndexPatternValue } from '../../../common/types'; +import type { Annotation, IndexPatternValue } from '../../../common/types'; import type { VisFields } from '../lib/fetch_fields'; const RESTRICT_FIELDS = [KBN_FIELD_TYPES.DATE]; @@ -68,20 +68,28 @@ export const AnnotationRow = ({ const model = useMemo(() => ({ ...getAnnotationDefaults(), ...annotation }), [annotation]); const htmlId = htmlIdGenerator(model.id); - const [fetchedIndex, setFetchedIndex] = useState(null); + const [fetchedIndex, setFetchedIndex] = useState(null); useEffect(() => { const updateFetchedIndex = async (index: IndexPatternValue) => { const { indexPatterns } = getDataStart(); + let fetchedIndexPattern: IndexPatternSelectProps['fetchedIndex'] = { + indexPattern: undefined, + indexPatternString: undefined, + }; - setFetchedIndex( - index + try { + fetchedIndexPattern = index ? await fetchIndexPattern(index, indexPatterns) : { - indexPattern: undefined, - indexPatternString: undefined, - } - ); + ...fetchedIndexPattern, + defaultIndex: await indexPatterns.getDefault(), + }; + } catch { + // nothing to be here + } + + setFetchedIndex(fetchedIndexPattern); }; updateFetchedIndex(model.index_pattern); @@ -124,7 +132,6 @@ export const AnnotationRow = ({ { @@ -144,17 +139,25 @@ export const IndexPattern = ({ useEffect(() => { async function fetchIndex() { const { indexPatterns } = getDataStart(); + let fetchedIndexPattern = { + indexPattern: undefined, + indexPatternString: undefined, + }; - setFetchedIndex( - index + try { + fetchedIndexPattern = index ? await fetchIndexPattern(index, indexPatterns, { fetchKibanaIndexForStringIndexes: true, }) : { - indexPattern: undefined, - indexPatternString: undefined, - } - ); + ...fetchedIndexPattern, + defaultIndex: await indexPatterns.getDefault(), + }; + } catch { + // nothing to be here + } + + setFetchedIndex(fetchedIndexPattern); } fetchIndex(); @@ -165,16 +168,6 @@ export const IndexPattern = ({ [model.hide_last_value_indicator, onChange] ); - const getTimefieldPlaceholder = () => { - if (!model[indexPatternName]) { - return defaultIndex?.timeFieldName; - } - - if (useKibanaIndices) { - return fetchedIndex?.indexPattern?.timeFieldName ?? undefined; - } - }; - return (
{!isTimeSeries && ( @@ -245,7 +238,6 @@ export const IndexPattern = ({ diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index.ts index 584f13e7a025b..4920677a04a2e 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index.ts +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index.ts @@ -6,4 +6,4 @@ * Side Public License, v 1. */ -export { IndexPatternSelect } from './index_pattern_select'; +export { IndexPatternSelect, IndexPatternSelectProps } from './index_pattern_select'; diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx index 07edfc2e6e0d7..927b3c608c16c 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/index_pattern_select.tsx @@ -14,22 +14,23 @@ import { EuiFormRow, EuiText, EuiLink, htmlIdGenerator } from '@elastic/eui'; import { getCoreStart } from '../../../../services'; import { PanelModelContext } from '../../../contexts/panel_model_context'; -import { isStringTypeIndexPattern } from '../../../../../common/index_patterns_utils'; - import { FieldTextSelect } from './field_text_select'; import { ComboBoxSelect } from './combo_box_select'; import type { IndexPatternValue, FetchedIndexPattern } from '../../../../../common/types'; -import { DefaultIndexPatternContext } from '../../../contexts/default_index_context'; import { USE_KIBANA_INDEXES_KEY } from '../../../../../common/constants'; +import { IndexPattern } from '../../../../../../data/common'; -interface IndexPatternSelectProps { - value: IndexPatternValue; +export interface IndexPatternSelectProps { indexPatternName: string; onChange: Function; disabled?: boolean; allowIndexSwitchingMode?: boolean; - fetchedIndex: FetchedIndexPattern | null; + fetchedIndex: + | (FetchedIndexPattern & { + defaultIndex?: IndexPattern | null; + }) + | null; } const defaultIndexPatternHelpText = i18n.translate( @@ -51,7 +52,6 @@ const indexPatternLabel = i18n.translate('visTypeTimeseries.indexPatternSelect.l }); export const IndexPatternSelect = ({ - value, indexPatternName, onChange, disabled, @@ -60,7 +60,6 @@ export const IndexPatternSelect = ({ }: IndexPatternSelectProps) => { const htmlId = htmlIdGenerator(); const panelModel = useContext(PanelModelContext); - const defaultIndex = useContext(DefaultIndexPatternContext); const useKibanaIndices = Boolean(panelModel?.[USE_KIBANA_INDEXES_KEY]); const Component = useKibanaIndices ? ComboBoxSelect : FieldTextSelect; @@ -105,13 +104,11 @@ export const IndexPatternSelect = ({ id={htmlId('indexPattern')} label={indexPatternLabel} helpText={ - !value && defaultIndexPatternHelpText + (!useKibanaIndices ? queryAllIndexesHelpText : '') + fetchedIndex.defaultIndex && + defaultIndexPatternHelpText + (!useKibanaIndices ? queryAllIndexesHelpText : '') } labelAppend={ - value && - allowIndexSwitchingMode && - isStringTypeIndexPattern(value) && - !fetchedIndex.indexPattern ? ( + fetchedIndex.indexPatternString && !fetchedIndex.indexPattern ? ( diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/types.ts b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/types.ts index 93b15402e3c24..18288f75d4c90 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/types.ts +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/index_pattern_select/types.ts @@ -7,10 +7,13 @@ */ import type { Assign } from '@kbn/utility-types'; import type { FetchedIndexPattern, IndexPatternValue } from '../../../../../common/types'; +import type { IndexPattern } from '../../../../../../data/common'; /** @internal **/ export interface SelectIndexComponentProps { - fetchedIndex: FetchedIndexPattern; + fetchedIndex: FetchedIndexPattern & { + defaultIndex?: IndexPattern | null; + }; onIndexChange: (value: IndexPatternValue) => void; onModeChange: (useKibanaIndexes: boolean, index?: FetchedIndexPattern) => void; 'data-test-subj': string; diff --git a/src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.tsx b/src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.tsx index 215640c30f3c4..d3b249f54fe34 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/query_bar_wrapper.tsx @@ -9,7 +9,6 @@ import React, { useContext, useEffect, useState } from 'react'; import { CoreStartContext } from '../contexts/query_input_bar_context'; -import { DefaultIndexPatternContext } from '../contexts/default_index_context'; import type { IndexPatternValue } from '../../../common/types'; import { QueryStringInput, QueryStringInputProps } from '../../../../../plugins/data/public'; @@ -31,7 +30,6 @@ export function QueryBarWrapper({ const [indexes, setIndexes] = useState([]); const coreStartContext = useContext(CoreStartContext); - const defaultIndex = useContext(DefaultIndexPatternContext); useEffect(() => { async function fetchIndexes() { @@ -48,15 +46,19 @@ export function QueryBarWrapper({ i.push(indexPattern); } } - } else if (defaultIndex) { - i.push(defaultIndex); + } else { + const defaultIndex = await indexPatternsService.getDefault(); + + if (defaultIndex) { + i.push(defaultIndex); + } } } setIndexes(i); } fetchIndexes(); - }, [indexPatterns, indexPatternsService, defaultIndex]); + }, [indexPatterns, indexPatternsService]); return ( { this.setState({ @@ -180,53 +179,46 @@ export class VisEditor extends Component - -
- {!this.props.vis.params.use_kibana_indexes && } -
- -
- + {!this.props.vis.params.use_kibana_indexes && } +
+ +
+ +
+ -
- -
- +
); } - componentDidMount() { - const dataStart = getDataStart(); - - dataStart.indexPatterns.getDefault().then(async (index) => { - const indexPatterns = extractIndexPatternValues(this.props.vis.params, index); - const visFields = await fetchFields(indexPatterns); + async componentDidMount() { + const indexPatterns = extractIndexPatternValues(this.props.vis.params, this.getDefaultIndex()); + const visFields = await fetchFields(indexPatterns); - this.setState({ - defaultIndex: index, - visFields, - }); + this.setState({ + visFields, }); this.props.eventEmitter.on('updateEditor', this.updateModel); @@ -236,6 +228,10 @@ export class VisEditor extends Component('defaultIndex') ?? ''; + } } // default export required for React.Lazy diff --git a/src/plugins/vis_type_timeseries/public/application/contexts/default_index_context.ts b/src/plugins/vis_type_timeseries/public/application/contexts/default_index_context.ts deleted file mode 100644 index a8770d86fba9b..0000000000000 --- a/src/plugins/vis_type_timeseries/public/application/contexts/default_index_context.ts +++ /dev/null @@ -1,12 +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 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 { IIndexPattern } from '../../../../data/public'; - -export const DefaultIndexPatternContext = React.createContext(null); diff --git a/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts b/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts index 7b31ff3296425..71e38be302579 100644 --- a/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts +++ b/src/plugins/vis_type_timeseries/public/application/lib/fetch_fields.ts @@ -22,9 +22,9 @@ export async function fetchFields( const patterns = Array.isArray(indexes) ? indexes : [indexes]; const coreStart = getCoreStart(); const dataStart = getDataStart(); + const defaultIndex = coreStart.uiSettings.get('defaultIndex'); try { - const defaultIndexPattern = await dataStart.indexPatterns.getDefault(); const indexFields = await Promise.all( patterns.map(async (pattern) => { if (typeof pattern !== 'string' && pattern?.id) { @@ -42,17 +42,14 @@ export async function fetchFields( }) ); - const fields: VisFields = patterns.reduce( - (cumulatedFields, currentPattern, index) => ({ + const fields: VisFields = patterns.reduce((cumulatedFields, currentPattern, index) => { + const key = getIndexPatternKey(currentPattern); + return { ...cumulatedFields, - [getIndexPatternKey(currentPattern)]: indexFields[index], - }), - {} - ); - - if (defaultIndexPattern) { - fields[''] = toSanitizedFieldType(await defaultIndexPattern.getNonScriptedFields()); - } + [key]: indexFields[index], + ...(key === defaultIndex ? { '': indexFields[index] } : {}), + }; + }, {}); return fields; } catch (error) {