From a4d4e5b316ad8464f1d907010f4f82f0e4caf048 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Wed, 29 Sep 2021 13:01:26 -0400 Subject: [PATCH 01/21] Fix bulkResolve for aliasMatch outcomes (#113188) --- .../saved_objects_client.test.ts | 22 ++++++-- .../saved_objects/saved_objects_client.ts | 56 ++++++++++--------- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/src/core/public/saved_objects/saved_objects_client.test.ts b/src/core/public/saved_objects/saved_objects_client.test.ts index 0f37d10b3f32d..101f86b299ffc 100644 --- a/src/core/public/saved_objects/saved_objects_client.test.ts +++ b/src/core/public/saved_objects/saved_objects_client.test.ts @@ -148,13 +148,15 @@ describe('SavedObjectsClient', () => { }); describe('#resolve', () => { - beforeEach(() => { + function mockResolvedObjects(...objects: Array>) { http.fetch.mockResolvedValue({ - resolved_objects: [ - { saved_object: doc, outcome: 'conflict', alias_target_id: 'another-id' }, - ], + resolved_objects: objects.map((obj) => ({ + saved_object: obj, + outcome: 'conflict', + alias_target_id: 'another-id', + })), }); - }); + } test('rejects if `type` parameter is undefined', () => { return expect( @@ -176,6 +178,7 @@ describe('SavedObjectsClient', () => { }); test('makes HTTP call', async () => { + mockResolvedObjects(doc); await savedObjectsClient.resolve(doc.type, doc.id); expect(http.fetch.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -191,10 +194,12 @@ describe('SavedObjectsClient', () => { test('batches several #resolve calls into a single HTTP call', async () => { // Await #resolve call to ensure batchQueue is empty and throttle has reset + mockResolvedObjects({ ...doc, type: 'type2' }); await savedObjectsClient.resolve('type2', doc.id); http.fetch.mockClear(); // Make two #resolve calls right after one another + mockResolvedObjects({ ...doc, type: 'type1' }, { ...doc, type: 'type0' }); savedObjectsClient.resolve('type1', doc.id); await savedObjectsClient.resolve('type0', doc.id); expect(http.fetch.mock.calls).toMatchInlineSnapshot(` @@ -213,9 +218,11 @@ describe('SavedObjectsClient', () => { test('removes duplicates when calling `_bulk_resolve`', async () => { // Await #resolve call to ensure batchQueue is empty and throttle has reset + mockResolvedObjects({ ...doc, type: 'type2' }); await savedObjectsClient.resolve('type2', doc.id); http.fetch.mockClear(); + mockResolvedObjects(doc, { ...doc, type: 'some-type', id: 'some-id' }); // the client will only request two objects, so we only mock two results savedObjectsClient.resolve(doc.type, doc.id); savedObjectsClient.resolve('some-type', 'some-id'); await savedObjectsClient.resolve(doc.type, doc.id); @@ -235,9 +242,11 @@ describe('SavedObjectsClient', () => { test('resolves with correct object when there are duplicates present', async () => { // Await #resolve call to ensure batchQueue is empty and throttle has reset + mockResolvedObjects({ ...doc, type: 'type2' }); await savedObjectsClient.resolve('type2', doc.id); http.fetch.mockClear(); + mockResolvedObjects(doc); const call1 = savedObjectsClient.resolve(doc.type, doc.id); const objFromCall2 = await savedObjectsClient.resolve(doc.type, doc.id); const objFromCall1 = await call1; @@ -252,8 +261,10 @@ describe('SavedObjectsClient', () => { test('do not share instances or references between duplicate callers', async () => { // Await #resolve call to ensure batchQueue is empty and throttle has reset await savedObjectsClient.resolve('type2', doc.id); + mockResolvedObjects({ ...doc, type: 'type2' }); http.fetch.mockClear(); + mockResolvedObjects(doc); const call1 = savedObjectsClient.resolve(doc.type, doc.id); const objFromCall2 = await savedObjectsClient.resolve(doc.type, doc.id); const objFromCall1 = await call1; @@ -263,6 +274,7 @@ describe('SavedObjectsClient', () => { }); test('resolves with ResolvedSimpleSavedObject instance', async () => { + mockResolvedObjects(doc); const result = await savedObjectsClient.resolve(doc.type, doc.id); expect(result.saved_object).toBeInstanceOf(SimpleSavedObject); expect(result.saved_object.type).toBe(doc.type); diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 218ffb94dd5d4..0b0bc58729e3f 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -151,9 +151,7 @@ interface ObjectTypeAndId { type: string; } -const getObjectsToFetch = ( - queue: Array -): ObjectTypeAndId[] => { +const getObjectsToFetch = (queue: BatchGetQueueEntry[]): ObjectTypeAndId[] => { const objects: ObjectTypeAndId[] = []; const inserted = new Set(); queue.forEach(({ id, type }) => { @@ -165,6 +163,24 @@ const getObjectsToFetch = ( return objects; }; +const getObjectsToResolve = (queue: BatchResolveQueueEntry[]) => { + const responseIndices: number[] = []; + const objectsToResolve: ObjectTypeAndId[] = []; + const inserted = new Map(); + queue.forEach(({ id, type }, currentIndex) => { + const key = `${type}|${id}`; + const indexForTypeAndId = inserted.get(key); + if (indexForTypeAndId === undefined) { + inserted.set(key, currentIndex); + objectsToResolve.push({ id, type }); + responseIndices.push(currentIndex); + } else { + responseIndices.push(indexForTypeAndId); + } + }); + return { objectsToResolve, responseIndices }; +}; + /** * Saved Objects is Kibana's data persisentence mechanism allowing plugins to * use Elasticsearch for storing plugin state. The client-side @@ -224,28 +240,18 @@ export class SavedObjectsClient { this.batchResolveQueue = []; try { - const objectsToFetch = getObjectsToFetch(queue); - const { resolved_objects: savedObjects } = await this.performBulkResolve(objectsToFetch); - - queue.forEach((queueItem) => { - const foundObject = savedObjects.find((resolveResponse) => { - return ( - resolveResponse.saved_object.id === queueItem.id && - resolveResponse.saved_object.type === queueItem.type - ); - }); - - if (foundObject) { - // multiple calls may have been requested the same object. - // we need to clone to avoid sharing references between the instances - queueItem.resolve(this.createResolvedSavedObject(cloneDeep(foundObject))); - } else { - queueItem.resolve( - this.createResolvedSavedObject({ - saved_object: pick(queueItem, ['id', 'type']), - } as SavedObjectsResolveResponse) - ); - } + const { objectsToResolve, responseIndices } = getObjectsToResolve(queue); + const { resolved_objects: resolvedObjects } = await this.performBulkResolve( + objectsToResolve + ); + + queue.forEach((queueItem, i) => { + // This differs from the older processBatchGetQueue approach because the resolved object IDs are *not* guaranteed to be the same. + // Instead, we rely on the guarantee that the objects in the bulkResolve response will be in the same order as the requests. + // However, we still need to clone the response object because we deduplicate batched requests. + const responseIndex = responseIndices[i]; + const clone = cloneDeep(resolvedObjects[responseIndex]); + queueItem.resolve(this.createResolvedSavedObject(clone)); }); } catch (err) { queue.forEach((queueItem) => { From 521bd35668d6967fe6cedefa6213bb22229cdb62 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Wed, 29 Sep 2021 12:10:49 -0500 Subject: [PATCH 02/21] [fleet] Add two Agent Detail page stories to Storybook (#113066) --- .../epm/screens/detail/detail.stories.tsx | 35 + .../fleet/storybook/context/application.ts | 9 +- .../context/fixtures/integration.nginx.ts | 664 ++++++++++++++++++ .../context/fixtures/integration.okta.ts | 263 +++++++ .../context/fixtures/readme.nginx.ts | 515 ++++++++++++++ .../storybook/context/fixtures/readme.okta.ts | 343 +++++++++ .../plugins/fleet/storybook/context/http.ts | 24 + 7 files changed, 1852 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/detail.stories.tsx create mode 100644 x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts create mode 100644 x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts create mode 100644 x-pack/plugins/fleet/storybook/context/fixtures/readme.nginx.ts create mode 100644 x-pack/plugins/fleet/storybook/context/fixtures/readme.okta.ts diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/detail.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/detail.stories.tsx new file mode 100644 index 0000000000000..67369c2f24cb3 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/detail.stories.tsx @@ -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 React from 'react'; + +import { MemoryRouter, Route } from 'react-router-dom'; + +import { INTEGRATIONS_ROUTING_PATHS } from '../../../../constants'; + +import { Detail as Component } from '.'; + +export default { + component: Component, + title: 'Sections/EPM/Detail', +}; + +export const nginx = () => ( + + + + + +); + +export const okta = () => ( + + + + + +); diff --git a/x-pack/plugins/fleet/storybook/context/application.ts b/x-pack/plugins/fleet/storybook/context/application.ts index 9a35514e21c67..7503de562427f 100644 --- a/x-pack/plugins/fleet/storybook/context/application.ts +++ b/x-pack/plugins/fleet/storybook/context/application.ts @@ -22,7 +22,14 @@ export const getApplication = () => { action(`Navigate to: ${app}`); }, getUrlForApp: (url: string) => url, - capabilities: {} as ApplicationStart['capabilities'], + capabilities: { + catalogue: {}, + management: {}, + navLinks: {}, + fleet: { + write: true, + }, + }, applications$: of(applications), }; diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts b/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts new file mode 100644 index 0000000000000..50262b73a6a41 --- /dev/null +++ b/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts @@ -0,0 +1,664 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { GetInfoResponse } from '../../../public/types'; +import { KibanaAssetType, ElasticsearchAssetType } from '../../../common/types'; + +export const response: GetInfoResponse['response'] = { + name: 'nginx', + title: 'Nginx', + version: '0.7.0', + release: 'experimental', + description: 'Nginx Integration', + type: 'integration', + download: '/epr/nginx/nginx-0.7.0.zip', + path: '/package/nginx/0.7.0', + icons: [ + { + src: '/img/logo_nginx.svg', + path: '/package/nginx/0.7.0/img/logo_nginx.svg', + title: 'logo nginx', + size: '32x32', + type: 'image/svg+xml', + }, + ], + format_version: '1.0.0', + readme: '/package/nginx/0.7.0/docs/README.md', + license: 'basic', + categories: ['web', 'security'], + conditions: { + kibana: { version: '^7.14.0' }, + }, + screenshots: [ + { + src: '/img/nginx-metrics-overview.png', + path: '/package/nginx/0.7.0/img/nginx-metrics-overview.png', + title: 'Nginx metrics overview', + size: '3360x2302', + type: 'image/png', + }, + { + src: '/img/nginx-logs-access-error.png', + path: '/package/nginx/0.7.0/img/nginx-logs-access-error.png', + title: 'Nginx access and error logs', + size: '3360x3590', + type: 'image/png', + }, + { + src: '/img/nginx-logs-overview.png', + path: '/package/nginx/0.7.0/img/nginx-logs-overview.png', + title: 'Nginx logs overview', + size: '3360x3590', + type: 'image/png', + }, + ], + assets: { + kibana: { + dashboard: [ + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.dashboard, + file: 'nginx-023d2930-f1a5-11e7-a9ef-93c69af7b129.json', + // path: '-0.7.0/kibana/dashboard/nginx-023d2930-f1a5-11e7-a9ef-93c69af7b129.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.dashboard, + file: 'nginx-046212a0-a2a1-11e7-928f-5dbe6f6f5519.json', + // path: 'nginx-0.7.0/kibana/dashboard/nginx-046212a0-a2a1-11e7-928f-5dbe6f6f5519.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.dashboard, + file: 'nginx-55a9e6e0-a29e-11e7-928f-5dbe6f6f5519.json', + // path: 'nginx-0.7.0/kibana/dashboard/nginx-55a9e6e0-a29e-11e7-928f-5dbe6f6f5519.json', + }, + ], + ml_module: [ + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.mlModule, + file: 'nginx-Logs-ml.json', + // path: 'nginx-0.7.0/kibana/ml_module/nginx-Logs-ml.json', + }, + ], + search: [ + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.search, + file: 'nginx-6d9e66d0-a1f0-11e7-928f-5dbe6f6f5519.json', + // path: 'nginx-0.7.0/kibana/search/nginx-6d9e66d0-a1f0-11e7-928f-5dbe6f6f5519.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.search, + file: 'nginx-9eb25600-a1f0-11e7-928f-5dbe6f6f5519.json', + // path: 'nginx-0.7.0/kibana/search/nginx-9eb25600-a1f0-11e7-928f-5dbe6f6f5519.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.search, + file: 'nginx-Logs-Nginx-integration.json', + // path: 'nginx-0.7.0/kibana/search/nginx-Logs-Nginx-integration.json', + }, + ], + visualization: [ + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-0dd6f320-a29f-11e7-928f-5dbe6f6f5519.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-0dd6f320-a29f-11e7-928f-5dbe6f6f5519.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-1cfb1a80-a1f4-11e7-928f-5dbe6f6f5519.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-1cfb1a80-a1f4-11e7-928f-5dbe6f6f5519.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-46322e50-a1f6-11e7-928f-5dbe6f6f5519.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-46322e50-a1f6-11e7-928f-5dbe6f6f5519.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-47a8e0f0-f1a4-11e7-a9ef-93c69af7b129.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-47a8e0f0-f1a4-11e7-a9ef-93c69af7b129.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-555df8a0-f1a1-11e7-a9ef-93c69af7b129.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-555df8a0-f1a1-11e7-a9ef-93c69af7b129.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-7cc9ea40-3af8-11eb-94b7-0dab91df36a6.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-7cc9ea40-3af8-11eb-94b7-0dab91df36a6.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-823b3c80-3af9-11eb-94b7-0dab91df36a6.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-823b3c80-3af9-11eb-94b7-0dab91df36a6.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-9184fa00-a1f5-11e7-928f-5dbe6f6f5519.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-9184fa00-a1f5-11e7-928f-5dbe6f6f5519.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-9484ecf0-3af5-11eb-94b7-0dab91df36a6.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-9484ecf0-3af5-11eb-94b7-0dab91df36a6.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-97109780-a2a5-11e7-928f-5dbe6f6f5519.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-97109780-a2a5-11e7-928f-5dbe6f6f5519.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-Access-Browsers.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-Access-Browsers.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-Access-Map.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-Access-Map.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-Access-OSes.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-Access-OSes.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-a1d92240-f1a1-11e7-a9ef-93c69af7b129.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-a1d92240-f1a1-11e7-a9ef-93c69af7b129.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-b70b1b20-a1f4-11e7-928f-5dbe6f6f5519.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-b70b1b20-a1f4-11e7-928f-5dbe6f6f5519.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-d763a570-f1a1-11e7-a9ef-93c69af7b129.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-d763a570-f1a1-11e7-a9ef-93c69af7b129.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-dcbffe30-f1a4-11e7-a9ef-93c69af7b129.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-dcbffe30-f1a4-11e7-a9ef-93c69af7b129.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-e302b5a0-3afb-11eb-94b7-0dab91df36a6.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-e302b5a0-3afb-11eb-94b7-0dab91df36a6.json', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'nginx-ea7f9e10-3af6-11eb-94b7-0dab91df36a6.json', + // path: 'nginx-0.7.0/kibana/visualization/nginx-ea7f9e10-3af6-11eb-94b7-0dab91df36a6.json', + }, + ], + // TODO: These were missing from the response, but typed to be required. + index_pattern: [], + lens: [], + map: [], + security_rule: [], + }, + elasticsearch: { + ingest_pipeline: [ + { + pkgkey: 'nginx-0.7.0', + service: 'elasticsearch', + type: ElasticsearchAssetType.ingestPipeline, + file: 'default.yml', + dataset: 'access', + // path: 'nginx-0.7.0/data_stream/access/elasticsearch/ingest_pipeline/default.yml', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'elasticsearch', + type: ElasticsearchAssetType.ingestPipeline, + file: 'third-party.yml', + dataset: 'access', + // path: 'nginx-0.7.0/data_stream/access/elasticsearch/ingest_pipeline/third-party.yml', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'elasticsearch', + type: ElasticsearchAssetType.ingestPipeline, + file: 'default.yml', + dataset: 'error', + // path: 'nginx-0.7.0/data_stream/error/elasticsearch/ingest_pipeline/default.yml', + }, + { + pkgkey: 'nginx-0.7.0', + service: 'elasticsearch', + type: ElasticsearchAssetType.ingestPipeline, + file: 'third-party.yml', + dataset: 'error', + // path: 'nginx-0.7.0/data_stream/error/elasticsearch/ingest_pipeline/third-party.yml', + }, + ], + // TODO: These were missing from the response, but typed to be required. + component_template: [], + data_stream_ilm_policy: [], + ilm_policy: [], + index_template: [], + transform: [], + }, + }, + policy_templates: [ + { + name: 'nginx', + title: 'Nginx logs and metrics', + description: 'Collect logs and metrics from Nginx instances', + inputs: [ + { + type: 'logfile', + title: 'Collect logs from Nginx instances', + description: 'Collecting Nginx access and error logs', + }, + { + type: 'httpjson', + vars: [ + { + name: 'url', + type: 'text', + title: 'URL of Splunk Enterprise Server', + description: 'i.e. scheme://host:port, path is automatic', + multi: false, + required: true, + show_user: true, + default: 'https://server.example.com:8089', + }, + { + name: 'username', + type: 'text', + title: 'Splunk REST API Username', + multi: false, + required: false, + show_user: true, + }, + { + name: 'password', + type: 'password', + title: 'Splunk REST API Password', + multi: false, + required: false, + show_user: true, + }, + { + name: 'token', + type: 'password', + title: 'Splunk Authorization Token', + description: + 'Bearer Token or Session Key, e.g. "Bearer eyJFd3e46..."\nor "Splunk 192fd3e...". Cannot be used with username\nand password.\n', + multi: false, + required: false, + show_user: true, + }, + { + name: 'ssl', + type: 'yaml', + title: 'SSL Configuration', + description: + 'i.e. certificate_authorities, supported_protocols, verification_mode etc.', + multi: false, + required: false, + show_user: false, + default: + '#certificate_authorities:\n# - |\n# -----BEGIN CERTIFICATE-----\n# MIIDCjCCAfKgAwIBAgITJ706Mu2wJlKckpIvkWxEHvEyijANBgkqhkiG9w0BAQsF\n# ADAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwIBcNMTkwNzIyMTkyOTA0WhgPMjExOTA2\n# MjgxOTI5MDRaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEB\n# BQADggEPADCCAQoCggEBANce58Y/JykI58iyOXpxGfw0/gMvF0hUQAcUrSMxEO6n\n# fZRA49b4OV4SwWmA3395uL2eB2NB8y8qdQ9muXUdPBWE4l9rMZ6gmfu90N5B5uEl\n# 94NcfBfYOKi1fJQ9i7WKhTjlRkMCgBkWPkUokvBZFRt8RtF7zI77BSEorHGQCk9t\n# /D7BS0GJyfVEhftbWcFEAG3VRcoMhF7kUzYwp+qESoriFRYLeDWv68ZOvG7eoWnP\n# PsvZStEVEimjvK5NSESEQa9xWyJOmlOKXhkdymtcUd/nXnx6UTCFgnkgzSdTWV41\n# CI6B6aJ9svCTI2QuoIq2HxX/ix7OvW1huVmcyHVxyUECAwEAAaNTMFEwHQYDVR0O\n# BBYEFPwN1OceFGm9v6ux8G+DZ3TUDYxqMB8GA1UdIwQYMBaAFPwN1OceFGm9v6ux\n# 8G+DZ3TUDYxqMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAG5D\n# 874A4YI7YUwOVsVAdbWtgp1d0zKcPRR+r2OdSbTAV5/gcS3jgBJ3i1BN34JuDVFw\n# 3DeJSYT3nxy2Y56lLnxDeF8CUTUtVQx3CuGkRg1ouGAHpO/6OqOhwLLorEmxi7tA\n# H2O8mtT0poX5AnOAhzVy7QW0D/k4WaoLyckM5hUa6RtvgvLxOwA0U+VGurCDoctu\n# 8F4QOgTAWyh8EZIwaKCliFRSynDpv3JTUwtfZkxo6K6nce1RhCWFAsMvDZL8Dgc0\n# yvgJ38BRsFOtkRuAGSf6ZUwTO8JJRRIFnpUzXflAnGivK9M13D5GEQMmIl6U9Pvk\n# sxSmbIUfc2SGJGCJD4I=\n# -----END CERTIFICATE-----\n', + }, + ], + title: 'Collect logs from third-party REST API (experimental)', + description: 'Collect logs from third-party REST API (experimental)', + }, + { + type: 'nginx/metrics', + vars: [ + { + name: 'hosts', + type: 'text', + title: 'Hosts', + multi: true, + required: true, + show_user: true, + default: ['http://127.0.0.1:80'], + }, + ], + title: 'Collect metrics from Nginx instances', + description: 'Collecting Nginx stub status metrics', + }, + ], + multiple: true, + }, + ], + data_streams: [ + { + type: 'logs', + dataset: 'nginx.access', + title: 'Nginx access logs', + release: 'experimental', + ingest_pipeline: 'default', + streams: [ + { + input: 'logfile', + vars: [ + { + name: 'paths', + type: 'text', + title: 'Paths', + multi: true, + required: true, + show_user: true, + default: ['/var/log/nginx/access.log*'], + }, + { + name: 'tags', + type: 'text', + title: 'Tags', + multi: true, + required: true, + show_user: false, + default: ['nginx-access'], + }, + { + name: 'preserve_original_event', + type: 'bool', + title: 'Preserve original event', + description: + 'Preserves a raw copy of the original event, added to the field `event.original`', + multi: false, + required: true, + show_user: true, + default: false, + }, + { + name: 'processors', + type: 'yaml', + title: 'Processors', + description: + 'Processors are used to reduce the number of fields in the exported event or to enhance the event with metadata. This executes in the agent before the logs are parsed. See [Processors](https://www.elastic.co/guide/en/beats/filebeat/current/filtering-and-enhancing-data.html) for details.\n', + multi: false, + required: false, + show_user: false, + }, + ], + template_path: 'stream.yml.hbs', + title: 'Nginx access logs', + description: 'Collect Nginx access logs', + enabled: true, + }, + { + input: 'httpjson', + vars: [ + { + name: 'interval', + type: 'text', + title: 'Interval to query Splunk Enterprise REST API', + description: 'Go Duration syntax (eg. 10s)', + multi: false, + required: true, + show_user: true, + default: '10s', + }, + { + name: 'search', + type: 'text', + title: 'Splunk search string', + multi: false, + required: true, + show_user: true, + default: 'search sourcetype=nginx:plus:access', + }, + { + name: 'tags', + type: 'text', + title: 'Tags', + multi: true, + required: false, + show_user: false, + default: ['forwarded', 'nginx-access'], + }, + { + name: 'preserve_original_event', + type: 'bool', + title: 'Preserve original event', + description: + 'Preserves a raw copy of the original event, added to the field `event.original`', + multi: false, + required: true, + show_user: true, + default: false, + }, + { + name: 'processors', + type: 'yaml', + title: 'Processors', + description: + 'Processors are used to reduce the number of fields in the exported event or to enhance the event with metadata. This executes in the agent before the logs are parsed. See [Processors](https://www.elastic.co/guide/en/beats/filebeat/current/filtering-and-enhancing-data.html) for details.', + multi: false, + required: false, + show_user: false, + }, + ], + template_path: 'httpjson.yml.hbs', + title: 'Nginx access logs via Splunk Enterprise REST API', + description: 'Collect Nginx access logs via Splunk Enterprise REST API', + enabled: false, + }, + ], + package: 'nginx', + path: 'access', + }, + { + type: 'logs', + dataset: 'nginx.error', + title: 'Nginx error logs', + release: 'experimental', + ingest_pipeline: 'default', + streams: [ + { + input: 'logfile', + vars: [ + { + name: 'paths', + type: 'text', + title: 'Paths', + multi: true, + required: true, + show_user: true, + default: ['/var/log/nginx/error.log*'], + }, + { + name: 'tags', + type: 'text', + title: 'Tags', + multi: true, + required: true, + show_user: false, + default: ['nginx-error'], + }, + { + name: 'preserve_original_event', + type: 'bool', + title: 'Preserve original event', + description: + 'Preserves a raw copy of the original event, added to the field `event.original`', + multi: false, + required: true, + show_user: true, + default: false, + }, + { + name: 'processors', + type: 'yaml', + title: 'Processors', + description: + 'Processors are used to reduce the number of fields in the exported event or to enhance the event with metadata. This executes in the agent before the logs are parsed. See [Processors](https://www.elastic.co/guide/en/beats/filebeat/current/filtering-and-enhancing-data.html) for details.\n', + multi: false, + required: false, + show_user: false, + }, + ], + template_path: 'stream.yml.hbs', + title: 'Nginx error logs', + description: 'Collect Nginx error logs', + enabled: true, + }, + { + input: 'httpjson', + vars: [ + { + name: 'interval', + type: 'text', + title: 'Interval to query REST API', + description: 'Go Duration syntax (eg. 10s)', + multi: false, + required: true, + show_user: true, + default: '10s', + }, + { + name: 'search', + type: 'text', + title: 'Search String', + multi: false, + required: true, + show_user: true, + default: 'search sourcetype=nginx:plus:error', + }, + { + name: 'tags', + type: 'text', + title: 'Tags', + multi: true, + required: false, + show_user: false, + default: ['forwarded', 'nginx-error'], + }, + { + name: 'preserve_original_event', + type: 'bool', + title: 'Preserve original event', + description: + 'Preserves a raw copy of the original event, added to the field `event.original`', + multi: false, + required: true, + show_user: true, + default: false, + }, + { + name: 'processors', + type: 'yaml', + title: 'Processors', + description: + 'Processors are used to reduce the number of fields in the exported event or to enhance the event with metadata. This executes in the agent before the logs are parsed. See [Processors](https://www.elastic.co/guide/en/beats/filebeat/current/filtering-and-enhancing-data.html) for details.', + multi: false, + required: false, + show_user: false, + }, + ], + template_path: 'httpjson.yml.hbs', + title: 'Nginx error logs via Splunk REST API', + description: 'Collect Nginx error logs via Splunk REST API', + enabled: false, + }, + ], + package: 'nginx', + path: 'error', + }, + { + type: 'metrics', + dataset: 'nginx.stubstatus', + title: 'Nginx stubstatus metrics', + release: 'experimental', + streams: [ + { + input: 'nginx/metrics', + vars: [ + { + name: 'period', + type: 'text', + title: 'Period', + multi: false, + required: true, + show_user: true, + default: '10s', + }, + { + name: 'server_status_path', + type: 'text', + title: 'Server Status Path', + multi: false, + required: true, + show_user: false, + default: '/nginx_status', + }, + ], + template_path: 'stream.yml.hbs', + title: 'Nginx stub status metrics', + description: 'Collect Nginx stub status metrics', + enabled: true, + }, + ], + package: 'nginx', + path: 'stubstatus', + }, + ], + owner: { + github: 'elastic/integrations', + }, + latestVersion: '0.7.0', + removable: true, + status: 'not_installed', +}; diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts b/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts new file mode 100644 index 0000000000000..efef00579f4bd --- /dev/null +++ b/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts @@ -0,0 +1,263 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { GetInfoResponse } from '../../../public/types'; +import { KibanaAssetType, ElasticsearchAssetType } from '../../../common/types'; + +export const response: GetInfoResponse['response'] = { + name: 'okta', + title: 'Okta', + version: '1.2.0', + release: 'ga', + description: 'This Elastic integration collects events from Okta', + type: 'integration', + download: '/epr/okta/okta-1.2.0.zip', + // path: '/package/okta/1.2.0', + icons: [ + { + src: '/img/okta-logo.svg', + // path: '/package/okta/1.2.0/img/okta-logo.svg', + title: 'Okta', + size: '216x216', + type: 'image/svg+xml', + }, + ], + format_version: '1.0.0', + readme: '/package/okta/1.2.0/docs/README.md', + license: 'basic', + categories: ['security'], + conditions: { + kibana: { version: '^7.14.0' }, + }, + screenshots: [ + { + src: '/img/filebeat-okta-dashboard.png', + // path: '/package/okta/1.2.0/img/filebeat-okta-dashboard.png', + title: 'Okta Dashboard', + size: '1024x662', + type: 'image/png', + }, + ], + assets: { + kibana: { + dashboard: [ + { + pkgkey: 'okta-1.2.0', + service: 'kibana', + type: KibanaAssetType.dashboard, + file: 'okta-749203a0-67b1-11ea-a76f-bf44814e437d.json', + // path: 'okta-1.2.0/kibana/dashboard/okta-749203a0-67b1-11ea-a76f-bf44814e437d.json', + }, + ], + map: [ + { + pkgkey: 'okta-1.2.0', + service: 'kibana', + type: KibanaAssetType.map, + file: 'okta-281ca660-67b1-11ea-a76f-bf44814e437d.json', + // path: 'okta-1.2.0/kibana/map/okta-281ca660-67b1-11ea-a76f-bf44814e437d.json', + }, + ], + search: [ + { + pkgkey: 'okta-1.2.0', + service: 'kibana', + type: KibanaAssetType.search, + file: 'okta-21028750-67ca-11ea-a76f-bf44814e437d.json', + // path: 'okta-1.2.0/kibana/search/okta-21028750-67ca-11ea-a76f-bf44814e437d.json', + }, + ], + visualization: [ + { + pkgkey: 'okta-1.2.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'okta-0a784b30-67c7-11ea-a76f-bf44814e437d.json', + // path: 'okta-1.2.0/kibana/visualization/okta-0a784b30-67c7-11ea-a76f-bf44814e437d.json', + }, + { + pkgkey: 'okta-1.2.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'okta-545d6a00-67ae-11ea-a76f-bf44814e437d.json', + // path: 'okta-1.2.0/kibana/visualization/okta-545d6a00-67ae-11ea-a76f-bf44814e437d.json', + }, + { + pkgkey: 'okta-1.2.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'okta-7c6ec080-67c6-11ea-a76f-bf44814e437d.json', + // path: 'okta-1.2.0/kibana/visualization/okta-7c6ec080-67c6-11ea-a76f-bf44814e437d.json', + }, + { + pkgkey: 'okta-1.2.0', + service: 'kibana', + type: KibanaAssetType.visualization, + file: 'okta-cda883a0-67c6-11ea-a76f-bf44814e437d.json', + // path: 'okta-1.2.0/kibana/visualization/okta-cda883a0-67c6-11ea-a76f-bf44814e437d.json', + }, + ], + // TODO: These were missing from the response, but typed to be required. + index_pattern: [], + lens: [], + ml_module: [], + security_rule: [], + }, + elasticsearch: { + ingest_pipeline: [ + { + pkgkey: 'okta-1.2.0', + service: 'elasticsearch', + type: ElasticsearchAssetType.ingestPipeline, + file: 'default.yml', + dataset: 'system', + // path: 'okta-1.2.0/data_stream/system/elasticsearch/ingest_pipeline/default.yml', + }, + ], + // TODO: These were missing from the response, but typed to be required. + component_template: [], + data_stream_ilm_policy: [], + ilm_policy: [], + index_template: [], + transform: [], + }, + }, + policy_templates: [ + { + name: 'okta', + title: 'Okta logs', + description: 'Collect logs from Okta', + inputs: [ + { + type: 'httpjson', + vars: [ + { + name: 'api_key', + type: 'text', + title: 'API Key', + multi: false, + required: false, + show_user: true, + }, + { + name: 'http_client_timeout', + type: 'text', + title: 'HTTP Client Timeout', + multi: false, + required: false, + show_user: true, + }, + { + name: 'interval', + type: 'text', + title: 'Interval', + multi: false, + required: true, + show_user: true, + default: '60s', + }, + { + name: 'initial_interval', + type: 'text', + title: 'Initial Interval', + multi: false, + required: true, + show_user: true, + default: '24h', + }, + { + name: 'ssl', + type: 'yaml', + title: 'SSL', + multi: false, + required: false, + show_user: true, + }, + { + name: 'url', + type: 'text', + title: 'Okta System Log API Url', + multi: false, + required: false, + show_user: true, + }, + { + name: 'proxy_url', + type: 'text', + title: 'Proxy URL', + description: + 'URL to proxy connections in the form of http[s]://:@:', + multi: false, + required: false, + show_user: false, + }, + ], + title: 'Collect Okta logs via API', + description: 'Collecting logs from Okta via API', + }, + ], + multiple: true, + }, + ], + data_streams: [ + { + type: 'logs', + dataset: 'okta.system', + title: 'Okta system logs', + release: 'experimental', + ingest_pipeline: 'default', + streams: [ + { + input: 'httpjson', + vars: [ + { + name: 'tags', + type: 'text', + title: 'Tags', + multi: true, + required: true, + show_user: false, + default: ['forwarded', 'okta-system'], + }, + { + name: 'preserve_original_event', + type: 'bool', + title: 'Preserve original event', + description: + 'Preserves a raw copy of the original event, added to the field `event.original`', + multi: false, + required: true, + show_user: true, + default: false, + }, + { + name: 'processors', + type: 'yaml', + title: 'Processors', + description: + 'Processors are used to reduce the number of fields in the exported event or to enhance the event with metadata. This executes in the agent before the logs are parsed. See [Processors](https://www.elastic.co/guide/en/beats/filebeat/current/filtering-and-enhancing-data.html) for details.\n', + multi: false, + required: false, + show_user: false, + }, + ], + template_path: 'httpjson.yml.hbs', + title: 'Okta system logs', + description: 'Collect Okta system logs', + enabled: true, + }, + ], + package: 'okta', + path: 'system', + }, + ], + owner: { + github: 'elastic/security-external-integrations', + }, + latestVersion: '1.2.0', + removable: true, + status: 'not_installed', +}; diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/readme.nginx.ts b/x-pack/plugins/fleet/storybook/context/fixtures/readme.nginx.ts new file mode 100644 index 0000000000000..34f6bbffa0217 --- /dev/null +++ b/x-pack/plugins/fleet/storybook/context/fixtures/readme.nginx.ts @@ -0,0 +1,515 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 readme = `# Nginx Integration + +This integration periodically fetches metrics from [Nginx](https://nginx.org/) servers. It can parse access and error +logs created by the HTTP server. + +## Compatibility + +The Nginx \`stubstatus\` metrics was tested with Nginx 1.19.5 and are expected to work with all version >= 1.9. +The logs were tested with version 1.19.5. +On Windows, the module was tested with Nginx installed from the Chocolatey repository. + +## Logs + +**Timezone support** + +This datasource parses logs that don’t contain timezone information. For these logs, the Elastic Agent reads the local +timezone and uses it when parsing to convert the timestamp to UTC. The timezone to be used for parsing is included +in the event in the \`event.timezone\` field. + +To disable this conversion, the event.timezone field can be removed with the drop_fields processor. + +If logs are originated from systems or applications with a different timezone to the local one, the \`event.timezone\` +field can be overwritten with the original timezone using the add_fields processor. + +### Access Logs + +Access logs collects the nginx access logs. + +An example event for \`access\` looks as following: + +\`\`\`json +{ + "agent": { + "hostname": "a73e7856c209", + "name": "a73e7856c209", + "id": "3987d2b3-b40a-4aa0-99fc-478f9d7079ea", + "ephemeral_id": "6d41da1c-5f71-4bd4-b326-a8913bfaa884", + "type": "filebeat", + "version": "7.11.0" + }, + "nginx": { + "access": { + "remote_ip_list": [ + "127.0.0.1" + ] + } + }, + "log": { + "file": { + "path": "/tmp/service_logs/access.log" + }, + "offset": 0 + }, + "elastic_agent": { + "id": "5ca3af72-37c3-48b6-92e8-176d154bb66f", + "version": "7.11.0", + "snapshot": true + }, + "source": { + "address": "127.0.0.1", + "ip": "127.0.0.1" + }, + "url": { + "original": "/server-status" + }, + "input": { + "type": "log" + }, + "@timestamp": "2020-12-03T11:41:57.000Z", + "ecs": { + "version": "1.6.0" + }, + "related": { + "ip": [ + "127.0.0.1" + ] + }, + "data_stream": { + "namespace": "ep", + "type": "logs", + "dataset": "nginx.access" + }, + "host": { + "hostname": "a73e7856c209", + "os": { + "kernel": "4.9.184-linuxkit", + "codename": "Core", + "name": "CentOS Linux", + "family": "redhat", + "version": "7 (Core)", + "platform": "centos" + }, + "containerized": true, + "ip": [ + "192.168.80.6" + ], + "name": "a73e7856c209", + "id": "06c26569966fd125c15acac5d7feffb6", + "mac": [ + "02:42:c0:a8:50:06" + ], + "architecture": "x86_64" + }, + "http": { + "request": { + "method": "get" + }, + "response": { + "status_code": 200, + "body": { + "bytes": 97 + } + }, + "version": "1.1" + }, + "event": { + "timezone": "+00:00", + "created": "2020-12-03T11:42:17.116Z", + "kind": "event", + "category": [ + "web" + ], + "type": [ + "access" + ], + "dataset": "nginx.access", + "outcome": "success" + }, + "user_agent": { + "original": "curl/7.64.0", + "name": "curl", + "device": { + "name": "Other" + }, + "version": "7.64.0" + } +} +\`\`\` + +**Exported fields** + +| Field | Description | Type | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| @timestamp | Event timestamp. | date | +| cloud.account.id | The cloud account or organization id used to identify different entities in a multi-tenant environment. Examples: AWS account id, Google Cloud ORG Id, or other unique identifier. | keyword | +| cloud.availability_zone | Availability zone in which this host is running. | keyword | +| cloud.image.id | Image ID for the cloud instance. | keyword | +| cloud.instance.id | Instance ID of the host machine. | keyword | +| cloud.instance.name | Instance name of the host machine. | keyword | +| cloud.machine.type | Machine type of the host machine. | keyword | +| cloud.project.id | Name of the project in Google Cloud. | keyword | +| cloud.provider | Name of the cloud provider. Example values are aws, azure, gcp, or digitalocean. | keyword | +| cloud.region | Region in which this host is running. | keyword | +| container.id | Unique container id. | keyword | +| container.image.name | Name of the image the container was built on. | keyword | +| container.labels | Image labels. | object | +| container.name | Container name. | keyword | +| data_stream.dataset | Data stream dataset. | constant_keyword | +| data_stream.namespace | Data stream namespace. | constant_keyword | +| data_stream.type | Data stream type. | constant_keyword | +| destination.domain | Destination domain. | keyword | +| destination.ip | IP address of the destination. | ip | +| destination.port | Port of the destination. | long | +| ecs.version | ECS version | keyword | +| event.created | Date/time when the event was first read by an agent, or by your pipeline. | date | +| event.dataset | Event dataset | constant_keyword | +| event.module | Event module | constant_keyword | +| host.architecture | Operating system architecture. | keyword | +| host.containerized | If the host is a container. | boolean | +| host.domain | Name of the domain of which the host is a member. For example, on Windows this could be the host's Active Directory domain or NetBIOS domain name. For Linux this could be the domain of the host's LDAP provider. | keyword | +| host.hostname | Hostname of the host. It normally contains what the \`hostname\` command returns on the host machine. | keyword | +| host.id | Unique host id. As hostname is not always unique, use values that are meaningful in your environment. Example: The current usage of \`beat.name\`. | keyword | +| host.ip | Host ip addresses. | ip | +| host.mac | Host mac addresses. | keyword | +| host.name | Name of the host. It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use. | keyword | +| host.os.build | OS build information. | keyword | +| host.os.codename | OS codename, if any. | keyword | +| host.os.family | OS family (such as redhat, debian, freebsd, windows). | keyword | +| host.os.kernel | Operating system kernel version as a raw string. | keyword | +| host.os.name | Operating system name, without the version. | keyword | +| host.os.platform | Operating system platform (such centos, ubuntu, windows). | keyword | +| host.os.version | Operating system version as a raw string. | keyword | +| host.type | Type of host. For Cloud providers this can be the machine type like \`t2.medium\`. If vm, this could be the container, for example, or other information meaningful in your environment. | keyword | +| http.request.method | HTTP request method. The field value must be normalized to lowercase for querying. See the documentation section "Implementing ECS". | keyword | +| http.request.referrer | Referrer for this HTTP request. | keyword | +| http.response.body.bytes | Size in bytes of the response body. | long | +| http.response.status_code | HTTP response status code. | long | +| http.version | HTTP version. | keyword | +| input.type | Input type | keyword | +| log.file.path | Log path | keyword | +| log.offset | Log offset | long | +| nginx.access.remote_ip_list | An array of remote IP addresses. It is a list because it is common to include, besides the client IP address, IP addresses from headers like \`X-Forwarded-For\`. Real source IP is restored to \`source.ip\`. | array | +| related.ip | All of the IPs seen on your event. | ip | +| source.address | An IP address, a domain, a unix socket | keyword | +| source.as.number | Unique number allocated to the autonomous system. | long | +| source.as.organization.name | Organization name. | keyword | +| source.geo.city_name | City name. | keyword | +| source.geo.continent_name | Name of the continent. | keyword | +| source.geo.country_iso_code | Country ISO code. | keyword | +| source.geo.country_name | Country name. | keyword | +| source.geo.location | Longitude and latitude. | geo_point | +| source.geo.region_iso_code | Region ISO code. | keyword | +| source.geo.region_name | Region name. | keyword | +| source.ip | IP address of the source | ip | +| tags | List of keywords used to tag each event. | keyword | +| url.domain | Domain of the url, such as "www.elastic.co". In some cases a URL may refer to an IP and/or port directly, without a domain name. In this case, the IP address would go to the \`domain\` field. If the URL contains a literal IPv6 address enclosed by \`[\` and \`]\` (IETF RFC 2732), the \`[\` and \`]\` characters should also be captured in the \`domain\` field. | keyword | +| url.extension | The field contains the file extension from the original request url. The file extension is only set if it exists, as not every url has a file extension. The leading period must not be included. For example, the value must be "png", not ".png". | keyword | +| url.fragment | Portion of the url after the \`#\`, such as "top". The \`#\` is not part of the fragment. | keyword | +| url.original | Unmodified original url as seen in the event source. Note that in network monitoring, the observed URL may be a full URL, whereas in access logs, the URL is often just represented as a path. This field is meant to represent the URL as it was observed, complete or not. | keyword | +| url.path | Path of the request, such as "/search". | keyword | +| url.scheme | Scheme of the request, such as "https". Note: The \`:\` is not part of the scheme. | keyword | +| user.name | Short name or login of the user. | keyword | +| user_agent.device.name | Name of the device. | keyword | +| user_agent.name | Name of the user agent. | keyword | +| user_agent.original | Unparsed user_agent string. | keyword | +| user_agent.os.full | Operating system name, including the version or code name. | keyword | +| user_agent.os.name | Operating system name, without the version. | keyword | +| user_agent.os.version | Operating system version as a raw string. | keyword | +| user_agent.version | Version of the user agent. | keyword | + + +### Error Logs + +Error logs collects the nginx error logs. + +An example event for \`error\` looks as following: + +\`\`\`json +{ + "agent": { + "hostname": "a73e7856c209", + "name": "a73e7856c209", + "id": "3987d2b3-b40a-4aa0-99fc-478f9d7079ea", + "ephemeral_id": "6d41da1c-5f71-4bd4-b326-a8913bfaa884", + "type": "filebeat", + "version": "7.11.0" + }, + "process": { + "pid": 1, + "thread": { + "id": 1 + } + }, + "nginx": { + "error": {} + }, + "log": { + "file": { + "path": "/tmp/service_logs/error.log" + }, + "offset": 0, + "level": "warn" + }, + "elastic_agent": { + "id": "5ca3af72-37c3-48b6-92e8-176d154bb66f", + "version": "7.11.0", + "snapshot": true + }, + "message": "conflicting server name \"localhost\" on 0.0.0.0:80, ignored", + "input": { + "type": "log" + }, + "@timestamp": "2020-12-03T11:44:39.000Z", + "ecs": { + "version": "1.6.0" + }, + "data_stream": { + "namespace": "ep", + "type": "logs", + "dataset": "nginx.error" + }, + "host": { + "hostname": "a73e7856c209", + "os": { + "kernel": "4.9.184-linuxkit", + "codename": "Core", + "name": "CentOS Linux", + "family": "redhat", + "version": "7 (Core)", + "platform": "centos" + }, + "containerized": true, + "ip": [ + "192.168.80.6" + ], + "name": "a73e7856c209", + "id": "06c26569966fd125c15acac5d7feffb6", + "mac": [ + "02:42:c0:a8:50:06" + ], + "architecture": "x86_64" + }, + "event": { + "timezone": "+00:00", + "created": "2020-12-03T11:44:52.803Z", + "kind": "event", + "category": [ + "web" + ], + "type": [ + "error" + ], + "dataset": "nginx.error" + } +} +\`\`\` + +**Exported fields** + +| Field | Description | Type | +| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| @timestamp | Event timestamp. | date | +| cloud.account.id | The cloud account or organization id used to identify different entities in a multi-tenant environment. Examples: AWS account id, Google Cloud ORG Id, or other unique identifier. | keyword | +| cloud.availability_zone | Availability zone in which this host is running. | keyword | +| cloud.image.id | Image ID for the cloud instance. | keyword | +| cloud.instance.id | Instance ID of the host machine. | keyword | +| cloud.instance.name | Instance name of the host machine. | keyword | +| cloud.machine.type | Machine type of the host machine. | keyword | +| cloud.project.id | Name of the project in Google Cloud. | keyword | +| cloud.provider | Name of the cloud provider. Example values are aws, azure, gcp, or digitalocean. | keyword | +| cloud.region | Region in which this host is running. | keyword | +| container.id | Unique container id. | keyword | +| container.image.name | Name of the image the container was built on. | keyword | +| container.labels | Image labels. | object | +| container.name | Container name. | keyword | +| data_stream.dataset | Data stream dataset. | constant_keyword | +| data_stream.namespace | Data stream namespace. | constant_keyword | +| data_stream.type | Data stream type. | constant_keyword | +| ecs.version | ECS version | keyword | +| event.created | Date/time when the event was first read by an agent, or by your pipeline. | date | +| event.dataset | Event dataset | constant_keyword | +| event.module | Event module | constant_keyword | +| host.architecture | Operating system architecture. | keyword | +| host.containerized | If the host is a container. | boolean | +| host.domain | Name of the domain of which the host is a member. For example, on Windows this could be the host's Active Directory domain or NetBIOS domain name. For Linux this could be the domain of the host's LDAP provider. | keyword | +| host.hostname | Hostname of the host. It normally contains what the \`hostname\` command returns on the host machine. | keyword | +| host.id | Unique host id. As hostname is not always unique, use values that are meaningful in your environment. Example: The current usage of \`beat.name\`. | keyword | +| host.ip | Host ip addresses. | ip | +| host.mac | Host mac addresses. | keyword | +| host.name | Name of the host. It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use. | keyword | +| host.os.build | OS build information. | keyword | +| host.os.codename | OS codename, if any. | keyword | +| host.os.family | OS family (such as redhat, debian, freebsd, windows). | keyword | +| host.os.kernel | Operating system kernel version as a raw string. | keyword | +| host.os.name | Operating system name, without the version. | keyword | +| host.os.platform | Operating system platform (such centos, ubuntu, windows). | keyword | +| host.os.version | Operating system version as a raw string. | keyword | +| host.type | Type of host. For Cloud providers this can be the machine type like \`t2.medium\`. If vm, this could be the container, for example, or other information meaningful in your environment. | keyword | +| input.type | Input type | keyword | +| log.file.path | Log path | keyword | +| log.level | Original log level of the log event. If the source of the event provides a log level or textual severity, this is the one that goes in \`log.level\`. If your source doesn't specify one, you may put your event transport's severity here (e.g. Syslog severity). Some examples are \`warn\`, \`err\`, \`i\`, \`informational\`. | keyword | +| log.offset | Log offset | long | +| message | For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message. | text | +| nginx.error.connection_id | Connection identifier. | long | +| process.pid | Process id. | long | +| process.thread.id | Thread ID. | long | +| tags | List of keywords used to tag each event. | keyword | + + +## Metrics + +### Stub Status Metrics + +The Nginx \`stubstatus\` stream collects data from the Nginx \`ngx_http_stub_status\` module. It scrapes the server status +data from the web page generated by \`ngx_http_stub_status\`. Please verify that your Nginx distribution comes with the mentioned +module and it's enabled in the Nginx configuration file: + +\`\`\` +location /nginx_status { + stub_status; + allow 127.0.0.1; # only allow requests from localhost + deny all; # deny all other hosts +} +\`\`\` + +It's highly recommended to replace \`127.0.0.1\` with your server’s IP address and make sure that this page accessible to only you. + +An example event for \`stubstatus\` looks as following: + +\`\`\`json +{ + "@timestamp": "2020-12-03T11:47:31.996Z", + "host": { + "hostname": "a73e7856c209", + "architecture": "x86_64", + "os": { + "codename": "Core", + "platform": "centos", + "version": "7 (Core)", + "family": "redhat", + "name": "CentOS Linux", + "kernel": "4.9.184-linuxkit" + }, + "name": "a73e7856c209", + "id": "06c26569966fd125c15acac5d7feffb6", + "containerized": true, + "ip": [ + "192.168.80.6" + ], + "mac": [ + "02:42:c0:a8:50:06" + ] + }, + "service": { + "type": "nginx", + "address": "http://elastic-package-service_nginx_1:80/server-status" + }, + "nginx": { + "stubstatus": { + "requests": 13, + "waiting": 0, + "hostname": "elastic-package-service_nginx_1:80", + "accepts": 13, + "handled": 13, + "current": 13, + "dropped": 0, + "writing": 1, + "active": 1, + "reading": 0 + } + }, + "elastic_agent": { + "snapshot": true, + "version": "7.11.0", + "id": "5ca3af72-37c3-48b6-92e8-176d154bb66f" + }, + "ecs": { + "version": "1.6.0" + }, + "event": { + "dataset": "nginx.stubstatus", + "module": "nginx", + "duration": 2231100 + }, + "metricset": { + "period": 10000, + "name": "stubstatus" + }, + "data_stream": { + "type": "metrics", + "dataset": "nginx.stubstatus", + "namespace": "ep" + }, + "agent": { + "type": "metricbeat", + "version": "7.11.0", + "hostname": "a73e7856c209", + "ephemeral_id": "1fbb4215-4ba3-42fa-9984-244b112c9a17", + "id": "2689a72c-6e18-45fe-b493-af1ec86af2b3", + "name": "a73e7856c209" + } +} +\`\`\` + +**Exported fields** + +| Field | Description | Type | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | +| @timestamp | Event timestamp. | date | +| cloud.account.id | The cloud account or organization id used to identify different entities in a multi-tenant environment. Examples: AWS account id, Google Cloud ORG Id, or other unique identifier. | keyword | +| cloud.availability_zone | Availability zone in which this host is running. | keyword | +| cloud.image.id | Image ID for the cloud instance. | keyword | +| cloud.instance.id | Instance ID of the host machine. | keyword | +| cloud.instance.name | Instance name of the host machine. | keyword | +| cloud.machine.type | Machine type of the host machine. | keyword | +| cloud.project.id | Name of the project in Google Cloud. | keyword | +| cloud.provider | Name of the cloud provider. Example values are aws, azure, gcp, or digitalocean. | keyword | +| cloud.region | Region in which this host is running. | keyword | +| container.id | Unique container id. | keyword | +| container.image.name | Name of the image the container was built on. | keyword | +| container.labels | Image labels. | object | +| container.name | Container name. | keyword | +| data_stream.dataset | Data stream dataset. | constant_keyword | +| data_stream.namespace | Data stream namespace. | constant_keyword | +| data_stream.type | Data stream type. | constant_keyword | +| ecs.version | ECS version | keyword | +| event.dataset | Event dataset | constant_keyword | +| event.module | Event module | constant_keyword | +| host.architecture | Operating system architecture. | keyword | +| host.containerized | If the host is a container. | boolean | +| host.domain | Name of the domain of which the host is a member. For example, on Windows this could be the host's Active Directory domain or NetBIOS domain name. For Linux this could be the domain of the host's LDAP provider. | keyword | +| host.hostname | Hostname of the host. It normally contains what the \`hostname\` command returns on the host machine. | keyword | +| host.id | Unique host id. As hostname is not always unique, use values that are meaningful in your environment. Example: The current usage of \`beat.name\`. | keyword | +| host.ip | Host ip addresses. | ip | +| host.mac | Host mac addresses. | keyword | +| host.name | Name of the host. It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use. | keyword | +| host.os.build | OS build information. | keyword | +| host.os.codename | OS codename, if any. | keyword | +| host.os.family | OS family (such as redhat, debian, freebsd, windows). | keyword | +| host.os.kernel | Operating system kernel version as a raw string. | keyword | +| host.os.name | Operating system name, without the version. | keyword | +| host.os.platform | Operating system platform (such centos, ubuntu, windows). | keyword | +| host.os.version | Operating system version as a raw string. | keyword | +| host.type | Type of host. For Cloud providers this can be the machine type like \`t2.medium\`. If vm, this could be the container, for example, or other information meaningful in your environment. | keyword | +| nginx.stubstatus.accepts | The total number of accepted client connections. | long | +| nginx.stubstatus.active | The current number of active client connections including Waiting connections. | long | +| nginx.stubstatus.current | The current number of client requests. | long | +| nginx.stubstatus.dropped | The total number of dropped client connections. | long | +| nginx.stubstatus.handled | The total number of handled client connections. | long | +| nginx.stubstatus.hostname | Nginx hostname. | keyword | +| nginx.stubstatus.reading | The current number of connections where Nginx is reading the request header. | long | +| nginx.stubstatus.requests | The total number of client requests. | long | +| nginx.stubstatus.waiting | The current number of idle client connections waiting for a request. | long | +| nginx.stubstatus.writing | The current number of connections where Nginx is writing the response back to the client. | long | +| service.address | Service address | keyword | +| service.type | Service type | keyword | + +`; diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/readme.okta.ts b/x-pack/plugins/fleet/storybook/context/fixtures/readme.okta.ts new file mode 100644 index 0000000000000..cdb9b708425a6 --- /dev/null +++ b/x-pack/plugins/fleet/storybook/context/fixtures/readme.okta.ts @@ -0,0 +1,343 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 readme = `# Okta Integration + +The Okta integration collects events from the Okta API, specifically reading from the Okta System Log API. + +## Logs + +### System + +The Okta System Log records system events related to your organization in order to provide an audit trail that can be used to understand platform activity and to diagnose problems. This module is implemented using the httpjson input and is configured to paginate through the logs while honoring any rate-limiting headers sent by Okta. + +An example event for \`system\` looks as following: + +\`\`\`json +{ + "@timestamp": "2020-02-14T20:18:57.718Z", + "agent": { + "ephemeral_id": "c8d18964-83d3-4be1-809c-201de8688e6b", + "hostname": "docker-fleet-agent", + "id": "62554406-4558-40ed-9cd2-c68d978882ff", + "name": "docker-fleet-agent", + "type": "filebeat", + "version": "7.13.0" + }, + "client": { + "geo": { + "city_name": "Dublin", + "country_name": "United States", + "location": { + "lat": 37.7201, + "lon": -121.919 + }, + "region_name": "California" + }, + "ip": "108.255.197.247", + "user": { + "full_name": "xxxxxx", + "id": "00u1abvz4pYqdM8ms4x6" + } + }, + "data_stream": { + "dataset": "okta.system", + "namespace": "ep", + "type": "logs" + }, + "ecs": { + "version": "1.9.0" + }, + "elastic_agent": { + "id": "52c5ef7a-f604-488a-b39c-4ab431eb930e", + "snapshot": true, + "version": "7.13.0" + }, + "event": { + "action": "user.session.start", + "category": [ + "authentication", + "session" + ], + "created": "2021-05-31T10:31:04.833Z", + "dataset": "okta.system", + "id": "3aeede38-4f67-11ea-abd3-1f5d113f2546", + "ingested": "2021-05-31T10:31:05.861084700Z", + "kind": "event", + "original": "{\"actor\":{\"alternateId\":\"xxxxxx@elastic.co\",\"detailEntry\":null,\"displayName\":\"xxxxxx\",\"id\":\"00u1abvz4pYqdM8ms4x6\",\"type\":\"User\"},\"authenticationContext\":{\"authenticationProvider\":null,\"authenticationStep\":0,\"credentialProvider\":null,\"credentialType\":null,\"externalSessionId\":\"102bZDNFfWaQSyEZQuDgWt-uQ\",\"interface\":null,\"issuer\":null},\"client\":{\"device\":\"Computer\",\"geographicalContext\":{\"city\":\"Dublin\",\"country\":\"United States\",\"geolocation\":{\"lat\":37.7201,\"lon\":-121.919},\"postalCode\":\"94568\",\"state\":\"California\"},\"id\":null,\"ipAddress\":\"108.255.197.247\",\"userAgent\":{\"browser\":\"FIREFOX\",\"os\":\"Mac OS X\",\"rawUserAgent\":\"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:72.0) Gecko/20100101 Firefox/72.0\"},\"zone\":\"null\"},\"debugContext\":{\"debugData\":{\"deviceFingerprint\":\"541daf91d15bef64a7e08c946fd9a9d0\",\"requestId\":\"XkcAsWb8WjwDP76xh@1v8wAABp0\",\"requestUri\":\"/api/v1/authn\",\"threatSuspected\":\"false\",\"url\":\"/api/v1/authn?\"}},\"displayMessage\":\"User login to Okta\",\"eventType\":\"user.session.start\",\"legacyEventType\":\"core.user_auth.login_success\",\"outcome\":{\"reason\":null,\"result\":\"SUCCESS\"},\"published\":\"2020-02-14T20:18:57.718Z\",\"request\":{\"ipChain\":[{\"geographicalContext\":{\"city\":\"Dublin\",\"country\":\"United States\",\"geolocation\":{\"lat\":37.7201,\"lon\":-121.919},\"postalCode\":\"94568\",\"state\":\"California\"},\"ip\":\"108.255.197.247\",\"source\":null,\"version\":\"V4\"}]},\"securityContext\":{\"asNumber\":null,\"asOrg\":null,\"domain\":null,\"isProxy\":null,\"isp\":null},\"severity\":\"INFO\",\"target\":null,\"transaction\":{\"detail\":{},\"id\":\"XkcAsWb8WjwDP76xh@1v8wAABp0\",\"type\":\"WEB\"},\"uuid\":\"3aeede38-4f67-11ea-abd3-1f5d113f2546\",\"version\":\"0\"}", + "outcome": "success", + "type": [ + "start", + "user" + ] + }, + "host": { + "name": "docker-fleet-agent" + }, + "input": { + "type": "httpjson" + }, + "okta": { + "actor": { + "alternate_id": "xxxxxx@elastic.co", + "display_name": "xxxxxx", + "id": "00u1abvz4pYqdM8ms4x6", + "type": "User" + }, + "authentication_context": { + "authentication_step": 0, + "external_session_id": "102bZDNFfWaQSyEZQuDgWt-uQ" + }, + "client": { + "device": "Computer", + "ip": "108.255.197.247", + "user_agent": { + "browser": "FIREFOX", + "os": "Mac OS X", + "raw_user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:72.0) Gecko/20100101 Firefox/72.0" + }, + "zone": "null" + }, + "debug_context": { + "debug_data": { + "device_fingerprint": "541daf91d15bef64a7e08c946fd9a9d0", + "request_id": "XkcAsWb8WjwDP76xh@1v8wAABp0", + "request_uri": "/api/v1/authn", + "threat_suspected": "false", + "url": "/api/v1/authn?" + } + }, + "display_message": "User login to Okta", + "event_type": "user.session.start", + "outcome": { + "result": "SUCCESS" + }, + "transaction": { + "id": "XkcAsWb8WjwDP76xh@1v8wAABp0", + "type": "WEB" + }, + "uuid": "3aeede38-4f67-11ea-abd3-1f5d113f2546" + }, + "related": { + "ip": [ + "108.255.197.247" + ], + "user": [ + "xxxxxx" + ] + }, + "source": { + "as": { + "number": 7018, + "organization": { + "name": "AT\u0026T Services, Inc." + } + }, + "geo": { + "city_name": "Dublin", + "continent_name": "North America", + "country_iso_code": "US", + "country_name": "United States", + "location": { + "lat": 37.7201, + "lon": -121.919 + }, + "region_iso_code": "US-CA", + "region_name": "California" + }, + "ip": "108.255.197.247", + "user": { + "full_name": "xxxxxx", + "id": "00u1abvz4pYqdM8ms4x6" + } + }, + "tags": [ + "forwarded", + "preserve_original_event" + ], + "user": { + "full_name": "xxxxxx" + }, + "user_agent": { + "device": { + "name": "Mac" + }, + "name": "Firefox", + "original": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:72.0) Gecko/20100101 Firefox/72.0", + "os": { + "full": "Mac OS X 10.15", + "name": "Mac OS X", + "version": "10.15" + }, + "version": "72.0." + } +} +\`\`\` + +**Exported fields** + +| Field | Description | Type | +| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| @timestamp | Event timestamp. | date | +| client.as.number | Unique number allocated to the autonomous system. The autonomous system number (ASN) uniquely identifies each network on the Internet. | long | +| client.as.organization.name | Organization name. | keyword | +| client.domain | Client domain. | keyword | +| client.geo.city_name | City name. | keyword | +| client.geo.country_name | Country name. | keyword | +| client.geo.location | Longitude and latitude. | geo_point | +| client.geo.region_name | Region name. | keyword | +| client.ip | IP address of the client (IPv4 or IPv6). | ip | +| client.user.full_name | User's full name, if available. | keyword | +| client.user.id | Unique identifier of the user. | keyword | +| cloud.account.id | The cloud account or organization id used to identify different entities in a multi-tenant environment. Examples: AWS account id, Google Cloud ORG Id, or other unique identifier. | keyword | +| cloud.availability_zone | Availability zone in which this host is running. | keyword | +| cloud.image.id | Image ID for the cloud instance. | keyword | +| cloud.instance.id | Instance ID of the host machine. | keyword | +| cloud.instance.name | Instance name of the host machine. | keyword | +| cloud.machine.type | Machine type of the host machine. | keyword | +| cloud.project.id | Name of the project in Google Cloud. | keyword | +| cloud.provider | Name of the cloud provider. Example values are aws, azure, gcp, or digitalocean. | keyword | +| cloud.region | Region in which this host is running. | keyword | +| container.id | Unique container id. | keyword | +| container.image.name | Name of the image the container was built on. | keyword | +| container.labels | Image labels. | object | +| container.name | Container name. | keyword | +| data_stream.dataset | Data stream dataset name. | constant_keyword | +| data_stream.namespace | Data stream namespace. | constant_keyword | +| data_stream.type | Data stream type. | constant_keyword | +| destination.as.number | Unique number allocated to the autonomous system. The autonomous system number (ASN) uniquely identifies each network on the Internet. | long | +| destination.as.organization.name | Organization name. | keyword | +| destination.geo.city_name | City name. | keyword | +| destination.geo.continent_name | Name of the continent. | keyword | +| destination.geo.country_iso_code | Country ISO code. | keyword | +| destination.geo.country_name | Country name. | keyword | +| destination.geo.location | Longitude and latitude. | geo_point | +| destination.geo.name | User-defined description of a location, at the level of granularity they care about. Could be the name of their data centers, the floor number, if this describes a local physical entity, city names. Not typically used in automated geolocation. | keyword | +| destination.geo.region_iso_code | Region ISO code. | keyword | +| destination.geo.region_name | Region name. | keyword | +| destination.ip | IP address of the destination (IPv4 or IPv6). | ip | +| ecs.version | ECS version this event conforms to. \`ecs.version\` is a required field and must exist in all events. When querying across multiple indices -- which may conform to slightly different ECS versions -- this field lets integrations adjust to the schema version of the events. | keyword | +| error.message | Error message. | match_only_text | +| event.action | The action captured by the event. This describes the information in the event. It is more specific than \`event.category\`. Examples are \`group-add\`, \`process-started\`, \`file-created\`. The value is normally defined by the implementer. | keyword | +| event.category | This is one of four ECS Categorization Fields, and indicates the second level in the ECS category hierarchy. \`event.category\` represents the "big buckets" of ECS categories. For example, filtering on \`event.category:process\` yields all events relating to process activity. This field is closely related to \`event.type\`, which is used as a subcategory. This field is an array. This will allow proper categorization of some events that fall in multiple categories. | keyword | +| event.dataset | Event dataset | constant_keyword | +| event.id | Unique ID to describe the event. | keyword | +| event.ingested | Timestamp when an event arrived in the central data store. This is different from \`@timestamp\`, which is when the event originally occurred. It's also different from \`event.created\`, which is meant to capture the first time an agent saw the event. In normal conditions, assuming no tampering, the timestamps should chronologically look like this: \`@timestamp\` \< \`event.created\` \< \`event.ingested\`. | date | +| event.kind | This is one of four ECS Categorization Fields, and indicates the highest level in the ECS category hierarchy. \`event.kind\` gives high-level information about what type of information the event contains, without being specific to the contents of the event. For example, values of this field distinguish alert events from metric events. The value of this field can be used to inform how these kinds of events should be handled. They may warrant different retention, different access control, it may also help understand whether the data coming in at a regular interval or not. | keyword | +| event.module | Event module | constant_keyword | +| event.original | Raw text message of entire event. Used to demonstrate log integrity or where the full log message (before splitting it up in multiple parts) may be required, e.g. for reindex. This field is not indexed and doc_values are disabled. It cannot be searched, but it can be retrieved from \`_source\`. If users wish to override this and index this field, please see \`Field data types\` in the \`Elasticsearch Reference\`. | keyword | +| event.outcome | This is one of four ECS Categorization Fields, and indicates the lowest level in the ECS category hierarchy. \`event.outcome\` simply denotes whether the event represents a success or a failure from the perspective of the entity that produced the event. Note that when a single transaction is described in multiple events, each event may populate different values of \`event.outcome\`, according to their perspective. Also note that in the case of a compound event (a single event that contains multiple logical events), this field should be populated with the value that best captures the overall success or failure from the perspective of the event producer. Further note that not all events will have an associated outcome. For example, this field is generally not populated for metric events, events with \`event.type:info\`, or any events for which an outcome does not make logical sense. | keyword | +| event.type | This is one of four ECS Categorization Fields, and indicates the third level in the ECS category hierarchy. \`event.type\` represents a categorization "sub-bucket" that, when used along with the \`event.category\` field values, enables filtering events down to a level appropriate for single visualization. This field is an array. This will allow proper categorization of some events that fall in multiple event types. | keyword | +| host.architecture | Operating system architecture. | keyword | +| host.containerized | If the host is a container. | boolean | +| host.domain | Name of the domain of which the host is a member. For example, on Windows this could be the host's Active Directory domain or NetBIOS domain name. For Linux this could be the domain of the host's LDAP provider. | keyword | +| host.hostname | Hostname of the host. It normally contains what the \`hostname\` command returns on the host machine. | keyword | +| host.id | Unique host id. As hostname is not always unique, use values that are meaningful in your environment. Example: The current usage of \`beat.name\`. | keyword | +| host.ip | Host ip addresses. | ip | +| host.mac | Host mac addresses. | keyword | +| host.name | Name of the host. It can contain what \`hostname\` returns on Unix systems, the fully qualified domain name, or a name specified by the user. The sender decides which value to use. | keyword | +| host.os.build | OS build information. | keyword | +| host.os.codename | OS codename, if any. | keyword | +| host.os.family | OS family (such as redhat, debian, freebsd, windows). | keyword | +| host.os.kernel | Operating system kernel version as a raw string. | keyword | +| host.os.name | Operating system name, without the version. | keyword | +| host.os.platform | Operating system platform (such centos, ubuntu, windows). | keyword | +| host.os.version | Operating system version as a raw string. | keyword | +| host.type | Type of host. For Cloud providers this can be the machine type like \`t2.medium\`. If vm, this could be the container, for example, or other information meaningful in your environment. | keyword | +| input.type | Type of Filebeat input. | keyword | +| log.file.path | Path to the log file. | keyword | +| log.flags | Flags for the log file. | keyword | +| log.offset | Offset of the entry in the log file. | long | +| message | For log events the message field contains the log message, optimized for viewing in a log viewer. For structured logs without an original message field, other fields can be concatenated to form a human-readable summary of the event. If multiple messages exist, they can be combined into one message. | match_only_text | +| okta.actor.alternate_id | Alternate identifier of the actor. | keyword | +| okta.actor.display_name | Display name of the actor. | keyword | +| okta.actor.id | Identifier of the actor. | keyword | +| okta.actor.type | Type of the actor. | keyword | +| okta.authentication_context.authentication_provider | The information about the authentication provider. Must be one of OKTA_AUTHENTICATION_PROVIDER, ACTIVE_DIRECTORY, LDAP, FEDERATION, SOCIAL, FACTOR_PROVIDER. | keyword | +| okta.authentication_context.authentication_step | The authentication step. | integer | +| okta.authentication_context.credential_provider | The information about credential provider. Must be one of OKTA_CREDENTIAL_PROVIDER, RSA, SYMANTEC, GOOGLE, DUO, YUBIKEY. | keyword | +| okta.authentication_context.credential_type | The information about credential type. Must be one of OTP, SMS, PASSWORD, ASSERTION, IWA, EMAIL, OAUTH2, JWT, CERTIFICATE, PRE_SHARED_SYMMETRIC_KEY, OKTA_CLIENT_SESSION, DEVICE_UDID. | keyword | +| okta.authentication_context.external_session_id | The session identifer of the external session if any. | keyword | +| okta.authentication_context.interface | The interface used. e.g., Outlook, Office365, wsTrust | keyword | +| okta.authentication_context.issuer.id | The identifier of the issuer. | keyword | +| okta.authentication_context.issuer.type | The type of the issuer. | keyword | +| okta.client.device | The information of the client device. | keyword | +| okta.client.id | The identifier of the client. | keyword | +| okta.client.ip | The IP address of the client. | ip | +| okta.client.user_agent.browser | The browser informaton of the client. | keyword | +| okta.client.user_agent.os | The OS informaton. | keyword | +| okta.client.user_agent.raw_user_agent | The raw informaton of the user agent. | keyword | +| okta.client.zone | The zone information of the client. | keyword | +| okta.debug_context.debug_data.device_fingerprint | The fingerprint of the device. | keyword | +| okta.debug_context.debug_data.request_id | The identifier of the request. | keyword | +| okta.debug_context.debug_data.request_uri | The request URI. | keyword | +| okta.debug_context.debug_data.threat_suspected | Threat suspected. | keyword | +| okta.debug_context.debug_data.url | The URL. | keyword | +| okta.display_message | The display message of the LogEvent. | keyword | +| okta.event_type | The type of the LogEvent. | keyword | +| okta.outcome.reason | The reason of the outcome. | keyword | +| okta.outcome.result | The result of the outcome. Must be one of: SUCCESS, FAILURE, SKIPPED, ALLOW, DENY, CHALLENGE, UNKNOWN. | keyword | +| okta.request.ip_chain.geographical_context.city | The city. | keyword | +| okta.request.ip_chain.geographical_context.country | The country. | keyword | +| okta.request.ip_chain.geographical_context.geolocation | Geolocation information. | geo_point | +| okta.request.ip_chain.geographical_context.postal_code | The postal code. | keyword | +| okta.request.ip_chain.geographical_context.state | The state. | keyword | +| okta.request.ip_chain.ip | IP address. | ip | +| okta.request.ip_chain.source | Source information. | keyword | +| okta.request.ip_chain.version | IP version. Must be one of V4, V6. | keyword | +| okta.security_context.as.number | The AS number. | integer | +| okta.security_context.as.organization.name | The organization name. | keyword | +| okta.security_context.domain | The domain name. | keyword | +| okta.security_context.is_proxy | Whether it is a proxy or not. | boolean | +| okta.security_context.isp | The Internet Service Provider. | keyword | +| okta.severity | The severity of the LogEvent. Must be one of DEBUG, INFO, WARN, or ERROR. | keyword | +| okta.target.alternate_id | Alternate identifier of the actor. | keyword | +| okta.target.display_name | Display name of the actor. | keyword | +| okta.target.id | Identifier of the actor. | keyword | +| okta.target.type | Type of the actor. | keyword | +| okta.transaction.id | Identifier of the transaction. | keyword | +| okta.transaction.type | The type of transaction. Must be one of "WEB", "JOB". | keyword | +| okta.uuid | The unique identifier of the Okta LogEvent. | keyword | +| okta.version | The version of the LogEvent. | keyword | +| related.ip | All of the IPs seen on your event. | ip | +| related.user | All the user names or other user identifiers seen on the event. | keyword | +| source.as.number | Unique number allocated to the autonomous system. The autonomous system number (ASN) uniquely identifies each network on the Internet. | long | +| source.as.organization.name | Organization name. | keyword | +| source.domain | Source domain. | keyword | +| source.geo.city_name | City name. | keyword | +| source.geo.continent_name | Name of the continent. | keyword | +| source.geo.country_iso_code | Country ISO code. | keyword | +| source.geo.country_name | Country name. | keyword | +| source.geo.location | Longitude and latitude. | geo_point | +| source.geo.name | User-defined description of a location, at the level of granularity they care about. Could be the name of their data centers, the floor number, if this describes a local physical entity, city names. Not typically used in automated geolocation. | keyword | +| source.geo.region_iso_code | Region ISO code. | keyword | +| source.geo.region_name | Region name. | keyword | +| source.ip | IP address of the source (IPv4 or IPv6). | ip | +| source.user.full_name | User's full name, if available. | keyword | +| source.user.id | Unique identifier of the user. | keyword | +| tags | List of keywords used to tag each event. | keyword | +| user.domain | Name of the directory the user is a member of. For example, an LDAP or Active Directory domain name. | keyword | +| user.email | User email address. | keyword | +| user.full_name | User's full name, if available. | keyword | +| user.id | Unique identifier of the user. | keyword | +| user.name | Short name or login of the user. | keyword | +| user.target.domain | Name of the directory the user is a member of. For example, an LDAP or Active Directory domain name. | keyword | +| user.target.email | User email address. | keyword | +| user.target.full_name | User's full name, if available. | keyword | +| user.target.group.domain | Name of the directory the group is a member of. For example, an LDAP or Active Directory domain name. | keyword | +| user.target.group.id | Unique identifier for the group on the system/platform. | keyword | +| user.target.group.name | Name of the group. | keyword | +| user.target.id | Unique identifier of the user. | keyword | +| user.target.name | Short name or login of the user. | keyword | +| user_agent.device.name | Name of the device. | keyword | +| user_agent.name | Name of the user agent. | keyword | +| user_agent.original | Unparsed user_agent string. | keyword | +| user_agent.os.full | Operating system name, including the version or code name. | keyword | +| user_agent.os.name | Operating system name, without the version. | keyword | +| user_agent.os.version | Operating system version as a raw string. | keyword | +| user_agent.version | Version of the user agent. | keyword | +`; diff --git a/x-pack/plugins/fleet/storybook/context/http.ts b/x-pack/plugins/fleet/storybook/context/http.ts index 3f2f46c8331be..c52429c243ba9 100644 --- a/x-pack/plugins/fleet/storybook/context/http.ts +++ b/x-pack/plugins/fleet/storybook/context/http.ts @@ -27,6 +27,8 @@ export const getHttp = (basepath = BASE_PATH) => { serverBasePath: basepath, }, get: (async (path: string, options: HttpFetchOptions) => { + // TODO: all of this needs revision, as it's far too clunky... but it works for now, + // with the few paths we're supporting. if (path === '/api/fleet/agents/setup') { if (!isReady) { isReady = true; @@ -50,6 +52,28 @@ export const getHttp = (basepath = BASE_PATH) => { return await import('./fixtures/packages'); } + // Ideally, this would be a markdown file instead of a ts file, but we don't have + // markdown-loader in our package.json, so we'll make do with what we have. + if (path.startsWith('/api/fleet/epm/packages/nginx/')) { + const { readme } = await import('./fixtures/readme.nginx'); + return readme; + } + + if (path.startsWith('/api/fleet/epm/packages/nginx')) { + return await import('./fixtures/integration.nginx'); + } + + // Ideally, this would be a markdown file instead of a ts file, but we don't have + // markdown-loader in our package.json, so we'll make do with what we have. + if (path.startsWith('/api/fleet/epm/packages/okta/')) { + const { readme } = await import('./fixtures/readme.okta'); + return readme; + } + + if (path.startsWith('/api/fleet/epm/packages/okta')) { + return await import('./fixtures/integration.okta'); + } + return {}; }) as HttpHandler, } as unknown as HttpStart; From f79a96fe7fb581b5387a722175e7303ecfdd5e81 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 29 Sep 2021 17:15:06 +0000 Subject: [PATCH 03/21] skip flaky suite (#113043) --- x-pack/test/functional/apps/lens/heatmap.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/lens/heatmap.ts b/x-pack/test/functional/apps/lens/heatmap.ts index 0655b09e84d56..ddc4130d388ce 100644 --- a/x-pack/test/functional/apps/lens/heatmap.ts +++ b/x-pack/test/functional/apps/lens/heatmap.ts @@ -13,7 +13,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const elasticChart = getService('elasticChart'); const testSubjects = getService('testSubjects'); - describe('lens heatmap', () => { + // FLAKY: https://github.com/elastic/kibana/issues/113043 + describe.skip('lens heatmap', () => { before(async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); From a7874ff8a5b2ee15c0ec298ff1a90b13f1e1b3ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Wed, 29 Sep 2021 20:07:13 +0200 Subject: [PATCH 04/21] [Fix] Replace Osquery query parser lib (#113425) --- package.json | 2 +- src/dev/license_checker/config.ts | 1 - typings/js_sql_parser.d.ts | 9 ++ .../queries/ecs_mapping_editor_field.tsx | 105 ++++++++++-------- yarn.lock | 14 +-- 5 files changed, 77 insertions(+), 54 deletions(-) create mode 100644 typings/js_sql_parser.d.ts diff --git a/package.json b/package.json index c07efa4e607c3..c2b30ef7ef150 100644 --- a/package.json +++ b/package.json @@ -266,6 +266,7 @@ "js-levenshtein": "^1.1.6", "js-search": "^1.4.3", "js-sha256": "^0.9.0", + "js-sql-parser": "^1.4.1", "js-yaml": "^3.14.0", "json-stable-stringify": "^1.0.1", "json-stringify-pretty-compact": "1.2.0", @@ -298,7 +299,6 @@ "nock": "12.0.3", "node-fetch": "^2.6.1", "node-forge": "^0.10.0", - "node-sql-parser": "^3.6.1", "nodemailer": "^6.6.2", "normalize-path": "^3.0.0", "object-hash": "^1.3.1", diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index faff660a73114..b996ae0167555 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -73,7 +73,6 @@ export const DEV_ONLY_LICENSE_ALLOWED = ['MPL-2.0']; export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint - 'node-sql-parser@3.6.1': ['(GPL-2.0 OR MIT)'], // GPL-2.0* https://github.com/taozhi8833998/node-sql-parser '@elastic/ems-client@7.15.0': ['Elastic License 2.0'], '@elastic/eui@38.0.1': ['SSPL-1.0 OR Elastic License 2.0'], diff --git a/typings/js_sql_parser.d.ts b/typings/js_sql_parser.d.ts new file mode 100644 index 0000000000000..b58091d0117e3 --- /dev/null +++ b/typings/js_sql_parser.d.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. + */ + +declare module 'js-sql-parser'; diff --git a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/ecs_mapping_editor_field.tsx b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/ecs_mapping_editor_field.tsx index 9f203d7bf751f..92f2cfa973ce8 100644 --- a/x-pack/plugins/osquery/public/scheduled_query_groups/queries/ecs_mapping_editor_field.tsx +++ b/x-pack/plugins/osquery/public/scheduled_query_groups/queries/ecs_mapping_editor_field.tsx @@ -6,7 +6,7 @@ */ import { produce } from 'immer'; -import { find, orderBy, sortedUniqBy, isArray, map } from 'lodash'; +import { isEmpty, find, orderBy, sortedUniqBy, isArray, map } from 'lodash'; import React, { forwardRef, useCallback, @@ -30,7 +30,7 @@ import { EuiText, EuiIcon, } from '@elastic/eui'; -import { Parser, Select } from 'node-sql-parser'; +import sqlParser from 'js-sql-parser'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; @@ -615,47 +615,65 @@ export const ECSMappingEditorField = ({ field, query, fieldRef }: ECSMappingEdit return currentValue; } - const parser = new Parser(); - let ast: Select; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ast: Record | undefined; try { - const parsedQuery = parser.astify(query); - ast = (isArray(parsedQuery) ? parsedQuery[0] : parsedQuery) as Select; + ast = sqlParser.parse(query)?.value; } catch (e) { return currentValue; } - const tablesOrderMap = ast?.from?.reduce((acc, table, index) => { - acc[table.as ?? table.table] = index; - return acc; - }, {}); - - const astOsqueryTables: Record = ast?.from?.reduce((acc, table) => { - const osqueryTable = find(osquerySchema, ['name', table.table]); - - if (osqueryTable) { - acc[table.as ?? table.table] = osqueryTable.columns; - } + const tablesOrderMap = + ast?.from?.value?.reduce( + ( + acc: { [x: string]: number }, + table: { value: { alias?: { value: string }; value: { value: string } } }, + index: number + ) => { + acc[table.value.alias?.value ?? table.value.value.value] = index; + return acc; + }, + {} + ) ?? {}; + + const astOsqueryTables: Record = + ast?.from?.value?.reduce( + ( + acc: { [x: string]: OsqueryColumn[] }, + table: { value: { alias?: { value: string }; value: { value: string } } } + ) => { + const osqueryTable = find(osquerySchema, ['name', table.value.value.value]); + + if (osqueryTable) { + acc[table.value.alias?.value ?? table.value.value.value] = osqueryTable.columns; + } - return acc; - }, {}); + return acc; + }, + {} + ) ?? {}; // Table doesn't exist in osquery schema - if ( - !isArray(ast?.columns) && - ast?.columns !== '*' && - !astOsqueryTables[ast?.from && ast?.from[0].table] - ) { + if (isEmpty(astOsqueryTables)) { return currentValue; } + /* Simple query select * from users; */ - if (ast?.columns === '*' && ast.from?.length && astOsqueryTables[ast.from[0].table]) { - const tableName = ast.from[0].as ?? ast.from[0].table; + if ( + ast?.selectItems?.value?.length && + ast?.selectItems?.value[0].value === '*' && + ast.from?.value?.length && + ast?.from.value[0].value?.value?.value && + astOsqueryTables[ast.from.value[0].value.value.value] + ) { + const tableName = + ast.from.value[0].value.alias?.value ?? ast.from.value[0].value.value.value; - return astOsqueryTables[ast.from[0].table].map((osqueryColumn) => ({ + return astOsqueryTables[ast.from.value[0].value.value.value].map((osqueryColumn) => ({ label: osqueryColumn.name, value: { name: osqueryColumn.name, @@ -672,39 +690,39 @@ export const ECSMappingEditorField = ({ field, query, fieldRef }: ECSMappingEdit select i.*, p.resident_size, p.user_time, p.system_time, time.minutes as counter from osquery_info i, processes p, time where p.pid = i.pid; */ const suggestions = - isArray(ast?.columns) && - ast?.columns - ?.map((column) => { - if (column.expr.column === '*' && astOsqueryTables[column.expr.table]) { - return astOsqueryTables[column.expr.table].map((osqueryColumn) => ({ + isArray(ast?.selectItems?.value) && + ast?.selectItems?.value + // @ts-expect-error update types + ?.map((selectItem) => { + const [table, column] = selectItem.value?.split('.'); + + if (column === '*' && astOsqueryTables[table]) { + return astOsqueryTables[table].map((osqueryColumn) => ({ label: osqueryColumn.name, value: { name: osqueryColumn.name, description: osqueryColumn.description, - table: column.expr.table, - tableOrder: tablesOrderMap[column.expr.table], + table, + tableOrder: tablesOrderMap[table], suggestion_label: `${osqueryColumn.name}`, }, })); } - if (astOsqueryTables && astOsqueryTables[column.expr.table]) { - const osqueryColumn = find(astOsqueryTables[column.expr.table], [ - 'name', - column.expr.column, - ]); + if (astOsqueryTables && astOsqueryTables[table]) { + const osqueryColumn = find(astOsqueryTables[table], ['name', column]); if (osqueryColumn) { - const label = column.as ?? column.expr.column; + const label = selectItem.hasAs ? selectItem.alias : column; return [ { - label: column.as ?? column.expr.column, + label, value: { name: osqueryColumn.name, description: osqueryColumn.description, - table: column.expr.table, - tableOrder: tablesOrderMap[column.expr.table], + table, + tableOrder: tablesOrderMap[table], suggestion_label: `${label}`, }, }, @@ -718,7 +736,6 @@ export const ECSMappingEditorField = ({ field, query, fieldRef }: ECSMappingEdit // Remove column duplicates by keeping the column from the table that appears last in the query return sortedUniqBy( - // @ts-expect-error update types orderBy(suggestions, ['value.suggestion_label', 'value.tableOrder'], ['asc', 'desc']), 'label' ); diff --git a/yarn.lock b/yarn.lock index bc0123c9f44ee..ac54144a8fc11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8362,7 +8362,7 @@ better-opn@^2.0.0: dependencies: open "^7.0.3" -big-integer@^1.6.16, big-integer@^1.6.48: +big-integer@^1.6.16: version "1.6.48" resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.48.tgz#8fd88bd1632cba4a1c8c3e3d7159f08bb95b4b9e" integrity sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w== @@ -17523,6 +17523,11 @@ js-sha3@0.8.0: resolved "https://registry.yarnpkg.com/js-sha3/-/js-sha3-0.8.0.tgz#b9b7a5da73afad7dedd0f8c463954cbde6818840" integrity sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q== +js-sql-parser@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/js-sql-parser/-/js-sql-parser-1.4.1.tgz#775516b3187dd5872ecec04bef8ed4a430242fda" + integrity sha512-J8zi3+/yK4FWSnVvLOjS2HIGfJhR6v7ApwIF8gZ/SpaO/tFIDlsgugD6ZMn6flXiuMsCjJxvhE0+xBgbdzvDDw== + js-string-escape@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef" @@ -20096,13 +20101,6 @@ node-sass@^6.0.1: stdout-stream "^1.4.0" "true-case-path" "^1.0.2" -node-sql-parser@^3.6.1: - version "3.6.1" - resolved "https://registry.yarnpkg.com/node-sql-parser/-/node-sql-parser-3.6.1.tgz#6f096e9df1f19d1e2daa658d864bd68b0e2cd2c6" - integrity sha512-AseDvELmUvL22L6C63DsTuzF+0i/HBIHjJq/uxC7jV3PGpAUib5Oe6oz4sgAniSUMPSZQbZmRore6Na68Sg4Tg== - dependencies: - big-integer "^1.6.48" - nodemailer@^6.6.2: version "6.6.2" resolved "https://registry.yarnpkg.com/nodemailer/-/nodemailer-6.6.2.tgz#e184c9ed5bee245a3e0bcabc7255866385757114" From 39899f8d2bb2ada6d0948c43c752a6544fac4c0b Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Wed, 29 Sep 2021 15:28:34 -0400 Subject: [PATCH 05/21] [App Search] Wired up the suggestions table to logic (#113322) --- .../components/suggestions_logic.test.tsx | 152 ++++++++++++++++++ .../components/suggestions_logic.tsx | 98 +++++++++++ .../components/suggestions_table.test.tsx | 37 ++++- .../components/suggestions_table.tsx | 42 ++--- .../curations/views/curations.test.tsx | 6 +- .../server/routes/app_search/index.ts | 2 + .../search_relevance_suggestions.test.ts | 40 +++++ .../search_relevance_suggestions.ts | 39 +++++ 8 files changed, 384 insertions(+), 32 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.tsx create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.test.tsx new file mode 100644 index 0000000000000..5afbce3661da3 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.test.tsx @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, +} from '../../../../__mocks__/kea_logic'; +import '../../../__mocks__/engine_logic.mock'; + +import { nextTick } from '@kbn/test/jest'; + +import { DEFAULT_META } from '../../../../shared/constants'; + +import { SuggestionsLogic } from './suggestions_logic'; + +const DEFAULT_VALUES = { + dataLoading: true, + suggestions: [], + meta: { + ...DEFAULT_META, + page: { + ...DEFAULT_META.page, + size: 10, + }, + }, +}; + +const MOCK_RESPONSE = { + meta: { + page: { + current: 1, + size: 10, + total_results: 1, + total_pages: 1, + }, + }, + results: [ + { + query: 'foo', + updated_at: '2021-07-08T14:35:50Z', + promoted: ['1', '2'], + }, + ], +}; + +describe('SuggestionsLogic', () => { + const { mount } = new LogicMounter(SuggestionsLogic); + const { flashAPIErrors } = mockFlashMessageHelpers; + const { http } = mockHttpValues; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has expected default values', () => { + mount(); + expect(SuggestionsLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('onSuggestionsLoaded', () => { + it('should set suggestion, meta state, & dataLoading to false', () => { + mount(); + + SuggestionsLogic.actions.onSuggestionsLoaded(MOCK_RESPONSE); + + expect(SuggestionsLogic.values).toEqual({ + ...DEFAULT_VALUES, + suggestions: MOCK_RESPONSE.results, + meta: MOCK_RESPONSE.meta, + dataLoading: false, + }); + }); + }); + + describe('onPaginate', () => { + it('should update meta', () => { + mount(); + + SuggestionsLogic.actions.onPaginate(2); + + expect(SuggestionsLogic.values).toEqual({ + ...DEFAULT_VALUES, + meta: { + ...DEFAULT_META, + page: { + ...DEFAULT_META.page, + current: 2, + }, + }, + }); + }); + }); + }); + + describe('listeners', () => { + describe('loadSuggestions', () => { + it('should set dataLoading state', () => { + mount({ dataLoading: false }); + + SuggestionsLogic.actions.loadSuggestions(); + + expect(SuggestionsLogic.values).toEqual({ + ...DEFAULT_VALUES, + dataLoading: true, + }); + }); + + it('should make an API call and set suggestions & meta state', async () => { + http.post.mockReturnValueOnce(Promise.resolve(MOCK_RESPONSE)); + mount(); + jest.spyOn(SuggestionsLogic.actions, 'onSuggestionsLoaded'); + + SuggestionsLogic.actions.loadSuggestions(); + await nextTick(); + + expect(http.post).toHaveBeenCalledWith( + '/internal/app_search/engines/some-engine/search_relevance_suggestions', + { + body: JSON.stringify({ + page: { + current: 1, + size: 10, + }, + filters: { + status: ['pending'], + type: 'curation', + }, + }), + } + ); + + expect(SuggestionsLogic.actions.onSuggestionsLoaded).toHaveBeenCalledWith(MOCK_RESPONSE); + }); + + it('handles errors', async () => { + http.post.mockReturnValueOnce(Promise.reject('error')); + mount(); + + SuggestionsLogic.actions.loadSuggestions(); + await nextTick(); + + expect(flashAPIErrors).toHaveBeenCalledWith('error'); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.tsx new file mode 100644 index 0000000000000..9352bdab51edd --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_logic.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 { kea, MakeLogicType } from 'kea'; + +import { Meta } from '../../../../../../common/types'; +import { DEFAULT_META } from '../../../../shared/constants'; +import { flashAPIErrors } from '../../../../shared/flash_messages'; +import { HttpLogic } from '../../../../shared/http'; +import { updateMetaPageIndex } from '../../../../shared/table_pagination'; +import { EngineLogic } from '../../engine'; +import { CurationSuggestion } from '../types'; + +interface SuggestionsAPIResponse { + results: CurationSuggestion[]; + meta: Meta; +} + +interface SuggestionsValues { + dataLoading: boolean; + suggestions: CurationSuggestion[]; + meta: Meta; +} + +interface SuggestionActions { + loadSuggestions(): void; + onPaginate(newPageIndex: number): { newPageIndex: number }; + onSuggestionsLoaded(response: SuggestionsAPIResponse): SuggestionsAPIResponse; +} + +export const SuggestionsLogic = kea>({ + path: ['enterprise_search', 'app_search', 'curations', 'suggestions_logic'], + actions: () => ({ + onPaginate: (newPageIndex) => ({ newPageIndex }), + onSuggestionsLoaded: ({ results, meta }) => ({ results, meta }), + loadSuggestions: true, + }), + reducers: () => ({ + dataLoading: [ + true, + { + loadSuggestions: () => true, + onSuggestionsLoaded: () => false, + }, + ], + suggestions: [ + [], + { + onSuggestionsLoaded: (_, { results }) => results, + }, + ], + meta: [ + { + ...DEFAULT_META, + page: { + ...DEFAULT_META.page, + size: 10, + }, + }, + { + onSuggestionsLoaded: (_, { meta }) => meta, + onPaginate: (state, { newPageIndex }) => updateMetaPageIndex(state, newPageIndex), + }, + ], + }), + listeners: ({ actions, values }) => ({ + loadSuggestions: async () => { + const { meta } = values; + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + try { + const response = await http.post( + `/internal/app_search/engines/${engineName}/search_relevance_suggestions`, + { + body: JSON.stringify({ + page: { + current: meta.page.current, + size: meta.page.size, + }, + filters: { + status: ['pending'], + type: 'curation', + }, + }), + } + ); + actions.onSuggestionsLoaded(response); + } catch (e) { + flashAPIErrors(e); + } + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.test.tsx index f12224908bd78..b49cea2519eda 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.test.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import { mockKibanaValues, setMockValues } from '../../../../__mocks__/kea_logic'; +import '../../../../__mocks__/shallow_useeffect.mock'; +import { mockKibanaValues, setMockActions, setMockValues } from '../../../../__mocks__/kea_logic'; import '../../../__mocks__/engine_logic.mock'; import React from 'react'; @@ -21,10 +22,31 @@ describe('SuggestionsTable', () => { const values = { engineName: 'some-engine', + dataLoading: false, + suggestions: [ + { + query: 'foo', + updated_at: '2021-07-08T14:35:50Z', + promoted: ['1', '2'], + }, + ], + meta: { + page: { + current: 1, + size: 10, + total_results: 2, + }, + }, + }; + + const mockActions = { + loadSuggestions: jest.fn(), + onPaginate: jest.fn(), }; beforeAll(() => { setMockValues(values); + setMockActions(mockActions); }); beforeEach(() => { @@ -79,4 +101,17 @@ describe('SuggestionsTable', () => { }); expect(navigateToUrl).toHaveBeenCalledWith('/engines/some-engine/curations/suggestions/foo'); }); + + it('fetches data on load', () => { + shallow(); + + expect(mockActions.loadSuggestions).toHaveBeenCalled(); + }); + + it('supports pagination', () => { + const wrapper = shallow(); + wrapper.find(EuiBasicTable).simulate('change', { page: { index: 0 } }); + + expect(mockActions.onPaginate).toHaveBeenCalledWith(1); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.tsx index 7dc664f39f0ff..779b86ce5156e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/components/suggestions_table.tsx @@ -5,7 +5,9 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect } from 'react'; + +import { useActions, useValues } from 'kea'; import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -22,6 +24,8 @@ import { generateEnginePath } from '../../engine'; import { CurationSuggestion } from '../types'; import { convertToDate } from '../utils'; +import { SuggestionsLogic } from './suggestions_logic'; + const getSuggestionRoute = (query: string) => { return generateEnginePath(ENGINE_CURATION_SUGGESTION_PATH, { query }); }; @@ -74,32 +78,14 @@ const columns: Array> = [ ]; export const SuggestionsTable: React.FC = () => { - // TODO wire up this data - const items: CurationSuggestion[] = [ - { - query: 'foo', - updated_at: '2021-07-08T14:35:50Z', - promoted: ['1', '2'], - }, - ]; - const meta = { - page: { - current: 1, - size: 10, - total_results: 100, - total_pages: 10, - }, - }; + const { loadSuggestions, onPaginate } = useActions(SuggestionsLogic); + const { meta, suggestions, dataLoading } = useValues(SuggestionsLogic); + + useEffect(() => { + loadSuggestions(); + }, [meta.page.current]); + const totalSuggestions = meta.page.total_results; - // TODO - // @ts-ignore - const onPaginate = (...params) => { - // eslint-disable-next-line no-console - console.log('paging...'); - // eslint-disable-next-line no-console - console.log(params); - }; - const isLoading = false; return ( { > { }); it('calls loadCurations on page load', () => { - setMockValues({ ...values, myRole: {} }); // Required for AppSearchPageTemplate to load - mountWithIntl(); + shallow(); expect(actions.loadCurations).toHaveBeenCalledTimes(1); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts index f6979bce0e780..737b21e6f5a92 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/index.ts @@ -22,6 +22,7 @@ import { registerResultSettingsRoutes } from './result_settings'; import { registerRoleMappingsRoutes } from './role_mappings'; import { registerSchemaRoutes } from './schema'; import { registerSearchRoutes } from './search'; +import { registerSearchRelevanceSuggestionsRoutes } from './search_relevance_suggestions'; import { registerSearchSettingsRoutes } from './search_settings'; import { registerSearchUIRoutes } from './search_ui'; import { registerSettingsRoutes } from './settings'; @@ -50,4 +51,5 @@ export const registerAppSearchRoutes = (dependencies: RouteDependencies) => { registerCrawlerEntryPointRoutes(dependencies); registerCrawlerCrawlRulesRoutes(dependencies); registerCrawlerSitemapRoutes(dependencies); + registerSearchRelevanceSuggestionsRoutes(dependencies); }; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts new file mode 100644 index 0000000000000..555a66cedc85e --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.test.ts @@ -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 { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; + +import { registerSearchRelevanceSuggestionsRoutes } from './search_relevance_suggestions'; + +describe('search relevance insights routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('POST /internal/app_search/engines/{name}/search_relevance_suggestions', () => { + const mockRouter = new MockRouter({ + method: 'post', + path: '/internal/app_search/engines/{engineName}/search_relevance_suggestions', + }); + + beforeEach(() => { + registerSearchRelevanceSuggestionsRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + mockRouter.callRoute({ + params: { engineName: 'some-engine' }, + }); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:engineName/search_relevance_suggestions', + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.ts new file mode 100644 index 0000000000000..147f68f0476ee --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/search_relevance_suggestions.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 { schema } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../plugin'; + +export function registerSearchRelevanceSuggestionsRoutes({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.post( + { + path: '/internal/app_search/engines/{engineName}/search_relevance_suggestions', + validate: { + params: schema.object({ + engineName: schema.string(), + }), + body: schema.object({ + page: schema.object({ + current: schema.number(), + size: schema.number(), + }), + filters: schema.object({ + status: schema.arrayOf(schema.string()), + type: schema.string(), + }), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:engineName/search_relevance_suggestions', + }) + ); +} From 72097aed0069dd20163fc771a4fe30498cda4e96 Mon Sep 17 00:00:00 2001 From: mgiota Date: Wed, 29 Sep 2021 21:31:04 +0200 Subject: [PATCH 06/21] [RAC][Observability]: test cases for alerts pagination functional tests (#112617) * [RAC][Observability]: test cases for alerts pagination functional tests * page size selector tests * create OPEN_ALERTS_ROWS_COUNT in workdlow status tests * add tests to check if page selector is rendered or not * reorganize tests to visible and non visible pagination controls * default rows per page test * page size selector tests * more page selector tests * write tests for pagination controls * move pagination tests to a new file * remove unused variables * reorganize observability alerts service * undo configuration change * fix workflow status tests after refactoring * clean up * pr review comments * change variable name * rewording pagination tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../{alerts.ts => alerts/common.ts} | 11 +- .../services/observability/alerts/index.ts | 21 +++ .../observability/alerts/pagination.ts | 109 +++++++++++++++ .../apps/observability/alerts/index.ts | 57 ++++---- .../apps/observability/alerts/pagination.ts | 129 ++++++++++++++++++ .../observability/alerts/workflow_status.ts | 32 +++-- .../apps/observability/index.ts | 1 + 7 files changed, 313 insertions(+), 47 deletions(-) rename x-pack/test/functional/services/observability/{alerts.ts => alerts/common.ts} (95%) create mode 100644 x-pack/test/functional/services/observability/alerts/index.ts create mode 100644 x-pack/test/functional/services/observability/alerts/pagination.ts create mode 100644 x-pack/test/observability_functional/apps/observability/alerts/pagination.ts diff --git a/x-pack/test/functional/services/observability/alerts.ts b/x-pack/test/functional/services/observability/alerts/common.ts similarity index 95% rename from x-pack/test/functional/services/observability/alerts.ts rename to x-pack/test/functional/services/observability/alerts/common.ts index 435da8ad94037..7098fdec2a9d4 100644 --- a/x-pack/test/functional/services/observability/alerts.ts +++ b/x-pack/test/functional/services/observability/alerts/common.ts @@ -7,8 +7,8 @@ import querystring from 'querystring'; import { chunk } from 'lodash'; -import { FtrProviderContext } from '../../ftr_provider_context'; -import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { WebElementWrapper } from '../../../../../../test/functional/services/lib/web_element_wrapper'; // Based on the x-pack/test/functional/es_archives/observability/alerts archive. const DATE_WITH_DATA = { @@ -19,12 +19,14 @@ const DATE_WITH_DATA = { const ALERTS_FLYOUT_SELECTOR = 'alertsFlyout'; const COPY_TO_CLIPBOARD_BUTTON_SELECTOR = 'copy-to-clipboard'; const ALERTS_TABLE_CONTAINER_SELECTOR = 'events-viewer-panel'; - const ACTION_COLUMN_INDEX = 1; type WorkflowStatus = 'open' | 'acknowledged' | 'closed'; -export function ObservabilityAlertsProvider({ getPageObjects, getService }: FtrProviderContext) { +export function ObservabilityAlertsCommonProvider({ + getPageObjects, + getService, +}: FtrProviderContext) { const testSubjects = getService('testSubjects'); const flyoutService = getService('flyout'); const pageObjects = getPageObjects(['common']); @@ -156,6 +158,7 @@ export function ObservabilityAlertsProvider({ getPageObjects, getService }: FtrP await actionsOverflowButton.click(); }; + // Workflow status const setWorkflowStatusForRow = async (rowIndex: number, workflowStatus: WorkflowStatus) => { await openActionsMenuForRow(rowIndex); diff --git a/x-pack/test/functional/services/observability/alerts/index.ts b/x-pack/test/functional/services/observability/alerts/index.ts new file mode 100644 index 0000000000000..f373b0d75c543 --- /dev/null +++ b/x-pack/test/functional/services/observability/alerts/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ObservabilityAlertsPaginationProvider } from './pagination'; +import { ObservabilityAlertsCommonProvider } from './common'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export function ObservabilityAlertsProvider(context: FtrProviderContext) { + const common = ObservabilityAlertsCommonProvider(context); + const pagination = ObservabilityAlertsPaginationProvider(context); + + return { + common, + pagination, + }; +} diff --git a/x-pack/test/functional/services/observability/alerts/pagination.ts b/x-pack/test/functional/services/observability/alerts/pagination.ts new file mode 100644 index 0000000000000..6bffcf3596e2d --- /dev/null +++ b/x-pack/test/functional/services/observability/alerts/pagination.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 { FtrProviderContext } from '../../../ftr_provider_context'; + +const ROWS_PER_PAGE_SELECTOR = 'tablePaginationPopoverButton'; +const PREV_BUTTON_SELECTOR = 'pagination-button-previous'; +const NEXT_BUTTON_SELECTOR = 'pagination-button-next'; +const TEN_ROWS_SELECTOR = 'tablePagination-10-rows'; +const TWENTY_FIVE_ROWS_SELECTOR = 'tablePagination-25-rows'; +const FIFTY_ROWS_SELECTOR = 'tablePagination-50-rows'; +const BUTTON_ONE_SELECTOR = 'pagination-button-0'; +const BUTTON_TWO_SELECTOR = 'pagination-button-1'; + +export function ObservabilityAlertsPaginationProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + + const getPageSizeSelector = async () => { + return await testSubjects.find(ROWS_PER_PAGE_SELECTOR); + }; + + const getPageSizeSelectorOrFail = async () => { + return await testSubjects.existOrFail(ROWS_PER_PAGE_SELECTOR); + }; + + const missingPageSizeSelectorOrFail = async () => { + return await testSubjects.missingOrFail(ROWS_PER_PAGE_SELECTOR); + }; + + const getTenRowsPageSelector = async () => { + return await testSubjects.find(TEN_ROWS_SELECTOR); + }; + + const getTwentyFiveRowsPageSelector = async () => { + return await testSubjects.find(TWENTY_FIVE_ROWS_SELECTOR); + }; + + const getFiftyRowsPageSelector = async () => { + return await testSubjects.find(FIFTY_ROWS_SELECTOR); + }; + + const getPrevPageButton = async () => { + return await testSubjects.find(PREV_BUTTON_SELECTOR); + }; + + const getPrevPageButtonOrFail = async () => { + return await testSubjects.existOrFail(PREV_BUTTON_SELECTOR); + }; + + const missingPrevPageButtonOrFail = async () => { + return await testSubjects.missingOrFail(PREV_BUTTON_SELECTOR); + }; + + const getNextPageButton = async () => { + return await testSubjects.find(NEXT_BUTTON_SELECTOR); + }; + + const getNextPageButtonOrFail = async () => { + return await testSubjects.existOrFail(NEXT_BUTTON_SELECTOR); + }; + + const getPaginationButtonOne = async () => { + return await testSubjects.find(BUTTON_ONE_SELECTOR); + }; + + const getPaginationButtonTwo = async () => { + return await testSubjects.find(BUTTON_TWO_SELECTOR); + }; + + const goToNextPage = async () => { + return await (await getNextPageButton()).click(); + }; + + const goToPrevPage = async () => { + return await (await getPrevPageButton()).click(); + }; + + const goToFirstPage = async () => { + await (await getPaginationButtonOne()).click(); + }; + + const getPrevButtonDisabledValue = async () => { + return await (await getPrevPageButton()).getAttribute('disabled'); + }; + + return { + getPageSizeSelector, + getPageSizeSelectorOrFail, + missingPageSizeSelectorOrFail, + getTenRowsPageSelector, + getTwentyFiveRowsPageSelector, + getFiftyRowsPageSelector, + getPrevPageButton, + getPrevPageButtonOrFail, + missingPrevPageButtonOrFail, + getNextPageButton, + getNextPageButtonOrFail, + getPaginationButtonOne, + getPaginationButtonTwo, + goToNextPage, + goToPrevPage, + goToFirstPage, + getPrevButtonDisabledValue, + }; +} diff --git a/x-pack/test/observability_functional/apps/observability/alerts/index.ts b/x-pack/test/observability_functional/apps/observability/alerts/index.ts index 856d7e60996ec..14019472eb2ca 100644 --- a/x-pack/test/observability_functional/apps/observability/alerts/index.ts +++ b/x-pack/test/observability_functional/apps/observability/alerts/index.ts @@ -31,7 +31,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); - await observability.alerts.navigateToTimeWithData(); + await observability.alerts.common.navigateToTimeWithData(); }); after(async () => { @@ -40,50 +40,50 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { describe('Alerts table', () => { it('Renders the table', async () => { - await observability.alerts.getTableOrFail(); + await observability.alerts.common.getTableOrFail(); }); it('Renders the correct number of cells', async () => { await retry.try(async () => { - const cells = await observability.alerts.getTableCells(); + const cells = await observability.alerts.common.getTableCells(); expect(cells.length).to.be(TOTAL_ALERTS_CELL_COUNT); }); }); describe('Filtering', () => { afterEach(async () => { - await observability.alerts.clearQueryBar(); + await observability.alerts.common.clearQueryBar(); }); after(async () => { // NOTE: We do this as the query bar takes the place of the datepicker when it is in focus, so we'll reset // back to default. - await observability.alerts.submitQuery(''); + await observability.alerts.common.submitQuery(''); }); it('Autocompletion works', async () => { - await observability.alerts.typeInQueryBar('kibana.alert.s'); + await observability.alerts.common.typeInQueryBar('kibana.alert.s'); await testSubjects.existOrFail('autocompleteSuggestion-field-kibana.alert.start-'); await testSubjects.existOrFail('autocompleteSuggestion-field-kibana.alert.status-'); }); it('Applies filters correctly', async () => { - await observability.alerts.submitQuery('kibana.alert.status: recovered'); + await observability.alerts.common.submitQuery('kibana.alert.status: recovered'); await retry.try(async () => { - const cells = await observability.alerts.getTableCells(); + const cells = await observability.alerts.common.getTableCells(); expect(cells.length).to.be(RECOVERED_ALERTS_CELL_COUNT); }); }); it('Displays a no data state when filters produce zero results', async () => { - await observability.alerts.submitQuery('kibana.alert.consumer: uptime'); - await observability.alerts.getNoDataStateOrFail(); + await observability.alerts.common.submitQuery('kibana.alert.consumer: uptime'); + await observability.alerts.common.getNoDataStateOrFail(); }); }); describe('Date selection', () => { after(async () => { - await observability.alerts.navigateToTimeWithData(); + await observability.alerts.common.navigateToTimeWithData(); }); it('Correctly applies date picker selections', async () => { @@ -91,7 +91,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await (await testSubjects.find('superDatePickerToggleQuickMenuButton')).click(); // We shouldn't expect any data for the last 15 minutes await (await testSubjects.find('superDatePickerCommonlyUsed_Last_15 minutes')).click(); - await observability.alerts.getNoDataStateOrFail(); + await observability.alerts.common.getNoDataStateOrFail(); await pageObjects.common.waitUntilUrlIncludes('rangeFrom=now-15m&rangeTo=now'); }); }); @@ -99,37 +99,38 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { describe('Flyout', () => { it('Can be opened', async () => { - await observability.alerts.openAlertsFlyout(); - await observability.alerts.getAlertsFlyoutOrFail(); + await observability.alerts.common.openAlertsFlyout(); + await observability.alerts.common.getAlertsFlyoutOrFail(); }); it('Can be closed', async () => { - await observability.alerts.closeAlertsFlyout(); + await observability.alerts.common.closeAlertsFlyout(); await testSubjects.missingOrFail('alertsFlyout'); }); describe('When open', async () => { before(async () => { - await observability.alerts.openAlertsFlyout(); + await observability.alerts.common.openAlertsFlyout(); }); after(async () => { - await observability.alerts.closeAlertsFlyout(); + await observability.alerts.common.closeAlertsFlyout(); }); it('Displays the correct title', async () => { await retry.try(async () => { const titleText = await ( - await observability.alerts.getAlertsFlyoutTitle() + await observability.alerts.common.getAlertsFlyoutTitle() ).getVisibleText(); expect(titleText).to.contain('Log threshold'); }); }); it('Displays the correct content', async () => { - const flyoutTitles = await observability.alerts.getAlertsFlyoutDescriptionListTitles(); + const flyoutTitles = + await observability.alerts.common.getAlertsFlyoutDescriptionListTitles(); const flyoutDescriptions = - await observability.alerts.getAlertsFlyoutDescriptionListDescriptions(); + await observability.alerts.common.getAlertsFlyoutDescriptionListDescriptions(); const expectedTitles = [ 'Status', @@ -158,7 +159,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('Displays a View in App button', async () => { - await observability.alerts.getAlertsFlyoutViewInAppButtonOrFail(); + await observability.alerts.common.getAlertsFlyoutViewInAppButtonOrFail(); }); }); }); @@ -166,35 +167,35 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { describe('Cell actions', () => { beforeEach(async () => { await retry.try(async () => { - const cells = await observability.alerts.getTableCells(); + const cells = await observability.alerts.common.getTableCells(); const alertStatusCell = cells[2]; await alertStatusCell.moveMouseTo(); await retry.waitFor( 'cell actions visible', - async () => await observability.alerts.copyToClipboardButtonExists() + async () => await observability.alerts.common.copyToClipboardButtonExists() ); }); }); afterEach(async () => { - await observability.alerts.clearQueryBar(); + await observability.alerts.common.clearQueryBar(); }); it('Copy button works', async () => { // NOTE: We don't have access to the clipboard in a headless environment, // so we'll just check the button is clickable in the functional tests. - await (await observability.alerts.getCopyToClipboardButton()).click(); + await (await observability.alerts.common.getCopyToClipboardButton()).click(); }); it('Filter for value works', async () => { - await (await observability.alerts.getFilterForValueButton()).click(); + await (await observability.alerts.common.getFilterForValueButton()).click(); const queryBarValue = await ( - await observability.alerts.getQueryBar() + await observability.alerts.common.getQueryBar() ).getAttribute('value'); expect(queryBarValue).to.be('kibana.alert.status: "active"'); // Wait for request await retry.try(async () => { - const cells = await observability.alerts.getTableCells(); + const cells = await observability.alerts.common.getTableCells(); expect(cells.length).to.be(ACTIVE_ALERTS_CELL_COUNT); }); }); diff --git a/x-pack/test/observability_functional/apps/observability/alerts/pagination.ts b/x-pack/test/observability_functional/apps/observability/alerts/pagination.ts new file mode 100644 index 0000000000000..5cefe8fd42c8a --- /dev/null +++ b/x-pack/test/observability_functional/apps/observability/alerts/pagination.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +const ROWS_NEEDED_FOR_PAGINATION = 10; +const DEFAULT_ROWS_PER_PAGE = 50; + +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + + describe('Observability alerts pagination', function () { + this.tags('includeFirefox'); + + const retry = getService('retry'); + const observability = getService('observability'); + + before(async () => { + await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); + await observability.alerts.common.navigateToTimeWithData(); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/functional/es_archives/observability/alerts'); + }); + + describe(`When less than ${ROWS_NEEDED_FOR_PAGINATION} alerts are found`, () => { + before(async () => { + // current archiver has 3 closed alerts + await observability.alerts.common.setWorkflowStatusFilter('closed'); + }); + + after(async () => { + await observability.alerts.common.setWorkflowStatusFilter('open'); + }); + + it('Does not render page size selector', async () => { + await observability.alerts.pagination.missingPageSizeSelectorOrFail(); + }); + + it('Does not render pagination controls', async () => { + await observability.alerts.pagination.missingPrevPageButtonOrFail(); + }); + }); + + describe(`When ${ROWS_NEEDED_FOR_PAGINATION} alerts are found`, () => { + before(async () => { + // current archiver has 12 open alerts + await observability.alerts.common.setWorkflowStatusFilter('open'); + }); + + describe('Page size selector', () => { + it('Renders page size selector', async () => { + await observability.alerts.pagination.getPageSizeSelectorOrFail(); + }); + + it('Default rows per page is 50', async () => { + await retry.try(async () => { + const defaultAlertsPerPage = await ( + await observability.alerts.pagination.getPageSizeSelector() + ).getVisibleText(); + expect(defaultAlertsPerPage).to.contain(DEFAULT_ROWS_PER_PAGE); + }); + }); + + it('Shows up to 10 rows per page', async () => { + await retry.try(async () => { + await (await observability.alerts.pagination.getPageSizeSelector()).click(); + await (await observability.alerts.pagination.getTenRowsPageSelector()).click(); + const tableRows = await observability.alerts.common.getTableCellsInRows(); + expect(tableRows.length).to.not.be.greaterThan(10); + }); + }); + + it('Shows up to 25 rows per page', async () => { + await retry.try(async () => { + await (await observability.alerts.pagination.getPageSizeSelector()).click(); + await (await observability.alerts.pagination.getTwentyFiveRowsPageSelector()).click(); + const tableRows = await observability.alerts.common.getTableCellsInRows(); + expect(tableRows.length).to.not.be.greaterThan(25); + }); + }); + }); + + describe('Pagination controls', () => { + before(async () => { + await (await observability.alerts.pagination.getPageSizeSelector()).click(); + await (await observability.alerts.pagination.getTenRowsPageSelector()).click(); + }); + beforeEach(async () => { + await observability.alerts.pagination.goToFirstPage(); + }); + + it('Renders previous page button', async () => { + await observability.alerts.pagination.getPrevPageButtonOrFail(); + }); + + it('Renders next page button', async () => { + await observability.alerts.pagination.getNextPageButtonOrFail(); + }); + + it('Previous page button is disabled', async () => { + const prevButtonDisabledValue = + await observability.alerts.pagination.getPrevButtonDisabledValue(); + expect(prevButtonDisabledValue).to.be('true'); + }); + + it('Goes to next page', async () => { + await observability.alerts.pagination.goToNextPage(); + const tableRows = await observability.alerts.common.getTableCellsInRows(); + expect(tableRows.length).to.be(2); + }); + + it('Goes to previous page', async () => { + await (await observability.alerts.pagination.getPaginationButtonTwo()).click(); + await observability.alerts.pagination.goToPrevPage(); + const tableRows = await observability.alerts.common.getTableCellsInRows(); + + expect(tableRows.length).to.be(10); + }); + }); + }); + }); +}; diff --git a/x-pack/test/observability_functional/apps/observability/alerts/workflow_status.ts b/x-pack/test/observability_functional/apps/observability/alerts/workflow_status.ts index d491e239c6035..a68636b8cb0c0 100644 --- a/x-pack/test/observability_functional/apps/observability/alerts/workflow_status.ts +++ b/x-pack/test/observability_functional/apps/observability/alerts/workflow_status.ts @@ -8,6 +8,8 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; +const OPEN_ALERTS_ROWS_COUNT = 12; + export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); @@ -19,7 +21,7 @@ export default ({ getService }: FtrProviderContext) => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); - await observability.alerts.navigateToTimeWithData(); + await observability.alerts.common.navigateToTimeWithData(); }); after(async () => { @@ -28,61 +30,61 @@ export default ({ getService }: FtrProviderContext) => { it('is filtered to only show "open" alerts by default', async () => { await retry.try(async () => { - const tableRows = await observability.alerts.getTableCellsInRows(); - expect(tableRows.length).to.be(12); + const tableRows = await observability.alerts.common.getTableCellsInRows(); + expect(tableRows.length).to.be(OPEN_ALERTS_ROWS_COUNT); }); }); it('can be set to "acknowledged" using the row menu', async () => { - await observability.alerts.setWorkflowStatusForRow(0, 'acknowledged'); + await observability.alerts.common.setWorkflowStatusForRow(0, 'acknowledged'); await retry.try(async () => { - const tableRows = await observability.alerts.getTableCellsInRows(); + const tableRows = await observability.alerts.common.getTableCellsInRows(); expect(tableRows.length).to.be(11); }); }); it('can be filtered to only show "acknowledged" alerts using the filter button', async () => { - await observability.alerts.setWorkflowStatusFilter('acknowledged'); + await observability.alerts.common.setWorkflowStatusFilter('acknowledged'); await retry.try(async () => { - const tableRows = await observability.alerts.getTableCellsInRows(); + const tableRows = await observability.alerts.common.getTableCellsInRows(); expect(tableRows.length).to.be(3); }); }); it('can be set to "closed" using the row menu', async () => { - await observability.alerts.setWorkflowStatusForRow(0, 'closed'); + await observability.alerts.common.setWorkflowStatusForRow(0, 'closed'); await retry.try(async () => { - const tableRows = await observability.alerts.getTableCellsInRows(); + const tableRows = await observability.alerts.common.getTableCellsInRows(); expect(tableRows.length).to.be(2); }); }); it('can be filtered to only show "closed" alerts using the filter button', async () => { - await observability.alerts.setWorkflowStatusFilter('closed'); + await observability.alerts.common.setWorkflowStatusFilter('closed'); await retry.try(async () => { - const tableRows = await observability.alerts.getTableCellsInRows(); + const tableRows = await observability.alerts.common.getTableCellsInRows(); expect(tableRows.length).to.be(4); }); }); it('can be set to "open" using the row menu', async () => { - await observability.alerts.setWorkflowStatusForRow(0, 'open'); + await observability.alerts.common.setWorkflowStatusForRow(0, 'open'); await retry.try(async () => { - const tableRows = await observability.alerts.getTableCellsInRows(); + const tableRows = await observability.alerts.common.getTableCellsInRows(); expect(tableRows.length).to.be(3); }); }); it('can be filtered to only show "open" alerts using the filter button', async () => { - await observability.alerts.setWorkflowStatusFilter('open'); + await observability.alerts.common.setWorkflowStatusFilter('open'); await retry.try(async () => { - const tableRows = await observability.alerts.getTableCellsInRows(); + const tableRows = await observability.alerts.common.getTableCellsInRows(); expect(tableRows.length).to.be(12); }); }); diff --git a/x-pack/test/observability_functional/apps/observability/index.ts b/x-pack/test/observability_functional/apps/observability/index.ts index b823e1ee0869b..019fb0994715e 100644 --- a/x-pack/test/observability_functional/apps/observability/index.ts +++ b/x-pack/test/observability_functional/apps/observability/index.ts @@ -13,5 +13,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./alerts')); loadTestFile(require.resolve('./alerts/workflow_status')); + loadTestFile(require.resolve('./alerts/pagination')); }); } From 564e61c1bcbae56c02cead4685c6f23cd552b5ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Wed, 29 Sep 2021 22:43:05 +0200 Subject: [PATCH 07/21] [Security Solution][Endpoint] Ability to assign existing trusted applications to the policy flyout (#112239) - Adds policy assignment flyout to the Policy Details Trusted Apps sub-tab --- .../artifact_entry_card.test.tsx | 43 +- .../artifact_entry_card_minified.test.tsx | 98 +++++ .../artifact_entry_card_minified.tsx | 143 ++++++ .../components/artifact_entry_card/index.ts | 1 + .../artifact_entry_card/test_utils.ts | 49 +++ .../action/policy_trusted_apps_action.ts | 42 +- .../policy_trusted_apps_middleware.ts | 200 ++++++++- .../reducer/initial_policy_details_state.ts | 4 +- .../reducer/trusted_apps_reducer.test.ts | 264 +++++++++++ .../reducer/trusted_apps_reducer.ts | 31 +- .../selectors/policy_settings_selectors.ts | 20 +- .../selectors/trusted_apps_selectors.test.ts | 409 ++++++++++++++++++ .../selectors/trusted_apps_selectors.ts | 97 ++++- .../pages/policy/test_utils/index.ts | 29 ++ .../public/management/pages/policy/types.ts | 9 +- .../policy/view/artifacts/assignable/index.ts | 8 + .../policy_artifacts_assignable_list.test.tsx | 85 ++++ .../policy_artifacts_assignable_list.tsx | 59 +++ .../pages/policy/view/policy_hooks.ts | 71 ++- .../policy/view/trusted_apps/flyout/index.ts | 8 + .../policy_trusted_apps_flyout.test.tsx | 184 ++++++++ .../flyout/policy_trusted_apps_flyout.tsx | 257 +++++++++++ .../layout/policy_trusted_apps_layout.tsx | 27 +- 23 files changed, 2057 insertions(+), 81 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.tsx create mode 100644 x-pack/plugins/security_solution/public/management/components/artifact_entry_card/test_utils.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.test.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.test.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/test_utils/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.tsx diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx index e6e4bb0c2643c..31e49aef0ac19 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card.test.tsx @@ -6,53 +6,12 @@ */ import React from 'react'; -import { cloneDeep } from 'lodash'; import { AppContextTestRender, createAppRootMockRenderer } from '../../../common/mock/endpoint'; import { ArtifactEntryCard, ArtifactEntryCardProps } from './artifact_entry_card'; -import { TrustedAppGenerator } from '../../../../common/endpoint/data_generators/trusted_app_generator'; import { act, fireEvent, getByTestId } from '@testing-library/react'; -import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { AnyArtifact } from './types'; import { isTrustedApp } from './hooks/use_normalized_artifact'; - -const getCommonItemDataOverrides = () => { - return { - name: 'some internal app', - description: 'this app is trusted by the company', - created_at: new Date('2021-07-01').toISOString(), - }; -}; - -const getTrustedAppProvider = () => - new TrustedAppGenerator('seed').generate(getCommonItemDataOverrides()); - -const getExceptionProvider = () => { - // cloneDeep needed because exception mock generator uses state across instances - return cloneDeep( - getExceptionListItemSchemaMock({ - ...getCommonItemDataOverrides(), - os_types: ['windows'], - updated_at: new Date().toISOString(), - created_by: 'Justa', - updated_by: 'Mara', - entries: [ - { - field: 'process.hash.*', - operator: 'included', - type: 'match', - value: '1234234659af249ddf3e40864e9fb241', - }, - { - field: 'process.executable.caseless', - operator: 'included', - type: 'match', - value: '/one/two/three', - }, - ], - tags: ['policy:all'], - }) - ); -}; +import { getTrustedAppProvider, getExceptionProvider } from './test_utils'; describe.each([ ['trusted apps', getTrustedAppProvider], diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.test.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.test.tsx new file mode 100644 index 0000000000000..1178e4b07e5bd --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.test.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 from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../../common/mock/endpoint'; +import { + ArtifactEntryCardMinified, + ArtifactEntryCardMinifiedProps, +} from './artifact_entry_card_minified'; +import { act, fireEvent } from '@testing-library/react'; +import { AnyArtifact } from './types'; +import { getTrustedAppProvider, getExceptionProvider } from './test_utils'; + +describe.each([ + ['trusted apps', getTrustedAppProvider], + ['exceptions/event filters', getExceptionProvider], +])('when using the ArtifactEntryCardMinified component with %s', (_, generateItem) => { + let item: AnyArtifact; + let appTestContext: AppContextTestRender; + let renderResult: ReturnType; + let render: (props: ArtifactEntryCardMinifiedProps) => ReturnType; + let onToggleSelectedArtifactMock: jest.Mock; + + beforeEach(() => { + onToggleSelectedArtifactMock = jest.fn(); + item = generateItem(); + appTestContext = createAppRootMockRenderer(); + render = (props) => { + renderResult = appTestContext.render( + + ); + return renderResult; + }; + }); + + it('should display title', async () => { + render({ item, isSelected: false, onToggleSelectedArtifact: onToggleSelectedArtifactMock }); + + expect(renderResult.getByTestId('testCard-title').textContent).toEqual('some internal app'); + }); + + it('should display description if one exists', async () => { + render({ item, isSelected: false, onToggleSelectedArtifact: onToggleSelectedArtifactMock }); + + expect(renderResult.getByTestId('testCard-description').textContent).toEqual(item.description); + }); + + it('should display default empty value if description does not exist', async () => { + item.description = undefined; + render({ item, isSelected: false, onToggleSelectedArtifact: onToggleSelectedArtifactMock }); + + expect(renderResult.getByTestId('testCard-description').textContent).toEqual('—'); + }); + + it('should collapse/uncollapse critera conditions', async () => { + render({ item, isSelected: false, onToggleSelectedArtifact: onToggleSelectedArtifactMock }); + + expect(renderResult.getByTestId('testCard-collapse').textContent).toEqual('Show details'); + await act(async () => { + await fireEvent.click(renderResult.getByTestId('testCard-collapse')); + }); + expect(renderResult.getByTestId('testCard-criteriaConditions').textContent).toEqual( + ' OSIS WindowsAND process.hash.*IS 1234234659af249ddf3e40864e9fb241AND process.executable.caselessIS /one/two/three' + ); + expect(renderResult.getByTestId('testCard-collapse').textContent).toEqual('Hide details'); + }); + + it('should select artifact when unselected by default', async () => { + render({ item, isSelected: false, onToggleSelectedArtifact: onToggleSelectedArtifactMock }); + + expect(onToggleSelectedArtifactMock).toHaveBeenCalledTimes(0); + await act(async () => { + await fireEvent.click(renderResult.getByTestId(`${item.name}_checkbox`)); + }); + expect(onToggleSelectedArtifactMock).toHaveBeenCalledTimes(1); + expect(onToggleSelectedArtifactMock).toHaveBeenCalledWith(true); + }); + + it('should select artifact when selected by default', async () => { + render({ item, isSelected: true, onToggleSelectedArtifact: onToggleSelectedArtifactMock }); + + expect(onToggleSelectedArtifactMock).toHaveBeenCalledTimes(0); + await act(async () => { + await fireEvent.click(renderResult.getByTestId(`${item.name}_checkbox`)); + }); + expect(onToggleSelectedArtifactMock).toHaveBeenCalledTimes(1); + expect(onToggleSelectedArtifactMock).toHaveBeenCalledWith(false); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.tsx new file mode 100644 index 0000000000000..5fb53e44a0ba7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/artifact_entry_card_minified.tsx @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, useState, useMemo } from 'react'; +import { + CommonProps, + EuiPanel, + EuiText, + EuiAccordion, + EuiTitle, + EuiCheckbox, + EuiSplitPanel, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, +} from '@elastic/eui'; +import styled from 'styled-components'; +import { getEmptyValue } from '../../../common/components/empty_value'; +import { CriteriaConditions, CriteriaConditionsProps } from './components/criteria_conditions'; +import { AnyArtifact } from './types'; +import { useNormalizedArtifact } from './hooks/use_normalized_artifact'; +import { useTestIdGenerator } from '../hooks/use_test_id_generator'; + +const CardContainerPanel = styled(EuiSplitPanel.Outer)` + &.artifactEntryCardMinified + &.artifactEntryCardMinified { + margin-top: ${({ theme }) => theme.eui.spacerSizes.l}; + } +`; + +const CustomSplitInnerPanel = styled(EuiSplitPanel.Inner)` + background-color: ${({ theme }) => theme.eui.euiColorLightestShade} !important; +`; + +export interface ArtifactEntryCardMinifiedProps extends CommonProps { + item: AnyArtifact; + isSelected: boolean; + onToggleSelectedArtifact: (selected: boolean) => void; +} + +/** + * Display Artifact Items (ex. Trusted App, Event Filter, etc) as a minified card. + * This component is a TS Generic that allows you to set what the Item type is + */ +export const ArtifactEntryCardMinified = memo( + ({ + item, + isSelected = false, + onToggleSelectedArtifact, + 'data-test-subj': dataTestSubj, + ...commonProps + }: ArtifactEntryCardMinifiedProps) => { + const artifact = useNormalizedArtifact(item); + const getTestId = useTestIdGenerator(dataTestSubj); + + const [accordionTrigger, setAccordionTrigger] = useState<'open' | 'closed'>('closed'); + + const handleOnToggleAccordion = useCallback(() => { + setAccordionTrigger((current) => (current === 'closed' ? 'open' : 'closed')); + }, []); + + const getAccordionTitle = useCallback( + () => (accordionTrigger === 'open' ? 'Hide details' : 'Show details'), + [accordionTrigger] + ); + + const cardTitle = useMemo( + () => ( + + + + onToggleSelectedArtifact(!isSelected)} + /> + + + +
{artifact.name}
+
+
+
+
+ ), + [artifact.name, getTestId, isSelected, onToggleSelectedArtifact] + ); + + return ( + + {cardTitle} + + + +
{'Description'}
+
+ +

+ {artifact.description || getEmptyValue()} +

+
+
+ + + + {getAccordionTitle()} + + + + + +
+
+ ); + } +); + +ArtifactEntryCardMinified.displayName = 'ArtifactEntryCardMinified'; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/index.ts b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/index.ts index 58c0e160f760d..f37d5d4e650e1 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/index.ts +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/index.ts @@ -6,3 +6,4 @@ */ export * from './artifact_entry_card'; +export * from './artifact_entry_card_minified'; diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/test_utils.ts b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/test_utils.ts new file mode 100644 index 0000000000000..219fa6a8f39b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/components/artifact_entry_card/test_utils.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { cloneDeep } from 'lodash'; +import { TrustedAppGenerator } from '../../../../common/endpoint/data_generators/trusted_app_generator'; +import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; + +export const getCommonItemDataOverrides = () => { + return { + name: 'some internal app', + description: 'this app is trusted by the company', + created_at: new Date('2021-07-01').toISOString(), + }; +}; + +export const getTrustedAppProvider = () => + new TrustedAppGenerator('seed').generate(getCommonItemDataOverrides()); + +export const getExceptionProvider = () => { + // cloneDeep needed because exception mock generator uses state across instances + return cloneDeep( + getExceptionListItemSchemaMock({ + ...getCommonItemDataOverrides(), + os_types: ['windows'], + updated_at: new Date().toISOString(), + created_by: 'Justa', + updated_by: 'Mara', + entries: [ + { + field: 'process.hash.*', + operator: 'included', + type: 'match', + value: '1234234659af249ddf3e40864e9fb241', + }, + { + field: 'process.executable.caseless', + operator: 'included', + type: 'match', + value: '/one/two/three', + }, + ], + tags: ['policy:all'], + }) + ); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/policy_trusted_apps_action.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/policy_trusted_apps_action.ts index 46e0f8293cc33..c7bc142eb78c5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/policy_trusted_apps_action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/action/policy_trusted_apps_action.ts @@ -5,9 +5,41 @@ * 2.0. */ -// TODO: defined trusted apps actions (code below only here to silence TS) +import { AsyncResourceState } from '../../../../../state'; +import { + PostTrustedAppCreateResponse, + GetTrustedListAppsResponse, +} from '../../../../../../../common/endpoint/types'; +export interface PolicyArtifactsAssignableListPageDataChanged { + type: 'policyArtifactsAssignableListPageDataChanged'; + payload: AsyncResourceState; +} + +export interface PolicyArtifactsUpdateTrustedApps { + type: 'policyArtifactsUpdateTrustedApps'; + payload: { + trustedAppIds: string[]; + }; +} + +export interface PolicyArtifactsUpdateTrustedAppsChanged { + type: 'policyArtifactsUpdateTrustedAppsChanged'; + payload: AsyncResourceState; +} + +export interface PolicyArtifactsAssignableListExistDataChanged { + type: 'policyArtifactsAssignableListExistDataChanged'; + payload: AsyncResourceState; +} + +export interface PolicyArtifactsAssignableListPageDataFilter { + type: 'policyArtifactsAssignableListPageDataFilter'; + payload: { filter: string }; +} + export type PolicyTrustedAppsAction = - | { - type: 'a'; - } - | { type: 'b' }; + | PolicyArtifactsAssignableListPageDataChanged + | PolicyArtifactsUpdateTrustedApps + | PolicyArtifactsUpdateTrustedAppsChanged + | PolicyArtifactsAssignableListExistDataChanged + | PolicyArtifactsAssignableListPageDataFilter; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/policy_trusted_apps_middleware.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/policy_trusted_apps_middleware.ts index 171bbd881302e..532e39b482401 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/policy_trusted_apps_middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/middleware/policy_trusted_apps_middleware.ts @@ -5,12 +5,208 @@ * 2.0. */ -import { MiddlewareRunner } from '../../../types'; +import pMap from 'p-map'; +import { find, isEmpty } from 'lodash/fp'; +import { PolicyDetailsState, MiddlewareRunner } from '../../../types'; +import { + policyIdFromParams, + isOnPolicyTrustedAppsPage, + getCurrentArtifactsLocation, + getAssignableArtifactsList, +} from '../selectors'; +import { + ImmutableArray, + ImmutableObject, + PostTrustedAppCreateRequest, + TrustedApp, +} from '../../../../../../../common/endpoint/types'; +import { ImmutableMiddlewareAPI } from '../../../../../../common/store'; +import { TrustedAppsHttpService, TrustedAppsService } from '../../../../trusted_apps/service'; +import { + createLoadedResourceState, + createLoadingResourceState, + createUninitialisedResourceState, + createFailedResourceState, +} from '../../../../../state'; +import { parseQueryFilterToKQL } from '../../../../../common/utils'; +import { SEARCHABLE_FIELDS } from '../../../../trusted_apps/constants'; +import { PolicyDetailsAction } from '../action'; + +const checkIfThereAreAssignableTrustedApps = async ( + store: ImmutableMiddlewareAPI, + trustedAppsService: TrustedAppsService +) => { + const state = store.getState(); + const policyId = policyIdFromParams(state); + + store.dispatch({ + type: 'policyArtifactsAssignableListExistDataChanged', + // Ignore will be fixed with when AsyncResourceState is refactored (#830) + // @ts-ignore + payload: createLoadingResourceState({ previousState: createUninitialisedResourceState() }), + }); + try { + const trustedApps = await trustedAppsService.getTrustedAppsList({ + page: 1, + per_page: 100, + kuery: `(not exception-list-agnostic.attributes.tags:"policy:${policyId}") AND (not exception-list-agnostic.attributes.tags:"policy:all")`, + }); + + store.dispatch({ + type: 'policyArtifactsAssignableListExistDataChanged', + payload: createLoadedResourceState(!isEmpty(trustedApps.data)), + }); + } catch (err) { + store.dispatch({ + type: 'policyArtifactsAssignableListExistDataChanged', + // Ignore will be fixed with when AsyncResourceState is refactored (#830) + // @ts-ignore + payload: createFailedResourceState(err.body ?? err), + }); + } +}; + +const searchTrustedApps = async ( + store: ImmutableMiddlewareAPI, + trustedAppsService: TrustedAppsService, + filter?: string +) => { + const state = store.getState(); + const policyId = policyIdFromParams(state); + + store.dispatch({ + type: 'policyArtifactsAssignableListPageDataChanged', + // Ignore will be fixed with when AsyncResourceState is refactored (#830) + // @ts-ignore + payload: createLoadingResourceState({ previousState: createUninitialisedResourceState() }), + }); + + try { + const kuery = [ + `(not exception-list-agnostic.attributes.tags:"policy:${policyId}") AND (not exception-list-agnostic.attributes.tags:"policy:all")`, + ]; + + if (filter) { + const filterKuery = parseQueryFilterToKQL(filter, SEARCHABLE_FIELDS) || undefined; + if (filterKuery) kuery.push(filterKuery); + } + + const trustedApps = await trustedAppsService.getTrustedAppsList({ + page: 1, + per_page: 100, + kuery: kuery.join(' AND '), + }); + + store.dispatch({ + type: 'policyArtifactsAssignableListPageDataChanged', + payload: createLoadedResourceState(trustedApps), + }); + + if (isEmpty(trustedApps.data)) { + checkIfThereAreAssignableTrustedApps(store, trustedAppsService); + } + } catch (err) { + store.dispatch({ + type: 'policyArtifactsAssignableListPageDataChanged', + // Ignore will be fixed with when AsyncResourceState is refactored (#830) + // @ts-ignore + payload: createFailedResourceState(err.body ?? err), + }); + } +}; + +interface UpdateTrustedAppWrapperProps { + entry: ImmutableObject; + policies: ImmutableArray; +} + +const updateTrustedApps = async ( + store: ImmutableMiddlewareAPI, + trustedAppsService: TrustedAppsService, + trustedAppsIds: ImmutableArray +) => { + const state = store.getState(); + const policyId = policyIdFromParams(state); + const availavleArtifacts = getAssignableArtifactsList(state); + + if (!availavleArtifacts || !availavleArtifacts.data.length) { + return; + } + + store.dispatch({ + type: 'policyArtifactsUpdateTrustedAppsChanged', + // Ignore will be fixed with when AsyncResourceState is refactored (#830) + // @ts-ignore + payload: createLoadingResourceState({ previousState: createUninitialisedResourceState() }), + }); + + try { + const trustedAppsUpdateActions = []; + + const updateTrustedApp = async ({ entry, policies }: UpdateTrustedAppWrapperProps) => + trustedAppsService.updateTrustedApp({ id: entry.id }, { + effectScope: { type: 'policy', policies: [...policies, policyId] }, + name: entry.name, + entries: entry.entries, + os: entry.os, + description: entry.description, + version: entry.version, + } as PostTrustedAppCreateRequest); + + for (const entryId of trustedAppsIds) { + const entry = find({ id: entryId }, availavleArtifacts.data) as ImmutableObject; + if (entry) { + const policies = entry.effectScope.type === 'policy' ? entry.effectScope.policies : []; + trustedAppsUpdateActions.push({ entry, policies }); + } + } + + const updatedTrustedApps = await pMap(trustedAppsUpdateActions, updateTrustedApp, { + concurrency: 5, + /** When set to false, instead of stopping when a promise rejects, it will wait for all the promises to settle + * and then reject with an aggregated error containing all the errors from the rejected promises. */ + stopOnError: false, + }); + + store.dispatch({ + type: 'policyArtifactsUpdateTrustedAppsChanged', + payload: createLoadedResourceState(updatedTrustedApps), + }); + } catch (err) { + store.dispatch({ + type: 'policyArtifactsUpdateTrustedAppsChanged', + // Ignore will be fixed with when AsyncResourceState is refactored (#830) + // @ts-ignore + payload: createFailedResourceState(err.body ?? err), + }); + } +}; export const policyTrustedAppsMiddlewareRunner: MiddlewareRunner = async ( coreStart, store, action ) => { - // FIXME: implement middlware for trusted apps + const http = coreStart.http; + const trustedAppsService = new TrustedAppsHttpService(http); + const state = store.getState(); + if ( + action.type === 'userChangedUrl' && + isOnPolicyTrustedAppsPage(state) && + getCurrentArtifactsLocation(state).show === 'list' + ) { + await searchTrustedApps(store, trustedAppsService); + } else if ( + action.type === 'policyArtifactsUpdateTrustedApps' && + isOnPolicyTrustedAppsPage(state) && + getCurrentArtifactsLocation(state).show === 'list' + ) { + await updateTrustedApps(store, trustedAppsService, action.payload.trustedAppIds); + } else if ( + action.type === 'policyArtifactsAssignableListPageDataFilter' && + isOnPolicyTrustedAppsPage(state) && + getCurrentArtifactsLocation(state).show === 'list' + ) { + await searchTrustedApps(store, trustedAppsService, action.payload.filter); + } }; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/initial_policy_details_state.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/initial_policy_details_state.ts index 723f8fe31bd2a..93108e6f376ae 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/initial_policy_details_state.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/initial_policy_details_state.ts @@ -34,6 +34,8 @@ export const initialPolicyDetailsState: () => Immutable = () show: undefined, filter: '', }, - availableList: createUninitialisedResourceState(), + assignableList: createUninitialisedResourceState(), + trustedAppsToUpdate: createUninitialisedResourceState(), + assignableListEntriesExist: createUninitialisedResourceState(), }, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.test.ts new file mode 100644 index 0000000000000..26efcaa68686d --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { PolicyDetailsState } from '../../../types'; +import { initialPolicyDetailsState } from '../reducer/initial_policy_details_state'; +import { policyTrustedAppsReducer } from './trusted_apps_reducer'; + +import { ImmutableObject } from '../../../../../../../common/endpoint/types'; +import { + createLoadedResourceState, + createUninitialisedResourceState, + createLoadingResourceState, + createFailedResourceState, +} from '../../../../../state'; +import { getMockListResponse, getAPIError, getMockCreateResponse } from '../../../test_utils'; + +describe('policy trusted apps reducer', () => { + let initialState: ImmutableObject; + + beforeEach(() => { + initialState = initialPolicyDetailsState(); + }); + + describe('PolicyTrustedApps', () => { + describe('policyArtifactsAssignableListPageDataChanged', () => { + it('sets assignable list uninitialised', () => { + const result = policyTrustedAppsReducer(initialState, { + type: 'policyArtifactsAssignableListPageDataChanged', + payload: createUninitialisedResourceState(), + }); + + expect(result).toStrictEqual({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableList: { + type: 'UninitialisedResourceState', + }, + }, + }); + }); + it('sets assignable list loading', () => { + const result = policyTrustedAppsReducer(initialState, { + type: 'policyArtifactsAssignableListPageDataChanged', + payload: createLoadingResourceState(createUninitialisedResourceState()), + }); + + expect(result).toStrictEqual({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableList: { + previousState: { + type: 'UninitialisedResourceState', + }, + type: 'LoadingResourceState', + }, + }, + }); + }); + it('sets assignable list loaded', () => { + const result = policyTrustedAppsReducer(initialState, { + type: 'policyArtifactsAssignableListPageDataChanged', + payload: createLoadedResourceState(getMockListResponse()), + }); + + expect(result).toStrictEqual({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableList: { + data: getMockListResponse(), + type: 'LoadedResourceState', + }, + }, + }); + }); + it('sets assignable list failed', () => { + const result = policyTrustedAppsReducer(initialState, { + type: 'policyArtifactsAssignableListPageDataChanged', + payload: createFailedResourceState(getAPIError()), + }); + + expect(result).toStrictEqual({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableList: { + type: 'FailedResourceState', + error: getAPIError(), + lastLoadedState: undefined, + }, + }, + }); + }); + }); + }); + + describe('policyArtifactsUpdateTrustedAppsChanged', () => { + it('sets update trusted app uninitialised', () => { + const result = policyTrustedAppsReducer(initialState, { + type: 'policyArtifactsUpdateTrustedAppsChanged', + payload: createUninitialisedResourceState(), + }); + + expect(result).toStrictEqual({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: { + type: 'UninitialisedResourceState', + }, + }, + }); + }); + it('sets update trusted app loading', () => { + const result = policyTrustedAppsReducer(initialState, { + type: 'policyArtifactsUpdateTrustedAppsChanged', + payload: createLoadingResourceState(createUninitialisedResourceState()), + }); + + expect(result).toStrictEqual({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: { + previousState: { + type: 'UninitialisedResourceState', + }, + type: 'LoadingResourceState', + }, + }, + }); + }); + it('sets update trusted app loaded', () => { + const result = policyTrustedAppsReducer(initialState, { + type: 'policyArtifactsUpdateTrustedAppsChanged', + payload: createLoadedResourceState([getMockCreateResponse()]), + }); + + expect(result).toStrictEqual({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: { + data: [getMockCreateResponse()], + type: 'LoadedResourceState', + }, + }, + }); + }); + it('sets update trusted app failed', () => { + const result = policyTrustedAppsReducer(initialState, { + type: 'policyArtifactsUpdateTrustedAppsChanged', + payload: createFailedResourceState(getAPIError()), + }); + + expect(result).toStrictEqual({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: { + type: 'FailedResourceState', + error: getAPIError(), + lastLoadedState: undefined, + }, + }, + }); + }); + }); + + describe('policyArtifactsAssignableListExistDataChanged', () => { + it('sets exists trusted app uninitialised', () => { + const result = policyTrustedAppsReducer(initialState, { + type: 'policyArtifactsAssignableListExistDataChanged', + payload: createUninitialisedResourceState(), + }); + + expect(result).toStrictEqual({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: { + type: 'UninitialisedResourceState', + }, + }, + }); + }); + it('sets exists trusted app loading', () => { + const result = policyTrustedAppsReducer(initialState, { + type: 'policyArtifactsAssignableListExistDataChanged', + payload: createLoadingResourceState(createUninitialisedResourceState()), + }); + + expect(result).toStrictEqual({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: { + previousState: { + type: 'UninitialisedResourceState', + }, + type: 'LoadingResourceState', + }, + }, + }); + }); + it('sets exists trusted app loaded negative', () => { + const result = policyTrustedAppsReducer(initialState, { + type: 'policyArtifactsAssignableListExistDataChanged', + payload: createLoadedResourceState(false), + }); + + expect(result).toStrictEqual({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: { + data: false, + type: 'LoadedResourceState', + }, + }, + }); + }); + it('sets exists trusted app loaded positive', () => { + const result = policyTrustedAppsReducer(initialState, { + type: 'policyArtifactsAssignableListExistDataChanged', + payload: createLoadedResourceState(true), + }); + + expect(result).toStrictEqual({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: { + data: true, + type: 'LoadedResourceState', + }, + }, + }); + }); + it('sets exists trusted app failed', () => { + const result = policyTrustedAppsReducer(initialState, { + type: 'policyArtifactsAssignableListExistDataChanged', + payload: createFailedResourceState(getAPIError()), + }); + + expect(result).toStrictEqual({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: { + type: 'FailedResourceState', + error: getAPIError(), + lastLoadedState: undefined, + }, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.ts index 7f2f9e437ca06..e2843ec83ee2a 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/reducer/trusted_apps_reducer.ts @@ -14,6 +14,35 @@ export const policyTrustedAppsReducer: ImmutableReducer { - // FIXME: implement trusted apps reducer + if (action.type === 'policyArtifactsAssignableListPageDataChanged') { + return { + ...state, + artifacts: { + ...state.artifacts, + assignableList: action.payload, + }, + }; + } + + if (action.type === 'policyArtifactsUpdateTrustedAppsChanged') { + return { + ...state, + artifacts: { + ...state.artifacts, + trustedAppsToUpdate: action.payload, + }, + }; + } + + if (action.type === 'policyArtifactsAssignableListExistDataChanged') { + return { + ...state, + artifacts: { + ...state.artifacts, + assignableListEntriesExist: action.payload, + }, + }; + } + return state; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts index 23ab0fd73c9e1..84049c98eaa11 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/policy_settings_selectors.ts @@ -9,7 +9,7 @@ import { createSelector } from 'reselect'; import { matchPath } from 'react-router-dom'; import { ILicense } from '../../../../../../../../licensing/common/types'; import { unsetPolicyFeaturesAccordingToLicenseLevel } from '../../../../../../../common/license/policy_config'; -import { PolicyDetailsArtifactsPageLocation, PolicyDetailsState } from '../../../types'; +import { PolicyDetailsState } from '../../../types'; import { Immutable, NewPolicyData, @@ -24,6 +24,7 @@ import { } from '../../../../../common/constants'; import { ManagementRoutePolicyDetailsParams } from '../../../../../types'; import { getPolicyDataForUpdate } from '../../../../../../../common/endpoint/service/policy/get_policy_data_for_update'; +import { isOnPolicyTrustedAppsPage } from './trusted_apps_selectors'; /** Returns the policy details */ export const policyDetails = (state: Immutable) => state.policyItem; @@ -80,13 +81,6 @@ export const needsToRefresh = (state: Immutable): boolean => return !state.policyItem && !state.apiError; }; -/** - * Returns current artifacts location - */ -export const getCurrentArtifactsLocation = ( - state: Immutable -): Immutable => state.artifacts.location; - /** Returns a boolean of whether the user is on the policy form page or not */ export const isOnPolicyFormPage = (state: Immutable) => { return ( @@ -97,16 +91,6 @@ export const isOnPolicyFormPage = (state: Immutable) => { ); }; -/** Returns a boolean of whether the user is on the policy details page or not */ -export const isOnPolicyTrustedAppsPage = (state: Immutable) => { - return ( - matchPath(state.location?.pathname ?? '', { - path: MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, - exact: true, - }) !== null - ); -}; - /** Returns a boolean of whether the user is on some of the policy details page or not */ export const isOnPolicyDetailsPage = (state: Immutable) => isOnPolicyFormPage(state) || isOnPolicyTrustedAppsPage(state); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.test.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.test.ts new file mode 100644 index 0000000000000..6d32988464957 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.test.ts @@ -0,0 +1,409 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PolicyDetailsState } from '../../../types'; +import { initialPolicyDetailsState } from '../reducer/initial_policy_details_state'; +import { + getCurrentArtifactsLocation, + getAssignableArtifactsList, + getAssignableArtifactsListIsLoading, + getUpdateArtifactsIsLoading, + getUpdateArtifactsIsFailed, + getUpdateArtifactsLoaded, + getAssignableArtifactsListExist, + getAssignableArtifactsListExistIsLoading, + getUpdateArtifacts, + isOnPolicyTrustedAppsPage, +} from './trusted_apps_selectors'; + +import { ImmutableObject } from '../../../../../../../common/endpoint/types'; +import { + createLoadedResourceState, + createUninitialisedResourceState, + createLoadingResourceState, + createFailedResourceState, +} from '../../../../../state'; +import { MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH } from '../../../../../common/constants'; +import { getMockListResponse, getAPIError, getMockCreateResponse } from '../../../test_utils'; + +describe('policy trusted apps selectors', () => { + let initialState: ImmutableObject; + + beforeEach(() => { + initialState = initialPolicyDetailsState(); + }); + + describe('isOnPolicyTrustedAppsPage()', () => { + it('when location is on policy trusted apps page', () => { + const isOnPage = isOnPolicyTrustedAppsPage({ + ...initialState, + location: { + pathname: MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, + search: '', + hash: '', + }, + }); + expect(isOnPage).toBeFalsy(); + }); + it('when location is not on policy trusted apps page', () => { + const isOnPage = isOnPolicyTrustedAppsPage({ + ...initialState, + location: { pathname: '', search: '', hash: '' }, + }); + expect(isOnPage).toBeFalsy(); + }); + }); + + describe('getCurrentArtifactsLocation()', () => { + it('when location is defined', () => { + const location = getCurrentArtifactsLocation(initialState); + expect(location).toEqual({ filter: '', page_index: 0, page_size: 10, show: undefined }); + }); + it('when location has show param to list', () => { + const location = getCurrentArtifactsLocation({ + ...initialState, + artifacts: { + ...initialState.artifacts, + location: { ...initialState.artifacts.location, show: 'list' }, + }, + }); + expect(location).toEqual({ filter: '', page_index: 0, page_size: 10, show: 'list' }); + }); + }); + + describe('getAssignableArtifactsList()', () => { + it('when assignable list is uninitialised', () => { + const assignableList = getAssignableArtifactsList(initialState); + expect(assignableList).toBeUndefined(); + }); + it('when assignable list is loading', () => { + const assignableList = getAssignableArtifactsList({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableList: createLoadingResourceState(createUninitialisedResourceState()), + }, + }); + expect(assignableList).toBeUndefined(); + }); + it('when assignable list is loaded', () => { + const assignableList = getAssignableArtifactsList({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableList: createLoadedResourceState(getMockListResponse()), + }, + }); + expect(assignableList).toEqual(getMockListResponse()); + }); + }); + + describe('getAssignableArtifactsListIsLoading()', () => { + it('when assignable list is loading', () => { + const isLoading = getAssignableArtifactsListIsLoading({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableList: createLoadingResourceState(createUninitialisedResourceState()), + }, + }); + expect(isLoading).toBeTruthy(); + }); + it('when assignable list is uninitialised', () => { + const isLoading = getAssignableArtifactsListIsLoading({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableList: createUninitialisedResourceState(), + }, + }); + expect(isLoading).toBeFalsy(); + }); + it('when assignable list is loaded', () => { + const isLoading = getAssignableArtifactsListIsLoading({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableList: createLoadedResourceState(getMockListResponse()), + }, + }); + expect(isLoading).toBeFalsy(); + }); + }); + + describe('getUpdateArtifactsIsLoading()', () => { + it('when update artifacts is loading', () => { + const isLoading = getUpdateArtifactsIsLoading({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createLoadingResourceState(createUninitialisedResourceState()), + }, + }); + expect(isLoading).toBeTruthy(); + }); + it('when update artifacts is uninitialised', () => { + const isLoading = getUpdateArtifactsIsLoading({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createUninitialisedResourceState(), + }, + }); + expect(isLoading).toBeFalsy(); + }); + it('when update artifacts is loaded', () => { + const isLoading = getUpdateArtifactsIsLoading({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createLoadedResourceState([getMockCreateResponse()]), + }, + }); + expect(isLoading).toBeFalsy(); + }); + }); + + describe('getUpdateArtifactsIsFailed()', () => { + it('when update artifacts is loading', () => { + const hasFailed = getUpdateArtifactsIsFailed({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createLoadingResourceState(createUninitialisedResourceState()), + }, + }); + expect(hasFailed).toBeFalsy(); + }); + it('when update artifacts is uninitialised', () => { + const hasFailed = getUpdateArtifactsIsFailed({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createUninitialisedResourceState(), + }, + }); + expect(hasFailed).toBeFalsy(); + }); + it('when update artifacts is loaded', () => { + const hasFailed = getUpdateArtifactsIsFailed({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createLoadedResourceState([getMockCreateResponse()]), + }, + }); + expect(hasFailed).toBeFalsy(); + }); + it('when update artifacts has failed', () => { + const hasFailed = getUpdateArtifactsIsFailed({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createFailedResourceState(getAPIError()), + }, + }); + expect(hasFailed).toBeTruthy(); + }); + }); + + describe('getUpdateArtifactsLoaded()', () => { + it('when update artifacts is loading', () => { + const isLoaded = getUpdateArtifactsLoaded({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createLoadingResourceState(createUninitialisedResourceState()), + }, + }); + expect(isLoaded).toBeFalsy(); + }); + it('when update artifacts is uninitialised', () => { + const isLoaded = getUpdateArtifactsLoaded({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createUninitialisedResourceState(), + }, + }); + expect(isLoaded).toBeFalsy(); + }); + it('when update artifacts is loaded', () => { + const isLoaded = getUpdateArtifactsLoaded({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createLoadedResourceState([getMockCreateResponse()]), + }, + }); + expect(isLoaded).toBeTruthy(); + }); + it('when update artifacts has failed', () => { + const isLoaded = getUpdateArtifactsLoaded({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createFailedResourceState(getAPIError()), + }, + }); + expect(isLoaded).toBeFalsy(); + }); + }); + + describe('getUpdateArtifacts()', () => { + it('when update artifacts is loading', () => { + const isLoading = getUpdateArtifacts({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createLoadingResourceState(createUninitialisedResourceState()), + }, + }); + expect(isLoading).toBeUndefined(); + }); + it('when update artifacts is uninitialised', () => { + const isLoading = getUpdateArtifacts({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createUninitialisedResourceState(), + }, + }); + expect(isLoading).toBeUndefined(); + }); + it('when update artifacts is loaded', () => { + const isLoading = getUpdateArtifacts({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createLoadedResourceState([getMockCreateResponse()]), + }, + }); + expect(isLoading).toEqual([getMockCreateResponse()]); + }); + it('when update artifacts has failed', () => { + const isLoading = getUpdateArtifacts({ + ...initialState, + artifacts: { + ...initialState.artifacts, + trustedAppsToUpdate: createFailedResourceState(getAPIError()), + }, + }); + expect(isLoading).toBeUndefined(); + }); + }); + + describe('getAssignableArtifactsListExist()', () => { + it('when check artifacts exists is loading', () => { + const exists = getAssignableArtifactsListExist({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: createLoadingResourceState( + createUninitialisedResourceState() + ), + }, + }); + expect(exists).toBeFalsy(); + }); + it('when check artifacts exists is uninitialised', () => { + const exists = getAssignableArtifactsListExist({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: createUninitialisedResourceState(), + }, + }); + expect(exists).toBeFalsy(); + }); + it('when check artifacts exists is loaded with negative result', () => { + const exists = getAssignableArtifactsListExist({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: createLoadedResourceState(false), + }, + }); + expect(exists).toBeFalsy(); + }); + it('when check artifacts exists is loaded with positive result', () => { + const exists = getAssignableArtifactsListExist({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: createLoadedResourceState(true), + }, + }); + expect(exists).toBeTruthy(); + }); + it('when check artifacts exists has failed', () => { + const exists = getAssignableArtifactsListExist({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: createFailedResourceState(getAPIError()), + }, + }); + expect(exists).toBeFalsy(); + }); + }); + + describe('getAssignableArtifactsListExistIsLoading()', () => { + it('when check artifacts exists is loading', () => { + const isLoading = getAssignableArtifactsListExistIsLoading({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: createLoadingResourceState( + createUninitialisedResourceState() + ), + }, + }); + expect(isLoading).toBeTruthy(); + }); + it('when check artifacts exists is uninitialised', () => { + const isLoading = getAssignableArtifactsListExistIsLoading({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: createUninitialisedResourceState(), + }, + }); + expect(isLoading).toBeFalsy(); + }); + it('when check artifacts exists is loaded with negative result', () => { + const isLoading = getAssignableArtifactsListExistIsLoading({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: createLoadedResourceState(false), + }, + }); + expect(isLoading).toBeFalsy(); + }); + it('when check artifacts exists is loaded with positive result', () => { + const isLoading = getAssignableArtifactsListExistIsLoading({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: createLoadedResourceState(true), + }, + }); + expect(isLoading).toBeFalsy(); + }); + it('when check artifacts exists has failed', () => { + const isLoading = getAssignableArtifactsListExistIsLoading({ + ...initialState, + artifacts: { + ...initialState.artifacts, + assignableListEntriesExist: createFailedResourceState(getAPIError()), + }, + }); + expect(isLoading).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.ts index f7a568b5ade0e..65d24ac58cab4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors/trusted_apps_selectors.ts @@ -5,6 +5,99 @@ * 2.0. */ -export const isOnTrustedAppsView = () => { - return true; +import { matchPath } from 'react-router-dom'; +import { PolicyDetailsArtifactsPageLocation, PolicyDetailsState } from '../../../types'; +import { + Immutable, + ImmutableArray, + PostTrustedAppCreateResponse, + GetTrustedListAppsResponse, +} from '../../../../../../../common/endpoint/types'; +import { MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH } from '../../../../../common/constants'; +import { + getLastLoadedResourceState, + isFailedResourceState, + isLoadedResourceState, + isLoadingResourceState, +} from '../../../../../state'; + +/** + * Returns current artifacts location + */ +export const getCurrentArtifactsLocation = ( + state: Immutable +): Immutable => state.artifacts.location; + +/** + * Returns current assignable artifacts list + */ +export const getAssignableArtifactsList = ( + state: Immutable +): Immutable | undefined => + getLastLoadedResourceState(state.artifacts.assignableList)?.data; + +/** + * Returns if assignable list is loading + */ +export const getAssignableArtifactsListIsLoading = ( + state: Immutable +): boolean => isLoadingResourceState(state.artifacts.assignableList); + +/** + * Returns if update action is loading + */ +export const getUpdateArtifactsIsLoading = (state: Immutable): boolean => + isLoadingResourceState(state.artifacts.trustedAppsToUpdate); + +/** + * Returns if update action is loading + */ +export const getUpdateArtifactsIsFailed = (state: Immutable): boolean => + isFailedResourceState(state.artifacts.trustedAppsToUpdate); + +/** + * Returns if update action is done successfully + */ +export const getUpdateArtifactsLoaded = (state: Immutable): boolean => { + return isLoadedResourceState(state.artifacts.trustedAppsToUpdate); +}; + +/** + * Returns true if there is data assignable even if the search didn't returned it. + */ +export const getAssignableArtifactsListExist = (state: Immutable): boolean => { + return ( + isLoadedResourceState(state.artifacts.assignableListEntriesExist) && + state.artifacts.assignableListEntriesExist.data + ); +}; + +/** + * Returns true if there is data assignable even if the search didn't returned it. + */ +export const getAssignableArtifactsListExistIsLoading = ( + state: Immutable +): boolean => { + return isLoadingResourceState(state.artifacts.assignableListEntriesExist); +}; + +/** + * Returns artifacts to be updated + */ +export const getUpdateArtifacts = ( + state: Immutable +): ImmutableArray | undefined => { + return state.artifacts.trustedAppsToUpdate.type === 'LoadedResourceState' + ? state.artifacts.trustedAppsToUpdate.data + : undefined; +}; + +/** Returns a boolean of whether the user is on the policy details page or not */ +export const isOnPolicyTrustedAppsPage = (state: Immutable) => { + return ( + matchPath(state.location?.pathname ?? '', { + path: MANAGEMENT_ROUTING_POLICY_DETAILS_TRUSTED_APPS_PATH, + exact: true, + }) !== null + ); }; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/test_utils/index.ts new file mode 100644 index 0000000000000..383b7e277babd --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/test_utils/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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + GetTrustedListAppsResponse, + PostTrustedAppCreateResponse, +} from '../../../../../common/endpoint/types'; + +import { createSampleTrustedApps, createSampleTrustedApp } from '../../trusted_apps/test_utils'; + +export const getMockListResponse: () => GetTrustedListAppsResponse = () => ({ + data: createSampleTrustedApps({}), + per_page: 100, + page: 1, + total: 100, +}); + +export const getMockCreateResponse: () => PostTrustedAppCreateResponse = () => + createSampleTrustedApp(1) as unknown as unknown as PostTrustedAppCreateResponse; + +export const getAPIError = () => ({ + statusCode: 500, + error: 'Internal Server Error', + message: 'Something is not right', +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/types.ts b/x-pack/plugins/security_solution/public/management/pages/policy/types.ts index 9000fb469afd3..8e4e31a3c70e9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/types.ts @@ -13,6 +13,8 @@ import { ProtectionFields, PolicyData, UIPolicyConfig, + PostTrustedAppCreateResponse, + GetTrustedListAppsResponse, MaybeImmutable, } from '../../../../common/endpoint/types'; import { ServerApiError } from '../../../common/types'; @@ -24,7 +26,6 @@ import { UpdatePackagePolicyResponse, } from '../../../../../fleet/common'; import { AsyncResourceState } from '../../state'; -import { TrustedAppsListData } from '../trusted_apps/state'; import { ImmutableMiddlewareAPI } from '../../../common/store'; import { AppAction } from '../../../common/store/actions'; @@ -96,7 +97,11 @@ export interface PolicyArtifactsState { /** artifacts location params */ location: PolicyDetailsArtifactsPageLocation; /** A list of artifacts can be linked to the policy */ - availableList: AsyncResourceState; + assignableList: AsyncResourceState; + /** Represents if avaialble trusted apps entries exist, regardless of whether the list is showing results */ + assignableListEntriesExist: AsyncResourceState; + /** A list of trusted apps going to be updated */ + trustedAppsToUpdate: AsyncResourceState; } export enum OS { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/index.ts new file mode 100644 index 0000000000000..4a46e30f825e8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/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 { PolicyArtifactsAssignableList } from './policy_artifacts_assignable_list'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.test.tsx new file mode 100644 index 0000000000000..a93ae878eb6dd --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { + PolicyArtifactsAssignableList, + PolicyArtifactsAssignableListProps, +} from './policy_artifacts_assignable_list'; +import * as reactTestingLibrary from '@testing-library/react'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../common/mock/endpoint'; +import { fireEvent } from '@testing-library/dom'; +import { getMockListResponse } from '../../../test_utils'; + +describe('Policy artifacts list', () => { + let mockedContext: AppContextTestRender; + let selectedArtifactsUpdatedMock: jest.Mock; + let render: ( + props: PolicyArtifactsAssignableListProps + ) => ReturnType; + const act = reactTestingLibrary.act; + + afterEach(() => reactTestingLibrary.cleanup()); + beforeEach(() => { + selectedArtifactsUpdatedMock = jest.fn(); + mockedContext = createAppRootMockRenderer(); + render = (props) => mockedContext.render(); + }); + + it('should artifacts list loading state', async () => { + const emptyArtifactsResponse = { data: [], per_page: 0, page: 0, total: 0 }; + const component = render({ + artifacts: emptyArtifactsResponse, + selectedArtifactIds: [], + isListLoading: true, + selectedArtifactsUpdated: selectedArtifactsUpdatedMock, + }); + + expect(component.getByTestId('loading-spinner')).not.toBeNull(); + }); + + it('should artifacts list without data', async () => { + const emptyArtifactsResponse = { data: [], per_page: 0, page: 0, total: 0 }; + const component = render({ + artifacts: emptyArtifactsResponse, + selectedArtifactIds: [], + isListLoading: false, + selectedArtifactsUpdated: selectedArtifactsUpdatedMock, + }); + expect(component.queryByTestId('artifactsList')).toBeNull(); + }); + + it('should artifacts list with data', async () => { + const artifactsResponse = getMockListResponse(); + const component = render({ + artifacts: artifactsResponse, + selectedArtifactIds: [], + isListLoading: false, + selectedArtifactsUpdated: selectedArtifactsUpdatedMock, + }); + expect(component.getByTestId('artifactsList')).not.toBeNull(); + }); + + it('should select an artifact from list', async () => { + const artifactsResponse = getMockListResponse(); + const component = render({ + artifacts: artifactsResponse, + selectedArtifactIds: [artifactsResponse.data[0].id], + isListLoading: false, + selectedArtifactsUpdated: selectedArtifactsUpdatedMock, + }); + const tACardCheckbox = component.getByTestId(`${getMockListResponse().data[1].name}_checkbox`); + + await act(async () => { + fireEvent.click(tACardCheckbox); + }); + + expect(selectedArtifactsUpdatedMock).toHaveBeenCalledWith(artifactsResponse.data[1].id, true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.tsx new file mode 100644 index 0000000000000..7046b289063f6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/artifacts/assignable/policy_artifacts_assignable_list.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 React, { useMemo } from 'react'; + +import { + GetTrustedListAppsResponse, + Immutable, + TrustedApp, +} from '../../../../../../../common/endpoint/types'; +import { Loader } from '../../../../../../common/components/loader'; +import { ArtifactEntryCardMinified } from '../../../../../components/artifact_entry_card'; + +export interface PolicyArtifactsAssignableListProps { + artifacts: Immutable; // Or other artifacts type like Event Filters or Endpoint Exceptions + selectedArtifactIds: string[]; + selectedArtifactsUpdated: (id: string, selected: boolean) => void; + isListLoading: boolean; +} + +export const PolicyArtifactsAssignableList = React.memo( + ({ artifacts, isListLoading, selectedArtifactIds, selectedArtifactsUpdated }) => { + const selectedArtifactIdsByKey = useMemo( + () => + selectedArtifactIds.reduce( + (acc: { [key: string]: boolean }, current) => ({ ...acc, [current]: true }), + {} + ), + [selectedArtifactIds] + ); + + const assignableList = useMemo(() => { + if (!artifacts || !artifacts.data.length) return null; + const items = Array.from(artifacts.data) as TrustedApp[]; + return ( +
+ {items.map((artifact) => ( + + selectedArtifactsUpdated(artifact.id, selected) + } + /> + ))} +
+ ); + }, [artifacts, selectedArtifactIdsByKey, selectedArtifactsUpdated]); + + return isListLoading ? :
{assignableList}
; + } +); + +PolicyArtifactsAssignableList.displayName = 'PolicyArtifactsAssignableList'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts index bbaeb7e7590d9..e62458c9ce47e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts @@ -5,13 +5,25 @@ * 2.0. */ +import { useCallback, useState } from 'react'; +import { useHistory } from 'react-router-dom'; import { useSelector } from 'react-redux'; -import { PolicyDetailsState } from '../types'; +import { i18n } from '@kbn/i18n'; +import { PolicyDetailsArtifactsPageLocation, PolicyDetailsState } from '../types'; import { State } from '../../../../common/store'; import { MANAGEMENT_STORE_GLOBAL_NAMESPACE, MANAGEMENT_STORE_POLICY_DETAILS_NAMESPACE, } from '../../../common/constants'; +import { getPolicyDetailsArtifactsListPath } from '../../../common/routing'; +import { + getCurrentArtifactsLocation, + getUpdateArtifacts, + getUpdateArtifactsLoaded, + getUpdateArtifactsIsFailed, + policyIdFromParams, +} from '../store/policy_details/selectors'; +import { useToasts } from '../../../../common/lib/kibana'; /** * Narrows global state down to the PolicyDetailsState before calling the provided Policy Details Selector @@ -28,3 +40,60 @@ export function usePolicyDetailsSelector( ) ); } + +export type NavigationCallback = ( + ...args: Parameters[0]> +) => Partial; + +export function usePolicyDetailsNavigateCallback() { + const location = usePolicyDetailsSelector(getCurrentArtifactsLocation); + const history = useHistory(); + const policyId = usePolicyDetailsSelector(policyIdFromParams); + + return useCallback( + (args: Partial) => + history.push( + getPolicyDetailsArtifactsListPath(policyId, { + ...location, + ...args, + }) + ), + [history, location, policyId] + ); +} + +export const usePolicyTrustedAppsNotification = () => { + const updateSuccessfull = usePolicyDetailsSelector(getUpdateArtifactsLoaded); + const updateFailed = usePolicyDetailsSelector(getUpdateArtifactsIsFailed); + const updatedArtifacts = usePolicyDetailsSelector(getUpdateArtifacts); + const toasts = useToasts(); + const [wasAlreadyHandled] = useState(new WeakSet()); + + if (updateSuccessfull && updatedArtifacts && !wasAlreadyHandled.has(updatedArtifacts)) { + wasAlreadyHandled.add(updatedArtifacts); + toasts.addSuccess({ + title: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.toastSuccess.title', + { + defaultMessage: 'Success', + } + ), + text: i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.toastSuccess.text', + { + defaultMessage: '"{names}" has been added to your trusted applications list.', + values: { names: updatedArtifacts.map((artifact) => artifact.data.name).join(', ') }, + } + ), + }); + } else if (updateFailed) { + toasts.addSuccess( + i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.toastError.text', + { + defaultMessage: 'An error occurred updating artifacts', + } + ) + ); + } +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/index.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/index.ts new file mode 100644 index 0000000000000..d3090a340fa2b --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/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 { PolicyTrustedAppsFlyout } from './policy_trusted_apps_flyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.tsx new file mode 100644 index 0000000000000..a586c3c9d1b29 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.test.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 React from 'react'; +import { PolicyTrustedAppsFlyout } from './policy_trusted_apps_flyout'; +import * as reactTestingLibrary from '@testing-library/react'; +import { fireEvent } from '@testing-library/dom'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../../common/mock/endpoint'; +import { MiddlewareActionSpyHelper } from '../../../../../../common/store/test_utils'; + +import { TrustedAppsHttpService } from '../../../../trusted_apps/service'; +import { PolicyDetailsState } from '../../../types'; +import { getMockCreateResponse, getMockListResponse } from '../../../test_utils'; +import { createLoadedResourceState, isLoadedResourceState } from '../../../../../state'; +import { getPolicyDetailsArtifactsListPath } from '../../../../../common/routing'; + +jest.mock('../../../../trusted_apps/service'); + +let mockedContext: AppContextTestRender; +let waitForAction: MiddlewareActionSpyHelper['waitForAction']; +let render: () => ReturnType; +const act = reactTestingLibrary.act; +const TrustedAppsHttpServiceMock = TrustedAppsHttpService as jest.Mock; +let getState: () => PolicyDetailsState; + +describe('Policy trusted apps flyout', () => { + beforeEach(() => { + TrustedAppsHttpServiceMock.mockImplementation(() => { + return { + getTrustedAppsList: () => getMockListResponse(), + updateTrustedApp: () => ({ + data: getMockCreateResponse(), + }), + }; + }); + mockedContext = createAppRootMockRenderer(); + waitForAction = mockedContext.middlewareSpy.waitForAction; + getState = () => mockedContext.store.getState().management.policyDetails; + render = () => mockedContext.render(); + }); + + afterEach(() => reactTestingLibrary.cleanup()); + + it('should renders flyout open correctly without assignable data', async () => { + const waitAssignableListExist = waitForAction('policyArtifactsAssignableListExistDataChanged', { + validate: (action) => isLoadedResourceState(action.payload), + }); + + TrustedAppsHttpServiceMock.mockImplementation(() => { + return { + getTrustedAppsList: () => ({ data: [] }), + }; + }); + const component = render(); + + mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234', { show: 'list' })); + + await waitForAction('policyArtifactsAssignableListPageDataChanged', { + validate: (action) => isLoadedResourceState(action.payload), + }); + await waitAssignableListExist; + + expect(component.getByTestId('confirmPolicyTrustedAppsFlyout')).not.toBeNull(); + expect(component.getByTestId('noAssignableItemsTrustedAppsFlyout')).not.toBeNull(); + }); + + it('should renders flyout open correctly without data', async () => { + TrustedAppsHttpServiceMock.mockImplementation(() => { + return { + getTrustedAppsList: () => ({ data: [] }), + }; + }); + const component = render(); + + mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234', { show: 'list' })); + await waitForAction('policyArtifactsAssignableListPageDataChanged', { + validate: (action) => isLoadedResourceState(action.payload), + }); + + mockedContext.store.dispatch({ + type: 'policyArtifactsAssignableListExistDataChanged', + payload: createLoadedResourceState(true), + }); + + expect(component.getByTestId('confirmPolicyTrustedAppsFlyout')).not.toBeNull(); + expect(component.getByTestId('noItemsFoundTrustedAppsFlyout')).not.toBeNull(); + }); + + it('should renders flyout open correctly', async () => { + const component = render(); + + mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234', { show: 'list' })); + await waitForAction('policyArtifactsAssignableListPageDataChanged', { + validate: (action) => isLoadedResourceState(action.payload), + }); + + expect(component.getByTestId('confirmPolicyTrustedAppsFlyout')).not.toBeNull(); + expect(component.getByTestId(`${getMockListResponse().data[0].name}_checkbox`)).not.toBeNull(); + }); + + it('should confirm flyout action', async () => { + const waitForUpdate = waitForAction('policyArtifactsUpdateTrustedAppsChanged', { + validate: (action) => isLoadedResourceState(action.payload), + }); + const waitChangeUrl = waitForAction('userChangedUrl'); + const component = render(); + + mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234', { show: 'list' })); + await waitForAction('policyArtifactsAssignableListPageDataChanged', { + validate: (action) => isLoadedResourceState(action.payload), + }); + + const tACardCheckbox = component.getByTestId(`${getMockListResponse().data[0].name}_checkbox`); + + await act(async () => { + fireEvent.click(tACardCheckbox); + }); + + const confirmButton = component.getByTestId('confirmPolicyTrustedAppsFlyout'); + + await act(async () => { + fireEvent.click(confirmButton); + }); + + await waitForUpdate; + await waitChangeUrl; + const currentLocation = getState().artifacts.location; + expect(currentLocation.show).toBeUndefined(); + }); + + it('should cancel flyout action', async () => { + const waitChangeUrl = waitForAction('userChangedUrl'); + const component = render(); + + mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234', { show: 'list' })); + await waitForAction('policyArtifactsAssignableListPageDataChanged', { + validate: (action) => isLoadedResourceState(action.payload), + }); + + const cancelButton = component.getByTestId('cancelPolicyTrustedAppsFlyout'); + + await act(async () => { + fireEvent.click(cancelButton); + }); + + await waitChangeUrl; + const currentLocation = getState().artifacts.location; + expect(currentLocation.show).toBeUndefined(); + }); + + it('should display warning message when too much results', async () => { + TrustedAppsHttpServiceMock.mockImplementation(() => { + return { + getTrustedAppsList: () => ({ ...getMockListResponse(), total: 101 }), + }; + }); + + const component = render(); + + mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234', { show: 'list' })); + await waitForAction('policyArtifactsAssignableListPageDataChanged', { + validate: (action) => isLoadedResourceState(action.payload), + }); + + expect(component.getByTestId('tooMuchResultsWarningMessageTrustedAppsFlyout')).not.toBeNull(); + }); + + it('should not display warning message when few results', async () => { + const component = render(); + + mockedContext.history.push(getPolicyDetailsArtifactsListPath('1234', { show: 'list' })); + await waitForAction('policyArtifactsAssignableListPageDataChanged', { + validate: (action) => isLoadedResourceState(action.payload), + }); + + expect(component.queryByTestId('tooMuchResultsWarningMessageTrustedAppsFlyout')).toBeNull(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.tsx new file mode 100644 index 0000000000000..cd291ed9f6eb0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/flyout/policy_trusted_apps_flyout.tsx @@ -0,0 +1,257 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useState, useCallback, useEffect } from 'react'; +import { useDispatch } from 'react-redux'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { isEmpty, without } from 'lodash/fp'; +import { + EuiButton, + EuiTitle, + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiSpacer, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, + EuiCallOut, + EuiEmptyPrompt, +} from '@elastic/eui'; +import { + policyDetails, + getCurrentArtifactsLocation, + getAssignableArtifactsList, + getAssignableArtifactsListIsLoading, + getUpdateArtifactsIsLoading, + getUpdateArtifactsLoaded, + getAssignableArtifactsListExist, + getAssignableArtifactsListExistIsLoading, +} from '../../../store/policy_details/selectors'; +import { + usePolicyDetailsNavigateCallback, + usePolicyDetailsSelector, + usePolicyTrustedAppsNotification, +} from '../../policy_hooks'; +import { PolicyArtifactsAssignableList } from '../../artifacts/assignable'; +import { SearchExceptions } from '../../../../../components/search_exceptions'; + +export const PolicyTrustedAppsFlyout = React.memo(() => { + usePolicyTrustedAppsNotification(); + const dispatch = useDispatch(); + const [selectedArtifactIds, setSelectedArtifactIds] = useState([]); + const location = usePolicyDetailsSelector(getCurrentArtifactsLocation); + const policyItem = usePolicyDetailsSelector(policyDetails); + const assignableArtifactsList = usePolicyDetailsSelector(getAssignableArtifactsList); + const isAssignableArtifactsListLoading = usePolicyDetailsSelector( + getAssignableArtifactsListIsLoading + ); + const isUpdateArtifactsLoading = usePolicyDetailsSelector(getUpdateArtifactsIsLoading); + const isUpdateArtifactsLoaded = usePolicyDetailsSelector(getUpdateArtifactsLoaded); + const isAssignableArtifactsListExist = usePolicyDetailsSelector(getAssignableArtifactsListExist); + const isAssignableArtifactsListExistLoading = usePolicyDetailsSelector( + getAssignableArtifactsListExistIsLoading + ); + + const navigateCallback = usePolicyDetailsNavigateCallback(); + + const policyName = policyItem?.name ?? ''; + + const handleListFlyoutClose = useCallback( + () => + navigateCallback({ + show: undefined, + }), + [navigateCallback] + ); + + useEffect(() => { + if (isUpdateArtifactsLoaded) { + handleListFlyoutClose(); + dispatch({ + type: 'policyArtifactsUpdateTrustedAppsChanged', + payload: { type: 'UninitialisedResourceState' }, + }); + } + }, [dispatch, handleListFlyoutClose, isUpdateArtifactsLoaded]); + + const handleOnConfirmAction = useCallback(() => { + dispatch({ + type: 'policyArtifactsUpdateTrustedApps', + payload: { trustedAppIds: selectedArtifactIds }, + }); + }, [dispatch, selectedArtifactIds]); + + const handleOnSearch = useCallback( + (filter) => { + dispatch({ + type: 'policyArtifactsAssignableListPageDataFilter', + payload: { filter }, + }); + }, + [dispatch] + ); + + const searchWarningMessage = useMemo( + () => ( + <> + + {i18n.translate( + 'xpack.securitySolution.endpoint.policy.trustedApps.layout.flyout.searchWarning.text', + { + defaultMessage: + 'Only the first 100 trusted applications are displayed. Please use the search bar to refine the results.', + } + )} + + + + ), + [] + ); + + const canShowPolicyArtifactsAssignableList = useMemo( + () => + isAssignableArtifactsListExistLoading || + isAssignableArtifactsListLoading || + !isEmpty(assignableArtifactsList?.data), + [ + assignableArtifactsList?.data, + isAssignableArtifactsListExistLoading, + isAssignableArtifactsListLoading, + ] + ); + + const entriesExists = useMemo( + () => isEmpty(assignableArtifactsList?.data) && isAssignableArtifactsListExist, + [assignableArtifactsList?.data, isAssignableArtifactsListExist] + ); + + return ( + + + +

+ +

+
+ + +
+ + {(assignableArtifactsList?.total || 0) > 100 ? searchWarningMessage : null} + + + + {canShowPolicyArtifactsAssignableList ? ( + { + setSelectedArtifactIds((currentSelectedArtifactIds) => + selected + ? [...currentSelectedArtifactIds, artifactId] + : without([artifactId], currentSelectedArtifactIds) + ); + }} + /> + ) : entriesExists ? ( + + } + /> + ) : ( + + } + /> + )} + + + + + + + + + + + + + + + +
+ ); +}); + +PolicyTrustedAppsFlyout.displayName = 'PolicyTrustedAppsFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx index d89f2612403ca..f29b6a9feae3b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/trusted_apps/layout/policy_trusted_apps_layout.tsx @@ -5,8 +5,7 @@ * 2.0. */ -import React, { useMemo, useCallback } from 'react'; - +import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButton, @@ -15,14 +14,27 @@ import { EuiPageHeaderSection, EuiPageContent, } from '@elastic/eui'; +import { getCurrentArtifactsLocation } from '../../../store/policy_details/selectors'; +import { usePolicyDetailsNavigateCallback, usePolicyDetailsSelector } from '../../policy_hooks'; +import { PolicyTrustedAppsFlyout } from '../flyout'; export const PolicyTrustedAppsLayout = React.memo(() => { - const onClickAssignTrustedAppButton = useCallback(() => { - /* TODO: to be implemented*/ - }, []); + const location = usePolicyDetailsSelector(getCurrentArtifactsLocation); + const navigateCallback = usePolicyDetailsNavigateCallback(); + + const showListFlyout = location.show === 'list'; + const assignTrustedAppButton = useMemo( () => ( - + + navigateCallback({ + show: 'list', + }) + } + > {i18n.translate( 'xpack.securitySolution.endpoint.policy.trustedApps.layout.assignToPolicy', { @@ -31,7 +43,7 @@ export const PolicyTrustedAppsLayout = React.memo(() => { )} ), - [onClickAssignTrustedAppButton] + [navigateCallback] ); return ( @@ -58,6 +70,7 @@ export const PolicyTrustedAppsLayout = React.memo(() => { {/* TODO: To be implemented */} {'Policy trusted apps layout content'} + {showListFlyout ? : null} ); }); From 729194586e0b0f29363eca721d84791880d18047 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Wed, 29 Sep 2021 15:43:39 -0500 Subject: [PATCH 08/21] [Metrics UI] Add separate setting for Alert If Group Disappears for metric threshold rules (#113032) * [Metrics UI] Add separate setting for Alert If Group Disappears for metric threshold rules * Fix type * Update wording * Clarify conditionals, remove stray console.log * Add additional comments * Fix comments * Improve test specificity * Improve default param handling and clarify ungrouped conditional * Rename isNotUngrouped to hasGroups * Fix tests * Fix error state on 0 hits * Fix type * Improve test clarity * Simplify conditions and rewrite comments Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/expression.tsx | 37 +++- .../public/alerting/metric_threshold/types.ts | 1 + .../metric_threshold/lib/evaluate_alert.ts | 8 +- .../metric_threshold_executor.test.ts | 179 ++++++++++++++---- .../metric_threshold_executor.ts | 47 ++++- .../register_metric_threshold_alert_type.ts | 2 + .../alerting/metric_threshold/test_mocks.ts | 5 + 7 files changed, 233 insertions(+), 46 deletions(-) diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index c6fbbb2481ea1..b1a37caf149d9 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -236,6 +236,13 @@ export const Expressions: React.FC = (props) => { if (!alertParams.sourceId) { setAlertParams('sourceId', source?.id || 'default'); } + + if (typeof alertParams.alertOnNoData === 'undefined') { + setAlertParams('alertOnNoData', true); + } + if (typeof alertParams.alertOnGroupDisappear === 'undefined') { + setAlertParams('alertOnGroupDisappear', true); + } }, [metadata, source]); // eslint-disable-line react-hooks/exhaustive-deps const handleFieldSearchChange = useCallback( @@ -248,6 +255,11 @@ export const Expressions: React.FC = (props) => { [alertParams.criteria] ); + const hasGroupBy = useMemo( + () => alertParams.groupBy && alertParams.groupBy.length > 0, + [alertParams.groupBy] + ); + return ( <> @@ -397,7 +409,7 @@ export const Expressions: React.FC = (props) => { = (props) => { }} /> - + + + {i18n.translate('xpack.infra.metrics.alertFlyout.alertOnGroupDisappear', { + defaultMessage: 'Alert me if a group stops reporting data', + })}{' '} + + + + + } + disabled={!hasGroupBy} + checked={Boolean(hasGroupBy && alertParams.alertOnGroupDisappear)} + onChange={(e) => setAlertParams('alertOnGroupDisappear', e.target.checked)} + /> ); diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts b/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts index a679579e57235..dd15faf2b11c3 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/types.ts @@ -61,5 +61,6 @@ export interface AlertParams { sourceId: string; filterQueryText?: string; alertOnNoData?: boolean; + alertOnGroupDisappear?: boolean; shouldDropPartialBuckets?: boolean; } diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts index 45eef3cc85a57..5bd7a4947b439 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts @@ -232,10 +232,14 @@ const getMetric: ( aggType, dropPartialBucketsOptions, calculatedTimerange, - isNumber(result.hits.total) ? result.hits.total : result.hits.total.value + result.hits + ? isNumber(result.hits.total) + ? result.hits.total + : result.hits.total.value + : 0 ), }; - } catch (e) { + } catch (e: any) { if (timeframe) { // This code should only ever be reached when previewing the alert, not executing it const causedByType = e.body?.error?.caused_by?.type; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index 869d0afd52367..bd9c0afefa3fc 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -112,41 +112,41 @@ describe('The metric threshold alert type', () => { }); test('alerts as expected with the > comparator', async () => { await execute(Comparator.GT, [0.75]); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeAlertAction(); await execute(Comparator.GT, [1.5]); expect(mostRecentAction(instanceID)).toBe(undefined); }); test('alerts as expected with the < comparator', async () => { await execute(Comparator.LT, [1.5]); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeAlertAction(); await execute(Comparator.LT, [0.75]); expect(mostRecentAction(instanceID)).toBe(undefined); }); test('alerts as expected with the >= comparator', async () => { await execute(Comparator.GT_OR_EQ, [0.75]); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeAlertAction(); await execute(Comparator.GT_OR_EQ, [1.0]); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeAlertAction(); await execute(Comparator.GT_OR_EQ, [1.5]); expect(mostRecentAction(instanceID)).toBe(undefined); }); test('alerts as expected with the <= comparator', async () => { await execute(Comparator.LT_OR_EQ, [1.5]); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeAlertAction(); await execute(Comparator.LT_OR_EQ, [1.0]); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeAlertAction(); await execute(Comparator.LT_OR_EQ, [0.75]); expect(mostRecentAction(instanceID)).toBe(undefined); }); test('alerts as expected with the between comparator', async () => { await execute(Comparator.BETWEEN, [0, 1.5]); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeAlertAction(); await execute(Comparator.BETWEEN, [0, 0.75]); expect(mostRecentAction(instanceID)).toBe(undefined); }); test('alerts as expected with the outside range comparator', async () => { await execute(Comparator.OUTSIDE_RANGE, [0, 0.75]); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeAlertAction(); await execute(Comparator.OUTSIDE_RANGE, [0, 1.5]); expect(mostRecentAction(instanceID)).toBe(undefined); }); @@ -189,12 +189,12 @@ describe('The metric threshold alert type', () => { const instanceIdB = 'b'; test('sends an alert when all groups pass the threshold', async () => { await execute(Comparator.GT, [0.75]); - expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); - expect(mostRecentAction(instanceIdB).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceIdA)).toBeAlertAction(); + expect(mostRecentAction(instanceIdB)).toBeAlertAction(); }); test('sends an alert when only some groups pass the threshold', async () => { await execute(Comparator.LT, [1.5]); - expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceIdA)).toBeAlertAction(); expect(mostRecentAction(instanceIdB)).toBe(undefined); }); test('sends no alert when no groups pass the threshold', async () => { @@ -267,7 +267,7 @@ describe('The metric threshold alert type', () => { test('sends an alert when all criteria cross the threshold', async () => { const instanceID = '*'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0]); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeAlertAction(); }); test('sends no alert when some, but not all, criteria cross the threshold', async () => { const instanceID = '*'; @@ -278,7 +278,7 @@ describe('The metric threshold alert type', () => { const instanceIdA = 'a'; const instanceIdB = 'b'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0], 'something'); - expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceIdA)).toBeAlertAction(); expect(mostRecentAction(instanceIdB)).toBe(undefined); }); test('sends all criteria to the action context', async () => { @@ -315,7 +315,7 @@ describe('The metric threshold alert type', () => { }); test('alerts based on the doc_count value instead of the aggregatedValue', async () => { await execute(Comparator.GT, [0.9]); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeAlertAction(); await execute(Comparator.LT, [0.5]); expect(mostRecentAction(instanceID)).toBe(undefined); }); @@ -350,8 +350,8 @@ describe('The metric threshold alert type', () => { expect(mostRecentAction(instanceIdA)).toBe(undefined); expect(mostRecentAction(instanceIdB)).toBe(undefined); await executeGroupBy(Comparator.LT_OR_EQ, [0], 'empty-response', resultState); - expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); - expect(mostRecentAction(instanceIdB).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceIdA)).toBeAlertAction(); + expect(mostRecentAction(instanceIdB)).toBeAlertAction(); await executeGroupBy(Comparator.LT_OR_EQ, [0]); expect(mostRecentAction(instanceIdA)).toBe(undefined); expect(mostRecentAction(instanceIdB)).toBe(undefined); @@ -379,7 +379,7 @@ describe('The metric threshold alert type', () => { }); test('alerts based on the p99 values', async () => { await execute(Comparator.GT, [1]); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeAlertAction(); await execute(Comparator.LT, [1]); expect(mostRecentAction(instanceID)).toBe(undefined); }); @@ -406,7 +406,7 @@ describe('The metric threshold alert type', () => { }); test('alerts based on the p95 values', async () => { await execute(Comparator.GT, [0.25]); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeAlertAction(); await execute(Comparator.LT, [0.95]); expect(mostRecentAction(instanceID)).toBe(undefined); }); @@ -433,7 +433,7 @@ describe('The metric threshold alert type', () => { }); test('sends a No Data alert when configured to do so', async () => { await execute(true); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeNoDataAction(); }); test('does not send a No Data alert when not configured to do so', async () => { await execute(false); @@ -446,7 +446,8 @@ describe('The metric threshold alert type', () => { const instanceID = '*'; const instanceIdA = 'a'; const instanceIdB = 'b'; - const execute = (metric: string, state?: any) => + const instanceIdC = 'c'; + const execute = (metric: string, alertOnGroupDisappear: boolean = true, state?: any) => executor({ ...mockOptions, services, @@ -462,25 +463,98 @@ describe('The metric threshold alert type', () => { }, ], alertOnNoData: true, + alertOnGroupDisappear, }, state: state ?? mockOptions.state.wrapped, }); - const resultState: any[] = []; + + const executeEmptyResponse = (...args: [boolean?, any?]) => execute('test.metric.3', ...args); + const execute3GroupsABCResponse = (...args: [boolean?, any?]) => + execute('test.metric.2', ...args); + const execute2GroupsABResponse = (...args: [boolean?, any?]) => + execute('test.metric.1', ...args); + + // Store state between tests. Jest won't preserve reassigning a let so use an array instead. + const interTestStateStorage: any[] = []; + test('first sends a No Data alert with the * group, but then reports groups when data is available', async () => { - resultState.push(await execute('test.metric.3')); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - resultState.push(await execute('test.metric.3', resultState.pop())); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); - resultState.push(await execute('test.metric.1', resultState.pop())); + let resultState = await executeEmptyResponse(); + expect(mostRecentAction(instanceID)).toBeNoDataAction(); + resultState = await executeEmptyResponse(true, resultState); + expect(mostRecentAction(instanceID)).toBeNoDataAction(); + resultState = await execute2GroupsABResponse(true, resultState); expect(mostRecentAction(instanceID)).toBe(undefined); - expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); - expect(mostRecentAction(instanceIdB).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceIdA)).toBeAlertAction(); + expect(mostRecentAction(instanceIdB)).toBeAlertAction(); + interTestStateStorage.push(resultState); // Hand off resultState to the next test }); test('sends No Data alerts for the previously detected groups when they stop reporting data, but not the * group', async () => { - await execute('test.metric.3', resultState.pop()); + // Pop a previous execution result instead of defining it manually + // The type signature of alert executor states are complex + const resultState = interTestStateStorage.pop(); + await executeEmptyResponse(true, resultState); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(mostRecentAction(instanceIdA)).toBeNoDataAction(); + expect(mostRecentAction(instanceIdB)).toBeNoDataAction(); + }); + test('does not send individual No Data alerts when groups disappear if alertOnGroupDisappear is disabled', async () => { + const resultState = await execute3GroupsABCResponse(false); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(mostRecentAction(instanceIdA)).toBeAlertAction(); + expect(mostRecentAction(instanceIdB)).toBeAlertAction(); + expect(mostRecentAction(instanceIdC)).toBeAlertAction(); + await execute2GroupsABResponse(false, resultState); expect(mostRecentAction(instanceID)).toBe(undefined); - expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); - expect(mostRecentAction(instanceIdB).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceIdA)).toBeAlertAction(); + expect(mostRecentAction(instanceIdB)).toBeAlertAction(); + expect(mostRecentAction(instanceIdC)).toBe(undefined); + }); + + describe('if alertOnNoData is disabled but alertOnGroupDisappear is enabled', () => { + const executeWeirdNoDataConfig = (metric: string, state?: any) => + executor({ + ...mockOptions, + services, + params: { + groupBy: 'something', + sourceId: 'default', + criteria: [ + { + ...baseNonCountCriterion, + comparator: Comparator.GT, + threshold: [0], + metric, + }, + ], + alertOnNoData: false, + alertOnGroupDisappear: true, + }, + state: state ?? mockOptions.state.wrapped, + }); + + const executeWeirdEmptyResponse = (...args: [any?]) => + executeWeirdNoDataConfig('test.metric.3', ...args); + const executeWeird2GroupsABResponse = (...args: [any?]) => + executeWeirdNoDataConfig('test.metric.1', ...args); + + test('does not send a No Data alert with the * group, but then reports groups when data is available', async () => { + let resultState = await executeWeirdEmptyResponse(); + expect(mostRecentAction(instanceID)).toBe(undefined); + resultState = await executeWeirdEmptyResponse(resultState); + expect(mostRecentAction(instanceID)).toBe(undefined); + resultState = await executeWeird2GroupsABResponse(resultState); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(mostRecentAction(instanceIdA)).toBeAlertAction(); + expect(mostRecentAction(instanceIdB)).toBeAlertAction(); + interTestStateStorage.push(resultState); // Hand off resultState to the next test + }); + test('sends No Data alerts for the previously detected groups when they stop reporting data, but not the * group', async () => { + const resultState = interTestStateStorage.pop(); // Import the resultState from the previous test + await executeWeirdEmptyResponse(resultState); + expect(mostRecentAction(instanceID)).toBe(undefined); + expect(mostRecentAction(instanceIdA)).toBeNoDataAction(); + expect(mostRecentAction(instanceIdB)).toBeNoDataAction(); + }); }); }); @@ -506,7 +580,7 @@ describe('The metric threshold alert type', () => { }); test('sends a No Data alert', async () => { await execute(); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeNoDataAction(); }); }); @@ -538,7 +612,7 @@ describe('The metric threshold alert type', () => { test('sends a recovery alert as soon as the metric recovers', async () => { await execute([0.5]); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeAlertAction(); expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute([2]); expect(mostRecentAction(instanceID).id).toBe(RecoveredActionGroup.id); @@ -554,7 +628,7 @@ describe('The metric threshold alert type', () => { }); test('sends a recovery alert again once the metric alerts and recovers again', async () => { await execute([0.5]); - expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); + expect(mostRecentAction(instanceID)).toBeAlertAction(); expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); await execute([2]); expect(mostRecentAction(instanceID).id).toBe(RecoveredActionGroup.id); @@ -622,8 +696,11 @@ const services: AlertServicesMock & }; services.scopedClusterClient.asCurrentUser.search.mockImplementation((params?: any): any => { const from = params?.body.query.bool.filter[0]?.range['@timestamp'].gte; + if (params.index === 'alternatebeat-*') return mocks.changedSourceIdResponse(from); + if (params.index === 'empty-response') return mocks.emptyMetricResponse; + const metric = params?.body.query.bool.filter[1]?.exists.field; if (metric === 'test.metric.3') { return elasticsearchClientMock.createSuccessTransportRequestPromise( @@ -705,6 +782,40 @@ function clearInstances() { alertInstances.clear(); } +interface Action { + id: string; + action: { alertState: string }; +} + +expect.extend({ + toBeAlertAction(action?: Action) { + const pass = action?.id === FIRED_ACTIONS.id && action?.action.alertState === 'ALERT'; + const message = () => `expected ${action} to be an ALERT action`; + return { + message, + pass, + }; + }, + toBeNoDataAction(action?: Action) { + const pass = action?.id === FIRED_ACTIONS.id && action?.action.alertState === 'NO DATA'; + const message = () => `expected ${action} to be a NO DATA action`; + return { + message, + pass, + }; + }, +}); + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace jest { + interface Matchers { + toBeAlertAction(action?: Action): R; + toBeNoDataAction(action?: Action): R; + } + } +} + const baseNonCountCriterion: Pick< NonCountMetricExpressionParams, 'aggType' | 'metric' | 'timeSize' | 'timeUnit' diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index f49b281909f4b..af5f945eeb4bb 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -74,11 +74,19 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => }, }); - const { sourceId, alertOnNoData } = params as { + const { + sourceId, + alertOnNoData, + alertOnGroupDisappear: _alertOnGroupDisappear, + } = params as { sourceId?: string; alertOnNoData: boolean; + alertOnGroupDisappear: boolean | undefined; }; + // For backwards-compatibility, interpret undefined alertOnGroupDisappear as true + const alertOnGroupDisappear = _alertOnGroupDisappear !== false; + const source = await libs.sources.getSourceConfiguration( savedObjectsClient, sourceId || 'default' @@ -86,12 +94,13 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => const config = source.configuration; const previousGroupBy = state.groupBy; - const prevGroups = isEqual(previousGroupBy, params.groupBy) - ? // Filter out the * key from the previous groups, only include it if it's one of - // the current groups. In case of a groupBy alert that starts out with no data and no - // groups, we don't want to persist the existence of the * alert instance - state.groups?.filter((g) => g !== UNGROUPED_FACTORY_KEY) ?? [] - : []; + const prevGroups = + alertOnGroupDisappear && isEqual(previousGroupBy, params.groupBy) + ? // Filter out the * key from the previous groups, only include it if it's one of + // the current groups. In case of a groupBy alert that starts out with no data and no + // groups, we don't want to persist the existence of the * alert instance + state.groups?.filter((g) => g !== UNGROUPED_FACTORY_KEY) ?? [] + : []; const alertResults = await evaluateAlert( services.scopedClusterClient.asCurrentUser, @@ -106,6 +115,8 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => // no data results on groups that get removed const groups = [...new Set([...prevGroups, ...resultGroups])]; + const hasGroups = !isEqual(groups, [UNGROUPED_FACTORY_KEY]); + for (const group of groups) { // AND logic; all criteria must be across the threshold const shouldAlertFire = alertResults.every((result) => @@ -147,7 +158,26 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => // .map((result) => buildRecoveredAlertReason(formatAlertResult(result[group]))) // .join('\n'); } - if (alertOnNoData) { + + /* NO DATA STATE HANDLING + * + * - `alertOnNoData` does not indicate IF the alert's next state is No Data, but whether or not the user WANTS TO BE ALERTED + * if the state were No Data. + * - `alertOnGroupDisappear`, on the other hand, determines whether or not it's possible to return a No Data state + * when a group disappears. + * + * This means we need to handle the possibility that `alertOnNoData` is false, but `alertOnGroupDisappear` is true + * + * nextState === NO_DATA would be true on both { '*': No Data } or, e.g. { 'a': No Data, 'b': OK, 'c': OK }, but if the user + * has for some reason disabled `alertOnNoData` and left `alertOnGroupDisappear` enabled, they would only care about the latter + * possibility. In this case, use hasGroups to determine whether to alert on a potential No Data state + * + * If `alertOnNoData` is true but `alertOnGroupDisappear` is false, we don't need to worry about the {a, b, c} possibility. + * At this point in the function, a false `alertOnGroupDisappear` would already have prevented group 'a' from being evaluated at all. + */ + if (alertOnNoData || (alertOnGroupDisappear && hasGroups)) { + // In the previous line we've determined if the user is interested in No Data states, so only now do we actually + // check to see if a No Data state has occurred if (nextState === AlertStates.NO_DATA) { reason = alertResults .filter((result) => result[group].isNoData) @@ -160,6 +190,7 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => .join('\n'); } } + if (reason) { const firstResult = first(alertResults); const timestamp = (firstResult && firstResult[group].timestamp) ?? moment().toISOString(); diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index 054585e541ba1..251531b4515a9 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -77,6 +77,8 @@ export async function registerMetricThresholdAlertType( ), sourceId: schema.string(), alertOnNoData: schema.maybe(schema.boolean()), + alertOnGroupDisappear: schema.maybe(schema.boolean()), + shouldDropPartialBuckets: schema.maybe(schema.boolean()), }, { unknowns: 'allow' } ), diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts index db6b771e91784..d023aa5e28a4e 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/test_mocks.ts @@ -159,6 +159,7 @@ export const basicCompositeResponse = (from: number) => ({ aggregatedIntervals: { buckets: bucketsA(from), }, + doc_count: 1, }, { key: { @@ -167,6 +168,7 @@ export const basicCompositeResponse = (from: number) => ({ aggregatedIntervals: { buckets: bucketsB(from), }, + doc_count: 1, }, ], }, @@ -190,6 +192,7 @@ export const alternateCompositeResponse = (from: number) => ({ aggregatedIntervals: { buckets: bucketsB(from), }, + doc_count: 1, }, { key: { @@ -198,6 +201,7 @@ export const alternateCompositeResponse = (from: number) => ({ aggregatedIntervals: { buckets: bucketsA(from), }, + doc_count: 1, }, { key: { @@ -206,6 +210,7 @@ export const alternateCompositeResponse = (from: number) => ({ aggregatedIntervals: { buckets: bucketsC(from), }, + doc_count: 1, }, ], }, From 3eb93a84f52e1c884de06ce089fcd6f1dbbf23fa Mon Sep 17 00:00:00 2001 From: ymao1 Date: Wed, 29 Sep 2021 16:56:55 -0400 Subject: [PATCH 09/21] =?UTF-8?q?[Alerting]=20Failing=20test:=20Chrome=20X?= =?UTF-8?q?-Pack=20UI=20Functional=20Tests.x-pack/test/functional=5Fwith?= =?UTF-8?q?=5Fes=5Fssl/apps/triggers=5Factions=5Fui/alert=5Fcreate=5Fflyou?= =?UTF-8?q?t=C2=B7ts=20-=20Actions=20and=20Triggers=20app=20create=20alert?= =?UTF-8?q?=20should=20show=20save=20confirmation=20before=20creating=20al?= =?UTF-8?q?ert=20with=20no=20actions=20(#112888)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Adding delay * Splitting tests * Fixing tests * Reverting change Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../apps/triggers_actions_ui/alert_create_flyout.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index 88ba4c37559c5..adad80874dbc9 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -103,8 +103,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('test.always-firing-SelectOption'); } - // FLAKY https://github.com/elastic/kibana/issues/112749 - describe.skip('create alert', function () { + describe('create alert', function () { before(async () => { await pageObjects.common.navigateToApp('triggersActions'); await testSubjects.click('rulesTab'); @@ -236,6 +235,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const toastTitle = await pageObjects.common.closeToast(); expect(toastTitle).to.eql(`Created rule "${alertName}"`); + await new Promise((resolve) => setTimeout(resolve, 1000)); await pageObjects.triggersActionsUI.searchAlerts(alertName); const searchResultsAfterSave = await pageObjects.triggersActionsUI.getAlertsList(); expect(searchResultsAfterSave).to.eql([ From 8a63be0accf8dd1243c2db3dfb9b3e6765fd0abf Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Wed, 29 Sep 2021 16:40:49 -0500 Subject: [PATCH 10/21] [Stack Monitoring] Migrate indices view to React (#113338) * [Stack Monitoring] Migrate indices view to React * Fix lint --- .../monitoring/public/application/index.tsx | 8 ++ .../pages/elasticsearch/indices_page.tsx | 99 +++++++++++++++++++ .../components/elasticsearch/index.d.ts | 1 + 3 files changed, 108 insertions(+) create mode 100644 x-pack/plugins/monitoring/public/application/pages/elasticsearch/indices_page.tsx diff --git a/x-pack/plugins/monitoring/public/application/index.tsx b/x-pack/plugins/monitoring/public/application/index.tsx index b9ea91bce9805..690ea26319bd3 100644 --- a/x-pack/plugins/monitoring/public/application/index.tsx +++ b/x-pack/plugins/monitoring/public/application/index.tsx @@ -24,6 +24,7 @@ import { BeatsOverviewPage } from './pages/beats/overview'; import { BeatsInstancesPage } from './pages/beats/instances'; import { CODE_PATH_ELASTICSEARCH, CODE_PATH_BEATS } from '../../common/constants'; import { ElasticsearchNodesPage } from './pages/elasticsearch/nodes_page'; +import { ElasticsearchIndicesPage } from './pages/elasticsearch/indices_page'; import { ElasticsearchNodePage } from './pages/elasticsearch/node_page'; import { MonitoringTimeContainer } from './hooks/use_monitoring_time'; import { BreadcrumbContainer } from './hooks/use_breadcrumbs'; @@ -81,6 +82,13 @@ const MonitoringApp: React.FC<{ /> {/* ElasticSearch Views */} + + = ({ clusters }) => { + const globalState = useContext(GlobalStateContext); + const { services } = useKibana<{ data: any }>(); + const { getPaginationTableProps } = useTable('elasticsearch.indices'); + const clusterUuid = globalState.cluster_uuid; + const cluster = find(clusters, { + cluster_uuid: clusterUuid, + }); + const [data, setData] = useState({} as any); + const [showSystemIndices, setShowSystemIndices] = useLocalStorage( + 'showSystemIndices', + false + ); + + const title = i18n.translate('xpack.monitoring.elasticsearch.indices.routeTitle', { + defaultMessage: 'Elasticsearch - Indices', + }); + + const pageTitle = i18n.translate('xpack.monitoring.elasticsearch.indices.pageTitle', { + defaultMessage: 'Elasticsearch indices', + }); + + const toggleShowSystemIndices = useCallback( + () => setShowSystemIndices(!showSystemIndices), + [showSystemIndices, setShowSystemIndices] + ); + + const getPageData = useCallback(async () => { + const bounds = services.data?.query.timefilter.timefilter.getBounds(); + const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/indices`; + const response = await services.http?.fetch(url, { + method: 'POST', + query: { + show_system_indices: showSystemIndices, + }, + body: JSON.stringify({ + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + setData(response); + }, [showSystemIndices, clusterUuid, services.data?.query.timefilter.timefilter, services.http]); + + return ( + +
+ ( + + {flyoutComponent} + + {bottomBarComponent} + + )} + /> +
+
+ ); +}; diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/index.d.ts b/x-pack/plugins/monitoring/public/components/elasticsearch/index.d.ts index 5aa2854e3631c..434115df0762c 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/index.d.ts +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/index.d.ts @@ -7,4 +7,5 @@ export const ElasticsearchOverview: FunctionComponent; export const ElasticsearchNodes: FunctionComponent; +export const ElasticsearchIndices: FunctionComponent; export const NodeReact: FunctionComponent; From 1090a356f3a9e678c1e3cc296cc0a6482f5d64d0 Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Wed, 29 Sep 2021 18:21:04 -0400 Subject: [PATCH 11/21] [Fleet] Register Sample Data (#113200) --- .../custom_integrations/common/index.ts | 7 +- .../custom_integrations/server/mocks.ts | 8 +- .../sample_data_resources/ecommerce/icon.svg | 6 ++ .../sample_data_resources/flights/icon.svg | 4 + .../sample_data_resources/logs/icon.svg | 4 + src/plugins/home/server/plugin.test.ts | 1 - src/plugins/home/server/plugin.ts | 4 +- .../sample_data/data_sets/ecommerce/index.ts | 1 + .../sample_data/data_sets/flights/index.ts | 1 + .../sample_data/data_sets/logs/index.ts | 1 + .../lib/register_with_integrations.ts | 36 ++++++++ .../sample_data/lib/sample_dataset_schema.ts | 1 + .../sample_data/sample_data_registry.mock.ts | 1 - .../sample_data/sample_data_registry.test.ts | 47 ++++++++++ .../sample_data/sample_data_registry.ts | 89 ++++++++++--------- .../tutorials/tutorials_registry.test.ts | 4 +- .../apis/custom_integration/integrations.ts | 6 +- .../plugins/fleet/common/types/models/epm.ts | 4 +- .../fleet/public/components/package_icon.tsx | 2 + 19 files changed, 172 insertions(+), 55 deletions(-) create mode 100644 src/plugins/home/public/assets/sample_data_resources/ecommerce/icon.svg create mode 100644 src/plugins/home/public/assets/sample_data_resources/flights/icon.svg create mode 100644 src/plugins/home/public/assets/sample_data_resources/logs/icon.svg create mode 100644 src/plugins/home/server/services/sample_data/lib/register_with_integrations.ts create mode 100644 src/plugins/home/server/services/sample_data/sample_data_registry.test.ts diff --git a/src/plugins/custom_integrations/common/index.ts b/src/plugins/custom_integrations/common/index.ts index 24ed44f3e5cfe..48f31cb0bcb0c 100755 --- a/src/plugins/custom_integrations/common/index.ts +++ b/src/plugins/custom_integrations/common/index.ts @@ -46,6 +46,11 @@ export const CATEGORY_DISPLAY = { export type Category = keyof typeof CATEGORY_DISPLAY; +export interface CustomIntegrationIcon { + src: string; + type: 'eui' | 'svg'; +} + export interface CustomIntegration { id: string; title: string; @@ -53,7 +58,7 @@ export interface CustomIntegration { type: 'ui_link'; uiInternalPath: string; isBeta: boolean; - icons: Array<{ src: string; type: string }>; + icons: CustomIntegrationIcon[]; categories: Category[]; shipper: string; } diff --git a/src/plugins/custom_integrations/server/mocks.ts b/src/plugins/custom_integrations/server/mocks.ts index 661c7e567aef6..1846885192853 100644 --- a/src/plugins/custom_integrations/server/mocks.ts +++ b/src/plugins/custom_integrations/server/mocks.ts @@ -6,17 +6,15 @@ * Side Public License, v 1. */ -import type { MockedKeys } from '@kbn/utility-types/jest'; - import { CustomIntegrationsPluginSetup } from '../server'; -function createCustomIntegrationsSetup(): MockedKeys { - const mock = { +function createCustomIntegrationsSetup(): jest.Mocked { + const mock: jest.Mocked = { registerCustomIntegration: jest.fn(), getAppendCustomIntegrations: jest.fn(), }; - return mock as MockedKeys; + return mock; } export const customIntegrationsMock = { diff --git a/src/plugins/home/public/assets/sample_data_resources/ecommerce/icon.svg b/src/plugins/home/public/assets/sample_data_resources/ecommerce/icon.svg new file mode 100644 index 0000000000000..ae2acbbfecb2a --- /dev/null +++ b/src/plugins/home/public/assets/sample_data_resources/ecommerce/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/plugins/home/public/assets/sample_data_resources/flights/icon.svg b/src/plugins/home/public/assets/sample_data_resources/flights/icon.svg new file mode 100644 index 0000000000000..fca14f79e33ad --- /dev/null +++ b/src/plugins/home/public/assets/sample_data_resources/flights/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/plugins/home/public/assets/sample_data_resources/logs/icon.svg b/src/plugins/home/public/assets/sample_data_resources/logs/icon.svg new file mode 100644 index 0000000000000..64b8422964d9b --- /dev/null +++ b/src/plugins/home/public/assets/sample_data_resources/logs/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/plugins/home/server/plugin.test.ts b/src/plugins/home/server/plugin.test.ts index fdf671e10ad09..2dc8a8e6484aa 100644 --- a/src/plugins/home/server/plugin.test.ts +++ b/src/plugins/home/server/plugin.test.ts @@ -53,7 +53,6 @@ describe('HomeServerPlugin', () => { homeServerPluginSetupDependenciesMock ); expect(setup).toHaveProperty('sampleData'); - expect(setup.sampleData).toHaveProperty('registerSampleDataset'); expect(setup.sampleData).toHaveProperty('getSampleDatasets'); expect(setup.sampleData).toHaveProperty('addSavedObjectsToSampleDataset'); expect(setup.sampleData).toHaveProperty('addAppLinksToSampleDataset'); diff --git a/src/plugins/home/server/plugin.ts b/src/plugins/home/server/plugin.ts index 7c830dd8d5bc3..5902544d9867f 100644 --- a/src/plugins/home/server/plugin.ts +++ b/src/plugins/home/server/plugin.ts @@ -40,7 +40,9 @@ export class HomeServerPlugin implements Plugin => { const setup = { - registerSampleDataset: jest.fn(), getSampleDatasets: jest.fn(), addSavedObjectsToSampleDataset: jest.fn(), addAppLinksToSampleDataset: jest.fn(), diff --git a/src/plugins/home/server/services/sample_data/sample_data_registry.test.ts b/src/plugins/home/server/services/sample_data/sample_data_registry.test.ts new file mode 100644 index 0000000000000..74c4d66c4fb02 --- /dev/null +++ b/src/plugins/home/server/services/sample_data/sample_data_registry.test.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 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 { MockedKeys } from '@kbn/utility-types/jest'; +import { CoreSetup } from '../../../../../core/server'; + +import { CustomIntegrationsPluginSetup } from '../../../../custom_integrations/server'; +import { customIntegrationsMock } from '../../../../custom_integrations/server/mocks'; +import { SampleDataRegistry } from './sample_data_registry'; +import { usageCollectionPluginMock } from '../../../../usage_collection/server/mocks'; +import { UsageCollectionSetup } from '../../../../usage_collection/server/plugin'; +import { coreMock } from '../../../../../core/server/mocks'; + +describe('SampleDataRegistry', () => { + let mockCoreSetup: MockedKeys; + let mockCustomIntegrationsPluginSetup: jest.Mocked; + let mockUsageCollectionPluginSetup: MockedKeys; + + beforeEach(() => { + mockCoreSetup = coreMock.createSetup(); + mockCustomIntegrationsPluginSetup = customIntegrationsMock.createSetup(); + mockUsageCollectionPluginSetup = usageCollectionPluginMock.createSetupContract(); + }); + + describe('setup', () => { + test('should register the three sample datasets', () => { + const initContext = coreMock.createPluginInitializerContext(); + const plugin = new SampleDataRegistry(initContext); + plugin.setup( + mockCoreSetup, + mockUsageCollectionPluginSetup, + mockCustomIntegrationsPluginSetup + ); + + const ids: string[] = + mockCustomIntegrationsPluginSetup.registerCustomIntegration.mock.calls.map((args) => { + return args[0].id; + }); + expect(ids).toEqual(['flights', 'logs', 'ecommerce']); + }); + }); +}); diff --git a/src/plugins/home/server/services/sample_data/sample_data_registry.ts b/src/plugins/home/server/services/sample_data/sample_data_registry.ts index ad7e1d45e290b..f966a05c12397 100644 --- a/src/plugins/home/server/services/sample_data/sample_data_registry.ts +++ b/src/plugins/home/server/services/sample_data/sample_data_registry.ts @@ -21,20 +21,54 @@ import { createListRoute, createInstallRoute } from './routes'; import { UsageCollectionSetup } from '../../../../usage_collection/server'; import { makeSampleDataUsageCollector, usage } from './usage'; import { createUninstallRoute } from './routes/uninstall'; - -const flightsSampleDataset = flightsSpecProvider(); -const logsSampleDataset = logsSpecProvider(); -const ecommerceSampleDataset = ecommerceSpecProvider(); +import { CustomIntegrationsPluginSetup } from '../../../../custom_integrations/server'; +import { registerSampleDatasetWithIntegration } from './lib/register_with_integrations'; export class SampleDataRegistry { constructor(private readonly initContext: PluginInitializerContext) {} - private readonly sampleDatasets: SampleDatasetSchema[] = [ - flightsSampleDataset, - logsSampleDataset, - ecommerceSampleDataset, - ]; + private readonly sampleDatasets: SampleDatasetSchema[] = []; + + private registerSampleDataSet( + specProvider: SampleDatasetProvider, + core: CoreSetup, + customIntegrations?: CustomIntegrationsPluginSetup + ) { + let value: SampleDatasetSchema; + try { + value = sampleDataSchema.validate(specProvider()); + } catch (error) { + throw new Error(`Unable to register sample dataset spec because it's invalid. ${error}`); + } + + if (customIntegrations && core) { + registerSampleDatasetWithIntegration(customIntegrations, core, value); + } - public setup(core: CoreSetup, usageCollections: UsageCollectionSetup | undefined) { + const defaultIndexSavedObjectJson = value.savedObjects.find((savedObjectJson: any) => { + return savedObjectJson.type === 'index-pattern' && savedObjectJson.id === value.defaultIndex; + }); + if (!defaultIndexSavedObjectJson) { + throw new Error( + `Unable to register sample dataset spec, defaultIndex: "${value.defaultIndex}" does not exist in savedObjects list.` + ); + } + + const dashboardSavedObjectJson = value.savedObjects.find((savedObjectJson: any) => { + return savedObjectJson.type === 'dashboard' && savedObjectJson.id === value.overviewDashboard; + }); + if (!dashboardSavedObjectJson) { + throw new Error( + `Unable to register sample dataset spec, overviewDashboard: "${value.overviewDashboard}" does not exist in savedObject list.` + ); + } + this.sampleDatasets.push(value); + } + + public setup( + core: CoreSetup, + usageCollections: UsageCollectionSetup | undefined, + customIntegrations?: CustomIntegrationsPluginSetup + ) { if (usageCollections) { makeSampleDataUsageCollector(usageCollections, this.initContext); } @@ -52,38 +86,11 @@ export class SampleDataRegistry { ); createUninstallRoute(router, this.sampleDatasets, usageTracker); - return { - registerSampleDataset: (specProvider: SampleDatasetProvider) => { - let value: SampleDatasetSchema; - try { - value = sampleDataSchema.validate(specProvider()); - } catch (error) { - throw new Error(`Unable to register sample dataset spec because it's invalid. ${error}`); - } - - const defaultIndexSavedObjectJson = value.savedObjects.find((savedObjectJson: any) => { - return ( - savedObjectJson.type === 'index-pattern' && savedObjectJson.id === value.defaultIndex - ); - }); - if (!defaultIndexSavedObjectJson) { - throw new Error( - `Unable to register sample dataset spec, defaultIndex: "${value.defaultIndex}" does not exist in savedObjects list.` - ); - } + this.registerSampleDataSet(flightsSpecProvider, core, customIntegrations); + this.registerSampleDataSet(logsSpecProvider, core, customIntegrations); + this.registerSampleDataSet(ecommerceSpecProvider, core, customIntegrations); - const dashboardSavedObjectJson = value.savedObjects.find((savedObjectJson: any) => { - return ( - savedObjectJson.type === 'dashboard' && savedObjectJson.id === value.overviewDashboard - ); - }); - if (!dashboardSavedObjectJson) { - throw new Error( - `Unable to register sample dataset spec, overviewDashboard: "${value.overviewDashboard}" does not exist in savedObject list.` - ); - } - this.sampleDatasets.push(value); - }, + return { getSampleDatasets: () => this.sampleDatasets, addSavedObjectsToSampleDataset: (id: string, savedObjects: SavedObject[]) => { diff --git a/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts b/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts index 5c7ec0a3382bf..15372949b1653 100644 --- a/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts +++ b/src/plugins/home/server/services/tutorials/tutorials_registry.test.ts @@ -71,7 +71,7 @@ describe('TutorialsRegistry', () => { let mockCoreSetup: MockedKeys; let testProvider: TutorialProvider; let testScopedTutorialContextFactory: ScopedTutorialContextFactory; - let mockCustomIntegrationsPluginSetup: MockedKeys; + let mockCustomIntegrationsPluginSetup: jest.Mocked; beforeEach(() => { mockCustomIntegrationsPluginSetup = customIntegrationsMock.createSetup(); @@ -107,8 +107,6 @@ describe('TutorialsRegistry', () => { const setup = new TutorialsRegistry().setup(mockCoreSetup, mockCustomIntegrationsPluginSetup); testProvider = ({}) => validTutorialProvider; expect(() => setup.registerTutorial(testProvider)).not.toThrowError(); - - // @ts-expect-error expect(mockCustomIntegrationsPluginSetup.registerCustomIntegration.mock.calls).toEqual([ [ { diff --git a/test/api_integration/apis/custom_integration/integrations.ts b/test/api_integration/apis/custom_integration/integrations.ts index d8f098fdc1fcf..2d1d085198bb4 100644 --- a/test/api_integration/apis/custom_integration/integrations.ts +++ b/test/api_integration/apis/custom_integration/integrations.ts @@ -20,7 +20,11 @@ export default function ({ getService }: FtrProviderContext) { .expect(200); expect(resp.body).to.be.an('array'); - expect(resp.body.length).to.be.above(0); + expect(resp.body.length).to.be.above(2); // Should at least have registered the three sample data-sets + + ['flights', 'logs', 'ecommerce'].forEach((sampleData) => { + expect(resp.body.findIndex((c: { id: string }) => c.id === sampleData)).to.be.above(-1); + }); }); }); } diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 371304765a5f8..06e3d13c2394b 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -19,6 +19,8 @@ import type { } from '../../constants'; import type { ValueOf } from '../../types'; +import type { CustomIntegrationIcon } from '../../../../../../src/plugins/custom_integrations/common'; + import type { PackageSpecManifest, PackageSpecIcon, @@ -368,7 +370,7 @@ export interface IntegrationCardItem { name: string; title: string; version: string; - icons: PackageSpecIcon[]; + icons: Array; integration: string; id: string; } diff --git a/x-pack/plugins/fleet/public/components/package_icon.tsx b/x-pack/plugins/fleet/public/components/package_icon.tsx index 85ae7971f46c2..7a2a9f1091301 100644 --- a/x-pack/plugins/fleet/public/components/package_icon.tsx +++ b/x-pack/plugins/fleet/public/components/package_icon.tsx @@ -24,6 +24,8 @@ export const CardIcon: React.FunctionComponent; + } else if (icons && icons.length === 1 && icons[0].type === 'svg') { + return ; } else { return ; } From 9dff4d0827e8e1424ca4d86f692dec6c738f84c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Thu, 30 Sep 2021 00:54:41 +0200 Subject: [PATCH 12/21] [APM] Add link to officials docs for APM UI settings (#113396) --- x-pack/plugins/apm/readme.md | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/apm/readme.md b/x-pack/plugins/apm/readme.md index fe7e77d28986c..b78fd6162e736 100644 --- a/x-pack/plugins/apm/readme.md +++ b/x-pack/plugins/apm/readme.md @@ -27,3 +27,4 @@ All files with a .stories.tsx extension will be loaded. You can access the devel - [Routing and Linking](./dev_docs/routing_and_linking.md) - [Telemetry](./dev_docs/telemetry.md) - [Features flags](./dev_docs/feature_flags.md) +- [Official APM UI settings docs](https://www.elastic.co/guide/en/kibana/current/apm-settings-in-kibana.html) From 9c00debcd5d3fb68dd75634489fb3df7289a7cd5 Mon Sep 17 00:00:00 2001 From: spalger Date: Thu, 30 Sep 2021 00:26:06 +0000 Subject: [PATCH 13/21] skip failing suite (#113486) --- .../apps/observability/alerts/pagination.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/observability_functional/apps/observability/alerts/pagination.ts b/x-pack/test/observability_functional/apps/observability/alerts/pagination.ts index 5cefe8fd42c8a..a00fbe2a77f34 100644 --- a/x-pack/test/observability_functional/apps/observability/alerts/pagination.ts +++ b/x-pack/test/observability_functional/apps/observability/alerts/pagination.ts @@ -14,7 +14,8 @@ const DEFAULT_ROWS_PER_PAGE = 50; export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); - describe('Observability alerts pagination', function () { + // FAILING: https://github.com/elastic/kibana/issues/113486 + describe.skip('Observability alerts pagination', function () { this.tags('includeFirefox'); const retry = getService('retry'); From 685a550806f4751acdb6ba421a1f6da88956ceb9 Mon Sep 17 00:00:00 2001 From: Mat Schaffer Date: Thu, 30 Sep 2021 14:04:57 +0900 Subject: [PATCH 14/21] [Stack Monitoring] Beats instance view pagination (#113284) * Beats instance view pagination * Fix beats listing sorting Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/application/hooks/use_table.ts | 20 +++++++++++++++---- .../application/pages/beats/instances.tsx | 11 ++++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_table.ts b/x-pack/plugins/monitoring/public/application/hooks/use_table.ts index 60264f3657fe3..600ac20cd0199 100644 --- a/x-pack/plugins/monitoring/public/application/hooks/use_table.ts +++ b/x-pack/plugins/monitoring/public/application/hooks/use_table.ts @@ -98,10 +98,22 @@ export function useTable(storageKey: string) { const [query, setQuery] = useState(''); - const onTableChange = () => { - // we are already updating the state in fetchMoreData. We would need to check in react - // if both methods are needed or we can clean one of them - // For now I just keep it so existing react components don't break + const onTableChange = ({ page, sort }: { page: Page; sort: Sorting['sort'] }) => { + setPagination({ + ...pagination, + ...{ + initialPageSize: page.size, + pageSize: page.size, + initialPageIndex: page.index, + pageIndex: page.index, + pageSizeOptions: PAGE_SIZE_OPTIONS, + }, + }); + setSorting(cleanSortingData({ sort })); + setLocalStorageData(storage, { + page, + sort, + }); }; const getPaginationRouteOptions = useCallback(() => { diff --git a/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx b/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx index 451c71deb472d..3f32e1abf9a88 100644 --- a/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx @@ -29,7 +29,7 @@ export const BeatsInstancesPage: React.FC = ({ clusters }) => { const globalState = useContext(GlobalStateContext); const { services } = useKibana<{ data: any }>(); const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); - const { getPaginationTableProps } = useTable('beats.instances'); + const { updateTotalItemCount, getPaginationTableProps } = useTable('beats.instances'); const clusterUuid = globalState.cluster_uuid; const ccs = globalState.ccs; const cluster = find(clusters, { @@ -66,7 +66,14 @@ export const BeatsInstancesPage: React.FC = ({ clusters }) => { }); setData(response); - }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http]); + updateTotalItemCount(response.stats.total); + }, [ + ccs, + clusterUuid, + services.data?.query.timefilter.timefilter, + services.http, + updateTotalItemCount, + ]); return ( Date: Thu, 30 Sep 2021 09:15:32 +0300 Subject: [PATCH 15/21] [TSVB] Gather stats for the usage of the aggregate function in table viz (#113247) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/telemetry/schema/oss_plugins.json | 6 ++ .../get_usage_collector.test.ts | 95 ++++++++++++++++++- .../usage_collector/get_usage_collector.ts | 40 ++++++-- .../register_timeseries_collector.ts | 4 + 4 files changed, 136 insertions(+), 9 deletions(-) diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index c174840ba604d..c6724056f77a5 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -9088,6 +9088,12 @@ "_meta": { "description": "Number of TSVB visualizations using \"last value\" as a time range" } + }, + "timeseries_table_use_aggregate_function": { + "type": "long", + "_meta": { + "description": "Number of TSVB table visualizations using aggregate function" + } } } }, diff --git a/src/plugins/vis_types/timeseries/server/usage_collector/get_usage_collector.test.ts b/src/plugins/vis_types/timeseries/server/usage_collector/get_usage_collector.test.ts index aac6d879f48fd..3f3a204d29263 100644 --- a/src/plugins/vis_types/timeseries/server/usage_collector/get_usage_collector.test.ts +++ b/src/plugins/vis_types/timeseries/server/usage_collector/get_usage_collector.test.ts @@ -49,6 +49,23 @@ const mockedSavedObject = { }), }, }, + { + attributes: { + visState: JSON.stringify({ + type: 'metrics', + title: 'TSVB visualization 4', + params: { + type: 'table', + series: [ + { + aggregate_by: 'test', + aggregate_function: 'max', + }, + ], + }, + }), + }, + }, ], } as SavedObjectsFindResponse; @@ -83,6 +100,27 @@ const mockedSavedObjectsByValue = [ }), }, }, + { + attributes: { + panelsJSON: JSON.stringify({ + type: 'visualization', + embeddableConfig: { + savedVis: { + type: 'metrics', + params: { + type: 'table', + series: [ + { + aggregate_by: 'test1', + aggregate_function: 'sum', + }, + ], + }, + }, + }, + }), + }, + }, ]; const getMockCollectorFetchContext = ( @@ -142,6 +180,58 @@ describe('Timeseries visualization usage collector', () => { expect(result).toBeUndefined(); }); + test('Returns undefined when aggregate function is null', async () => { + const mockCollectorFetchContext = getMockCollectorFetchContext({ + saved_objects: [ + { + attributes: { + panelsJSON: JSON.stringify({ + type: 'visualization', + embeddableConfig: { + savedVis: { + type: 'metrics', + params: { + type: 'table', + series: [ + { + aggregate_by: null, + aggregate_function: null, + }, + ], + }, + }, + }, + }), + }, + }, + { + attributes: { + panelsJSON: JSON.stringify({ + type: 'visualization', + embeddableConfig: { + savedVis: { + type: 'metrics', + params: { + type: 'table', + series: [ + { + axis_position: 'right', + }, + ], + }, + }, + }, + }), + }, + }, + ], + } as SavedObjectsFindResponse); + + const result = await getStats(mockCollectorFetchContext.soClient); + + expect(result).toBeUndefined(); + }); + test('Summarizes visualizations response data', async () => { const mockCollectorFetchContext = getMockCollectorFetchContext( mockedSavedObject, @@ -149,8 +239,9 @@ describe('Timeseries visualization usage collector', () => { ); const result = await getStats(mockCollectorFetchContext.soClient); - expect(result).toMatchObject({ - timeseries_use_last_value_mode_total: 3, + expect(result).toStrictEqual({ + timeseries_use_last_value_mode_total: 5, + timeseries_table_use_aggregate_function: 2, }); }); }); diff --git a/src/plugins/vis_types/timeseries/server/usage_collector/get_usage_collector.ts b/src/plugins/vis_types/timeseries/server/usage_collector/get_usage_collector.ts index 8309d51a9d56d..58f0c9c7f1459 100644 --- a/src/plugins/vis_types/timeseries/server/usage_collector/get_usage_collector.ts +++ b/src/plugins/vis_types/timeseries/server/usage_collector/get_usage_collector.ts @@ -15,14 +15,16 @@ import type { SavedObjectsFindResult, } from '../../../../../core/server'; import type { SavedVisState } from '../../../../visualizations/common'; +import type { Panel } from '../../common/types'; export interface TimeseriesUsage { timeseries_use_last_value_mode_total: number; + timeseries_table_use_aggregate_function: number; } const doTelemetryFoVisualizations = async ( soClient: SavedObjectsClientContract | ISavedObjectsRepository, - telemetryUseLastValueMode: (savedVis: SavedVisState) => void + calculateTelemetry: (savedVis: SavedVisState) => void ) => { const finder = await soClient.createPointInTimeFinder({ type: 'visualization', @@ -34,9 +36,9 @@ const doTelemetryFoVisualizations = async ( (response.saved_objects || []).forEach(({ attributes }: SavedObjectsFindResult) => { if (attributes?.visState) { try { - const visState: SavedVisState = JSON.parse(attributes.visState); + const visState: SavedVisState = JSON.parse(attributes.visState); - telemetryUseLastValueMode(visState); + calculateTelemetry(visState); } catch { // nothing to be here, "so" not valid } @@ -48,12 +50,12 @@ const doTelemetryFoVisualizations = async ( const doTelemetryForByValueVisualizations = async ( soClient: SavedObjectsClientContract | ISavedObjectsRepository, - telemetryUseLastValueMode: (savedVis: SavedVisState) => void + telemetryUseLastValueMode: (savedVis: SavedVisState) => void ) => { const byValueVisualizations = await findByValueEmbeddables(soClient, 'visualization'); for (const item of byValueVisualizations) { - telemetryUseLastValueMode(item.savedVis as unknown as SavedVisState); + telemetryUseLastValueMode(item.savedVis as unknown as SavedVisState); } }; @@ -62,9 +64,10 @@ export const getStats = async ( ): Promise => { const timeseriesUsage = { timeseries_use_last_value_mode_total: 0, + timeseries_table_use_aggregate_function: 0, }; - function telemetryUseLastValueMode(visState: SavedVisState) { + function telemetryUseLastValueMode(visState: SavedVisState) { if ( visState.type === 'metrics' && visState.params.type !== 'timeseries' && @@ -75,10 +78,33 @@ export const getStats = async ( } } + function telemetryTableAggFunction(visState: SavedVisState) { + if ( + visState.type === 'metrics' && + visState.params.type === 'table' && + visState.params.series && + visState.params.series.length > 0 + ) { + const usesAggregateFunction = visState.params.series.some( + (s) => s.aggregate_by && s.aggregate_function + ); + if (usesAggregateFunction) { + timeseriesUsage.timeseries_table_use_aggregate_function++; + } + } + } + await Promise.all([ + // last value usage telemetry doTelemetryFoVisualizations(soClient, telemetryUseLastValueMode), doTelemetryForByValueVisualizations(soClient, telemetryUseLastValueMode), + // table aggregate function telemetry + doTelemetryFoVisualizations(soClient, telemetryTableAggFunction), + doTelemetryForByValueVisualizations(soClient, telemetryTableAggFunction), ]); - return timeseriesUsage.timeseries_use_last_value_mode_total ? timeseriesUsage : undefined; + return timeseriesUsage.timeseries_use_last_value_mode_total || + timeseriesUsage.timeseries_table_use_aggregate_function + ? timeseriesUsage + : undefined; }; diff --git a/src/plugins/vis_types/timeseries/server/usage_collector/register_timeseries_collector.ts b/src/plugins/vis_types/timeseries/server/usage_collector/register_timeseries_collector.ts index 6fccd7ef30171..b96d6ce4c5da8 100644 --- a/src/plugins/vis_types/timeseries/server/usage_collector/register_timeseries_collector.ts +++ b/src/plugins/vis_types/timeseries/server/usage_collector/register_timeseries_collector.ts @@ -18,6 +18,10 @@ export function registerTimeseriesUsageCollector(collectorSet: UsageCollectionSe type: 'long', _meta: { description: 'Number of TSVB visualizations using "last value" as a time range' }, }, + timeseries_table_use_aggregate_function: { + type: 'long', + _meta: { description: 'Number of TSVB table visualizations using aggregate function' }, + }, }, fetch: async ({ soClient }) => await getStats(soClient), }); From a9d80ac46addfd2ec4d5b40369d4115f73052ad8 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 30 Sep 2021 09:54:53 +0300 Subject: [PATCH 16/21] Cleanup of the kibanaLegacy unused functions and remove unecessary dependencies from Analytics plugins (#113358) * [Discover][Table] Remove unused dependencies of the kibanaLegacy plugin * More removals of kibanaLegacy plugin dependencies * Revert discover changes * Remove the unused functions from the kibana_legacy plugin * Removes unused translations --- src/plugins/dashboard/kibana.json | 1 - src/plugins/dashboard/public/plugin.tsx | 3 - .../public/services/kibana_legacy.ts | 9 - src/plugins/dashboard/tsconfig.json | 1 - .../kibana_legacy/public/angular/index.ts | 4 - .../angular/subscribe_with_scope.test.ts | 186 -------- .../public/angular/subscribe_with_scope.ts | 74 --- .../public/angular/watch_multi.d.ts | 9 - .../public/angular/watch_multi.js | 137 ------ .../angular_bootstrap/bind_html/bind_html.js | 17 - .../public/angular_bootstrap/index.ts | 50 --- .../angular_bootstrap/tooltip/position.js | 167 ------- .../angular_bootstrap/tooltip/tooltip.js | 423 ------------------ .../tooltip/tooltip_html_unsafe_popup.html | 4 - .../tooltip/tooltip_popup.html | 4 - src/plugins/kibana_legacy/public/index.ts | 1 - src/plugins/kibana_legacy/public/mocks.ts | 1 - .../public/notify/lib/add_fatal_error.ts | 27 -- .../public/notify/lib/format_stack.ts | 24 - .../kibana_legacy/public/notify/lib/index.ts | 2 - .../public/paginate/_paginate.scss | 56 --- .../public/paginate/paginate.d.ts | 10 - .../kibana_legacy/public/paginate/paginate.js | 217 --------- .../public/paginate/paginate_controls.html | 98 ---- src/plugins/kibana_legacy/public/plugin.ts | 8 - .../kibana_legacy/public/utils/index.ts | 4 - .../public/utils/kbn_accessible_click.d.ts | 13 - .../public/utils/kbn_accessible_click.js | 60 --- .../utils/register_listen_event_listener.d.ts | 9 - .../utils/register_listen_event_listener.js | 25 -- src/plugins/vis_types/table/kibana.json | 3 +- src/plugins/vis_types/table/tsconfig.json | 1 - x-pack/plugins/discover_enhanced/kibana.json | 2 +- .../discover_enhanced/public/plugin.ts | 3 - .../plugins/discover_enhanced/tsconfig.json | 1 - .../translations/translations/ja-JP.json | 4 - .../translations/translations/zh-CN.json | 4 - 37 files changed, 2 insertions(+), 1660 deletions(-) delete mode 100644 src/plugins/dashboard/public/services/kibana_legacy.ts delete mode 100644 src/plugins/kibana_legacy/public/angular/subscribe_with_scope.test.ts delete mode 100644 src/plugins/kibana_legacy/public/angular/subscribe_with_scope.ts delete mode 100644 src/plugins/kibana_legacy/public/angular/watch_multi.d.ts delete mode 100644 src/plugins/kibana_legacy/public/angular/watch_multi.js delete mode 100755 src/plugins/kibana_legacy/public/angular_bootstrap/bind_html/bind_html.js delete mode 100644 src/plugins/kibana_legacy/public/angular_bootstrap/index.ts delete mode 100755 src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/position.js delete mode 100755 src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/tooltip.js delete mode 100644 src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/tooltip_html_unsafe_popup.html delete mode 100644 src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/tooltip_popup.html delete mode 100644 src/plugins/kibana_legacy/public/notify/lib/add_fatal_error.ts delete mode 100644 src/plugins/kibana_legacy/public/notify/lib/format_stack.ts delete mode 100644 src/plugins/kibana_legacy/public/paginate/_paginate.scss delete mode 100644 src/plugins/kibana_legacy/public/paginate/paginate.d.ts delete mode 100644 src/plugins/kibana_legacy/public/paginate/paginate.js delete mode 100644 src/plugins/kibana_legacy/public/paginate/paginate_controls.html delete mode 100644 src/plugins/kibana_legacy/public/utils/kbn_accessible_click.d.ts delete mode 100644 src/plugins/kibana_legacy/public/utils/kbn_accessible_click.js delete mode 100644 src/plugins/kibana_legacy/public/utils/register_listen_event_listener.d.ts delete mode 100644 src/plugins/kibana_legacy/public/utils/register_listen_event_listener.js diff --git a/src/plugins/dashboard/kibana.json b/src/plugins/dashboard/kibana.json index 164be971d22b7..d13b833790a22 100644 --- a/src/plugins/dashboard/kibana.json +++ b/src/plugins/dashboard/kibana.json @@ -10,7 +10,6 @@ "data", "embeddable", "inspector", - "kibanaLegacy", "navigation", "savedObjects", "share", diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index d4e4de2558678..496526c08ece8 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -31,7 +31,6 @@ import { createKbnUrlTracker } from './services/kibana_utils'; import { UsageCollectionSetup } from './services/usage_collection'; import { UiActionsSetup, UiActionsStart } from './services/ui_actions'; import { PresentationUtilPluginStart } from './services/presentation_util'; -import { KibanaLegacySetup, KibanaLegacyStart } from './services/kibana_legacy'; import { FeatureCatalogueCategory, HomePublicPluginSetup } from './services/home'; import { NavigationPublicPluginStart as NavigationStart } from './services/navigation'; import { DataPublicPluginSetup, DataPublicPluginStart, esFilters } from './services/data'; @@ -98,7 +97,6 @@ export interface DashboardSetupDependencies { data: DataPublicPluginSetup; embeddable: EmbeddableSetup; home?: HomePublicPluginSetup; - kibanaLegacy: KibanaLegacySetup; urlForwarding: UrlForwardingSetup; share?: SharePluginSetup; uiActions: UiActionsSetup; @@ -107,7 +105,6 @@ export interface DashboardSetupDependencies { export interface DashboardStartDependencies { data: DataPublicPluginStart; - kibanaLegacy: KibanaLegacyStart; urlForwarding: UrlForwardingStart; embeddable: EmbeddableStart; inspector: InspectorStartContract; diff --git a/src/plugins/dashboard/public/services/kibana_legacy.ts b/src/plugins/dashboard/public/services/kibana_legacy.ts deleted file mode 100644 index 247ee2c49d87b..0000000000000 --- a/src/plugins/dashboard/public/services/kibana_legacy.ts +++ /dev/null @@ -1,9 +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. - */ - -export { KibanaLegacySetup, KibanaLegacyStart } from '../../../kibana_legacy/public'; diff --git a/src/plugins/dashboard/tsconfig.json b/src/plugins/dashboard/tsconfig.json index 7558ade4705be..78a1958a43156 100644 --- a/src/plugins/dashboard/tsconfig.json +++ b/src/plugins/dashboard/tsconfig.json @@ -16,7 +16,6 @@ "references": [ { "path": "../../core/tsconfig.json" }, { "path": "../inspector/tsconfig.json" }, - { "path": "../kibana_legacy/tsconfig.json" }, { "path": "../kibana_react/tsconfig.json" }, { "path": "../kibana_utils/tsconfig.json" }, { "path": "../share/tsconfig.json" }, diff --git a/src/plugins/kibana_legacy/public/angular/index.ts b/src/plugins/kibana_legacy/public/angular/index.ts index 369495698591d..8ba68a88271bf 100644 --- a/src/plugins/kibana_legacy/public/angular/index.ts +++ b/src/plugins/kibana_legacy/public/angular/index.ts @@ -5,10 +5,6 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ - -// @ts-ignore -export { watchMultiDecorator } from './watch_multi'; export * from './angular_config'; // @ts-ignore export { createTopNavDirective, createTopNavHelper, loadKbnTopNavDirectives } from './kbn_top_nav'; -export { subscribeWithScope } from './subscribe_with_scope'; diff --git a/src/plugins/kibana_legacy/public/angular/subscribe_with_scope.test.ts b/src/plugins/kibana_legacy/public/angular/subscribe_with_scope.test.ts deleted file mode 100644 index c1c057886c564..0000000000000 --- a/src/plugins/kibana_legacy/public/angular/subscribe_with_scope.test.ts +++ /dev/null @@ -1,186 +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 * as Rx from 'rxjs'; -import { subscribeWithScope } from './subscribe_with_scope'; - -// eslint-disable-next-line prefer-const -let $rootScope: Scope; - -class Scope { - public $$phase?: string; - public $root = $rootScope; - public $apply = jest.fn((fn: () => void) => fn()); -} - -$rootScope = new Scope(); - -afterEach(() => { - jest.clearAllMocks(); -}); - -it('subscribes to the passed observable, returns subscription', () => { - const $scope = new Scope(); - - const unsubSpy = jest.fn(); - const subSpy = jest.fn(() => unsubSpy); - const observable = new Rx.Observable(subSpy); - - const subscription = subscribeWithScope($scope as any, observable); - expect(subSpy).toHaveBeenCalledTimes(1); - expect(unsubSpy).not.toHaveBeenCalled(); - - subscription.unsubscribe(); - - expect(subSpy).toHaveBeenCalledTimes(1); - expect(unsubSpy).toHaveBeenCalledTimes(1); -}); - -it('calls observer.next() if already in a digest cycle, wraps in $scope.$apply if not', () => { - const subject = new Rx.Subject(); - const nextSpy = jest.fn(); - const $scope = new Scope(); - - subscribeWithScope($scope as any, subject, { next: nextSpy }); - - subject.next(); - expect($scope.$apply).toHaveBeenCalledTimes(1); - expect(nextSpy).toHaveBeenCalledTimes(1); - - jest.clearAllMocks(); - - $rootScope.$$phase = '$digest'; - subject.next(); - expect($scope.$apply).not.toHaveBeenCalled(); - expect(nextSpy).toHaveBeenCalledTimes(1); -}); - -it('reports fatalError if observer.next() throws', () => { - const fatalError = jest.fn(); - const $scope = new Scope(); - subscribeWithScope( - $scope as any, - Rx.of(undefined), - { - next() { - throw new Error('foo bar'); - }, - }, - fatalError - ); - - expect(fatalError.mock.calls).toMatchInlineSnapshot(` -Array [ - Array [ - [Error: foo bar], - ], -] -`); -}); - -it('reports fatal error if observer.error is not defined and observable errors', () => { - const fatalError = jest.fn(); - const $scope = new Scope(); - const error = new Error('foo'); - error.stack = `${error.message}\n---stack trace ---`; - subscribeWithScope($scope as any, Rx.throwError(error), undefined, fatalError); - - expect(fatalError.mock.calls).toMatchInlineSnapshot(` -Array [ - Array [ - [Error: Uncaught error in subscribeWithScope(): foo ----stack trace ---], - ], -] -`); -}); - -it('reports fatal error if observer.error throws', () => { - const fatalError = jest.fn(); - const $scope = new Scope(); - subscribeWithScope( - $scope as any, - Rx.throwError(new Error('foo')), - { - error: () => { - throw new Error('foo'); - }, - }, - fatalError - ); - - expect(fatalError.mock.calls).toMatchInlineSnapshot(` -Array [ - Array [ - [Error: foo], - ], -] -`); -}); - -it('does not report fatal error if observer.error handles the error', () => { - const fatalError = jest.fn(); - const $scope = new Scope(); - subscribeWithScope( - $scope as any, - Rx.throwError(new Error('foo')), - { - error: () => { - // noop, swallow error - }, - }, - fatalError - ); - - expect(fatalError.mock.calls).toEqual([]); -}); - -it('reports fatal error if observer.complete throws', () => { - const fatalError = jest.fn(); - const $scope = new Scope(); - subscribeWithScope( - $scope as any, - Rx.EMPTY, - { - complete: () => { - throw new Error('foo'); - }, - }, - fatalError - ); - - expect(fatalError.mock.calls).toMatchInlineSnapshot(` -Array [ - Array [ - [Error: foo], - ], -] -`); -}); - -it('preserves the context of the observer functions', () => { - const $scope = new Scope(); - const observer = { - next() { - expect(this).toBe(observer); - }, - complete() { - expect(this).toBe(observer); - }, - }; - - subscribeWithScope($scope as any, Rx.of([1, 2, 3]), observer); - - const observer2 = { - error() { - expect(this).toBe(observer); - }, - }; - - subscribeWithScope($scope as any, Rx.throwError(new Error('foo')), observer2); -}); diff --git a/src/plugins/kibana_legacy/public/angular/subscribe_with_scope.ts b/src/plugins/kibana_legacy/public/angular/subscribe_with_scope.ts deleted file mode 100644 index 4c3f17e4e46e5..0000000000000 --- a/src/plugins/kibana_legacy/public/angular/subscribe_with_scope.ts +++ /dev/null @@ -1,74 +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 { IScope } from 'angular'; -import * as Rx from 'rxjs'; -import { AngularHttpError } from '../notify/lib'; - -type FatalErrorFn = (error: AngularHttpError | Error | string, location?: string) => void; - -function callInDigest($scope: IScope, fn: () => void, fatalError?: FatalErrorFn) { - try { - // this is terrible, but necessary to synchronously deliver subscription values - // to angular scopes. This is required by some APIs, like the `config` service, - // and beneficial for root level directives where additional digest cycles make - // kibana sluggish to load. - // - // If you copy this code elsewhere you better have a good reason :) - if ($scope.$root.$$phase) { - fn(); - } else { - $scope.$apply(() => fn()); - } - } catch (error) { - if (fatalError) { - fatalError(error); - } - } -} - -/** - * Subscribe to an observable at a $scope, ensuring that the digest cycle - * is run for subscriber hooks and routing errors to fatalError if not handled. - */ -export function subscribeWithScope( - $scope: IScope, - observable: Rx.Observable, - observer?: Rx.PartialObserver, - fatalError?: FatalErrorFn -) { - return observable.subscribe({ - next(value) { - if (observer && observer.next) { - callInDigest($scope, () => observer.next!(value), fatalError); - } - }, - error(error) { - callInDigest( - $scope, - () => { - if (observer && observer.error) { - observer.error(error); - } else { - throw new Error( - `Uncaught error in subscribeWithScope(): ${ - error ? error.stack || error.message : error - }` - ); - } - }, - fatalError - ); - }, - complete() { - if (observer && observer.complete) { - callInDigest($scope, () => observer.complete!(), fatalError); - } - }, - }); -} diff --git a/src/plugins/kibana_legacy/public/angular/watch_multi.d.ts b/src/plugins/kibana_legacy/public/angular/watch_multi.d.ts deleted file mode 100644 index 5d2031148f3de..0000000000000 --- a/src/plugins/kibana_legacy/public/angular/watch_multi.d.ts +++ /dev/null @@ -1,9 +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. - */ - -export function watchMultiDecorator($provide: unknown): void; diff --git a/src/plugins/kibana_legacy/public/angular/watch_multi.js b/src/plugins/kibana_legacy/public/angular/watch_multi.js deleted file mode 100644 index f4ba6e7875124..0000000000000 --- a/src/plugins/kibana_legacy/public/angular/watch_multi.js +++ /dev/null @@ -1,137 +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 _ from 'lodash'; - -export function watchMultiDecorator($provide) { - $provide.decorator('$rootScope', function ($delegate) { - /** - * Watch multiple expressions with a single callback. Along - * with making code simpler it also merges all of the watcher - * handlers within a single tick. - * - * # expression format - * expressions can be specified in one of the following ways: - * 1. string that evaluates to a value on scope. Creates a regular $watch - * expression. - * 'someScopeValue.prop' === $scope.$watch('someScopeValue.prop', fn); - * - * 2. #1 prefixed with '[]', which uses $watchCollection rather than $watch. - * '[]expr' === $scope.$watchCollection('expr', fn); - * - * 3. #1 prefixed with '=', which uses $watch with objectEquality turned on - * '=expr' === $scope.$watch('expr', fn, true); - * - * 4. a function that will be called, like a normal function water - * - * 5. an object with any of the properties: - * `get`: the getter called on each iteration - * `deep`: a flag to turn on objectEquality in $watch - * `fn`: the watch registration function ($scope.$watch or $scope.$watchCollection) - * - * @param {array[string|function|obj]} expressions - the list of expressions to $watch - * @param {Function} fn - the callback function - * @return {Function} - an unwatch function, just like the return value of $watch - */ - $delegate.constructor.prototype.$watchMulti = function (expressions, fn) { - if (!Array.isArray(expressions)) { - throw new TypeError('expected an array of expressions to watch'); - } - - if (!_.isFunction(fn)) { - throw new TypeError('expected a function that is triggered on each watch'); - } - const $scope = this; - const vals = new Array(expressions.length); - const prev = new Array(expressions.length); - let fire = false; - let init = 0; - const neededInits = expressions.length; - - // first, register all of the multi-watchers - const unwatchers = expressions.map(function (expr, i) { - expr = normalizeExpression($scope, expr); - if (!expr) return; - - return expr.fn.call( - $scope, - expr.get, - function (newVal, oldVal) { - if (newVal === oldVal) { - init += 1; - } - - vals[i] = newVal; - prev[i] = oldVal; - fire = true; - }, - expr.deep - ); - }); - - // then, the watcher that checks to see if any of - // the other watchers triggered this cycle - let flip = false; - unwatchers.push( - $scope.$watch( - function () { - if (init < neededInits) return init; - - if (fire) { - fire = false; - flip = !flip; - } - return flip; - }, - function () { - if (init < neededInits) return false; - - fn(vals.slice(0), prev.slice(0)); - vals.forEach(function (v, i) { - prev[i] = v; - }); - } - ) - ); - - return function () { - unwatchers.forEach((listener) => listener()); - }; - }; - - function normalizeExpression($scope, expr) { - if (!expr) return; - const norm = { - fn: $scope.$watch, - deep: false, - }; - - if (_.isFunction(expr)) return _.assign(norm, { get: expr }); - if (_.isObject(expr)) return _.assign(norm, expr); - if (!_.isString(expr)) return; - - if (expr.substr(0, 2) === '[]') { - return _.assign(norm, { - fn: $scope.$watchCollection, - get: expr.substr(2), - }); - } - - if (expr.charAt(0) === '=') { - return _.assign(norm, { - deep: true, - get: expr.substr(1), - }); - } - - return _.assign(norm, { get: expr }); - } - - return $delegate; - }); -} diff --git a/src/plugins/kibana_legacy/public/angular_bootstrap/bind_html/bind_html.js b/src/plugins/kibana_legacy/public/angular_bootstrap/bind_html/bind_html.js deleted file mode 100755 index 77844a3dd1363..0000000000000 --- a/src/plugins/kibana_legacy/public/angular_bootstrap/bind_html/bind_html.js +++ /dev/null @@ -1,17 +0,0 @@ -/* eslint-disable */ - -import angular from 'angular'; - -export function initBindHtml() { - angular - .module('ui.bootstrap.bindHtml', []) - - .directive('bindHtmlUnsafe', function() { - return function(scope, element, attr) { - element.addClass('ng-binding').data('$binding', attr.bindHtmlUnsafe); - scope.$watch(attr.bindHtmlUnsafe, function bindHtmlUnsafeWatchAction(value) { - element.html(value || ''); - }); - }; - }); -} diff --git a/src/plugins/kibana_legacy/public/angular_bootstrap/index.ts b/src/plugins/kibana_legacy/public/angular_bootstrap/index.ts deleted file mode 100644 index 1f15107a02762..0000000000000 --- a/src/plugins/kibana_legacy/public/angular_bootstrap/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* eslint-disable */ - -import { once } from 'lodash'; -import angular from 'angular'; - -// @ts-ignore -import { initBindHtml } from './bind_html/bind_html'; -// @ts-ignore -import { initBootstrapTooltip } from './tooltip/tooltip'; - -import tooltipPopup from './tooltip/tooltip_popup.html'; - -import tooltipUnsafePopup from './tooltip/tooltip_html_unsafe_popup.html'; - -export const initAngularBootstrap = once(() => { - /* - * angular-ui-bootstrap - * http://angular-ui.github.io/bootstrap/ - - * Version: 0.12.1 - 2015-02-20 - * License: MIT - */ - angular.module('ui.bootstrap', [ - 'ui.bootstrap.tpls', - 'ui.bootstrap.bindHtml', - 'ui.bootstrap.tooltip', - ]); - - angular.module('ui.bootstrap.tpls', [ - 'template/tooltip/tooltip-html-unsafe-popup.html', - 'template/tooltip/tooltip-popup.html', - ]); - - initBindHtml(); - initBootstrapTooltip(); - - angular.module('template/tooltip/tooltip-html-unsafe-popup.html', []).run([ - '$templateCache', - function($templateCache: any) { - $templateCache.put('template/tooltip/tooltip-html-unsafe-popup.html', tooltipUnsafePopup); - }, - ]); - - angular.module('template/tooltip/tooltip-popup.html', []).run([ - '$templateCache', - function($templateCache: any) { - $templateCache.put('template/tooltip/tooltip-popup.html', tooltipPopup); - }, - ]); -}); diff --git a/src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/position.js b/src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/position.js deleted file mode 100755 index 24c8a8c5979cd..0000000000000 --- a/src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/position.js +++ /dev/null @@ -1,167 +0,0 @@ -/* eslint-disable */ - -import angular from 'angular'; - -export function initBootstrapPosition() { - angular - .module('ui.bootstrap.position', []) - - /** - * A set of utility methods that can be use to retrieve position of DOM elements. - * It is meant to be used where we need to absolute-position DOM elements in - * relation to other, existing elements (this is the case for tooltips, popovers, - * typeahead suggestions etc.). - */ - .factory('$position', [ - '$document', - '$window', - function($document, $window) { - function getStyle(el, cssprop) { - if (el.currentStyle) { - //IE - return el.currentStyle[cssprop]; - } else if ($window.getComputedStyle) { - return $window.getComputedStyle(el)[cssprop]; - } - // finally try and get inline style - return el.style[cssprop]; - } - - /** - * Checks if a given element is statically positioned - * @param element - raw DOM element - */ - function isStaticPositioned(element) { - return (getStyle(element, 'position') || 'static') === 'static'; - } - - /** - * returns the closest, non-statically positioned parentOffset of a given element - * @param element - */ - const parentOffsetEl = function(element) { - const docDomEl = $document[0]; - let offsetParent = element.offsetParent || docDomEl; - while (offsetParent && offsetParent !== docDomEl && isStaticPositioned(offsetParent)) { - offsetParent = offsetParent.offsetParent; - } - return offsetParent || docDomEl; - }; - - return { - /** - * Provides read-only equivalent of jQuery's position function: - * http://api.jquery.com/position/ - */ - position: function(element) { - const elBCR = this.offset(element); - let offsetParentBCR = { top: 0, left: 0 }; - const offsetParentEl = parentOffsetEl(element[0]); - if (offsetParentEl != $document[0]) { - offsetParentBCR = this.offset(angular.element(offsetParentEl)); - offsetParentBCR.top += offsetParentEl.clientTop - offsetParentEl.scrollTop; - offsetParentBCR.left += offsetParentEl.clientLeft - offsetParentEl.scrollLeft; - } - - const boundingClientRect = element[0].getBoundingClientRect(); - return { - width: boundingClientRect.width || element.prop('offsetWidth'), - height: boundingClientRect.height || element.prop('offsetHeight'), - top: elBCR.top - offsetParentBCR.top, - left: elBCR.left - offsetParentBCR.left, - }; - }, - - /** - * Provides read-only equivalent of jQuery's offset function: - * http://api.jquery.com/offset/ - */ - offset: function(element) { - const boundingClientRect = element[0].getBoundingClientRect(); - return { - width: boundingClientRect.width || element.prop('offsetWidth'), - height: boundingClientRect.height || element.prop('offsetHeight'), - top: - boundingClientRect.top + - ($window.pageYOffset || $document[0].documentElement.scrollTop), - left: - boundingClientRect.left + - ($window.pageXOffset || $document[0].documentElement.scrollLeft), - }; - }, - - /** - * Provides coordinates for the targetEl in relation to hostEl - */ - positionElements: function(hostEl, targetEl, positionStr, appendToBody) { - const positionStrParts = positionStr.split('-'); - const pos0 = positionStrParts[0]; - const pos1 = positionStrParts[1] || 'center'; - - let hostElPos; - let targetElWidth; - let targetElHeight; - let targetElPos; - - hostElPos = appendToBody ? this.offset(hostEl) : this.position(hostEl); - - targetElWidth = targetEl.prop('offsetWidth'); - targetElHeight = targetEl.prop('offsetHeight'); - - const shiftWidth = { - center: function() { - return hostElPos.left + hostElPos.width / 2 - targetElWidth / 2; - }, - left: function() { - return hostElPos.left; - }, - right: function() { - return hostElPos.left + hostElPos.width; - }, - }; - - const shiftHeight = { - center: function() { - return hostElPos.top + hostElPos.height / 2 - targetElHeight / 2; - }, - top: function() { - return hostElPos.top; - }, - bottom: function() { - return hostElPos.top + hostElPos.height; - }, - }; - - switch (pos0) { - case 'right': - targetElPos = { - top: shiftHeight[pos1](), - left: shiftWidth[pos0](), - }; - break; - case 'left': - targetElPos = { - top: shiftHeight[pos1](), - left: hostElPos.left - targetElWidth, - }; - break; - case 'bottom': - targetElPos = { - top: shiftHeight[pos0](), - left: shiftWidth[pos1](), - }; - break; - default: - targetElPos = { - top: hostElPos.top - targetElHeight, - left: shiftWidth[pos1](), - }; - break; - } - - return targetElPos; - }, - }; - }, - ]); -} diff --git a/src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/tooltip.js b/src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/tooltip.js deleted file mode 100755 index 05235fde9419b..0000000000000 --- a/src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/tooltip.js +++ /dev/null @@ -1,423 +0,0 @@ -/* eslint-disable */ - -import angular from 'angular'; - -import { initBootstrapPosition } from './position'; - -export function initBootstrapTooltip() { - initBootstrapPosition(); - /** - * The following features are still outstanding: animation as a - * function, placement as a function, inside, support for more triggers than - * just mouse enter/leave, html tooltips, and selector delegation. - */ - angular - .module('ui.bootstrap.tooltip', ['ui.bootstrap.position']) - - /** - * The $tooltip service creates tooltip- and popover-like directives as well as - * houses global options for them. - */ - .provider('$tooltip', function() { - // The default options tooltip and popover. - const defaultOptions = { - placement: 'top', - animation: true, - popupDelay: 0, - }; - - // Default hide triggers for each show trigger - const triggerMap = { - mouseenter: 'mouseleave', - click: 'click', - focus: 'blur', - }; - - // The options specified to the provider globally. - const globalOptions = {}; - - /** - * `options({})` allows global configuration of all tooltips in the - * application. - * - * var app = angular.module( 'App', ['ui.bootstrap.tooltip'], function( $tooltipProvider ) { - * // place tooltips left instead of top by default - * $tooltipProvider.options( { placement: 'left' } ); - * }); - */ - this.options = function(value) { - angular.extend(globalOptions, value); - }; - - /** - * This allows you to extend the set of trigger mappings available. E.g.: - * - * $tooltipProvider.setTriggers( 'openTrigger': 'closeTrigger' ); - */ - this.setTriggers = function setTriggers(triggers) { - angular.extend(triggerMap, triggers); - }; - - /** - * This is a helper function for translating camel-case to snake-case. - */ - function snake_case(name) { - const regexp = /[A-Z]/g; - const separator = '-'; - return name.replace(regexp, function(letter, pos) { - return (pos ? separator : '') + letter.toLowerCase(); - }); - } - - /** - * Returns the actual instance of the $tooltip service. - * TODO support multiple triggers - */ - this.$get = [ - '$window', - '$compile', - '$timeout', - '$document', - '$position', - '$interpolate', - function($window, $compile, $timeout, $document, $position, $interpolate) { - return function $tooltip(type, prefix, defaultTriggerShow) { - const options = angular.extend({}, defaultOptions, globalOptions); - - /** - * Returns an object of show and hide triggers. - * - * If a trigger is supplied, - * it is used to show the tooltip; otherwise, it will use the `trigger` - * option passed to the `$tooltipProvider.options` method; else it will - * default to the trigger supplied to this directive factory. - * - * The hide trigger is based on the show trigger. If the `trigger` option - * was passed to the `$tooltipProvider.options` method, it will use the - * mapped trigger from `triggerMap` or the passed trigger if the map is - * undefined; otherwise, it uses the `triggerMap` value of the show - * trigger; else it will just use the show trigger. - */ - function getTriggers(trigger) { - const show = trigger || options.trigger || defaultTriggerShow; - const hide = triggerMap[show] || show; - return { - show: show, - hide: hide, - }; - } - - const directiveName = snake_case(type); - - const startSym = $interpolate.startSymbol(); - const endSym = $interpolate.endSymbol(); - const template = - '
' + - '
'; - - return { - restrict: 'EA', - compile: function(tElem, tAttrs) { - const tooltipLinker = $compile(template); - - return function link(scope, element, attrs) { - let tooltip; - let tooltipLinkedScope; - let transitionTimeout; - let popupTimeout; - let appendToBody = angular.isDefined(options.appendToBody) - ? options.appendToBody - : false; - let triggers = getTriggers(undefined); - const hasEnableExp = angular.isDefined(attrs[prefix + 'Enable']); - let ttScope = scope.$new(true); - - const positionTooltip = function() { - const ttPosition = $position.positionElements( - element, - tooltip, - ttScope.placement, - appendToBody - ); - ttPosition.top += 'px'; - ttPosition.left += 'px'; - - // Now set the calculated positioning. - tooltip.css(ttPosition); - }; - - // By default, the tooltip is not open. - // TODO add ability to start tooltip opened - ttScope.isOpen = false; - - function toggleTooltipBind() { - if (!ttScope.isOpen) { - showTooltipBind(); - } else { - hideTooltipBind(); - } - } - - // Show the tooltip with delay if specified, otherwise show it immediately - function showTooltipBind() { - if (hasEnableExp && !scope.$eval(attrs[prefix + 'Enable'])) { - return; - } - - prepareTooltip(); - - if (ttScope.popupDelay) { - // Do nothing if the tooltip was already scheduled to pop-up. - // This happens if show is triggered multiple times before any hide is triggered. - if (!popupTimeout) { - popupTimeout = $timeout(show, ttScope.popupDelay, false); - popupTimeout - .then(reposition => reposition()) - .catch(error => { - // if the timeout is canceled then the string `canceled` is thrown. To prevent - // this from triggering an 'unhandled promise rejection' in angular 1.5+ the - // $timeout service explicitly tells $q that the promise it generated is "handled" - // but that does not include down chain promises like the one created by calling - // `popupTimeout.then()`. Because of this we need to ignore the "canceled" string - // and only propagate real errors - if (error !== 'canceled') { - throw error; - } - }); - } - } else { - show()(); - } - } - - function hideTooltipBind() { - scope.$evalAsync(function() { - hide(); - }); - } - - // Show the tooltip popup element. - function show() { - popupTimeout = null; - - // If there is a pending remove transition, we must cancel it, lest the - // tooltip be mysteriously removed. - if (transitionTimeout) { - $timeout.cancel(transitionTimeout); - transitionTimeout = null; - } - - // Don't show empty tooltips. - if (!ttScope.content) { - return angular.noop; - } - - createTooltip(); - - // Set the initial positioning. - tooltip.css({ top: 0, left: 0, display: 'block' }); - ttScope.$digest(); - - positionTooltip(); - - // And show the tooltip. - ttScope.isOpen = true; - ttScope.$digest(); // digest required as $apply is not called - - // Return positioning function as promise callback for correct - // positioning after draw. - return positionTooltip; - } - - // Hide the tooltip popup element. - function hide() { - // First things first: we don't show it anymore. - ttScope.isOpen = false; - - //if tooltip is going to be shown after delay, we must cancel this - $timeout.cancel(popupTimeout); - popupTimeout = null; - - // And now we remove it from the DOM. However, if we have animation, we - // need to wait for it to expire beforehand. - // FIXME: this is a placeholder for a port of the transitions library. - if (ttScope.animation) { - if (!transitionTimeout) { - transitionTimeout = $timeout(removeTooltip, 500); - } - } else { - removeTooltip(); - } - } - - function createTooltip() { - // There can only be one tooltip element per directive shown at once. - if (tooltip) { - removeTooltip(); - } - tooltipLinkedScope = ttScope.$new(); - tooltip = tooltipLinker(tooltipLinkedScope, function(tooltip) { - if (appendToBody) { - $document.find('body').append(tooltip); - } else { - element.after(tooltip); - } - }); - } - - function removeTooltip() { - transitionTimeout = null; - if (tooltip) { - tooltip.remove(); - tooltip = null; - } - if (tooltipLinkedScope) { - tooltipLinkedScope.$destroy(); - tooltipLinkedScope = null; - } - } - - function prepareTooltip() { - prepPlacement(); - prepPopupDelay(); - } - - /** - * Observe the relevant attributes. - */ - attrs.$observe(type, function(val) { - ttScope.content = val; - - if (!val && ttScope.isOpen) { - hide(); - } - }); - - attrs.$observe(prefix + 'Title', function(val) { - ttScope.title = val; - }); - - function prepPlacement() { - const val = attrs[prefix + 'Placement']; - ttScope.placement = angular.isDefined(val) ? val : options.placement; - } - - function prepPopupDelay() { - const val = attrs[prefix + 'PopupDelay']; - const delay = parseInt(val, 10); - ttScope.popupDelay = !isNaN(delay) ? delay : options.popupDelay; - } - - const unregisterTriggers = function() { - element.unbind(triggers.show, showTooltipBind); - element.unbind(triggers.hide, hideTooltipBind); - }; - - function prepTriggers() { - const val = attrs[prefix + 'Trigger']; - unregisterTriggers(); - - triggers = getTriggers(val); - - if (triggers.show === triggers.hide) { - element.bind(triggers.show, toggleTooltipBind); - } else { - element.bind(triggers.show, showTooltipBind); - element.bind(triggers.hide, hideTooltipBind); - } - } - - prepTriggers(); - - const animation = scope.$eval(attrs[prefix + 'Animation']); - ttScope.animation = angular.isDefined(animation) - ? !!animation - : options.animation; - - const appendToBodyVal = scope.$eval(attrs[prefix + 'AppendToBody']); - appendToBody = angular.isDefined(appendToBodyVal) - ? appendToBodyVal - : appendToBody; - - // if a tooltip is attached to we need to remove it on - // location change as its parent scope will probably not be destroyed - // by the change. - if (appendToBody) { - scope.$on( - '$locationChangeSuccess', - function closeTooltipOnLocationChangeSuccess() { - if (ttScope.isOpen) { - hide(); - } - } - ); - } - - // Make sure tooltip is destroyed and removed. - scope.$on('$destroy', function onDestroyTooltip() { - $timeout.cancel(transitionTimeout); - $timeout.cancel(popupTimeout); - unregisterTriggers(); - removeTooltip(); - ttScope = null; - }); - }; - }, - }; - }; - }, - ]; - }) - - .directive('tooltip', [ - '$tooltip', - function($tooltip) { - return $tooltip('tooltip', 'tooltip', 'mouseenter'); - }, - ]) - - .directive('tooltipPopup', function() { - return { - restrict: 'EA', - replace: true, - scope: { content: '@', placement: '@', animation: '&', isOpen: '&' }, - templateUrl: 'template/tooltip/tooltip-popup.html', - }; - }) - - .directive('tooltipHtmlUnsafe', [ - '$tooltip', - function($tooltip) { - return $tooltip('tooltipHtmlUnsafe', 'tooltip', 'mouseenter'); - }, - ]) - - .directive('tooltipHtmlUnsafePopup', function() { - return { - restrict: 'EA', - replace: true, - scope: { content: '@', placement: '@', animation: '&', isOpen: '&' }, - templateUrl: 'template/tooltip/tooltip-html-unsafe-popup.html', - }; - }); -} diff --git a/src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/tooltip_html_unsafe_popup.html b/src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/tooltip_html_unsafe_popup.html deleted file mode 100644 index b48bf70498906..0000000000000 --- a/src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/tooltip_html_unsafe_popup.html +++ /dev/null @@ -1,4 +0,0 @@ -
-
-
-
\ No newline at end of file diff --git a/src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/tooltip_popup.html b/src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/tooltip_popup.html deleted file mode 100644 index eed4ca7d93016..0000000000000 --- a/src/plugins/kibana_legacy/public/angular_bootstrap/tooltip/tooltip_popup.html +++ /dev/null @@ -1,4 +0,0 @@ -
-
-
-
\ No newline at end of file diff --git a/src/plugins/kibana_legacy/public/index.ts b/src/plugins/kibana_legacy/public/index.ts index 13271532881cb..74c3c2351fde0 100644 --- a/src/plugins/kibana_legacy/public/index.ts +++ b/src/plugins/kibana_legacy/public/index.ts @@ -15,7 +15,6 @@ export const plugin = () => new KibanaLegacyPlugin(); export * from './plugin'; -export { PaginateDirectiveProvider, PaginateControlsDirectiveProvider } from './paginate/paginate'; export * from './angular'; export * from './notify'; export * from './utils'; diff --git a/src/plugins/kibana_legacy/public/mocks.ts b/src/plugins/kibana_legacy/public/mocks.ts index 510e59c7ff190..3eac98a84d40a 100644 --- a/src/plugins/kibana_legacy/public/mocks.ts +++ b/src/plugins/kibana_legacy/public/mocks.ts @@ -15,7 +15,6 @@ const createSetupContract = (): Setup => ({}); const createStartContract = (): Start => ({ loadFontAwesome: jest.fn(), - loadAngularBootstrap: jest.fn(), }); export const kibanaLegacyPluginMock = { diff --git a/src/plugins/kibana_legacy/public/notify/lib/add_fatal_error.ts b/src/plugins/kibana_legacy/public/notify/lib/add_fatal_error.ts deleted file mode 100644 index 42207432861aa..0000000000000 --- a/src/plugins/kibana_legacy/public/notify/lib/add_fatal_error.ts +++ /dev/null @@ -1,27 +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 { FatalErrorsSetup } from '../../../../../core/public'; -import { - AngularHttpError, - formatAngularHttpError, - isAngularHttpError, -} from './format_angular_http_error'; - -export function addFatalError( - fatalErrors: FatalErrorsSetup, - error: AngularHttpError | Error | string, - location?: string -) { - // add support for angular http errors to newPlatformFatalErrors - if (isAngularHttpError(error)) { - error = formatAngularHttpError(error); - } - - fatalErrors.add(error, location); -} diff --git a/src/plugins/kibana_legacy/public/notify/lib/format_stack.ts b/src/plugins/kibana_legacy/public/notify/lib/format_stack.ts deleted file mode 100644 index 345891fd156ce..0000000000000 --- a/src/plugins/kibana_legacy/public/notify/lib/format_stack.ts +++ /dev/null @@ -1,24 +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 { i18n } from '@kbn/i18n'; - -// browsers format Error.stack differently; always include message -export function formatStack(err: Record) { - if (err.stack && err.stack.indexOf(err.message) === -1) { - return i18n.translate('kibana_legacy.notify.toaster.errorMessage', { - defaultMessage: `Error: {errorMessage} - {errorStack}`, - values: { - errorMessage: err.message, - errorStack: err.stack, - }, - }); - } - return err.stack; -} diff --git a/src/plugins/kibana_legacy/public/notify/lib/index.ts b/src/plugins/kibana_legacy/public/notify/lib/index.ts index 41f723f9c1ea2..59ad069c9793c 100644 --- a/src/plugins/kibana_legacy/public/notify/lib/index.ts +++ b/src/plugins/kibana_legacy/public/notify/lib/index.ts @@ -8,10 +8,8 @@ export { formatESMsg } from './format_es_msg'; export { formatMsg } from './format_msg'; -export { formatStack } from './format_stack'; export { isAngularHttpError, formatAngularHttpError, AngularHttpError, } from './format_angular_http_error'; -export { addFatalError } from './add_fatal_error'; diff --git a/src/plugins/kibana_legacy/public/paginate/_paginate.scss b/src/plugins/kibana_legacy/public/paginate/_paginate.scss deleted file mode 100644 index 9824ff2d8dff3..0000000000000 --- a/src/plugins/kibana_legacy/public/paginate/_paginate.scss +++ /dev/null @@ -1,56 +0,0 @@ -paginate { - display: block; - - paginate-controls { - display: flex; - align-items: center; - padding: $euiSizeXS $euiSizeXS $euiSizeS; - text-align: center; - - .pagination-other-pages { - flex: 1 0 auto; - display: flex; - justify-content: center; - } - - .pagination-other-pages-list { - flex: 0 0 auto; - display: flex; - justify-content: center; - padding: 0; - margin: 0; - list-style: none; - - > li { - flex: 0 0 auto; - user-select: none; - - a { - text-decoration: none; - background-color: $euiColorLightestShade; - margin-left: $euiSizeXS / 2; - padding: $euiSizeS $euiSizeM; - } - - a:hover { - text-decoration: underline; - } - - &.active a { // stylelint-disable-line selector-no-qualifying-type - text-decoration: none !important; - font-weight: $euiFontWeightBold; - color: $euiColorDarkShade; - cursor: default; - } - } - } - - .pagination-size { - flex: 0 0 auto; - - input[type=number] { - width: 3em; - } - } - } -} diff --git a/src/plugins/kibana_legacy/public/paginate/paginate.d.ts b/src/plugins/kibana_legacy/public/paginate/paginate.d.ts deleted file mode 100644 index 770f7f7ec7c12..0000000000000 --- a/src/plugins/kibana_legacy/public/paginate/paginate.d.ts +++ /dev/null @@ -1,10 +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. - */ - -export function PaginateDirectiveProvider($parse: any, $compile: any): any; -export function PaginateControlsDirectiveProvider(): any; diff --git a/src/plugins/kibana_legacy/public/paginate/paginate.js b/src/plugins/kibana_legacy/public/paginate/paginate.js deleted file mode 100644 index ebab8f35571de..0000000000000 --- a/src/plugins/kibana_legacy/public/paginate/paginate.js +++ /dev/null @@ -1,217 +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 _ from 'lodash'; -import { i18n } from '@kbn/i18n'; -import './_paginate.scss'; -import paginateControlsTemplate from './paginate_controls.html'; - -export function PaginateDirectiveProvider($parse, $compile) { - return { - restrict: 'E', - scope: true, - link: { - pre: function ($scope, $el, attrs) { - if (_.isUndefined(attrs.bottomControls)) attrs.bottomControls = true; - if ($el.find('paginate-controls.paginate-bottom').length === 0 && attrs.bottomControls) { - $el.append($compile('')($scope)); - } - }, - post: function ($scope, $el, attrs) { - if (_.isUndefined(attrs.topControls)) attrs.topControls = false; - if ($el.find('paginate-controls.paginate-top').length === 0 && attrs.topControls) { - $el.prepend($compile('')($scope)); - } - - const paginate = $scope.paginate; - - // add some getters to the controller powered by attributes - paginate.getList = $parse(attrs.list); - paginate.perPageProp = attrs.perPageProp; - - if (attrs.perPage) { - paginate.perPage = attrs.perPage; - $scope.showSelector = false; - } else { - $scope.showSelector = true; - } - - paginate.otherWidthGetter = $parse(attrs.otherWidth); - - paginate.init(); - }, - }, - controllerAs: 'paginate', - controller: function ($scope, $document) { - const self = this; - const ALL = 0; - const allSizeTitle = i18n.translate('kibana_legacy.paginate.size.allDropDownOptionLabel', { - defaultMessage: 'All', - }); - - self.sizeOptions = [ - { title: '10', value: 10 }, - { title: '25', value: 25 }, - { title: '100', value: 100 }, - { title: allSizeTitle, value: ALL }, - ]; - - // setup the watchers, called in the post-link function - self.init = function () { - self.perPage = _.parseInt(self.perPage) || $scope[self.perPageProp]; - - $scope.$watchMulti( - ['paginate.perPage', self.perPageProp, self.otherWidthGetter], - function (vals, oldVals) { - const intChanges = vals[0] !== oldVals[0]; - - if (intChanges) { - if (!setPerPage(self.perPage)) { - // if we are not able to set the external value, - // render now, otherwise wait for the external value - // to trigger the watcher again - self.renderList(); - } - return; - } - - self.perPage = _.parseInt(self.perPage) || $scope[self.perPageProp]; - if (self.perPage == null) { - self.perPage = ALL; - return; - } - - self.renderList(); - } - ); - - $scope.$watch('page', self.changePage); - $scope.$watchCollection(self.getList, function (list) { - $scope.list = list; - self.renderList(); - }); - }; - - self.goToPage = function (number) { - if (number) { - if (number.hasOwnProperty('number')) number = number.number; - $scope.page = $scope.pages[number - 1] || $scope.pages[0]; - } - }; - - self.goToTop = function goToTop() { - $document.scrollTop(0); - }; - - self.renderList = function () { - $scope.pages = []; - if (!$scope.list) return; - - const perPage = _.parseInt(self.perPage); - const count = perPage ? Math.ceil($scope.list.length / perPage) : 1; - - _.times(count, function (i) { - let page; - - if (perPage) { - const start = perPage * i; - page = $scope.list.slice(start, start + perPage); - } else { - page = $scope.list.slice(0); - } - - page.number = i + 1; - page.i = i; - - page.count = count; - page.first = page.number === 1; - page.last = page.number === count; - page.firstItem = (page.number - 1) * perPage + 1; - page.lastItem = Math.min(page.number * perPage, $scope.list.length); - - page.prev = $scope.pages[i - 1]; - if (page.prev) page.prev.next = page; - - $scope.pages.push(page); - }); - - // set the new page, or restore the previous page number - if ($scope.page && $scope.page.i < $scope.pages.length) { - $scope.page = $scope.pages[$scope.page.i]; - } else { - $scope.page = $scope.pages[0]; - } - - if ($scope.page && $scope.onPageChanged) { - $scope.onPageChanged($scope.page); - } - }; - - self.changePage = function (page) { - if (!page) { - $scope.otherPages = null; - return; - } - - // setup the list of the other pages to link to - $scope.otherPages = []; - const width = +self.otherWidthGetter($scope) || 5; - let left = page.i - Math.round((width - 1) / 2); - let right = left + width - 1; - - // shift neg count from left to right - if (left < 0) { - right += 0 - left; - left = 0; - } - - // shift extra right nums to left - const lastI = page.count - 1; - if (right > lastI) { - right = lastI; - left = right - width + 1; - } - - for (let i = left; i <= right; i++) { - const other = $scope.pages[i]; - - if (!other) continue; - - $scope.otherPages.push(other); - if (other.last) $scope.otherPages.containsLast = true; - if (other.first) $scope.otherPages.containsFirst = true; - } - - if ($scope.onPageChanged) { - $scope.onPageChanged($scope.page); - } - }; - - function setPerPage(val) { - let $ppParent = $scope; - - while ($ppParent && !_.has($ppParent, self.perPageProp)) { - $ppParent = $ppParent.$parent; - } - - if ($ppParent) { - $ppParent[self.perPageProp] = val; - return true; - } - } - }, - }; -} - -export function PaginateControlsDirectiveProvider() { - // this directive is automatically added by paginate if not found within it's $el - return { - restrict: 'E', - template: paginateControlsTemplate, - }; -} diff --git a/src/plugins/kibana_legacy/public/paginate/paginate_controls.html b/src/plugins/kibana_legacy/public/paginate/paginate_controls.html deleted file mode 100644 index a553bc2231720..0000000000000 --- a/src/plugins/kibana_legacy/public/paginate/paginate_controls.html +++ /dev/null @@ -1,98 +0,0 @@ - - -
-
    -
  • - -
  • -
  • - -
  • - -
  • - - ... -
  • - -
  • - -
  • - -
  • - ... - -
  • - -
  • - -
  • -
  • - -
  • -
-
- -
-
- - -
-
diff --git a/src/plugins/kibana_legacy/public/plugin.ts b/src/plugins/kibana_legacy/public/plugin.ts index e5244c110ad20..ac78e8cac4f07 100644 --- a/src/plugins/kibana_legacy/public/plugin.ts +++ b/src/plugins/kibana_legacy/public/plugin.ts @@ -24,14 +24,6 @@ export class KibanaLegacyPlugin { loadFontAwesome: async () => { await import('./font_awesome'); }, - /** - * Loads angular bootstrap modules. Should be removed once the last consumer has migrated to EUI - * @deprecated - */ - loadAngularBootstrap: async () => { - const { initAngularBootstrap } = await import('./angular_bootstrap'); - initAngularBootstrap(); - }, }; } } diff --git a/src/plugins/kibana_legacy/public/utils/index.ts b/src/plugins/kibana_legacy/public/utils/index.ts index 94233558b4627..9bfc185b6a69e 100644 --- a/src/plugins/kibana_legacy/public/utils/index.ts +++ b/src/plugins/kibana_legacy/public/utils/index.ts @@ -6,9 +6,5 @@ * Side Public License, v 1. */ -// @ts-ignore -export { KbnAccessibleClickProvider } from './kbn_accessible_click'; // @ts-ignore export { PrivateProvider, IPrivate } from './private'; -// @ts-ignore -export { registerListenEventListener } from './register_listen_event_listener'; diff --git a/src/plugins/kibana_legacy/public/utils/kbn_accessible_click.d.ts b/src/plugins/kibana_legacy/public/utils/kbn_accessible_click.d.ts deleted file mode 100644 index 16adb3750e9ea..0000000000000 --- a/src/plugins/kibana_legacy/public/utils/kbn_accessible_click.d.ts +++ /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 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 { Injectable, IDirectiveFactory, IScope, IAttributes, IController } from 'angular'; - -export const KbnAccessibleClickProvider: Injectable< - IDirectiveFactory ->; diff --git a/src/plugins/kibana_legacy/public/utils/kbn_accessible_click.js b/src/plugins/kibana_legacy/public/utils/kbn_accessible_click.js deleted file mode 100644 index adcd133bf1719..0000000000000 --- a/src/plugins/kibana_legacy/public/utils/kbn_accessible_click.js +++ /dev/null @@ -1,60 +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 { accessibleClickKeys, keys } from '@elastic/eui'; - -export function KbnAccessibleClickProvider() { - return { - restrict: 'A', - controller: ($element) => { - $element.on('keydown', (e) => { - // Prevent a scroll from occurring if the user has hit space. - if (e.key === keys.SPACE) { - e.preventDefault(); - } - }); - }, - link: (scope, element, attrs) => { - // The whole point of this directive is to hack in functionality that native buttons provide - // by default. - const elementType = element.prop('tagName'); - - if (elementType === 'BUTTON') { - throw new Error(`kbnAccessibleClick doesn't need to be used on a button.`); - } - - if (elementType === 'A' && attrs.href !== undefined) { - throw new Error( - `kbnAccessibleClick doesn't need to be used on a link if it has a href attribute.` - ); - } - - // We're emulating a click action, so we should already have a regular click handler defined. - if (!attrs.ngClick) { - throw new Error('kbnAccessibleClick requires ng-click to be defined on its element.'); - } - - // If the developer hasn't already specified attributes required for accessibility, add them. - if (attrs.tabindex === undefined) { - element.attr('tabindex', '0'); - } - - if (attrs.role === undefined) { - element.attr('role', 'button'); - } - - element.on('keyup', (e) => { - // Support keyboard accessibility by emulating mouse click on ENTER or SPACE keypress. - if (accessibleClickKeys[e.key]) { - // Delegate to the click handler on the element (assumed to be ng-click). - element.click(); - } - }); - }, - }; -} diff --git a/src/plugins/kibana_legacy/public/utils/register_listen_event_listener.d.ts b/src/plugins/kibana_legacy/public/utils/register_listen_event_listener.d.ts deleted file mode 100644 index 800965baba4b4..0000000000000 --- a/src/plugins/kibana_legacy/public/utils/register_listen_event_listener.d.ts +++ /dev/null @@ -1,9 +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. - */ - -export function registerListenEventListener($rootScope: unknown): void; diff --git a/src/plugins/kibana_legacy/public/utils/register_listen_event_listener.js b/src/plugins/kibana_legacy/public/utils/register_listen_event_listener.js deleted file mode 100644 index be91a69a9240d..0000000000000 --- a/src/plugins/kibana_legacy/public/utils/register_listen_event_listener.js +++ /dev/null @@ -1,25 +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. - */ - -export function registerListenEventListener($rootScope) { - /** - * Helper that registers an event listener, and removes that listener when - * the $scope is destroyed. - * - * @param {EventEmitter} emitter - the event emitter to listen to - * @param {string} eventName - the event name - * @param {Function} handler - the event handler - * @return {undefined} - */ - $rootScope.constructor.prototype.$listen = function (emitter, eventName, handler) { - emitter.on(eventName, handler); - this.$on('$destroy', function () { - emitter.off(eventName, handler); - }); - }; -} diff --git a/src/plugins/vis_types/table/kibana.json b/src/plugins/vis_types/table/kibana.json index b3ebd5117bbc8..a56965a214349 100644 --- a/src/plugins/vis_types/table/kibana.json +++ b/src/plugins/vis_types/table/kibana.json @@ -6,8 +6,7 @@ "requiredPlugins": [ "expressions", "visualizations", - "data", - "kibanaLegacy" + "data" ], "requiredBundles": [ "kibanaUtils", diff --git a/src/plugins/vis_types/table/tsconfig.json b/src/plugins/vis_types/table/tsconfig.json index 9325064d571d0..578b10fb09be8 100644 --- a/src/plugins/vis_types/table/tsconfig.json +++ b/src/plugins/vis_types/table/tsconfig.json @@ -20,7 +20,6 @@ { "path": "../../usage_collection/tsconfig.json" }, { "path": "../../expressions/tsconfig.json" }, { "path": "../../kibana_utils/tsconfig.json" }, - { "path": "../../kibana_legacy/tsconfig.json" }, { "path": "../../kibana_react/tsconfig.json" }, { "path": "../../vis_default_editor/tsconfig.json" }, { "path": "../../field_formats/tsconfig.json" } diff --git a/x-pack/plugins/discover_enhanced/kibana.json b/x-pack/plugins/discover_enhanced/kibana.json index cb05cbd3e724d..426da33ae64f1 100644 --- a/x-pack/plugins/discover_enhanced/kibana.json +++ b/x-pack/plugins/discover_enhanced/kibana.json @@ -5,7 +5,7 @@ "server": true, "ui": true, "requiredPlugins": ["uiActions", "embeddable", "discover"], - "optionalPlugins": ["share", "kibanaLegacy", "usageCollection"], + "optionalPlugins": ["share", "usageCollection"], "configPath": ["xpack", "discoverEnhanced"], "requiredBundles": ["kibanaUtils", "data"], "owner": { diff --git a/x-pack/plugins/discover_enhanced/public/plugin.ts b/x-pack/plugins/discover_enhanced/public/plugin.ts index 3b437ead81d78..63a7e1b6c1c6b 100644 --- a/x-pack/plugins/discover_enhanced/public/plugin.ts +++ b/x-pack/plugins/discover_enhanced/public/plugin.ts @@ -12,7 +12,6 @@ import { APPLY_FILTER_TRIGGER } from '../../../../src/plugins/data/public'; import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public'; import { DiscoverSetup, DiscoverStart } from '../../../../src/plugins/discover/public'; import { SharePluginSetup, SharePluginStart } from '../../../../src/plugins/share/public'; -import { KibanaLegacySetup, KibanaLegacyStart } from '../../../../src/plugins/kibana_legacy/public'; import { EmbeddableSetup, EmbeddableStart, @@ -24,7 +23,6 @@ import { Config } from '../common'; export interface DiscoverEnhancedSetupDependencies { discover: DiscoverSetup; embeddable: EmbeddableSetup; - kibanaLegacy?: KibanaLegacySetup; share?: SharePluginSetup; uiActions: UiActionsSetup; } @@ -32,7 +30,6 @@ export interface DiscoverEnhancedSetupDependencies { export interface DiscoverEnhancedStartDependencies { discover: DiscoverStart; embeddable: EmbeddableStart; - kibanaLegacy?: KibanaLegacyStart; share?: SharePluginStart; uiActions: UiActionsStart; } diff --git a/x-pack/plugins/discover_enhanced/tsconfig.json b/x-pack/plugins/discover_enhanced/tsconfig.json index f372b7a7539ac..8ce3e5bf9f63a 100644 --- a/x-pack/plugins/discover_enhanced/tsconfig.json +++ b/x-pack/plugins/discover_enhanced/tsconfig.json @@ -12,7 +12,6 @@ { "path": "../../../src/plugins/data/tsconfig.json" }, { "path": "../../../src/plugins/discover/tsconfig.json" }, { "path": "../../../src/plugins/share/tsconfig.json" }, - { "path": "../../../src/plugins/kibana_legacy/tsconfig.json" }, { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, { "path": "../../../src/plugins/url_forwarding/tsconfig.json" }, { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ac8616ab2dd8d..2b58733c23049 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4182,12 +4182,8 @@ "inspector.view": "{viewName} を表示", "kibana_legacy.notify.fatalError.errorStatusMessage": "エラー {errStatus} {errStatusText}: {errMessage}", "kibana_legacy.notify.fatalError.unavailableServerErrorMessage": "HTTP リクエストで接続に失敗しました。Kibana サーバーが実行されていて、ご使用のブラウザの接続が正常に動作していることを確認するか、システム管理者にお問い合わせください。", - "kibana_legacy.notify.toaster.errorMessage": "エラー:{errorMessage}\n {errorStack}", "kibana_legacy.notify.toaster.errorStatusMessage": "エラー {errStatus} {errStatusText}: {errMessage}", "kibana_legacy.notify.toaster.unavailableServerErrorMessage": "HTTP リクエストで接続に失敗しました。Kibana サーバーが実行されていて、ご使用のブラウザの接続が正常に動作していることを確認するか、システム管理者にお問い合わせください。", - "kibana_legacy.paginate.controls.pageSizeLabel": "ページサイズ", - "kibana_legacy.paginate.controls.scrollTopButtonLabel": "最上部に移動", - "kibana_legacy.paginate.size.allDropDownOptionLabel": "すべて", "kibana_utils.history.savedObjectIsMissingNotificationMessage": "保存されたオブジェクトがありません", "kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "URL を完全に復元できません。共有機能を使用していることを確認してください。", "kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "セッションがいっぱいで安全に削除できるアイテムが見つからないため、Kibana は履歴アイテムを保存できません。\n\nこれは大抵新規タブに移動することで解決されますが、より大きな問題が原因である可能性もあります。このメッセージが定期的に表示される場合は、{gitHubIssuesUrl} で問題を報告してください。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b844315c8b6e5..3564f8b62f5cc 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4222,12 +4222,8 @@ "inspector.view": "视图:{viewName}", "kibana_legacy.notify.fatalError.errorStatusMessage": "错误 {errStatus} {errStatusText}:{errMessage}", "kibana_legacy.notify.fatalError.unavailableServerErrorMessage": "HTTP 请求无法连接。请检查 Kibana 服务器是否正在运行以及您的浏览器是否具有有效的连接,或请联系您的系统管理员。", - "kibana_legacy.notify.toaster.errorMessage": "错误:{errorMessage}\n {errorStack}", "kibana_legacy.notify.toaster.errorStatusMessage": "错误 {errStatus} {errStatusText}:{errMessage}", "kibana_legacy.notify.toaster.unavailableServerErrorMessage": "HTTP 请求无法连接。请检查 Kibana 服务器是否正在运行以及您的浏览器是否具有有效的连接,或请联系您的系统管理员。", - "kibana_legacy.paginate.controls.pageSizeLabel": "页面大小", - "kibana_legacy.paginate.controls.scrollTopButtonLabel": "滚动至顶部", - "kibana_legacy.paginate.size.allDropDownOptionLabel": "全部", "kibana_utils.history.savedObjectIsMissingNotificationMessage": "已保存对象缺失", "kibana_utils.stateManagement.stateHash.unableToRestoreUrlErrorMessage": "无法完全还原 URL,请确保使用共享功能。", "kibana_utils.stateManagement.stateHash.unableToStoreHistoryInSessionErrorMessage": "Kibana 无法将历史记录项存储在您的会话中,因为其已满,另外,似乎没有任何可安全删除的项目。\n\n通常,这可以通过移到全新的选项卡来解决,但这种情况可能是由更大的问题造成。如果您定期看到这个消息,请在 {gitHubIssuesUrl} 报告问题。", From a33ae63025be42ad6326945ecd882721833c3c6c Mon Sep 17 00:00:00 2001 From: Gloria Hornero Date: Thu, 30 Sep 2021 10:40:04 +0200 Subject: [PATCH 17/21] [Security Solution][Detections] Fixes 'Detection Rule "reference url" links don't work if they are created with missing "http://' (#112452) * updates url validation to don't accept urls without http or https prefix * fixes typo * fixes linter issue * refactors to follow recommendations * Update x-pack/plugins/security_solution/public/common/utils/validators/index.test.ts Co-authored-by: Frank Hassanabad Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Frank Hassanabad --- .../cypress/tasks/alerts_detection_rules.ts | 3 +- .../common/utils/validators/index.test.ts | 30 ++++++++++++++++++- .../public/common/utils/validators/index.ts | 22 +++++++++----- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts index 0e81f75a19046..7d2116cff7bfb 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts_detection_rules.ts @@ -45,6 +45,7 @@ import { RULE_DETAILS_DELETE_BTN, } from '../screens/alerts_detection_rules'; import { ALL_ACTIONS, DELETE_RULE } from '../screens/rule_details'; +import { LOADING_INDICATOR } from '../screens/security_header'; export const activateRule = (rulePosition: number) => { cy.get(RULE_SWITCH).eq(rulePosition).click({ force: true }); @@ -71,7 +72,7 @@ export const duplicateFirstRule = () => { * flake. */ export const duplicateRuleFromMenu = () => { - cy.get(ALL_ACTIONS).should('be.visible'); + cy.get(LOADING_INDICATOR).should('not.exist'); cy.root() .pipe(($el) => { $el.find(ALL_ACTIONS).trigger('click'); diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/index.test.ts b/x-pack/plugins/security_solution/public/common/utils/validators/index.test.ts index 2eebb1723ea1f..2758a9724e897 100644 --- a/x-pack/plugins/security_solution/public/common/utils/validators/index.test.ts +++ b/x-pack/plugins/security_solution/public/common/utils/validators/index.test.ts @@ -9,12 +9,40 @@ import { isUrlInvalid } from '.'; describe('helpers', () => { describe('isUrlInvalid', () => { - test('verifies invalid url', () => { + test('should verify invalid url', () => { expect(isUrlInvalid('this is not a url')).toBeTruthy(); }); + test('should verify as invalid url without http(s):// prefix', () => { + expect(isUrlInvalid('www.thisIsNotValid.com')).toBeTruthy(); + }); + test('verifies valid url', () => { expect(isUrlInvalid('https://www.elastic.co/')).toBeFalsy(); }); + + test('should verify valid wwww such as 4 of them.', () => { + expect(isUrlInvalid('https://wwww.example.com')).toBeFalsy(); + }); + + test('should validate characters such as %22 being part of a correct URL.', () => { + expect(isUrlInvalid('https://www.exam%22ple.com')).toBeFalsy(); + }); + + test('should validate characters incorrectly such as ]', () => { + expect(isUrlInvalid('https://www.example.com[')).toBeTruthy(); + }); + + test('should verify valid http url', () => { + expect(isUrlInvalid('http://www.example.com/')).toBeFalsy(); + }); + + test('should verify as valid when given an empty string', () => { + expect(isUrlInvalid('')).toBeFalsy(); + }); + + test('empty spaces should valid as not valid ', () => { + expect(isUrlInvalid(' ')).toBeTruthy(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/utils/validators/index.ts b/x-pack/plugins/security_solution/public/common/utils/validators/index.ts index 6d85e1fdd981e..7f0e8c30177f8 100644 --- a/x-pack/plugins/security_solution/public/common/utils/validators/index.ts +++ b/x-pack/plugins/security_solution/public/common/utils/validators/index.ts @@ -5,16 +5,24 @@ * 2.0. */ -import { isEmpty } from 'lodash/fp'; - export * from './is_endpoint_host_isolated'; -const urlExpression = - /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/gi; +const allowedSchemes = ['http:', 'https:']; export const isUrlInvalid = (url: string | null | undefined) => { - if (!isEmpty(url) && url != null && url.match(urlExpression) == null) { - return true; + try { + if (url != null) { + if (url === '') { + return false; + } else { + const urlParsed = new URL(url); + if (allowedSchemes.includes(urlParsed.protocol)) { + return false; + } + } + } + } catch (error) { + // intentionally left empty } - return false; + return true; }; From 9a530b2496b6247c45e76675f87685a3b79f653d Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 30 Sep 2021 10:44:20 +0200 Subject: [PATCH 18/21] [Reporting] Add deprecation titles (#113387) * added i18n and titles to config deprecations * Updated jest test * Add newline at end of file * remove unneeded space --- .../reporting/server/config/index.test.ts | 4 +- .../plugins/reporting/server/config/index.ts | 42 ++++++++++++++----- 2 files changed, 34 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/reporting/server/config/index.test.ts b/x-pack/plugins/reporting/server/config/index.test.ts index 464387ebf90c7..62936fb2f14f3 100644 --- a/x-pack/plugins/reporting/server/config/index.test.ts +++ b/x-pack/plugins/reporting/server/config/index.test.ts @@ -37,7 +37,7 @@ describe('deprecations', () => { const { messages } = applyReportingDeprecations({ index, roles: { enabled: false } }); expect(messages).toMatchInlineSnapshot(` Array [ - "\\"xpack.reporting.index\\" is deprecated. Multitenancy by changing \\"kibana.index\\" will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy for more details", + "Multitenancy by changing \\"kibana.index\\" will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy for more details", ] `); }); @@ -47,7 +47,7 @@ describe('deprecations', () => { const { messages } = applyReportingDeprecations({ roles: { enabled: true } }); expect(messages).toMatchInlineSnapshot(` Array [ - "\\"xpack.reporting.roles\\" is deprecated. Granting reporting privilege through a \\"reporting_user\\" role will not be supported starting in 8.0. Please set \\"xpack.reporting.roles.enabled\\" to \\"false\\" and grant reporting privileges to users using Kibana application privileges **Management > Security > Roles**.", + "Granting reporting privilege through a \\"reporting_user\\" role will not be supported starting in 8.0. Please set \\"xpack.reporting.roles.enabled\\" to \\"false\\" and grant reporting privileges to users using Kibana application privileges **Management > Security > Roles**.", ] `); }); diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts index 119f49df014e2..c7afdb22f8bdb 100644 --- a/x-pack/plugins/reporting/server/config/index.ts +++ b/x-pack/plugins/reporting/server/config/index.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { i18n } from '@kbn/i18n'; import { PluginConfigDescriptor } from 'kibana/server'; import { get } from 'lodash'; import { ConfigSchema, ReportingConfigType } from './schema'; @@ -27,11 +28,21 @@ export const config: PluginConfigDescriptor = { const reporting = get(settings, fromPath); if (reporting?.index) { addDeprecation({ - message: `"${fromPath}.index" is deprecated. Multitenancy by changing "kibana.index" will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy for more details`, + title: i18n.translate('xpack.reporting.deprecations.reportingIndex.title', { + defaultMessage: 'Setting "{fromPath}.index" is deprecated', + values: { fromPath }, + }), + message: i18n.translate('xpack.reporting.deprecations.reportingIndex.description', { + defaultMessage: `Multitenancy by changing "kibana.index" will not be supported starting in 8.0. See https://ela.st/kbn-remove-legacy-multitenancy for more details`, + }), correctiveActions: { manualSteps: [ - `If you rely on this setting to achieve multitenancy you should use Spaces, cross-cluster replication, or cross-cluster search instead.`, - `To migrate to Spaces, we encourage using saved object management to export your saved objects from a tenant into the default tenant in a space.`, + i18n.translate('xpack.reporting.deprecations.reportingIndex.manualStepOne', { + defaultMessage: `If you rely on this setting to achieve multitenancy you should use Spaces, cross-cluster replication, or cross-cluster search instead.`, + }), + i18n.translate('xpack.reporting.deprecations.reportingIndex.manualStepTwo', { + defaultMessage: `To migrate to Spaces, we encourage using saved object management to export your saved objects from a tenant into the default tenant in a space.`, + }), ], }, }); @@ -39,15 +50,26 @@ export const config: PluginConfigDescriptor = { if (reporting?.roles?.enabled !== false) { addDeprecation({ - message: - `"${fromPath}.roles" is deprecated. Granting reporting privilege through a "reporting_user" role will not be supported ` + - `starting in 8.0. Please set "xpack.reporting.roles.enabled" to "false" and grant reporting privileges to users ` + - `using Kibana application privileges **Management > Security > Roles**.`, + title: i18n.translate('xpack.reporting.deprecations.reportingRoles.title', { + defaultMessage: 'Setting "{fromPath}.roles" is deprecated', + values: { fromPath }, + }), + message: i18n.translate('xpack.reporting.deprecations.reportingRoles.description', { + defaultMessage: + `Granting reporting privilege through a "reporting_user" role will not be supported` + + ` starting in 8.0. Please set "xpack.reporting.roles.enabled" to "false" and grant reporting privileges to users` + + ` using Kibana application privileges **Management > Security > Roles**.`, + }), correctiveActions: { manualSteps: [ - `Set 'xpack.reporting.roles.enabled' to 'false' in your kibana configs.`, - `Grant reporting privileges to users using Kibana application privileges` + - `under **Management > Security > Roles**.`, + i18n.translate('xpack.reporting.deprecations.reportingRoles.manualStepOne', { + defaultMessage: `Set 'xpack.reporting.roles.enabled' to 'false' in your kibana configs.`, + }), + i18n.translate('xpack.reporting.deprecations.reportingRoles.manualStepTwo', { + defaultMessage: + `Grant reporting privileges to users using Kibana application privileges` + + ` under **Management > Security > Roles**.`, + }), ], }, }); From 48315da1e1b334126d0b6c846c51d6ed731f4a80 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Thu, 30 Sep 2021 12:11:32 +0300 Subject: [PATCH 19/21] Revert [Vega] Fix: Scrollbars are appearing in default Vega configurations (#113110) * Revert [Vega] Fix: Scrollbars are appearing in default Vega configurations Revert: #97210 * update screens Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/__snapshots__/vega_visualization.test.js.snap | 6 +++--- src/plugins/vis_types/vega/public/components/vega_vis.scss | 2 +- .../vis_types/vega/public/vega_view/vega_base_view.js | 3 +-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/plugins/vis_types/vega/public/__snapshots__/vega_visualization.test.js.snap b/src/plugins/vis_types/vega/public/__snapshots__/vega_visualization.test.js.snap index 8915dbcc149c4..c70c4406a34f2 100644 --- a/src/plugins/vis_types/vega/public/__snapshots__/vega_visualization.test.js.snap +++ b/src/plugins/vis_types/vega/public/__snapshots__/vega_visualization.test.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`VegaVisualizations VegaVisualization - basics should show vega graph (may fail in dev env) 1`] = `"
"`; +exports[`VegaVisualizations VegaVisualization - basics should show vega graph (may fail in dev env) 1`] = `"
"`; -exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 1`] = `"
  • \\"width\\" and \\"height\\" params are ignored because \\"autosize\\" is enabled. Set \\"autosize\\": \\"none\\" to disable
"`; +exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 1`] = `"
  • \\"width\\" and \\"height\\" params are ignored because \\"autosize\\" is enabled. Set \\"autosize\\": \\"none\\" to disable
"`; -exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 2`] = `"
  • \\"width\\" and \\"height\\" params are ignored because \\"autosize\\" is enabled. Set \\"autosize\\": \\"none\\" to disable
"`; +exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 2`] = `"
  • \\"width\\" and \\"height\\" params are ignored because \\"autosize\\" is enabled. Set \\"autosize\\": \\"none\\" to disable
"`; diff --git a/src/plugins/vis_types/vega/public/components/vega_vis.scss b/src/plugins/vis_types/vega/public/components/vega_vis.scss index 5b96eb9a560c7..f0062869e0046 100644 --- a/src/plugins/vis_types/vega/public/components/vega_vis.scss +++ b/src/plugins/vis_types/vega/public/components/vega_vis.scss @@ -18,7 +18,7 @@ z-index: 0; flex: 1 1 100%; - //display determined by js + display: block; max-width: 100%; max-height: 100%; width: 100%; diff --git a/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js b/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js index a41197293bbdc..741586b983a71 100644 --- a/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js @@ -83,10 +83,9 @@ export class VegaBaseView { return; } - const containerDisplay = this._parser.useResize ? 'flex' : 'block'; this._$container = $('
') // Force a height here because css is not loaded in mocha test - .css({ height: '100%', display: containerDisplay }) + .css('height', '100%') .appendTo(this._$parentEl); this._$controls = $( `
` From 50134cbec65a87515be8f21da85d7ebbd5350d33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Thu, 30 Sep 2021 10:18:09 +0100 Subject: [PATCH 20/21] [Mappings editor] Remove boost parameter from field types (#113142) --- .../forms/hook_form_lib/hooks/use_field.ts | 27 +- .../client_integration/helpers/constants.ts | 8 - .../helpers/setup_environment.tsx | 35 ++- .../common/constants/index.ts | 2 + .../common/constants/plugin.ts | 6 + .../plugins/index_management/common/index.ts | 2 +- .../public/application/app_context.tsx | 2 + .../datatypes/scaled_float_datatype.test.tsx | 9 +- .../datatypes/text_datatype.test.tsx | 10 +- .../client_integration/helpers/index.ts | 1 + .../helpers/mappings_editor.helpers.tsx | 8 +- .../helpers/setup_environment.tsx | 8 + .../field_parameters/boost_parameter.tsx | 1 + .../fields/edit_field/edit_field.tsx | 291 +++++++++--------- .../edit_field/edit_field_container.tsx | 5 + .../fields/field_types/boolean_type.tsx | 9 +- .../fields/field_types/date_type.tsx | 10 +- .../fields/field_types/flattened_type.tsx | 9 +- .../fields/field_types/index.ts | 3 + .../fields/field_types/ip_type.tsx | 9 +- .../fields/field_types/keyword_type.tsx | 10 +- .../fields/field_types/numeric_type.tsx | 9 +- .../fields/field_types/range_type.tsx | 9 +- .../fields/field_types/text_type.tsx | 9 +- .../fields/field_types/token_count_type.tsx | 10 +- .../public/application/index.tsx | 35 ++- .../public/application/lib/indices.ts | 7 +- .../application/mount_management_section.ts | 5 +- .../store/selectors/indices_filter.test.ts | 7 +- .../plugins/index_management/public/index.ts | 5 +- .../plugins/index_management/public/plugin.ts | 9 +- .../index_management/public/shared_imports.ts | 1 + 32 files changed, 348 insertions(+), 223 deletions(-) delete mode 100644 x-pack/plugins/index_management/__jest__/client_integration/helpers/constants.ts diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts index af6904dbacdd9..dedc390c47719 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts @@ -504,18 +504,21 @@ export const useField = ( const { resetValue = true, defaultValue: updatedDefaultValue } = resetOptions; setPristine(true); - setIsModified(false); - setValidating(false); - setIsChangingValue(false); - setIsValidated(false); - setStateErrors([]); - - if (resetValue) { - hasBeenReset.current = true; - const newValue = deserializeValue(updatedDefaultValue ?? defaultValue); - // updateStateIfMounted('value', newValue); - setValue(newValue); - return newValue; + + if (isMounted.current) { + setIsModified(false); + setValidating(false); + setIsChangingValue(false); + setIsValidated(false); + setStateErrors([]); + + if (resetValue) { + hasBeenReset.current = true; + const newValue = deserializeValue(updatedDefaultValue ?? defaultValue); + // updateStateIfMounted('value', newValue); + setValue(newValue); + return newValue; + } } }, [deserializeValue, defaultValue, setValue, setStateErrors] diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/constants.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/constants.ts deleted file mode 100644 index e2a60dde00ec6..0000000000000 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/constants.ts +++ /dev/null @@ -1,8 +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 const MAJOR_VERSION = '8.0.0'; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx index 3066742d4c0e0..9f8199215df5b 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx @@ -14,9 +14,12 @@ import { SemVer } from 'semver'; import { notificationServiceMock, docLinksServiceMock, + uiSettingsServiceMock, } from '../../../../../../src/core/public/mocks'; import { GlobalFlyout } from '../../../../../../src/plugins/es_ui_shared/public'; +import { createKibanaReactContext } from '../../../../../../src/plugins/kibana_react/public'; +import { MAJOR_VERSION } from '../../../common'; import { AppContextProvider } from '../../../public/application/app_context'; import { httpService } from '../../../public/application/services/http'; import { breadcrumbService } from '../../../public/application/services/breadcrumbs'; @@ -32,13 +35,10 @@ import { } from '../../../public/application/components'; import { componentTemplatesMockDependencies } from '../../../public/application/components/component_templates/__jest__'; import { init as initHttpRequests } from './http_requests'; -import { MAJOR_VERSION } from './constants'; const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); const { GlobalFlyoutProvider } = GlobalFlyout; -export const kibanaVersion = new SemVer(MAJOR_VERSION); - export const services = { extensionsService: new ExtensionsService(), uiMetricService: new UiMetricService('index_management'), @@ -54,6 +54,15 @@ const appDependencies = { plugins: {}, } as any; +export const kibanaVersion = new SemVer(MAJOR_VERSION); + +const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ + uiSettings: uiSettingsServiceMock.createSetupContract(), + kibanaVersion: { + get: () => kibanaVersion, + }, +}); + export const setupEnvironment = () => { // Mock initialization of services // @ts-ignore @@ -75,14 +84,16 @@ export const WithAppDependencies = (props: any) => { const mergedDependencies = merge({}, appDependencies, overridingDependencies); return ( - - - - - - - - - + + + + + + + + + + + ); }; diff --git a/x-pack/plugins/index_management/common/constants/index.ts b/x-pack/plugins/index_management/common/constants/index.ts index a2ad51e5c89f0..373044aef9d45 100644 --- a/x-pack/plugins/index_management/common/constants/index.ts +++ b/x-pack/plugins/index_management/common/constants/index.ts @@ -53,3 +53,5 @@ export { UIM_TEMPLATE_CLONE, UIM_TEMPLATE_SIMULATE, } from './ui_metric'; + +export { MAJOR_VERSION } from './plugin'; diff --git a/x-pack/plugins/index_management/common/constants/plugin.ts b/x-pack/plugins/index_management/common/constants/plugin.ts index 605dbf3859a0e..ad57398000426 100644 --- a/x-pack/plugins/index_management/common/constants/plugin.ts +++ b/x-pack/plugins/index_management/common/constants/plugin.ts @@ -17,3 +17,9 @@ export const PLUGIN = { defaultMessage: 'Index Management', }), }; + +// Ideally we want to access the Kibana major version from core +// "PluginInitializerContext.env.packageInfo.version". In some cases it is not possible +// to dynamically inject that version without a huge refactor on the code base. +// We will then keep this single constant to declare on which major branch we are. +export const MAJOR_VERSION = '8.0.0'; diff --git a/x-pack/plugins/index_management/common/index.ts b/x-pack/plugins/index_management/common/index.ts index ed8fd87643946..127123609b186 100644 --- a/x-pack/plugins/index_management/common/index.ts +++ b/x-pack/plugins/index_management/common/index.ts @@ -8,7 +8,7 @@ // TODO: https://github.com/elastic/kibana/issues/110892 /* eslint-disable @kbn/eslint/no_export_all */ -export { API_BASE_PATH, BASE_PATH } from './constants'; +export { API_BASE_PATH, BASE_PATH, MAJOR_VERSION } from './constants'; export { getTemplateParameter } from './lib'; diff --git a/x-pack/plugins/index_management/public/application/app_context.tsx b/x-pack/plugins/index_management/public/application/app_context.tsx index f8ebfdf7c46b7..f562ab9d15a8b 100644 --- a/x-pack/plugins/index_management/public/application/app_context.tsx +++ b/x-pack/plugins/index_management/public/application/app_context.tsx @@ -7,6 +7,7 @@ import React, { createContext, useContext } from 'react'; import { ScopedHistory } from 'kibana/public'; +import { SemVer } from 'semver'; import { ManagementAppMountParams } from 'src/plugins/management/public'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; @@ -37,6 +38,7 @@ export interface AppDependencies { uiSettings: CoreSetup['uiSettings']; url: SharePluginStart['url']; docLinks: CoreStart['docLinks']; + kibanaVersion: SemVer; } export const AppContextProvider = ({ diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/scaled_float_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/scaled_float_datatype.test.tsx index b6af657ee5f96..17e7317e098cc 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/scaled_float_datatype.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/scaled_float_datatype.test.tsx @@ -7,7 +7,7 @@ import { act } from 'react-dom/test-utils'; -import { componentHelpers, MappingsEditorTestBed } from '../helpers'; +import { componentHelpers, MappingsEditorTestBed, kibanaVersion } from '../helpers'; const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; @@ -92,6 +92,13 @@ describe('Mappings editor: scaled float datatype', () => { await updateFieldAndCloseFlyout(); expect(exists('mappingsEditorFieldEdit')).toBe(false); + if (kibanaVersion.major < 7) { + expect(exists('boostParameterToggle')).toBe(true); + } else { + // Since 8.x the boost parameter is deprecated + expect(exists('boostParameterToggle')).toBe(false); + } + // It should have the default parameters values added, plus the scaling factor updatedMappings.properties.myField = { ...defaultScaledFloatParameters, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx index ebca896de0b86..db8678478aa3d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx @@ -7,7 +7,7 @@ import { act } from 'react-dom/test-utils'; -import { componentHelpers, MappingsEditorTestBed } from '../helpers'; +import { componentHelpers, MappingsEditorTestBed, kibanaVersion } from '../helpers'; import { getFieldConfig } from '../../../lib'; const { setup, getMappingsEditorDataFactory } = componentHelpers.mappingsEditor; @@ -64,6 +64,7 @@ describe('Mappings editor: text datatype', () => { const { component, + exists, actions: { startEditField, getToggleValue, updateFieldAndCloseFlyout }, } = testBed; @@ -74,6 +75,13 @@ describe('Mappings editor: text datatype', () => { const indexFieldConfig = getFieldConfig('index'); expect(getToggleValue('indexParameter.formRowToggle')).toBe(indexFieldConfig.defaultValue); + if (kibanaVersion.major < 7) { + expect(exists('boostParameterToggle')).toBe(true); + } else { + // Since 8.x the boost parameter is deprecated + expect(exists('boostParameterToggle')).toBe(false); + } + // Save the field and close the flyout await updateFieldAndCloseFlyout(); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/index.ts index bec4e5a5c88a1..fbe24557ae6a1 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/index.ts @@ -13,6 +13,7 @@ import { } from './mappings_editor.helpers'; export { nextTick, getRandomString, findTestSubject, TestBed } from '@kbn/test/jest'; +export { kibanaVersion } from './setup_environment'; export const componentHelpers = { mappingsEditor: { setup: mappingsEditorSetup, getMappingsEditorDataFactory }, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx index 3110b3ce24041..074d96e9be4c1 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx @@ -123,12 +123,10 @@ const createActions = (testBed: TestBed) => { component.update(); - if (subType !== undefined) { + if (subType !== undefined && type === 'other') { await act(async () => { - if (type === 'other') { - // subType is a text input - form.setInputValue('createFieldForm.fieldSubType', subType); - } + // subType is a text input + form.setInputValue('createFieldForm.fieldSubType', subType); }); } diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx index 7055dcc74ce7b..5e3ae3c1544ae 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/setup_environment.tsx @@ -6,6 +6,8 @@ */ import React from 'react'; +import { SemVer } from 'semver'; + /* eslint-disable-next-line @kbn/eslint/no-restricted-paths */ import '../../../../../../../../../../src/plugins/es_ui_shared/public/components/code_editor/jest_mock'; import { GlobalFlyout } from '../../../../../../../../../../src/plugins/es_ui_shared/public'; @@ -13,9 +15,12 @@ import { docLinksServiceMock, uiSettingsServiceMock, } from '../../../../../../../../../../src/core/public/mocks'; +import { MAJOR_VERSION } from '../../../../../../../common'; import { MappingsEditorProvider } from '../../../mappings_editor_context'; import { createKibanaReactContext } from '../../../shared_imports'; +export const kibanaVersion = new SemVer(MAJOR_VERSION); + jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); @@ -72,6 +77,9 @@ const { GlobalFlyoutProvider } = GlobalFlyout; const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ uiSettings: uiSettingsServiceMock.createSetupContract(), + kibanaVersion: { + get: () => kibanaVersion, + }, }); const defaultProps = { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/boost_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/boost_parameter.tsx index edafb57ad2e57..07156611461d6 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/boost_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/boost_parameter.tsx @@ -33,6 +33,7 @@ export const BoostParameter = ({ defaultToggleValue }: Props) => ( href: documentationService.getBoostLink(), }} defaultToggleValue={defaultToggleValue} + data-test-subj="boostParameter" > {/* Boost level */} , which wrapps the form with @@ -50,161 +52,164 @@ export interface Props { // the height calculaction and does not render the footer position correctly. const FormWrapper: React.FC = ({ children }) => <>{children}; -export const EditField = React.memo(({ form, field, allFields, exitEdit, updateField }: Props) => { - const submitForm = async () => { - const { isValid, data } = await form.submit(); - - if (isValid) { - updateField({ ...field, source: data }); - } - }; - - const { isMultiField } = field; - - return ( -
- - - - {/* We need an extra div to get out of flex grow */} -
- {/* Title */} - -

- {isMultiField - ? i18n.translate('xpack.idxMgmt.mappingsEditor.editMultiFieldTitle', { - defaultMessage: "Edit multi-field '{fieldName}'", - values: { - fieldName: limitStringLength(field.source.name), - }, - }) - : i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldTitle', { - defaultMessage: "Edit field '{fieldName}'", +export const EditField = React.memo( + ({ form, field, allFields, exitEdit, updateField, kibanaVersion }: Props) => { + const submitForm = async () => { + const { isValid, data } = await form.submit(); + + if (isValid) { + updateField({ ...field, source: data }); + } + }; + + const { isMultiField } = field; + + return ( + + + + + {/* We need an extra div to get out of flex grow */} +
+ {/* Title */} + +

+ {isMultiField + ? i18n.translate('xpack.idxMgmt.mappingsEditor.editMultiFieldTitle', { + defaultMessage: "Edit multi-field '{fieldName}'", + values: { + fieldName: limitStringLength(field.source.name), + }, + }) + : i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldTitle', { + defaultMessage: "Edit field '{fieldName}'", + values: { + fieldName: limitStringLength(field.source.name), + }, + })} +

+
+
+
+ + {/* Documentation link */} + + {({ type, subType }) => { + const linkDocumentation = + documentationService.getTypeDocLink(subType?.[0]?.value) || + documentationService.getTypeDocLink(type?.[0]?.value); + + if (!linkDocumentation) { + return null; + } + + const typeDefinition = TYPE_DEFINITION[type[0].value as MainType]; + const subTypeDefinition = TYPE_DEFINITION[subType?.[0].value as SubType]; + + return ( + + + {i18n.translate('xpack.idxMgmt.mappingsEditor.editField.typeDocumentation', { + defaultMessage: '{type} documentation', values: { - fieldName: limitStringLength(field.source.name), + type: subTypeDefinition ? subTypeDefinition.label : typeDefinition.label, }, })} -

-
-
-
+ + + ); + }} + +
+ + {/* Field path */} + + + {field.path.join(' > ')} + + +
+ + + - {/* Documentation link */} {({ type, subType }) => { - const linkDocumentation = - documentationService.getTypeDocLink(subType?.[0]?.value) || - documentationService.getTypeDocLink(type?.[0]?.value); + const ParametersForm = getParametersFormForType(type?.[0].value, subType?.[0].value); - if (!linkDocumentation) { + if (!ParametersForm) { return null; } - const typeDefinition = TYPE_DEFINITION[type[0].value as MainType]; - const subTypeDefinition = TYPE_DEFINITION[subType?.[0].value as SubType]; - return ( - - - {i18n.translate('xpack.idxMgmt.mappingsEditor.editField.typeDocumentation', { - defaultMessage: '{type} documentation', - values: { - type: subTypeDefinition ? subTypeDefinition.label : typeDefinition.label, - }, - })} - - + ); }} - - - {/* Field path */} - - - {field.path.join(' > ')} - - - - - - - - - {({ type, subType }) => { - const ParametersForm = getParametersFormForType(type?.[0].value, subType?.[0].value); - - if (!ParametersForm) { - return null; - } - - return ( - + + + {form.isSubmitted && !form.isValid && ( + <> + - ); - }} - - - - - {form.isSubmitted && !form.isValid && ( - <> - - - - )} - - - - - {i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldCancelButtonLabel', { - defaultMessage: 'Cancel', - })} - - - - - {i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldUpdateButtonLabel', { - defaultMessage: 'Update', - })} - - - - - - ); -}); + + + )} + + + + + {i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldCancelButtonLabel', { + defaultMessage: 'Cancel', + })} + + + + + {i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldUpdateButtonLabel', { + defaultMessage: 'Update', + })} + + + + + + ); + } +); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx index 9a9e1179db237..2db0d2b6c544b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useMemo } from 'react'; +import { useKibana } from '../../../../../../index'; import { useForm } from '../../../../shared_imports'; import { useDispatch, useMappingsState } from '../../../../mappings_state_context'; import { Field } from '../../../../types'; @@ -30,6 +31,9 @@ export const EditFieldContainer = React.memo(({ exitEdit }: Props) => { const { fields, documentFields } = useMappingsState(); const dispatch = useDispatch(); const { updateField, modal } = useUpdateField(); + const { + services: { kibanaVersion }, + } = useKibana(); const { status, fieldToEdit } = documentFields; const isEditing = status === 'editingField'; @@ -73,6 +77,7 @@ export const EditFieldContainer = React.memo(({ exitEdit }: Props) => { allFields={fields.byId} exitEdit={exitEdit} updateField={updateField} + kibanaVersion={kibanaVersion.get()} /> {renderModal()} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/boolean_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/boolean_type.tsx index 2cba31279b270..ad7f7e6d93c41 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/boolean_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/boolean_type.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { SemVer } from 'semver'; import { i18n } from '@kbn/i18n'; @@ -57,9 +58,10 @@ const nullValueOptions = [ interface Props { field: NormalizedField; + kibanaVersion: SemVer; } -export const BooleanType = ({ field }: Props) => { +export const BooleanType = ({ field, kibanaVersion }: Props) => { return ( <> @@ -96,7 +98,10 @@ export const BooleanType = ({ field }: Props) => { - + {/* The "boost" parameter is deprecated since 8.x */} + {kibanaVersion.major < 8 && ( + + )} ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/date_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/date_type.tsx index eed3c48804266..ea71e7fcce5d2 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/date_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/date_type.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; - +import { SemVer } from 'semver'; import { i18n } from '@kbn/i18n'; import { NormalizedField, Field as FieldType } from '../../../../types'; @@ -43,9 +43,10 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { interface Props { field: NormalizedField; + kibanaVersion: SemVer; } -export const DateType = ({ field }: Props) => { +export const DateType = ({ field, kibanaVersion }: Props) => { return ( <> @@ -79,7 +80,10 @@ export const DateType = ({ field }: Props) => { - + {/* The "boost" parameter is deprecated since 8.x */} + {kibanaVersion.major < 8 && ( + + )} ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx index 6759f66e0db38..0f58c75ca9cb7 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/flattened_type.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { SemVer } from 'semver'; import { NormalizedField, Field as FieldType } from '../../../../types'; import { UseField, Field } from '../../../../shared_imports'; @@ -27,6 +28,7 @@ import { BasicParametersSection, EditFieldFormRow, AdvancedParametersSection } f interface Props { field: NormalizedField; + kibanaVersion: SemVer; } const getDefaultToggleValue = (param: string, field: FieldType) => { @@ -45,7 +47,7 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { } }; -export const FlattenedType = React.memo(({ field }: Props) => { +export const FlattenedType = React.memo(({ field, kibanaVersion }: Props) => { return ( <> @@ -89,7 +91,10 @@ export const FlattenedType = React.memo(({ field }: Props) => { - + {/* The "boost" parameter is deprecated since 8.x */} + {kibanaVersion.major < 8 && ( + + )} ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts index c7aab6fbe09ae..f62a19e55a835 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/index.ts @@ -6,6 +6,8 @@ */ import { ComponentType } from 'react'; +import { SemVer } from 'semver'; + import { MainType, SubType, DataType, NormalizedField, NormalizedFields } from '../../../../types'; import { AliasType } from './alias_type'; @@ -75,6 +77,7 @@ export const getParametersFormForType = ( field: NormalizedField; allFields: NormalizedFields['byId']; isMultiField: boolean; + kibanaVersion: SemVer; }> | undefined => subType === undefined diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/ip_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/ip_type.tsx index d3f2ae5c6bf0e..3ea56805829d5 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/ip_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/ip_type.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { SemVer } from 'semver'; import { NormalizedField, Field as FieldType } from '../../../../types'; import { getFieldConfig } from '../../../../lib'; @@ -36,9 +37,10 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { interface Props { field: NormalizedField; + kibanaVersion: SemVer; } -export const IpType = ({ field }: Props) => { +export const IpType = ({ field, kibanaVersion }: Props) => { return ( <> @@ -54,7 +56,10 @@ export const IpType = ({ field }: Props) => { - + {/* The "boost" parameter is deprecated since 8.x */} + {kibanaVersion.major < 8 && ( + + )} ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/keyword_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/keyword_type.tsx index 30329925f844f..9d820c1b07636 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/keyword_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/keyword_type.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; - +import { SemVer } from 'semver'; import { i18n } from '@kbn/i18n'; import { documentationService } from '../../../../../../services/documentation'; @@ -48,9 +48,10 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { interface Props { field: NormalizedField; + kibanaVersion: SemVer; } -export const KeywordType = ({ field }: Props) => { +export const KeywordType = ({ field, kibanaVersion }: Props) => { return ( <> @@ -104,7 +105,10 @@ export const KeywordType = ({ field }: Props) => { - + {/* The "boost" parameter is deprecated since 8.x */} + {kibanaVersion.major < 8 && ( + + )} ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx index 9d9778b14b954..7035a730f15f4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/numeric_type.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { SemVer } from 'semver'; import { NormalizedField, Field as FieldType, ComboBoxOption } from '../../../../types'; import { getFieldConfig } from '../../../../lib'; @@ -43,9 +44,10 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { interface Props { field: NormalizedField; + kibanaVersion: SemVer; } -export const NumericType = ({ field }: Props) => { +export const NumericType = ({ field, kibanaVersion }: Props) => { return ( <> @@ -102,7 +104,10 @@ export const NumericType = ({ field }: Props) => { - + {/* The "boost" parameter is deprecated since 8.x */} + {kibanaVersion.major < 8 && ( + + )} ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx index ccfa049d9e777..9b8dae490d819 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/range_type.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import { SemVer } from 'semver'; import { NormalizedField, @@ -32,9 +33,10 @@ const getDefaultToggleValue = (param: ParameterName, field: FieldType) => { interface Props { field: NormalizedField; + kibanaVersion: SemVer; } -export const RangeType = ({ field }: Props) => { +export const RangeType = ({ field, kibanaVersion }: Props) => { return ( <> @@ -67,7 +69,10 @@ export const RangeType = ({ field }: Props) => { - + {/* The "boost" parameter is deprecated since 8.x */} + {kibanaVersion.major < 8 && ( + + )} ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/text_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/text_type.tsx index 2a007e7741d4a..6857e20dc1ec4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/text_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/text_type.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { EuiSpacer, EuiDualRange, EuiFormRow, EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { SemVer } from 'semver'; import { documentationService } from '../../../../../../services/documentation'; import { NormalizedField, Field as FieldType } from '../../../../types'; @@ -36,6 +37,7 @@ import { BasicParametersSection, EditFieldFormRow, AdvancedParametersSection } f interface Props { field: NormalizedField; + kibanaVersion: SemVer; } const getDefaultToggleValue = (param: string, field: FieldType) => { @@ -73,7 +75,7 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { } }; -export const TextType = React.memo(({ field }: Props) => { +export const TextType = React.memo(({ field, kibanaVersion }: Props) => { const onIndexPrefixesChanage = (minField: FieldHook, maxField: FieldHook) => ([min, max]: any) => { @@ -246,7 +248,10 @@ export const TextType = React.memo(({ field }: Props) => { - + {/* The "boost" parameter is deprecated since 8.x */} + {kibanaVersion.major < 8 && ( + + )} ); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/token_count_type.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/token_count_type.tsx index 2c56100ede037..3c0e8a28f3090 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/token_count_type.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/field_types/token_count_type.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; - +import { SemVer } from 'semver'; import { i18n } from '@kbn/i18n'; import { documentationService } from '../../../../../../services/documentation'; @@ -43,9 +43,10 @@ const getDefaultToggleValue = (param: string, field: FieldType) => { interface Props { field: NormalizedField; + kibanaVersion: SemVer; } -export const TokenCountType = ({ field }: Props) => { +export const TokenCountType = ({ field, kibanaVersion }: Props) => { return ( <> @@ -113,7 +114,10 @@ export const TokenCountType = ({ field }: Props) => { - + {/* The "boost" parameter is deprecated since 8.x */} + {kibanaVersion.major < 8 && ( + + )} ); diff --git a/x-pack/plugins/index_management/public/application/index.tsx b/x-pack/plugins/index_management/public/application/index.tsx index 758470604fc5a..fc64dad0ae7ba 100644 --- a/x-pack/plugins/index_management/public/application/index.tsx +++ b/x-pack/plugins/index_management/public/application/index.tsx @@ -8,11 +8,16 @@ import React from 'react'; import { Provider } from 'react-redux'; import { render, unmountComponentAtNode } from 'react-dom'; +import { SemVer } from 'semver'; -import { CoreStart } from '../../../../../src/core/public'; +import { CoreStart, CoreSetup } from '../../../../../src/core/public'; import { API_BASE_PATH } from '../../common'; -import { createKibanaReactContext, GlobalFlyout } from '../shared_imports'; +import { + createKibanaReactContext, + GlobalFlyout, + useKibana as useKibanaReactPlugin, +} from '../shared_imports'; import { AppContextProvider, AppDependencies } from './app_context'; import { App } from './app'; @@ -31,12 +36,16 @@ export const renderApp = ( const { i18n, docLinks, notifications, application } = core; const { Context: I18nContext } = i18n; - const { services, history, setBreadcrumbs, uiSettings } = dependencies; + const { services, history, setBreadcrumbs, uiSettings, kibanaVersion } = dependencies; // uiSettings is required by the CodeEditor component used to edit runtime field Painless scripts. - const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ - uiSettings, - }); + const { Provider: KibanaReactContextProvider } = + createKibanaReactContext({ + uiSettings, + kibanaVersion: { + get: () => kibanaVersion, + }, + }); const componentTemplateProviderValues = { httpClient: services.httpService.httpClient, @@ -72,4 +81,16 @@ export const renderApp = ( }; }; -export { AppDependencies }; +interface KibanaReactContextServices { + uiSettings: CoreSetup['uiSettings']; + kibanaVersion: { + get: () => SemVer; + }; +} + +// We override useKibana() from the react plugin to return a typed version for this app +const useKibana = () => { + return useKibanaReactPlugin(); +}; + +export { AppDependencies, useKibana }; diff --git a/x-pack/plugins/index_management/public/application/lib/indices.ts b/x-pack/plugins/index_management/public/application/lib/indices.ts index 1378dc64b8d5b..fc93aa6f54448 100644 --- a/x-pack/plugins/index_management/public/application/lib/indices.ts +++ b/x-pack/plugins/index_management/public/application/lib/indices.ts @@ -4,11 +4,12 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import SemVer from 'semver/classes/semver'; +import { SemVer } from 'semver'; + +import { MAJOR_VERSION } from '../../../common'; import { Index } from '../../../common'; -const version = '8.0.0'; -const kibanaVersion = new SemVer(version); +const kibanaVersion = new SemVer(MAJOR_VERSION); export const isHiddenIndex = (index: Index): boolean => { if (kibanaVersion.major < 8) { diff --git a/x-pack/plugins/index_management/public/application/mount_management_section.ts b/x-pack/plugins/index_management/public/application/mount_management_section.ts index 083a8831291dd..17ac095128a76 100644 --- a/x-pack/plugins/index_management/public/application/mount_management_section.ts +++ b/x-pack/plugins/index_management/public/application/mount_management_section.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { SemVer } from 'semver'; import { CoreSetup } from 'src/core/public'; import { ManagementAppMountParams } from 'src/plugins/management/public/'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/public'; @@ -50,7 +51,8 @@ export async function mountManagementSection( usageCollection: UsageCollectionSetup, params: ManagementAppMountParams, extensionsService: ExtensionsService, - isFleetEnabled: boolean + isFleetEnabled: boolean, + kibanaVersion: SemVer ) { const { element, setBreadcrumbs, history } = params; const [core, startDependencies] = await coreSetup.getStartServices(); @@ -88,6 +90,7 @@ export async function mountManagementSection( uiSettings, url, docLinks, + kibanaVersion, }; const unmountAppCallback = renderApp(element, { core, dependencies: appDependencies }); diff --git a/x-pack/plugins/index_management/public/application/store/selectors/indices_filter.test.ts b/x-pack/plugins/index_management/public/application/store/selectors/indices_filter.test.ts index e55566b8356d1..b32f2736a9684 100644 --- a/x-pack/plugins/index_management/public/application/store/selectors/indices_filter.test.ts +++ b/x-pack/plugins/index_management/public/application/store/selectors/indices_filter.test.ts @@ -4,15 +4,16 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import SemVer from 'semver/classes/semver'; +import { SemVer } from 'semver'; + +import { MAJOR_VERSION } from '../../../../common'; import { ExtensionsService } from '../../../services'; import { getFilteredIndices } from '.'; // @ts-ignore import { defaultTableState } from '../reducers/table_state'; import { setExtensionsService } from './extension_service'; -const version = '8.0.0'; -const kibanaVersion = new SemVer(version); +const kibanaVersion = new SemVer(MAJOR_VERSION); describe('getFilteredIndices selector', () => { let extensionService: ExtensionsService; diff --git a/x-pack/plugins/index_management/public/index.ts b/x-pack/plugins/index_management/public/index.ts index 10feabf0a9d0f..f9ccc788a36c0 100644 --- a/x-pack/plugins/index_management/public/index.ts +++ b/x-pack/plugins/index_management/public/index.ts @@ -6,11 +6,12 @@ */ import './index.scss'; +import { PluginInitializerContext } from 'src/core/public'; import { IndexMgmtUIPlugin } from './plugin'; /** @public */ -export const plugin = () => { - return new IndexMgmtUIPlugin(); +export const plugin = (ctx: PluginInitializerContext) => { + return new IndexMgmtUIPlugin(ctx); }; export { IndexManagementPluginSetup } from './types'; diff --git a/x-pack/plugins/index_management/public/plugin.ts b/x-pack/plugins/index_management/public/plugin.ts index 44854a9de9a0f..b7e810b15dbf9 100644 --- a/x-pack/plugins/index_management/public/plugin.ts +++ b/x-pack/plugins/index_management/public/plugin.ts @@ -6,8 +6,9 @@ */ import { i18n } from '@kbn/i18n'; +import { SemVer } from 'semver'; -import { CoreSetup } from '../../../../src/core/public'; +import { CoreSetup, PluginInitializerContext } from '../../../../src/core/public'; import { setExtensionsService } from './application/store/selectors/extension_service'; import { ExtensionsService } from './services'; @@ -20,7 +21,7 @@ import { PLUGIN } from '../common/constants/plugin'; export class IndexMgmtUIPlugin { private extensionsService = new ExtensionsService(); - constructor() { + constructor(private ctx: PluginInitializerContext) { // Temporary hack to provide the service instances in module files in order to avoid a big refactor // For the selectors we should expose them through app dependencies and read them from there on each container component. setExtensionsService(this.extensionsService); @@ -31,6 +32,7 @@ export class IndexMgmtUIPlugin { plugins: SetupDependencies ): IndexManagementPluginSetup { const { fleet, usageCollection, management } = plugins; + const kibanaVersion = new SemVer(this.ctx.env.packageInfo.version); management.sections.section.data.registerApp({ id: PLUGIN.id, @@ -43,7 +45,8 @@ export class IndexMgmtUIPlugin { usageCollection, params, this.extensionsService, - Boolean(fleet) + Boolean(fleet), + kibanaVersion ); }, }); diff --git a/x-pack/plugins/index_management/public/shared_imports.ts b/x-pack/plugins/index_management/public/shared_imports.ts index 275f8af818caf..4e1c420795904 100644 --- a/x-pack/plugins/index_management/public/shared_imports.ts +++ b/x-pack/plugins/index_management/public/shared_imports.ts @@ -56,4 +56,5 @@ export { isJSON } from '../../../../src/plugins/es_ui_shared/static/validators/s export { createKibanaReactContext, reactRouterNavigate, + useKibana, } from '../../../../src/plugins/kibana_react/public'; From bbf3d4b9ca32e362d313ed2736d49236a0a95af7 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Thu, 30 Sep 2021 11:30:30 +0200 Subject: [PATCH 21/21] [Exploratory View] Add to case button (#112463) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../observability/public/application/types.ts | 2 + .../header/add_to_case_action.test.tsx | 54 ++++++++ .../header/add_to_case_action.tsx | 89 ++++++++++++ .../shared/exploratory_view/header/header.tsx | 4 + .../hooks/use_add_to_case.test.tsx | 68 ++++++++++ .../exploratory_view/hooks/use_add_to_case.ts | 128 ++++++++++++++++++ .../shared/exploratory_view/rtl_helpers.tsx | 2 + 7 files changed, 347 insertions(+) create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_add_to_case.test.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_add_to_case.ts diff --git a/x-pack/plugins/observability/public/application/types.ts b/x-pack/plugins/observability/public/application/types.ts index 09c5de1e694c8..0bf3fd1ea5a03 100644 --- a/x-pack/plugins/observability/public/application/types.ts +++ b/x-pack/plugins/observability/public/application/types.ts @@ -20,6 +20,7 @@ import { IStorageWrapper } from '../../../../../src/plugins/kibana_utils/public' import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; import { LensPublicStart } from '../../../lens/public'; import { TriggersAndActionsUIPublicPluginStart } from '../../../triggers_actions_ui/public'; +import { CasesUiStart } from '../../../cases/public'; export interface ObservabilityAppServices { http: HttpStart; @@ -36,4 +37,5 @@ export interface ObservabilityAppServices { triggersActionsUi: TriggersAndActionsUIPublicPluginStart; lens: LensPublicStart; + cases: CasesUiStart; } diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx new file mode 100644 index 0000000000000..619ea0d21ae15 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.test.tsx @@ -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. + */ + +import React from 'react'; +import { render } from '../rtl_helpers'; +import { fireEvent } from '@testing-library/dom'; +import { AddToCaseAction } from './add_to_case_action'; + +describe('AddToCaseAction', function () { + it('should render properly', async function () { + const { findByText } = render( + + ); + expect(await findByText('Add to case')).toBeInTheDocument(); + }); + + it('should be able to click add to case button', async function () { + const initSeries = { + data: { + 'uptime-pings-histogram': { + dataType: 'synthetics' as const, + reportType: 'kpi-over-time' as const, + breakdown: 'monitor.status', + time: { from: 'now-15m', to: 'now' }, + }, + }, + }; + + const { findByText, core } = render( + , + { initSeries } + ); + fireEvent.click(await findByText('Add to case')); + + expect(core?.cases?.getAllCasesSelectorModal).toHaveBeenCalledTimes(1); + expect(core?.cases?.getAllCasesSelectorModal).toHaveBeenCalledWith( + expect.objectContaining({ + createCaseNavigation: expect.objectContaining({ href: '/app/observability/cases/create' }), + owner: ['observability'], + userCanCrud: true, + }) + ); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx new file mode 100644 index 0000000000000..4fa8deb2700d0 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/add_to_case_action.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLink } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback } from 'react'; +import { toMountPoint, useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import { ObservabilityAppServices } from '../../../../application/types'; +import { AllCasesSelectorModalProps } from '../../../../../../cases/public'; +import { TypedLensByValueInput } from '../../../../../../lens/public'; +import { useAddToCase } from '../hooks/use_add_to_case'; +import { Case, SubCase } from '../../../../../../cases/common'; +import { observabilityFeatureId } from '../../../../../common'; + +export interface AddToCaseProps { + timeRange: { from: string; to: string }; + lensAttributes: TypedLensByValueInput['attributes'] | null; +} + +export function AddToCaseAction({ lensAttributes, timeRange }: AddToCaseProps) { + const kServices = useKibana().services; + + const { cases, http } = kServices; + + const getToastText = useCallback( + (theCase) => toMountPoint(), + [http.basePath] + ); + + const { createCaseUrl, goToCreateCase, onCaseClicked, isCasesOpen, setIsCasesOpen, isSaving } = + useAddToCase({ + lensAttributes, + getToastText, + timeRange, + }); + + const getAllCasesSelectorModalProps: AllCasesSelectorModalProps = { + createCaseNavigation: { + href: createCaseUrl, + onClick: goToCreateCase, + }, + onRowClick: onCaseClicked, + userCanCrud: true, + owner: [observabilityFeatureId], + onClose: () => { + setIsCasesOpen(false); + }, + }; + + return ( + <> + { + if (lensAttributes) { + setIsCasesOpen(true); + } + }} + > + {i18n.translate('xpack.observability.expView.heading.addToCase', { + defaultMessage: 'Add to case', + })} + + {isCasesOpen && + lensAttributes && + cases.getAllCasesSelectorModal(getAllCasesSelectorModalProps)} + + ); +} + +function CaseToastText({ theCase, basePath }: { theCase: Case | SubCase; basePath: string }) { + return ( + + + + {i18n.translate('xpack.observability.expView.heading.addToCase.notification.viewCase', { + defaultMessage: 'View case', + })} + + + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index 706ba546b2848..7adef4779ea94 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -14,6 +14,7 @@ import { DataViewLabels } from '../configurations/constants'; import { ObservabilityAppServices } from '../../../../application/types'; import { useSeriesStorage } from '../hooks/use_series_storage'; import { combineTimeRanges } from '../exploratory_view'; +import { AddToCaseAction } from './add_to_case_action'; interface Props { seriesId: string; @@ -56,6 +57,9 @@ export function ExploratoryViewHeader({ seriesId, lensAttributes }: Props) { + + + { + setData(result); + }, [result]); + + return ( + + Add new case + result.onCaseClicked({ id: 'test' } as any)}> + On case click + + + ); + } + + const renderSetup = render(); + + return { setData, ...renderSetup }; + } + it('should return expected result', async function () { + const { setData, core, findByText } = setupTestComponent(); + + expect(setData).toHaveBeenLastCalledWith({ + createCaseUrl: '/app/observability/cases/create', + goToCreateCase: expect.any(Function), + isCasesOpen: false, + isSaving: false, + onCaseClicked: expect.any(Function), + setIsCasesOpen: expect.any(Function), + }); + fireEvent.click(await findByText('Add new case')); + + expect(core.application?.navigateToApp).toHaveBeenCalledTimes(1); + expect(core.application?.navigateToApp).toHaveBeenCalledWith('observability', { + path: '/cases/create', + }); + + fireEvent.click(await findByText('On case click')); + + expect(core.http?.post).toHaveBeenCalledTimes(1); + expect(core.http?.post).toHaveBeenCalledWith('/api/cases/test/comments', { + body: '{"comment":"!{lens{\\"attributes\\":{\\"title\\":\\"Test lens attributes\\"},\\"timeRange\\":{\\"to\\":\\"now\\",\\"from\\":\\"now-5m\\"}}}","type":"user","owner":"observability"}', + }); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_add_to_case.ts b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_add_to_case.ts new file mode 100644 index 0000000000000..5ec9e1d4ab4b5 --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/hooks/use_add_to_case.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, useMemo, useState } from 'react'; +import { isEmpty } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { HttpSetup, MountPoint } from 'kibana/public'; +import { useKibana } from '../../../../utils/kibana_react'; +import { Case, SubCase } from '../../../../../../cases/common'; +import { TypedLensByValueInput } from '../../../../../../lens/public'; +import { AddToCaseProps } from '../header/add_to_case_action'; +import { observabilityFeatureId } from '../../../../../common'; + +const appendSearch = (search?: string) => + isEmpty(search) ? '' : `${search?.startsWith('?') ? search : `?${search}`}`; + +const getCreateCaseUrl = (search?: string | null) => + `/cases/create${appendSearch(search ?? undefined)}`; + +async function addToCase( + http: HttpSetup, + theCase: Case | SubCase, + attributes: TypedLensByValueInput['attributes'], + timeRange: { from: string; to: string } +) { + const apiPath = `/api/cases/${theCase?.id}/comments`; + + const vizPayload = { + attributes, + timeRange, + }; + + const payload = { + comment: `!{lens${JSON.stringify(vizPayload)}}`, + type: 'user', + owner: observabilityFeatureId, + }; + + return http.post(apiPath, { body: JSON.stringify(payload) }); +} + +export const useAddToCase = ({ + lensAttributes, + getToastText, + timeRange, +}: AddToCaseProps & { getToastText: (thaCase: Case | SubCase) => MountPoint }) => { + const [isSaving, setIsSaving] = useState(false); + const [isCasesOpen, setIsCasesOpen] = useState(false); + + const { + http, + application: { navigateToApp, getUrlForApp }, + notifications: { toasts }, + } = useKibana().services; + + const createCaseUrl = useMemo( + () => getUrlForApp(observabilityFeatureId) + getCreateCaseUrl(), + [getUrlForApp] + ); + + const goToCreateCase = useCallback( + async (ev) => { + ev.preventDefault(); + return navigateToApp(observabilityFeatureId, { + path: getCreateCaseUrl(), + }); + }, + [navigateToApp] + ); + + const onCaseClicked = useCallback( + (theCase?: Case | SubCase) => { + if (theCase && lensAttributes) { + setIsCasesOpen(false); + setIsSaving(true); + addToCase(http, theCase, lensAttributes, timeRange).then( + () => { + setIsSaving(false); + toasts.addSuccess( + { + title: i18n.translate( + 'xpack.observability.expView.heading.addToCase.notification', + { + defaultMessage: 'Successfully added visualization to the case: {caseTitle}', + values: { caseTitle: theCase.title }, + } + ), + text: getToastText(theCase), + }, + { + toastLifeTimeMs: 10000, + } + ); + }, + (error) => { + toasts.addError(error, { + title: i18n.translate( + 'xpack.observability.expView.heading.addToCase.notification.error', + { + defaultMessage: 'Failed to add visualization to the selected case.', + } + ), + }); + } + ); + } else { + navigateToApp(observabilityFeatureId, { + path: getCreateCaseUrl(), + openInNewTab: true, + }); + } + }, + [getToastText, http, lensAttributes, navigateToApp, timeRange, toasts] + ); + + return { + createCaseUrl, + goToCreateCase, + onCaseClicked, + isSaving, + isCasesOpen, + setIsCasesOpen, + }; +}; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx index bc77a0520925f..a577a8df3e3d9 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/rtl_helpers.tsx @@ -39,6 +39,7 @@ import { createStubIndexPattern } from '../../../../../../../src/plugins/data/co import { AppDataType, UrlFilter } from './types'; import { dataPluginMock } from '../../../../../../../src/plugins/data/public/mocks'; import { ListItem } from '../../../hooks/use_values_list'; +import { casesPluginMock } from '../../../../../cases/public/mocks'; interface KibanaProps { services?: KibanaServices; @@ -118,6 +119,7 @@ export const mockCore: () => Partial