From 1213fa0bcd544e940f8d0add8886a7fa84777f7b Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 9 Nov 2022 17:25:15 +0100 Subject: [PATCH 01/18] =?UTF-8?q?Upgrade=20`loader-utils`=20dependency=20(?= =?UTF-8?q?`1.1.3`=20=E2=86=92=20`2.0.3`).=20(#144879)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Upgrade `loader-utils` dependency (`1.1.3` → `2.0.3`). Change log (`loader-utils`): https://github.com/webpack/loader-utils/blob/master/CHANGELOG.md#200-2020-03-17 __Note to reviewers:__ Change log isn't really helpful, and not all versions are covered. The only important part is the list of the breaking changes in `2.0.0`: * :green_circle: minimum required Node.js version is 8.9.0 (sounds good) * :yellow_circle: the `getOptions` method returns empty object on empty query (would be great if code owners validate if it's okay, it looks like theme_loader is the only place where _we_ directly use this method) * :yellow_circle: Use md4 by default (would be great if code owners validate if it's okay) cc @elastic/kibana-security --- package.json | 4 +-- .../kbn-optimizer/src/worker/theme_loader.ts | 2 +- yarn.lock | 26 +++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 6fa13e0a89b991..5ea68680babb41 100644 --- a/package.json +++ b/package.json @@ -869,7 +869,7 @@ "@types/jsonwebtoken": "^8.5.6", "@types/license-checker": "15.0.0", "@types/listr": "^0.14.0", - "@types/loader-utils": "^1.1.3", + "@types/loader-utils": "^2.0.3", "@types/lodash": "^4.14.159", "@types/lru-cache": "^5.1.0", "@types/lz-string": "^1.3.34", @@ -1061,7 +1061,7 @@ "license-checker": "^25.0.1", "listr": "^0.14.1", "lmdb-store": "^1.6.11", - "loader-utils": "^1.2.3", + "loader-utils": "^2.0.3", "marge": "^1.0.1", "micromatch": "^4.0.5", "mini-css-extract-plugin": "1.1.0", diff --git a/packages/kbn-optimizer/src/worker/theme_loader.ts b/packages/kbn-optimizer/src/worker/theme_loader.ts index 10a82eb148f63d..af5681e721784f 100644 --- a/packages/kbn-optimizer/src/worker/theme_loader.ts +++ b/packages/kbn-optimizer/src/worker/theme_loader.ts @@ -20,7 +20,7 @@ export default function (this: webpack.loader.LoaderContext) { this.cacheable(true); const options = getOptions(this); - const bundleId: string = options.bundleId!; + const bundleId = options.bundleId as string; const themeTags = parseThemeTags(options.themeTags); const cases = ALL_THEMES.map((tag) => { diff --git a/yarn.lock b/yarn.lock index 911bd697b6567a..2b5cfe80382f1a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6881,13 +6881,13 @@ "@types/node" "*" rxjs "^6.5.1" -"@types/loader-utils@^1.1.3": - version "1.1.3" - resolved "https://registry.yarnpkg.com/@types/loader-utils/-/loader-utils-1.1.3.tgz#82b9163f2ead596c68a8c03e450fbd6e089df401" - integrity sha512-euKGFr2oCB3ASBwG39CYJMR3N9T0nanVqXdiH7Zu/Nqddt6SmFRxytq/i2w9LQYNQekEtGBz+pE3qG6fQTNvRg== +"@types/loader-utils@^2.0.3": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/loader-utils/-/loader-utils-2.0.3.tgz#fbc2337358f8f4a7dc532ac0a3646c74275edf2d" + integrity sha512-sDXXzZnTLXgdso54/iOpAFSDgqhVXabCvwGAt77Agadh/Xk0QYgOk520r3tpOouI098gyqGIFywx8Op1voc3vQ== dependencies: "@types/node" "*" - "@types/webpack" "*" + "@types/webpack" "^4" "@types/lodash@^4.14.159": version "4.14.159" @@ -7684,7 +7684,7 @@ "@types/source-list-map" "*" source-map "^0.6.1" -"@types/webpack@*", "@types/webpack@^4.4.31", "@types/webpack@^4.41.26", "@types/webpack@^4.41.3", "@types/webpack@^4.41.8": +"@types/webpack@*", "@types/webpack@^4", "@types/webpack@^4.4.31", "@types/webpack@^4.41.26", "@types/webpack@^4.41.3", "@types/webpack@^4.41.8": version "4.41.32" resolved "https://registry.yarnpkg.com/@types/webpack/-/webpack-4.41.32.tgz#a7bab03b72904070162b2f169415492209e94212" integrity sha512-cb+0ioil/7oz5//7tZUSwbrSAN/NWHrQylz5cW8G0dWTcF/g+/dSdMlKVZspBYuMAN1+WnwHrkxiRrLcwd0Heg== @@ -18320,18 +18320,18 @@ loader-runner@^4.2.0: integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== loader-utils@^1.0.0, loader-utils@^1.0.2, loader-utils@^1.1.0, loader-utils@^1.2.3: - version "1.4.0" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613" - integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA== + version "1.4.1" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.1.tgz#278ad7006660bccc4d2c0c1578e17c5c78d5c0e0" + integrity sha512-1Qo97Y2oKaU+Ro2xnDMR26g1BwMT29jNbem1EvcujW2jqt+j5COXyscjM7bLQkM9HaxI7pkWeW7gnI072yMI9Q== dependencies: big.js "^5.2.2" emojis-list "^3.0.0" json5 "^1.0.1" -loader-utils@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0" - integrity sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ== +loader-utils@^2.0.0, loader-utils@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.3.tgz#d4b15b8504c63d1fc3f2ade52d41bc8459d6ede1" + integrity sha512-THWqIsn8QRnvLl0shHYVBN9syumU8pYWEHPTmkiVGd+7K5eFNVSY6AJhRvgGF70gg1Dz+l/k8WicvFCxdEs60A== dependencies: big.js "^5.2.2" emojis-list "^3.0.0" From 7fbf260cc939218c3f74dac4cb32f6d8588d176f Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Wed, 9 Nov 2022 16:36:34 +0000 Subject: [PATCH 02/18] chore(NA): enable missing ts performant flags (#144872) This PR enables `incremental` and `skipDefaultLibCheck` across the board on our TS setup as recommended on the official TS performance wiki. --- src/dev/typescript/run_type_check_cli.ts | 1 - tsconfig.base.json | 10 ++++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/dev/typescript/run_type_check_cli.ts b/src/dev/typescript/run_type_check_cli.ts index 65704cd82574f2..ad1907f0f120a9 100644 --- a/src/dev/typescript/run_type_check_cli.ts +++ b/src/dev/typescript/run_type_check_cli.ts @@ -117,7 +117,6 @@ function createTypeCheckConfigs(projects: Project[], bazelPackages: BazelPackage compilerOptions: { ...parsed.compilerOptions, composite: true, - incremental: true, rootDir: '.', paths: undefined, }, diff --git a/tsconfig.base.json b/tsconfig.base.json index b3ee0219247ebc..76b8d88a827ac7 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1209,8 +1209,10 @@ "useUnknownInCatchVariables": false, // disabled for better IDE support, enabled when running the type_check script "composite": false, - // disabled for better IDE support, enabled when running the type_check script - "incremental": false, + // enabled for improved performance + "incremental": true, + // Do not check d.ts files ts ships by default + "skipDefaultLibCheck": true, // Do not check d.ts files by default "skipLibCheck": true, // enables "core language features" @@ -1261,7 +1263,7 @@ "@testing-library/jest-dom", "@emotion/react/types/css-prop", "@kbn/ambient-ui-types", - "@kbn/ambient-storybook-types", + "@kbn/ambient-storybook-types" ] - } + }, } From bfa1a7f20baa743b936d6a93e89e55d1e818210f Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Wed, 9 Nov 2022 16:40:23 +0000 Subject: [PATCH 03/18] [ML] Adding cloud trial end date to ml info (#144793) Adds a `isCloudTrial` flag to ML's `/api/ml/info` endpoint. If `xpack.cloud.trial_end_date` is set in the kibana config and it is greater than the current time, we can assume that we're currently in a could trial. If `xpack.cloud.trial_end_date` is not set, `isCloudTrial` is not added to the endpoint response. This is the same behaviour as the `cloudId` property. Adds a `isCloudTrial()` function to our server info util functions which can be used in conjunction with our `isCloud()` function. To test, these cloud settings can be added to the kibana config: ``` xpack.cloud.id: 'cloud_message_test:ZXUtd2VzdC0yLmF3cy5jbG91ZC5lcy5pbyQ4NWQ2NjZmMzM1MGM0NjllOGMzMjQyZDc2YTdmNDU5YyQxNmI1ZDM2ZGE1Mzk0YjlkYjIyZWJlNDk1OWY1OGQzMg==' xpack.cloud.trial_end_date: '2022-11-20T09:39:52.554Z' ``` --- .../new_job_awaiting_node_shared.tsx | 2 ++ .../services/__mocks__/ml_info_response.json | 3 ++- .../application/services/ml_api_service/index.ts | 1 + .../public/application/services/ml_server_info.test.ts | 4 +++- .../ml/public/application/services/ml_server_info.ts | 10 +++++++++- x-pack/plugins/ml/server/routes/system.ts | 6 ++++-- 6 files changed, 21 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared/new_job_awaiting_node_shared.tsx b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared/new_job_awaiting_node_shared.tsx index 7c634cfed66828..2e932da5dc4653 100644 --- a/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared/new_job_awaiting_node_shared.tsx +++ b/x-pack/plugins/ml/public/application/components/jobs_awaiting_node_warning/new_job_awaiting_node_shared/new_job_awaiting_node_shared.tsx @@ -63,9 +63,11 @@ const MLJobsAwaitingNodeWarning: FC = ({ jobIds }) => { try { const resp = await ml.mlInfo(); const cloudId = resp.cloudId ?? null; + const isCloudTrial = resp.isCloudTrial === true; setCloudInfo({ isCloud: cloudId !== null, cloudId, + isCloudTrial, deploymentId: cloudId === null ? null : extractDeploymentId(cloudId), }); } catch (error) { diff --git a/x-pack/plugins/ml/public/application/services/__mocks__/ml_info_response.json b/x-pack/plugins/ml/public/application/services/__mocks__/ml_info_response.json index ab6dcf8a5b5f66..800afc3560ae92 100644 --- a/x-pack/plugins/ml/public/application/services/__mocks__/ml_info_response.json +++ b/x-pack/plugins/ml/public/application/services/__mocks__/ml_info_response.json @@ -17,5 +17,6 @@ "limits": { "max_model_memory_limit": "128mb" }, - "cloudId": "cloud_message_test:ZXUtd2VzdC0yLmF3cy5jbG91ZC5lcy5pbyQ4NWQ2NjZmMzM1MGM0NjllOGMzMjQyZDc2YTdmNDU5YyQxNmI1ZDM2ZGE1Mzk0YjlkYjIyZWJlNDk1OWY1OGQzMg==" + "cloudId": "cloud_message_test:ZXUtd2VzdC0yLmF3cy5jbG91ZC5lcy5pbyQ4NWQ2NjZmMzM1MGM0NjllOGMzMjQyZDc2YTdmNDU5YyQxNmI1ZDM2ZGE1Mzk0YjlkYjIyZWJlNDk1OWY1OGQzMg==", + "isCloudTrial": true } diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index dbe41669485fc1..d55668d6f6e650 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -55,6 +55,7 @@ export interface MlInfoResponse { }; upgrade_mode: boolean; cloudId?: string; + isCloudTrial?: boolean; } export interface BucketSpanEstimatorResponse { diff --git a/x-pack/plugins/ml/public/application/services/ml_server_info.test.ts b/x-pack/plugins/ml/public/application/services/ml_server_info.test.ts index 994718a46fff5d..4aabe52b034d11 100644 --- a/x-pack/plugins/ml/public/application/services/ml_server_info.test.ts +++ b/x-pack/plugins/ml/public/application/services/ml_server_info.test.ts @@ -9,6 +9,7 @@ import { loadMlServerInfo, getCloudDeploymentId, isCloud, + isCloudTrial, getNewJobDefaults, getNewJobLimits, extractDeploymentId, @@ -34,8 +35,9 @@ describe('ml_server_info', () => { }); describe('cloud information', () => { - it('should get could deployment id', () => { + it('should get could deployment id and trial info', () => { expect(isCloud()).toBe(true); + expect(isCloudTrial()).toBe(true); expect(getCloudDeploymentId()).toBe('85d666f3350c469e8c3242d76a7f459c'); }); }); diff --git a/x-pack/plugins/ml/public/application/services/ml_server_info.ts b/x-pack/plugins/ml/public/application/services/ml_server_info.ts index c13ac943374907..10bbb153e08641 100644 --- a/x-pack/plugins/ml/public/application/services/ml_server_info.ts +++ b/x-pack/plugins/ml/public/application/services/ml_server_info.ts @@ -11,6 +11,7 @@ import { MlServerDefaults, MlServerLimits } from '../../../common/types/ml_serve export interface CloudInfo { cloudId: string | null; isCloud: boolean; + isCloudTrial: boolean; deploymentId: string | null; } @@ -23,6 +24,7 @@ let limits: MlServerLimits = {}; const cloudInfo: CloudInfo = { cloudId: null, isCloud: false, + isCloudTrial: false, deploymentId: null, }; @@ -31,9 +33,11 @@ export async function loadMlServerInfo() { const resp = await ml.mlInfo(); defaults = resp.defaults; limits = resp.limits; - cloudInfo.cloudId = resp.cloudId || null; + cloudInfo.cloudId = resp.cloudId ?? null; cloudInfo.isCloud = resp.cloudId !== undefined; + cloudInfo.isCloudTrial = resp.isCloudTrial === true; cloudInfo.deploymentId = !resp.cloudId ? null : extractDeploymentId(resp.cloudId); + return { defaults, limits, cloudId: cloudInfo }; } catch (error) { return { defaults, limits, cloudId: cloudInfo }; @@ -56,6 +60,10 @@ export function isCloud(): boolean { return cloudInfo.isCloud; } +export function isCloudTrial(): boolean { + return cloudInfo.isCloudTrial; +} + export function getCloudDeploymentId(): string | null { return cloudInfo.deploymentId; } diff --git a/x-pack/plugins/ml/server/routes/system.ts b/x-pack/plugins/ml/server/routes/system.ts index 36a381444a8552..c067b8e3ff438c 100644 --- a/x-pack/plugins/ml/server/routes/system.ts +++ b/x-pack/plugins/ml/server/routes/system.ts @@ -165,9 +165,11 @@ export function systemRoutes( routeGuard.basicLicenseAPIGuard(async ({ mlClient, response }) => { try { const body = await mlClient.info(); - const cloudId = cloud && cloud.cloudId; + const cloudId = cloud?.cloudId; + const isCloudTrial = cloud?.trialEndDate && Date.now() < cloud.trialEndDate.getTime(); + return response.ok({ - body: { ...body, cloudId }, + body: { ...body, cloudId, isCloudTrial }, }); } catch (error) { return response.customError(wrapError(error)); From 49f3c24428ce06a23ad06f98cc850d448966aeff Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 9 Nov 2022 09:50:46 -0700 Subject: [PATCH 04/18] [Maps] enable allowJs (#144742) Fixes https://github.com/elastic/kibana/issues/144287 PR resolves TS errors when setting allowJs to true in Maps plugin Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...tring.test.js => parse_xml_string.test.ts} | 0 ...arse_xml_string.js => parse_xml_string.ts} | 2 +- .../layers/create_basemap_layer_descriptor.ts | 1 - .../ems_vector_tile_layer.tsx | 6 ++-- .../convert_to_lines.test.ts | 1 - ...onvert_to_lines.js => convert_to_lines.ts} | 7 ++-- .../es_pew_pew_source/es_pew_pew_source.tsx | 2 -- .../top_hits/top_hits_form.tsx | 1 - .../classes/sources/wms_source/wms_source.tsx | 2 +- ...test.js.snap => symbol_utils.test.ts.snap} | 0 .../components/color/dynamic_color_form.tsx | 1 - ...c_label_form.js => dynamic_label_form.tsx} | 21 ++++++++++-- ...ic_label_form.js => static_label_form.tsx} | 19 +++++++++-- .../label/vector_style_label_editor.tsx | 8 ++--- .../vector/components/legend/symbol_icon.tsx | 4 +-- .../components/size/static_size_form.tsx | 1 - .../components/symbol/custom_icon_modal.tsx | 2 -- ...mic_icon_form.js => dynamic_icon_form.tsx} | 28 +++++++++++---- .../components/symbol/icon_map_select.tsx | 4 +-- .../vector/components/symbol/icon_preview.tsx | 22 ++++++------ ...atic_icon_form.js => static_icon_form.tsx} | 17 ++++++++-- .../symbol/vector_style_icon_editor.tsx | 21 ++++++++---- .../vector/components/vector_style_editor.tsx | 3 -- .../properties/dynamic_color_property.tsx | 1 - .../properties/dynamic_icon_property.tsx | 6 +--- .../vector/properties/static_icon_property.ts | 1 - ...bol_utils.test.js => symbol_utils.test.ts} | 0 .../{symbol_utils.js => symbol_utils.tsx} | 34 +++++++++++++------ .../classes/styles/vector/vector_style.tsx | 1 - .../components/validated_number_input.tsx | 4 +-- .../connected_components/mb_map/mb_map.tsx | 16 +++++---- x-pack/plugins/maps/server/plugin.ts | 3 -- .../sample_data/ecommerce_saved_objects.js | 2 +- .../sample_data/web_logs_saved_objects.js | 2 +- .../saved_objects/saved_object_migrations.ts | 5 --- x-pack/plugins/maps/tsconfig.json | 6 +--- 36 files changed, 152 insertions(+), 102 deletions(-) rename x-pack/plugins/maps/common/{parse_xml_string.test.js => parse_xml_string.test.ts} (100%) rename x-pack/plugins/maps/common/{parse_xml_string.js => parse_xml_string.ts} (88%) rename x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/{convert_to_lines.js => convert_to_lines.ts} (90%) rename x-pack/plugins/maps/public/classes/styles/vector/__snapshots__/{symbol_utils.test.js.snap => symbol_utils.test.ts.snap} (100%) rename x-pack/plugins/maps/public/classes/styles/vector/components/label/{dynamic_label_form.js => dynamic_label_form.tsx} (62%) rename x-pack/plugins/maps/public/classes/styles/vector/components/label/{static_label_form.js => static_label_form.tsx} (64%) rename x-pack/plugins/maps/public/classes/styles/vector/components/symbol/{dynamic_icon_form.js => dynamic_icon_form.tsx} (68%) rename x-pack/plugins/maps/public/classes/styles/vector/components/symbol/{static_icon_form.js => static_icon_form.tsx} (62%) rename x-pack/plugins/maps/public/classes/styles/vector/{symbol_utils.test.js => symbol_utils.test.ts} (100%) rename x-pack/plugins/maps/public/classes/styles/vector/{symbol_utils.js => symbol_utils.tsx} (86%) diff --git a/x-pack/plugins/maps/common/parse_xml_string.test.js b/x-pack/plugins/maps/common/parse_xml_string.test.ts similarity index 100% rename from x-pack/plugins/maps/common/parse_xml_string.test.js rename to x-pack/plugins/maps/common/parse_xml_string.test.ts diff --git a/x-pack/plugins/maps/common/parse_xml_string.js b/x-pack/plugins/maps/common/parse_xml_string.ts similarity index 88% rename from x-pack/plugins/maps/common/parse_xml_string.js rename to x-pack/plugins/maps/common/parse_xml_string.ts index 7d5c6064b93b19..f9e21548eebd16 100644 --- a/x-pack/plugins/maps/common/parse_xml_string.js +++ b/x-pack/plugins/maps/common/parse_xml_string.ts @@ -8,7 +8,7 @@ import { parseString } from 'xml2js'; // promise based wrapper around parseString -export async function parseXmlString(xmlString) { +export async function parseXmlString(xmlString: string): Promise { const parsePromise = new Promise((resolve, reject) => { parseString(xmlString, (error, result) => { if (error) { diff --git a/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts b/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts index dd569951f90e45..f0c92dca19d4fe 100644 --- a/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts +++ b/x-pack/plugins/maps/public/classes/layers/create_basemap_layer_descriptor.ts @@ -9,7 +9,6 @@ import _ from 'lodash'; import { LayerDescriptor } from '../../../common/descriptor_types'; import { getKibanaTileMap } from '../../util'; import { getEMSSettings } from '../../kibana_services'; -// @ts-expect-error import { KibanaTilemapSource } from '../sources/kibana_tilemap_source'; import { RasterTileLayer } from './raster_tile_layer/raster_tile_layer'; import { EmsVectorTileLayer } from './ems_vector_tile_layer/ems_vector_tile_layer'; diff --git a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx index bedf997566ec2d..91886121780030 100644 --- a/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/ems_vector_tile_layer/ems_vector_tile_layer.tsx @@ -11,7 +11,6 @@ import { type blendMode, type EmsSpriteSheet, TMSService } from '@elastic/ems-cl import { i18n } from '@kbn/i18n'; import _ from 'lodash'; import { EuiIcon } from '@elastic/eui'; -// @ts-expect-error import { RGBAImage } from './image_utils'; import { AbstractLayer, type LayerIcon } from '../layer'; import { @@ -271,7 +270,10 @@ export class EmsVectorTileLayer extends AbstractLayer { const data = new RGBAImage({ width, height }); RGBAImage.copy(imgData, data, { x, y }, { x: 0, y: 0 }, { width, height }); - mbMap.addImage(imageId, data, { pixelRatio, sdf }); + mbMap.addImage(imageId, data as RGBAImage & { width: number; height: number }, { + pixelRatio, + sdf, + }); } } diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/convert_to_lines.test.ts b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/convert_to_lines.test.ts index 6ec7416c62fb7f..1c6699cc180b67 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/convert_to_lines.test.ts +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/convert_to_lines.test.ts @@ -5,7 +5,6 @@ * 2.0. */ -// @ts-ignore import { convertToLines } from './convert_to_lines'; const esResponse = { diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/convert_to_lines.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/convert_to_lines.ts similarity index 90% rename from x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/convert_to_lines.js rename to x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/convert_to_lines.ts index 546e9724a3cb0e..beeae2a5237aa0 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/convert_to_lines.js +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/convert_to_lines.ts @@ -6,20 +6,21 @@ */ import _ from 'lodash'; +import { FeatureCollection } from 'geojson'; import { extractPropertiesFromBucket } from '../../../../common/elasticsearch_util'; const LAT_INDEX = 0; const LON_INDEX = 1; const PEW_PEW_BUCKET_KEYS_TO_IGNORE = ['key', 'sourceCentroid']; -function parsePointFromKey(key) { +function parsePointFromKey(key: string) { const split = key.split(','); const lat = parseFloat(split[LAT_INDEX]); const lon = parseFloat(split[LON_INDEX]); return [lon, lat]; } -export function convertToLines(esResponse) { +export function convertToLines(esResponse: any) { const lineFeatures = []; const destBuckets = _.get(esResponse, 'aggregations.destSplit.buckets', []); @@ -46,6 +47,6 @@ export function convertToLines(esResponse) { featureCollection: { type: 'FeatureCollection', features: lineFeatures, - }, + } as FeatureCollection, }; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.tsx b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.tsx index 8e9f0aabf30707..dcec30b1f71aca 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.tsx @@ -18,11 +18,9 @@ import type { } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { i18n } from '@kbn/i18n'; -// @ts-expect-error import { UpdateSourceEditor } from './update_source_editor'; import { SOURCE_TYPES, VECTOR_SHAPE_TYPE } from '../../../../common/constants'; import { getDataSourceLabel, getDataViewLabel } from '../../../../common/i18n_getters'; -// @ts-expect-error import { convertToLines } from './convert_to_lines'; import { AbstractESAggSource } from '../es_agg_source'; import { registerSource } from '../source_registry'; diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/top_hits/top_hits_form.tsx b/x-pack/plugins/maps/public/classes/sources/es_search_source/top_hits/top_hits_form.tsx index a5786cb0bed0d7..54b09ed831c8ef 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/top_hits/top_hits_form.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/top_hits/top_hits_form.tsx @@ -12,7 +12,6 @@ import { DataViewField } from '@kbn/data-views-plugin/public'; import { SortDirection } from '@kbn/data-plugin/public'; import { SingleFieldSelect } from '../../../../components/single_field_select'; import { getIndexPatternService } from '../../../../kibana_services'; -// @ts-expect-error import { ValidatedRange } from '../../../../components/validated_range'; import { DEFAULT_MAX_INNER_RESULT_WINDOW } from '../../../../../common/constants'; import { loadIndexSettings } from '../util/load_index_settings'; diff --git a/x-pack/plugins/maps/public/classes/sources/wms_source/wms_source.tsx b/x-pack/plugins/maps/public/classes/sources/wms_source/wms_source.tsx index 480b253264e5c5..3207420b3fe3b0 100644 --- a/x-pack/plugins/maps/public/classes/sources/wms_source/wms_source.tsx +++ b/x-pack/plugins/maps/public/classes/sources/wms_source/wms_source.tsx @@ -75,7 +75,7 @@ export class WMSSource extends AbstractSource implements IRasterSource { return this._descriptor.serviceUrl; } - getUrlTemplate() { + async getUrlTemplate() { const client = new WmsClient({ serviceUrl: this._descriptor.serviceUrl }); return client.getUrlTemplate(this._descriptor.layers, this._descriptor.styles || ''); } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/__snapshots__/symbol_utils.test.js.snap b/x-pack/plugins/maps/public/classes/styles/vector/__snapshots__/symbol_utils.test.ts.snap similarity index 100% rename from x-pack/plugins/maps/public/classes/styles/vector/__snapshots__/symbol_utils.test.js.snap rename to x-pack/plugins/maps/public/classes/styles/vector/__snapshots__/symbol_utils.test.ts.snap diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.tsx index fd0f367d2954d6..27641aca604bbd 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.tsx @@ -17,7 +17,6 @@ import { EuiSwitchEvent, } from '@elastic/eui'; import { FieldSelect } from '../field_select'; -// @ts-expect-error import { ColorMapSelect } from './color_map_select'; import { OtherCategoryColorPicker } from './other_category_color_picker'; import { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/label/dynamic_label_form.js b/x-pack/plugins/maps/public/classes/styles/vector/components/label/dynamic_label_form.tsx similarity index 62% rename from x-pack/plugins/maps/public/classes/styles/vector/components/label/dynamic_label_form.js rename to x-pack/plugins/maps/public/classes/styles/vector/components/label/dynamic_label_form.tsx index 5ac61bdb456d4a..a208b806b28df8 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/label/dynamic_label_form.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/label/dynamic_label_form.tsx @@ -5,19 +5,34 @@ * 2.0. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { FieldSelect } from '../field_select'; +import { StyleField } from '../../style_fields_helper'; +import { VECTOR_STYLES } from '../../../../../../common/constants'; +import { LabelDynamicOptions } from '../../../../../../common/descriptor_types'; +import { DynamicTextProperty } from '../../properties/dynamic_text_property'; + +interface Props { + fields: StyleField[]; + onDynamicStyleChange: (propertyName: VECTOR_STYLES, options: LabelDynamicOptions) => void; + staticDynamicSelect?: ReactNode; + styleProperty: DynamicTextProperty; +} export function DynamicLabelForm({ fields, onDynamicStyleChange, staticDynamicSelect, styleProperty, -}) { +}: Props) { const styleOptions = styleProperty.getOptions(); - const onFieldChange = ({ field }) => { + const onFieldChange = ({ field }: { field: StyleField | null }) => { + if (!field) { + return; + } + onDynamicStyleChange(styleProperty.getStyleName(), { ...styleOptions, field }); }; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/label/static_label_form.js b/x-pack/plugins/maps/public/classes/styles/vector/components/label/static_label_form.tsx similarity index 64% rename from x-pack/plugins/maps/public/classes/styles/vector/components/label/static_label_form.js rename to x-pack/plugins/maps/public/classes/styles/vector/components/label/static_label_form.tsx index 4c037a6fc4e678..3a4c1c66e90eda 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/label/static_label_form.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/label/static_label_form.tsx @@ -5,12 +5,25 @@ * 2.0. */ -import React from 'react'; +import React, { ChangeEvent, ReactNode } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFieldText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { VECTOR_STYLES } from '../../../../../../common/constants'; +import { LabelStaticOptions } from '../../../../../../common/descriptor_types'; +import { StaticTextProperty } from '../../properties/static_text_property'; -export function StaticLabelForm({ onStaticStyleChange, staticDynamicSelect, styleProperty }) { - const onValueChange = (event) => { +interface Props { + onStaticStyleChange: (propertyName: VECTOR_STYLES, options: LabelStaticOptions) => void; + staticDynamicSelect?: ReactNode; + styleProperty: StaticTextProperty; +} + +export function StaticLabelForm({ + onStaticStyleChange, + staticDynamicSelect, + styleProperty, +}: Props) { + const onValueChange = (event: ChangeEvent) => { onStaticStyleChange(styleProperty.getStyleName(), { value: event.target.value }); }; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/label/vector_style_label_editor.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/label/vector_style_label_editor.tsx index b5149933a2efde..efa18fd32c06d7 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/label/vector_style_label_editor.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/label/vector_style_label_editor.tsx @@ -8,19 +8,19 @@ import React from 'react'; import { Props, StylePropEditor } from '../style_prop_editor'; -// @ts-expect-error import { DynamicLabelForm } from './dynamic_label_form'; -// @ts-expect-error import { StaticLabelForm } from './static_label_form'; import { LabelDynamicOptions, LabelStaticOptions } from '../../../../../../common/descriptor_types'; +import { DynamicTextProperty } from '../../properties/dynamic_text_property'; +import { StaticTextProperty } from '../../properties/static_text_property'; type LabelEditorProps = Omit, 'children'>; export function VectorStyleLabelEditor(props: LabelEditorProps) { const labelForm = props.styleProperty.isDynamic() ? ( - + ) : ( - + ); return {labelForm}; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/symbol_icon.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/symbol_icon.tsx index fd9b952dbbdeae..5887cfe00496f7 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/symbol_icon.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/symbol_icon.tsx @@ -6,10 +6,10 @@ */ import React, { Component, CSSProperties } from 'react'; -// @ts-expect-error +import { CommonProps } from '@elastic/eui'; import { styleSvg, buildSrcUrl } from '../../symbol_utils'; -interface Props { +interface Props extends CommonProps { symbolId: string; fill?: string; stroke?: string; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/size/static_size_form.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/size/static_size_form.tsx index a3a7ad7e50bda0..6df76f22fac0e2 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/size/static_size_form.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/size/static_size_form.tsx @@ -8,7 +8,6 @@ import React, { ReactNode } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -// @ts-expect-error import { ValidatedRange } from '../../../../../components/validated_range'; import { SizeStaticOptions } from '../../../../../../common/descriptor_types'; import { VECTOR_STYLES } from '../../../../../../common/constants'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/custom_icon_modal.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/custom_icon_modal.tsx index 167672ad536a09..76659ba9c50bc4 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/custom_icon_modal.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/custom_icon_modal.tsx @@ -28,9 +28,7 @@ import { import { METRIC_TYPE } from '@kbn/analytics'; import { i18n } from '@kbn/i18n'; import { IconPreview } from './icon_preview'; -// @ts-expect-error import { getCustomIconId } from '../../symbol_utils'; -// @ts-expect-error import { ValidatedRange } from '../../../../../components/validated_range'; import { CustomIcon } from '../../../../../../common/descriptor_types'; import { APP_ID } from '../../../../../../common'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/dynamic_icon_form.js b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/dynamic_icon_form.tsx similarity index 68% rename from x-pack/plugins/maps/public/classes/styles/vector/components/symbol/dynamic_icon_form.js rename to x-pack/plugins/maps/public/classes/styles/vector/components/symbol/dynamic_icon_form.tsx index 3bc8208e2325ef..9227f0fb3fc1e6 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/dynamic_icon_form.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/dynamic_icon_form.tsx @@ -6,10 +6,23 @@ */ import _ from 'lodash'; -import React, { Fragment } from 'react'; -import { FieldSelect } from '../field_select'; +import React, { Fragment, ReactNode } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { IconMapSelect } from './icon_map_select'; +import { VECTOR_STYLES } from '../../../../../../common/constants'; +import { CustomIcon, IconDynamicOptions } from '../../../../../../common/descriptor_types'; +import { FieldSelect } from '../field_select'; +import { IconMapSelect, StyleOptionChanges } from './icon_map_select'; +import { StyleField } from '../../style_fields_helper'; +import { DynamicIconProperty } from '../../properties/dynamic_icon_property'; + +interface Props { + customIcons: CustomIcon[]; + fields: StyleField[]; + onCustomIconsChange: (customIcons: CustomIcon[]) => void; + onDynamicStyleChange: (propertyName: VECTOR_STYLES, options: IconDynamicOptions) => void; + staticDynamicSelect?: ReactNode; + styleProperty: DynamicIconProperty; +} export function DynamicIconForm({ fields, @@ -18,10 +31,13 @@ export function DynamicIconForm({ customIcons, staticDynamicSelect, styleProperty, -}) { +}: Props) { const styleOptions = styleProperty.getOptions(); - const onFieldChange = ({ field }) => { + const onFieldChange = ({ field }: { field: StyleField | null }) => { + if (!field) { + return; + } const { name, origin } = field; onDynamicStyleChange(styleProperty.getStyleName(), { ...styleOptions, @@ -29,7 +45,7 @@ export function DynamicIconForm({ }); }; - const onIconMapChange = (newOptions) => { + const onIconMapChange = (newOptions: StyleOptionChanges) => { onDynamicStyleChange(styleProperty.getStyleName(), { ...styleOptions, ...newOptions, diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_map_select.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_map_select.tsx index 37b6a9185ad71f..a6c2321f917ec0 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_map_select.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_map_select.tsx @@ -9,9 +9,7 @@ import React, { Component, Fragment } from 'react'; import { EuiSuperSelect, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -// @ts-expect-error import { IconStops } from './icon_stops'; -// @ts-expect-error import { getIconPaletteOptions, PREFERRED_ICONS } from '../../symbol_utils'; import { CustomIcon, @@ -28,7 +26,7 @@ const DEFAULT_ICON_STOPS: IconStop[] = [ { stop: '', icon: PREFERRED_ICONS[1], iconSource: ICON_SOURCE.MAKI }, ]; -interface StyleOptionChanges { +export interface StyleOptionChanges { customIconStops?: IconStop[]; iconPaletteId?: string | null; useCustomIconMap: boolean; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_preview.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_preview.tsx index d93be52dcc4d4c..cc7dc0dd9b3165 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_preview.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/icon_preview.tsx @@ -19,11 +19,7 @@ import { import { maplibregl, Map as MapboxMap } from '@kbn/mapbox-gl'; import { i18n } from '@kbn/i18n'; import { ResizeChecker } from '@kbn/kibana-utils-plugin/public'; -import { - CUSTOM_ICON_PIXEL_RATIO, - createSdfIcon, - // @ts-expect-error -} from '../../symbol_utils'; +import { CUSTOM_ICON_PIXEL_RATIO, createSdfIcon } from '../../symbol_utils'; export interface Props { svg: string; @@ -93,13 +89,15 @@ export class IconPreview extends Component { return; } const imageData = await createSdfIcon({ svg, cutoff, radius }); - if (map.hasImage(IconPreview.iconId)) { - map.updateImage(IconPreview.iconId, imageData); - } else { - map.addImage(IconPreview.iconId, imageData, { - sdf: true, - pixelRatio: CUSTOM_ICON_PIXEL_RATIO, - }); + if (imageData) { + if (map.hasImage(IconPreview.iconId)) { + map.updateImage(IconPreview.iconId, imageData); + } else { + map.addImage(IconPreview.iconId, imageData, { + sdf: true, + pixelRatio: CUSTOM_ICON_PIXEL_RATIO, + }); + } } map.setLayoutProperty('icon-layer', 'icon-image', IconPreview.iconId); map.setLayoutProperty('icon-layer', 'icon-size', 6); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/static_icon_form.js b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/static_icon_form.tsx similarity index 62% rename from x-pack/plugins/maps/public/classes/styles/vector/components/symbol/static_icon_form.js rename to x-pack/plugins/maps/public/classes/styles/vector/components/symbol/static_icon_form.tsx index 6ec372496e8be6..86259e58c9d2d9 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/static_icon_form.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/static_icon_form.tsx @@ -5,9 +5,20 @@ * 2.0. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { VECTOR_STYLES } from '../../../../../../common/constants'; +import { CustomIcon, IconStaticOptions } from '../../../../../../common/descriptor_types'; import { IconSelect } from './icon_select'; +import { StaticIconProperty } from '../../properties/static_icon_property'; + +interface Props { + customIcons: CustomIcon[]; + onCustomIconsChange: (customIcons: CustomIcon[]) => void; + onStaticStyleChange: (propertyName: VECTOR_STYLES, options: IconStaticOptions) => void; + staticDynamicSelect?: ReactNode; + styleProperty: StaticIconProperty; +} export function StaticIconForm({ onStaticStyleChange, @@ -15,8 +26,8 @@ export function StaticIconForm({ customIcons, staticDynamicSelect, styleProperty, -}) { - const onChange = ({ selectedIconId }) => { +}: Props) { + const onChange = ({ selectedIconId }: { selectedIconId: string }) => { onStaticStyleChange(styleProperty.getStyleName(), { value: selectedIconId, }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/vector_style_icon_editor.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/vector_style_icon_editor.tsx index 5c87ce34fb4ce8..9e192b4bd05ebc 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/vector_style_icon_editor.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/symbol/vector_style_icon_editor.tsx @@ -8,19 +8,28 @@ import React from 'react'; import { Props, StylePropEditor } from '../style_prop_editor'; -// @ts-expect-error import { DynamicIconForm } from './dynamic_icon_form'; -// @ts-expect-error import { StaticIconForm } from './static_icon_form'; -import { IconDynamicOptions, IconStaticOptions } from '../../../../../../common/descriptor_types'; +import { + CustomIcon, + IconDynamicOptions, + IconStaticOptions, +} from '../../../../../../common/descriptor_types'; +import { DynamicIconProperty } from '../../properties/dynamic_icon_property'; +import { StaticIconProperty } from '../../properties/static_icon_property'; type IconEditorProps = Omit, 'children'>; -export function VectorStyleIconEditor(props: IconEditorProps) { +export function VectorStyleIconEditor( + props: IconEditorProps & { + customIcons: CustomIcon[]; + onCustomIconsChange: (customIcons: CustomIcon[]) => void; + } +) { const iconForm = props.styleProperty.isDynamic() ? ( - + ) : ( - + ); return {iconForm}; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx index d5855271cef33c..61149cccb2a4c1 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.tsx @@ -12,14 +12,11 @@ import { i18n } from '@kbn/i18n'; import { EuiButtonGroup, EuiFormRow, EuiSpacer, EuiSwitch, EuiSwitchEvent } from '@elastic/eui'; import { VectorStyleColorEditor } from './color/vector_style_color_editor'; import { VectorStyleSizeEditor } from './size/vector_style_size_editor'; -// @ts-expect-error import { VectorStyleSymbolizeAsEditor } from './symbol/vector_style_symbolize_as_editor'; import { VectorStyleIconEditor } from './symbol/vector_style_icon_editor'; import { VectorStyleLabelEditor } from './label/vector_style_label_editor'; import { LabelZoomRangeEditor } from './label/label_zoom_range_editor'; -// @ts-expect-error import { VectorStyleLabelBorderSizeEditor } from './label/vector_style_label_border_size_editor'; -// @ts-expect-error import { OrientationEditor } from './orientation/orientation_editor'; import { getDefaultDynamicProperties, getDefaultStaticProperties } from '../vector_style_defaults'; import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_palettes'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx index 091c4c4e36b2ed..6daa8cf84afaa3 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx @@ -22,7 +22,6 @@ import { FieldFormatter, VECTOR_STYLES, } from '../../../../../common/constants'; -// @ts-expect-error import { isCategoricalStopsInvalid } from '../components/color/color_stops_utils'; import { OTHER_CATEGORY_LABEL, OTHER_CATEGORY_DEFAULT_COLOR } from '../style_util'; import { Break, BreakedLegend } from '../components/legend/breaked_legend'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.tsx index 05729b27114a04..f3042e8f17dd90 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_icon_property.tsx @@ -10,11 +10,7 @@ import { EuiTextColor } from '@elastic/eui'; import type { Map as MbMap } from '@kbn/mapbox-gl'; import { DynamicStyleProperty } from './dynamic_style_property'; import { IVectorStyle } from '../vector_style'; -import { - getIconPalette, - getMakiSymbolAnchor, - // @ts-expect-error -} from '../symbol_utils'; +import { getIconPalette, getMakiSymbolAnchor } from '../symbol_utils'; import { BreakedLegend } from '../components/legend/breaked_legend'; import { OTHER_CATEGORY_LABEL, assignCategoriesToPalette } from '../style_util'; import { LegendProps } from './style_property'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/static_icon_property.ts b/x-pack/plugins/maps/public/classes/styles/vector/properties/static_icon_property.ts index 0a2464d8bed8b1..83cab4633d2122 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/static_icon_property.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/static_icon_property.ts @@ -7,7 +7,6 @@ import type { Map as MbMap } from '@kbn/mapbox-gl'; import { StaticStyleProperty } from './static_style_property'; -// @ts-expect-error import { getMakiSymbolAnchor } from '../symbol_utils'; import { IconStaticOptions } from '../../../../../common/descriptor_types'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.test.js b/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.test.ts similarity index 100% rename from x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.test.js rename to x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.test.ts diff --git a/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js b/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.tsx similarity index 86% rename from x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js rename to x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.tsx index 108a2eb686dcfb..b77b3b69337a48 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/symbol_utils.tsx @@ -5,10 +5,11 @@ * 2.0. */ -import React from 'react'; +import React, { CSSProperties } from 'react'; import xml2js from 'xml2js'; import uuid from 'uuid/v4'; import { Canvg } from 'canvg'; +// @ts-expect-error import calcSDF from 'bitmap-sdf'; import { CUSTOM_ICON_SIZE, @@ -41,7 +42,17 @@ export const SYMBOL_OPTIONS = Object.entries(MAKI_ICONS).map(([value, { svg, lab * @param {number} [radius=0.25] - size of SDF around the cutoff as percent of output icon size * @return {ImageData} image that can be added to a MapLibre map with option `{ sdf: true }` */ -export async function createSdfIcon({ svg, renderSize = 64, cutoff = 0.25, radius = 0.25 }) { +export async function createSdfIcon({ + svg, + renderSize = 64, + cutoff = 0.25, + radius = 0.25, +}: { + svg: string; + renderSize?: number; + cutoff?: number; + radius?: number; +}): Promise { const buffer = 3; const size = renderSize + buffer * 4; const svgCanvas = document.createElement('canvas'); @@ -66,6 +77,9 @@ export async function createSdfIcon({ svg, renderSize = 64, cutoff = 0.25, radiu canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); + if (!ctx) { + return null; + } const imageData = ctx.createImageData(size, size); for (let i = 0; i < size; i++) { @@ -79,11 +93,11 @@ export async function createSdfIcon({ svg, renderSize = 64, cutoff = 0.25, radiu return imageData; } -export function getMakiSymbol(symbolId) { +export function getMakiSymbol(symbolId: string) { return MAKI_ICONS?.[symbolId]; } -export function getMakiSymbolAnchor(symbolId) { +export function getMakiSymbolAnchor(symbolId: string) { switch (symbolId) { case 'embassy': case 'marker': @@ -98,14 +112,14 @@ export function getCustomIconId() { return `${CUSTOM_ICON_PREFIX_SDF}${uuid()}`; } -export function buildSrcUrl(svgString) { +export function buildSrcUrl(svgString: string) { const domUrl = window.URL || window.webkitURL || window; const svg = new Blob([svgString], { type: 'image/svg+xml' }); return domUrl.createObjectURL(svg); } -export async function styleSvg(svgString, fill, stroke) { - const svgXml = await parseXmlString(svgString); +export async function styleSvg(svgString: string, fill?: string, stroke?: string) { + const svgXml = (await parseXmlString(svgString)) as { svg: any }; // Elements nested under svg root may define style attribute // Wildcard descendent selector provides more specificity to ensure root svg style attribute is applied instead of children style attributes @@ -139,7 +153,7 @@ const ICON_PALETTES = [ ]; // PREFERRED_ICONS is used to provide less random default icon values for forms that need default icon values -export const PREFERRED_ICONS = []; +export const PREFERRED_ICONS: string[] = []; ICON_PALETTES.forEach((iconPalette) => { iconPalette.icons.forEach((icon) => { if (!PREFERRED_ICONS.includes(icon)) { @@ -152,7 +166,7 @@ export function getIconPaletteOptions() { const isDarkMode = getIsDarkMode(); return ICON_PALETTES.map(({ id, icons }) => { const iconsDisplay = icons.map((iconId) => { - const style = { + const style: CSSProperties = { width: '10%', position: 'relative', height: '100%', @@ -177,7 +191,7 @@ export function getIconPaletteOptions() { }); } -export function getIconPalette(paletteId) { +export function getIconPalette(paletteId: string | null) { const palette = ICON_PALETTES.find(({ id }) => id === paletteId); return palette ? [...palette.icons] : []; } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx index fafec2dcc02990..b57f8adcddd8e9 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx @@ -25,7 +25,6 @@ import { VECTOR_STYLES, } from '../../../../common/constants'; import { StyleMeta } from './style_meta'; -// @ts-expect-error import { getMakiSymbol } from './symbol_utils'; import { VectorIcon } from './components/legend/vector_icon'; import { VectorStyleLegend } from './components/legend/vector_style_legend'; diff --git a/x-pack/plugins/maps/public/components/validated_number_input.tsx b/x-pack/plugins/maps/public/components/validated_number_input.tsx index cd525cf1ee2c97..0b8e01308c27e7 100644 --- a/x-pack/plugins/maps/public/components/validated_number_input.tsx +++ b/x-pack/plugins/maps/public/components/validated_number_input.tsx @@ -6,8 +6,8 @@ */ import React, { Component, ChangeEvent, ReactNode } from 'react'; -// @ts-expect-error -import { EuiFieldNumber, EuiFormRow, EuiFormRowDisplayKeys } from '@elastic/eui'; +import { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; +import { EuiFormRowDisplayKeys } from '@elastic/eui/src/components/form/form_row/form_row'; import { i18n } from '@kbn/i18n'; import _ from 'lodash'; diff --git a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx index 865ad53ebe3da5..639c8fe5a20dbb 100644 --- a/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx +++ b/x-pack/plugins/maps/public/connected_components/mb_map/mb_map.tsx @@ -48,7 +48,6 @@ import { RenderToolTipContent } from '../../classes/tooltips/tooltip_property'; import { TileStatusTracker } from './tile_status_tracker'; import { DrawFeatureControl } from './draw_control/draw_feature_control'; import type { MapExtentState } from '../../reducers/map/types'; -// @ts-expect-error import { CUSTOM_ICON_PIXEL_RATIO, createSdfIcon } from '../../classes/styles/vector/symbol_utils'; import { MAKI_ICONS } from '../../classes/styles/vector/maki_icons'; import { KeydownScrollZoom } from './keydown_scroll_zoom/keydown_scroll_zoom'; @@ -303,10 +302,12 @@ export class MbMap extends Component { for (const [symbolId, { svg }] of Object.entries(MAKI_ICONS)) { if (!mbMap.hasImage(symbolId)) { const imageData = await createSdfIcon({ renderSize: MAKI_ICON_SIZE, svg }); - mbMap.addImage(symbolId, imageData, { - pixelRatio, - sdf: true, - }); + if (imageData) { + mbMap.addImage(symbolId, imageData, { + pixelRatio, + sdf: true, + }); + } } } } @@ -412,7 +413,10 @@ export class MbMap extends Component { const mbMap = this.state.mbMap; for (const { symbolId, svg, cutoff, radius } of this.props.customIcons) { createSdfIcon({ svg, renderSize: CUSTOM_ICON_SIZE, cutoff, radius }).then( - (imageData: ImageData) => { + (imageData: ImageData | null) => { + if (!imageData) { + return; + } if (mbMap.hasImage(symbolId)) mbMap.updateImage(symbolId, imageData); else mbMap.addImage(symbolId, imageData, { diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index 838b6c3cc7ef5b..0fe317beef05ef 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -17,11 +17,8 @@ import { import { HomeServerPluginSetup } from '@kbn/home-plugin/server'; import { DataViewPersistableStateService } from '@kbn/data-views-plugin/common'; import type { EMSSettings } from '@kbn/maps-ems-plugin/server'; -// @ts-expect-error import { getEcommerceSavedObjects } from './sample_data/ecommerce_saved_objects'; -// @ts-expect-error import { getFlightsSavedObjects } from './sample_data/flights_saved_objects'; -// @ts-expect-error import { getWebLogsSavedObjects } from './sample_data/web_logs_saved_objects'; import { registerMapsUsageCollector } from './maps_telemetry/collectors/register'; import { APP_ID, APP_ICON, MAP_SAVED_OBJECT_TYPE, getFullPath } from '../common/constants'; diff --git a/x-pack/plugins/maps/server/sample_data/ecommerce_saved_objects.js b/x-pack/plugins/maps/server/sample_data/ecommerce_saved_objects.js index e778b9e4162302..9169c107100b17 100644 --- a/x-pack/plugins/maps/server/sample_data/ecommerce_saved_objects.js +++ b/x-pack/plugins/maps/server/sample_data/ecommerce_saved_objects.js @@ -444,7 +444,7 @@ export const getEcommerceSavedObjects = () => { id: '2c9c1f60-1909-11e9-919b-ffe5949a18d2', type: 'map', updated_at: '2019-01-15T21:12:56.253Z', - version: 5, + version: '5', references: [ { name: 'layer_1_join_0_index_pattern', diff --git a/x-pack/plugins/maps/server/sample_data/web_logs_saved_objects.js b/x-pack/plugins/maps/server/sample_data/web_logs_saved_objects.js index 5cc460160a676e..07724e3dfd0724 100644 --- a/x-pack/plugins/maps/server/sample_data/web_logs_saved_objects.js +++ b/x-pack/plugins/maps/server/sample_data/web_logs_saved_objects.js @@ -242,7 +242,7 @@ export const getWebLogsSavedObjects = () => { id: 'de71f4f0-1902-11e9-919b-ffe5949a18d2', type: 'map', updated_at: '2019-01-15T20:30:25.436Z', - version: 5, + version: '5', references: [ { name: 'layer_1_join_0_index_pattern', diff --git a/x-pack/plugins/maps/server/saved_objects/saved_object_migrations.ts b/x-pack/plugins/maps/server/saved_objects/saved_object_migrations.ts index cbe2c1da8dab90..421054ac750672 100644 --- a/x-pack/plugins/maps/server/saved_objects/saved_object_migrations.ts +++ b/x-pack/plugins/maps/server/saved_objects/saved_object_migrations.ts @@ -7,15 +7,10 @@ import type { SavedObjectMigrationContext, SavedObjectUnsanitizedDoc } from '@kbn/core/server'; import { extractReferences } from '../../common/migrations/references'; -// @ts-expect-error import { emsRasterTileToEmsVectorTile } from '../../common/migrations/ems_raster_tile_to_ems_vector_tile'; -// @ts-expect-error import { topHitsTimeToSort } from '../../common/migrations/top_hits_time_to_sort'; -// @ts-expect-error import { moveApplyGlobalQueryToSources } from '../../common/migrations/move_apply_global_query'; -// @ts-expect-error import { addFieldMetaOptions } from '../../common/migrations/add_field_meta_options'; -// @ts-expect-error import { migrateSymbolStyleDescriptor } from '../../common/migrations/migrate_symbol_style_descriptor'; import { migrateUseTopHitsToScalingType } from '../../common/migrations/scaling_type'; import { migrateJoinAggKey } from '../../common/migrations/join_agg_key'; diff --git a/x-pack/plugins/maps/tsconfig.json b/x-pack/plugins/maps/tsconfig.json index f38cce537f2675..68c51855fe62e7 100644 --- a/x-pack/plugins/maps/tsconfig.json +++ b/x-pack/plugins/maps/tsconfig.json @@ -4,11 +4,7 @@ "outDir": "./target/types", "emitDeclarationOnly": true, "declaration": true, - // there is still a decent amount of JS in this plugin and we are taking - // advantage of the fact that TS doesn't know the types of that code and - // gives us `any`. Once that code is converted to .ts we can remove this - // and allow TS to infer types from any JS file imported. - "allowJs": false + "allowJs": true }, "include": [ "common/**/*", From d20bdb56d74bc2152126356e5acbe1ef58ab60f6 Mon Sep 17 00:00:00 2001 From: Mark Hopkin Date: Wed, 9 Nov 2022 16:53:08 +0000 Subject: [PATCH 05/18] [Fleet] Bugfix: always use posix paths for zip files (#144899) ## Summary Always use posix style paths when generating paths for the package archives. --- .../fleet/server/services/epm/archive/parse.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/archive/parse.ts b/x-pack/plugins/fleet/server/services/epm/archive/parse.ts index 8bcda08fdec6b3..d4ee87dc232f00 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/parse.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/parse.ts @@ -179,7 +179,7 @@ function parseAndVerifyArchive(paths: string[], topLevelDirOverride?: string): A }); // The package must contain a manifest file ... - const manifestFile = path.join(toplevelDir, MANIFEST_NAME); + const manifestFile = path.posix.join(toplevelDir, MANIFEST_NAME); const manifestBuffer = MANIFESTS[manifestFile]; if (!paths.includes(manifestFile) || !manifestBuffer) { throw new PackageInvalidArchiveError(`Package must contain a top-level ${MANIFEST_NAME} file.`); @@ -263,7 +263,7 @@ export function parseAndVerifyDataStreams( const dataStreamPaths = new Set(); const dataStreams: RegistryDataStream[] = []; const pkgBasePath = pkgBasePathOverride || pkgToPkgKey({ name: pkgName, version: pkgVersion }); - const dataStreamsBasePath = path.join(pkgBasePath, 'data_stream'); + const dataStreamsBasePath = path.posix.join(pkgBasePath, 'data_stream'); // pick all paths matching name-version/data_stream/DATASTREAM_NAME/... // from those, pick all unique data stream names paths.forEach((filePath) => { @@ -275,8 +275,8 @@ export function parseAndVerifyDataStreams( }); dataStreamPaths.forEach((dataStreamPath) => { - const fullDataStreamPath = path.join(dataStreamsBasePath, dataStreamPath); - const manifestFile = path.join(fullDataStreamPath, MANIFEST_NAME); + const fullDataStreamPath = path.posix.join(dataStreamsBasePath, dataStreamPath); + const manifestFile = path.posix.join(fullDataStreamPath, MANIFEST_NAME); const manifestBuffer = MANIFESTS[manifestFile]; if (!paths.includes(manifestFile) || !manifestBuffer) { throw new PackageInvalidArchiveError( @@ -547,7 +547,10 @@ const isDefaultPipelineFile = (pipelinePath: string) => pipelinePath.endsWith(DEFAULT_INGEST_PIPELINE_FILE_NAME_JSON); export function parseDefaultIngestPipeline(fullDataStreamPath: string, paths: string[]) { - const ingestPipelineDirPath = path.join(fullDataStreamPath, '/elasticsearch/ingest_pipeline'); + const ingestPipelineDirPath = path.posix.join( + fullDataStreamPath, + '/elasticsearch/ingest_pipeline' + ); const defaultIngestPipelinePaths = paths.filter( (pipelinePath) => pipelinePath.startsWith(ingestPipelineDirPath) && isDefaultPipelineFile(pipelinePath) From 192068d9bd3b1b12c4920b35eeecd9be3d29d177 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 9 Nov 2022 09:53:59 -0700 Subject: [PATCH 06/18] [Reporting] Unskip screenshotting timeouts test (#144580) ## Summary Closes #135309 See: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/1528 --- .../fixtures/baseline/warnings_capture_a.png | Bin 109473 -> 0 bytes .../fixtures/baseline/warnings_capture_b.png | Bin 0 -> 95272 bytes .../reporting_and_timeout/index.ts | 50 ++++++++++++++---- 3 files changed, 40 insertions(+), 10 deletions(-) delete mode 100644 x-pack/test/reporting_functional/reporting_and_timeout/fixtures/baseline/warnings_capture_a.png create mode 100644 x-pack/test/reporting_functional/reporting_and_timeout/fixtures/baseline/warnings_capture_b.png diff --git a/x-pack/test/reporting_functional/reporting_and_timeout/fixtures/baseline/warnings_capture_a.png b/x-pack/test/reporting_functional/reporting_and_timeout/fixtures/baseline/warnings_capture_a.png deleted file mode 100644 index bc23cc89727ca7e3c79f8afdc11c215612d72e74..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 109473 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

*Y**^$AhHOfc75yypM)~=BWmb|B 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 XG8p@dhi29P!O1eSv(9M4x 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 literal 0 HcmV?d00001 diff --git a/x-pack/test/reporting_functional/reporting_and_timeout/index.ts b/x-pack/test/reporting_functional/reporting_and_timeout/index.ts index 17f13bf868019c..a6e100ca707c75 100644 --- a/x-pack/test/reporting_functional/reporting_and_timeout/index.ts +++ b/x-pack/test/reporting_functional/reporting_and_timeout/index.ts @@ -6,12 +6,15 @@ */ import expect from '@kbn/expect'; +import fs from 'fs'; import path from 'path'; +import { PNG } from 'pngjs'; import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'reporting', 'dashboard']); + const log = getService('log'); const reportingAPI = getService('reportingAPI'); const reportingFunctional = getService('reportingFunctional'); const browser = getService('browser'); @@ -19,11 +22,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const config = getService('config'); const screenshotDir = config.get('screenshots.directory'); - // FLAKY: https://github.com/elastic/kibana/issues/135309 - describe.skip('Reporting Functional Tests with forced timeout', function () { + describe('Reporting Functional Tests with forced timeout', function () { const dashboardTitle = 'Ecom Dashboard Hidden Panel Titles'; - const baselineAPng = path.resolve(__dirname, 'fixtures/baseline/warnings_capture_a.png'); - const sessionPng = 'warnings_capture_session_a'; + const sessionPngFullPage = 'warnings_capture_session_a'; + const sessionPngCropped = 'warnings_capture_session_b'; + const baselinePng = path.resolve(__dirname, 'fixtures/baseline/warnings_capture_b.png'); let url: string; before(async () => { @@ -33,7 +36,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.loadSavedDashboard(dashboardTitle); await PageObjects.reporting.setTimepickerInEcommerceDataRange(); - await browser.setWindowSize(1600, 850); + await browser.setWindowSize(800, 850); await PageObjects.reporting.openPngReportingPanel(); await PageObjects.reporting.clickGenerateReportButton(); @@ -51,17 +54,44 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('adds a visual warning in the report output', async () => { const captureData = await PageObjects.reporting.getRawPdfReportData(url); - const pngSessionFilePath = await PageObjects.reporting.writeSessionReport( - sessionPng, + const sessionReport = await PageObjects.reporting.writeSessionReport( + sessionPngFullPage, 'png', captureData, screenshotDir ); - // allow minor visual differences: https://github.com/elastic/kibana/issues/135309#issuecomment-1169095186 + const region = { height: 320, width: 1540, srcX: 20, srcY: 10 }; + const dstPath = path.resolve(screenshotDir, sessionPngCropped + '.png'); + const dst = new PNG({ width: region.width, height: region.height }); + + const pngSessionFilePath = await new Promise((resolve) => { + fs.createReadStream(sessionReport) + .pipe(new PNG()) + .on('parsed', function () { + log.info(`cropping report to the visual warning area`); + this.bitblt(dst, region.srcX, region.srcY, region.width, region.height, 0, 0); + dst.pack().pipe(fs.createWriteStream(dstPath)); + resolve(dstPath); + }); + }); + + log.info(`saved cropped file to ${dstPath}`); + expect( - await png.checkIfPngsMatch(pngSessionFilePath, baselineAPng, screenshotDir) - ).to.be.lessThan(0.015); // this factor of difference allows passing whether or not the page has loaded things like the loading graphics and titlebars + await png.checkIfPngsMatch(pngSessionFilePath, baselinePng, screenshotDir) + ).to.be.lessThan(0.09); + + /** + * This test may fail when styling differences affect the result. To update the snapshot: + * + * 1. Run the functional test, to generate new temporary files for screenshot comparison. + * 2. Save the screenshot as the new baseline file: + * cp \ + * x-pack/test/functional/screenshots/session/warnings_capture_session_b_actual.png \ + * x-pack/test/reporting_functional/reporting_and_timeout/fixtures/baseline/warnings_capture_b.png + * 3. Commit the changes to the .png file + */ }); }); } From df43ad4e1e33ddb214ab76e480b7d0427d91a01e Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Wed, 9 Nov 2022 17:57:20 +0100 Subject: [PATCH 07/18] [APM] Skip test after change in fleet url params (#144905) Skipped failing test after change in fleep url params https://github.com/elastic/kibana/pull/144343 Test will be fixed during test plan https://github.com/elastic/kibana/issues/144907 --- .../power_user/integration_settings/integration_policy.cy.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/power_user/integration_settings/integration_policy.cy.ts b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/power_user/integration_settings/integration_policy.cy.ts index 528e260940e3c9..46f93cfcd29d90 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/e2e/power_user/integration_settings/integration_policy.cy.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/e2e/power_user/integration_settings/integration_policy.cy.ts @@ -66,7 +66,7 @@ describe('when navigating to integration page', () => { cy.getByTestSubj('addIntegrationPolicyButton').click(); }); - it('checks validators for required fields', () => { + it.skip('checks validators for required fields', () => { const requiredFields = policyFormFields.filter((field) => field.required); requiredFields.map((field) => { @@ -76,7 +76,7 @@ describe('when navigating to integration page', () => { }); }); - it('should display Tail-based section on latest version', () => { + it.skip('should display Tail-based section on latest version', () => { cy.visitKibana('/app/fleet/integrations/apm/add-integration'); cy.contains('Tail-based sampling').should('exist'); }); From 1a660f027fe2f8374418c0bad24d8001f0ba19b8 Mon Sep 17 00:00:00 2001 From: Kaarina Tungseth Date: Wed, 9 Nov 2022 10:59:41 -0600 Subject: [PATCH 08/18] [Lens] Updates Series color tooltip (#144846) ## Summary Updates the Series color tooltip to: `You are unable to apply custom colors to individual series when the layer includes a "Break down by" field.` --- .../public/visualizations/xy/xy_config_panel/color_picker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/color_picker.tsx b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/color_picker.tsx index 2d0b28f4fac9a4..22166ba85fc348 100644 --- a/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/color_picker.tsx +++ b/x-pack/plugins/lens/public/visualizations/xy/xy_config_panel/color_picker.tsx @@ -27,7 +27,7 @@ const tooltipContent = { }), disabled: i18n.translate('xpack.lens.configPanel.color.tooltip.disabled', { defaultMessage: - 'Individual series cannot be custom colored when the layer includes a “Break down by.“', + 'You are unable to apply custom colors to individual series when the layer includes a "Break down by" field.', }), }; From 49986dab34ac564fcad269aa8c11c63a9f7fd3a3 Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Wed, 9 Nov 2022 09:02:16 -0800 Subject: [PATCH 09/18] testing a flaky functional test ( chained input control) (#144770) trying to fix: https://github.com/elastic/kibana/issues/96997 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../dashboard_elements/input_control_vis/chained_controls.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/functional/apps/dashboard_elements/input_control_vis/chained_controls.ts b/test/functional/apps/dashboard_elements/input_control_vis/chained_controls.ts index 2f91c789a478ba..0b2b4ac1b78efa 100644 --- a/test/functional/apps/dashboard_elements/input_control_vis/chained_controls.ts +++ b/test/functional/apps/dashboard_elements/input_control_vis/chained_controls.ts @@ -17,9 +17,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const find = getService('find'); const comboBox = getService('comboBox'); - // FLAKY: https://github.com/elastic/kibana/issues/96997 - // FLAKY: https://github.com/elastic/kibana/issues/100372 - describe.skip('chained controls', function () { + describe('chained controls', function () { this.tags('includeFirefox'); before(async () => { @@ -42,7 +40,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('should filter child control options by parent control value', async () => { await comboBox.set('listControlSelect0', 'BR'); - const childControlMenu = await comboBox.getOptionsList('listControlSelect1'); expect(childControlMenu.trim().split('\n').join()).to.equal( '14.61.182.136,3.174.21.181,6.183.121.70,71.241.97.89,9.69.255.135' From 17625d57801e38454ae2416dbe65f3788d8a3fa9 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Wed, 9 Nov 2022 12:20:49 -0500 Subject: [PATCH 10/18] [Synthetics] Overview - fix disabled status count (#144903) ## Summary Fixes a typo with disabled status count. --- .../monitors_page/overview/overview/overview_status.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_status.tsx b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_status.tsx index 13c23501e144a0..743a95018e86b9 100644 --- a/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_status.tsx +++ b/x-pack/plugins/synthetics/public/apps/synthetics/components/monitors_page/overview/overview/overview_status.tsx @@ -86,9 +86,9 @@ export function OverviewStatus() { } } else if (status) { setStatusConfig({ - up: status?.up, - down: status?.down || 0, - disabledCount: 0, + up: status.up, + down: status.down, + disabledCount: status.disabledCount, }); } }, [status, statusFilter]); From 59f2ff5c2dabf3a5bb8d36d7e62cf21d8e0ff84d Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Wed, 9 Nov 2022 12:22:33 -0500 Subject: [PATCH 11/18] Add October newsletter link (#144860) Adds link to October newsletter --- nav-kibana-dev.docnav.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nav-kibana-dev.docnav.json b/nav-kibana-dev.docnav.json index 8b49d43be942ac..17222ef4a5266e 100644 --- a/nav-kibana-dev.docnav.json +++ b/nav-kibana-dev.docnav.json @@ -175,6 +175,9 @@ { "label": "Contributors Newsletters", "items": [ + { + "id": "kibOctober2022ContributorNewsletter" + }, { "id": "kibSeptember2022ContributorNewsletter" }, From c88a680c605c7f600a92fbf226893cb24e59bd7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 9 Nov 2022 18:49:32 +0100 Subject: [PATCH 12/18] [Logs UI] Use the Unified Search Bar for date range selection (#144351) This enables the date-picker of the unified search bar introduced into the Logs UI in #143222 and simultaneously removes the custom date picker. closes https://github.com/elastic/kibana/issues/142767 --- src/plugins/kibana_utils/public/index.ts | 1 + x-pack/plugins/infra/common/time/time_key.ts | 25 +- x-pack/plugins/infra/public/apps/logs_app.tsx | 16 +- .../containers/logs/log_position/index.ts | 4 +- .../log_position/log_position_state.test.ts | 287 +++++++++++ .../logs/log_position/log_position_state.ts | 477 ++++++++++-------- .../log_position_timefilter_state.ts | 55 ++ .../replace_log_position_in_query_string.ts | 24 + .../logs/log_position/use_log_position.ts | 201 ++++++++ .../use_log_position_url_state_sync.ts | 78 +++ .../with_log_position_url_state.tsx | 151 ------ .../stream/components/stream_live_button.tsx | 31 ++ .../pages/logs/stream/page_providers.tsx | 11 +- .../public/pages/logs/stream/page_toolbar.tsx | 148 +++--- x-pack/plugins/infra/public/utils/datemath.ts | 24 +- .../public/utils/kbn_url_state_context.ts | 34 ++ .../public/utils/state_container_devtools.ts | 51 ++ .../public/utils/timefilter_state_storage.ts | 64 +++ .../public/utils/wrap_state_container.ts | 23 + .../shared/page_template/page_template.tsx | 4 +- .../apps/infra/logs_source_configuration.ts | 6 +- .../page_objects/infra_logs_page.ts | 5 +- .../page_objects/observability_page.ts | 9 + .../pages/alerts/state_synchronization.ts | 45 +- 24 files changed, 1285 insertions(+), 489 deletions(-) create mode 100644 x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.test.ts create mode 100644 x-pack/plugins/infra/public/containers/logs/log_position/log_position_timefilter_state.ts create mode 100644 x-pack/plugins/infra/public/containers/logs/log_position/replace_log_position_in_query_string.ts create mode 100644 x-pack/plugins/infra/public/containers/logs/log_position/use_log_position.ts create mode 100644 x-pack/plugins/infra/public/containers/logs/log_position/use_log_position_url_state_sync.ts delete mode 100644 x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx create mode 100644 x-pack/plugins/infra/public/pages/logs/stream/components/stream_live_button.tsx create mode 100644 x-pack/plugins/infra/public/utils/kbn_url_state_context.ts create mode 100644 x-pack/plugins/infra/public/utils/state_container_devtools.ts create mode 100644 x-pack/plugins/infra/public/utils/timefilter_state_storage.ts create mode 100644 x-pack/plugins/infra/public/utils/wrap_state_container.ts diff --git a/src/plugins/kibana_utils/public/index.ts b/src/plugins/kibana_utils/public/index.ts index 4aa6244e1b24ea..d8882f74ee3b16 100644 --- a/src/plugins/kibana_utils/public/index.ts +++ b/src/plugins/kibana_utils/public/index.ts @@ -93,6 +93,7 @@ export { replaceUrlHashQuery, } from './state_management/url'; export type { + IStateStorage, IStateSyncConfig, ISyncStateRef, IKbnUrlStateStorage, diff --git a/x-pack/plugins/infra/common/time/time_key.ts b/x-pack/plugins/infra/common/time/time_key.ts index 42b14625d22a97..efc5a8e7b85170 100644 --- a/x-pack/plugins/infra/common/time/time_key.ts +++ b/x-pack/plugins/infra/common/time/time_key.ts @@ -6,14 +6,23 @@ */ import { ascending, bisector } from 'd3-array'; +import * as rt from 'io-ts'; import { pick } from 'lodash'; -export interface TimeKey { - time: number; - tiebreaker: number; - gid?: string; - fromAutoReload?: boolean; -} +export const minimalTimeKeyRT = rt.type({ + time: rt.number, + tiebreaker: rt.number, +}); +export type MinimalTimeKey = rt.TypeOf; + +export const timeKeyRT = rt.intersection([ + minimalTimeKeyRT, + rt.partial({ + gid: rt.string, + fromAutoReload: rt.boolean, + }), +]); +export type TimeKey = rt.TypeOf; export interface UniqueTimeKey extends TimeKey { gid: string; @@ -95,3 +104,7 @@ export const getNextTimeKey = (timeKey: TimeKey) => ({ time: timeKey.time, tiebreaker: timeKey.tiebreaker + 1, }); + +export const isSameTimeKey = (firstKey: TimeKey | null, secondKey: TimeKey | null): boolean => + firstKey === secondKey || + (firstKey != null && secondKey != null && compareTimeKeys(firstKey, secondKey) === 0); diff --git a/x-pack/plugins/infra/public/apps/logs_app.tsx b/x-pack/plugins/infra/public/apps/logs_app.tsx index 0b345150daf8d2..9e5722e316b004 100644 --- a/x-pack/plugins/infra/public/apps/logs_app.tsx +++ b/x-pack/plugins/infra/public/apps/logs_app.tsx @@ -19,6 +19,7 @@ import { LogsPage } from '../pages/logs'; import { InfraClientStartDeps, InfraClientStartExports } from '../types'; import { CommonInfraProviders, CoreProviders } from './common_providers'; import { prepareMountElement } from './common_styles'; +import { KbnUrlStateStorageFromRouterProvider } from '../utils/kbn_url_state_context'; export const renderApp = ( core: CoreStart, @@ -69,11 +70,16 @@ const LogsApp: React.FC<{ triggersActionsUI={plugins.triggersActionsUi} > - - - {uiCapabilities?.logs?.show && } - - + + + + {uiCapabilities?.logs?.show && } + + + diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/index.ts b/x-pack/plugins/infra/public/containers/logs/log_position/index.ts index 41d284caf94259..e4e6ad6c54deb8 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_position/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_position/index.ts @@ -6,4 +6,6 @@ */ export * from './log_position_state'; -export * from './with_log_position_url_state'; +export * from './replace_log_position_in_query_string'; +export * from './use_log_position'; +export type { LogPositionUrlState } from './use_log_position_url_state_sync'; diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.test.ts b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.test.ts new file mode 100644 index 00000000000000..b87dca28fc048b --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.test.ts @@ -0,0 +1,287 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import { createInitialLogPositionState, updateStateFromUrlState } from './log_position_state'; + +describe('function createInitialLogPositionState', () => { + it('initializes state without url and timefilter', () => { + const initialState = createInitialLogPositionState({ + initialStateFromUrl: null, + initialStateFromTimefilter: null, + now: getTestMoment().toDate(), + }); + + expect(initialState).toMatchInlineSnapshot(` + Object { + "latestPosition": null, + "refreshInterval": Object { + "pause": true, + "value": 5000, + }, + "targetPosition": null, + "timeRange": Object { + "expression": Object { + "from": "now-1d", + "to": "now", + }, + "lastChangedCompletely": 1640995200000, + }, + "timestamps": Object { + "endTimestamp": 1640995200000, + "lastChangedTimestamp": 1640995200000, + "startTimestamp": 1640908800000, + }, + "visiblePositions": Object { + "endKey": null, + "middleKey": null, + "pagesAfterEnd": Infinity, + "pagesBeforeStart": Infinity, + "startKey": null, + }, + } + `); + }); + + it('initializes state from complete url state', () => { + const initialState = createInitialLogPositionState({ + initialStateFromUrl: { + start: 'now-2d', + end: 'now-1d', + position: { + time: getTestMoment().subtract(36, 'hours').valueOf(), + tiebreaker: 0, + }, + streamLive: false, + }, + initialStateFromTimefilter: null, + now: getTestMoment().toDate(), + }); + + expect(initialState).toMatchInlineSnapshot(` + Object { + "latestPosition": Object { + "tiebreaker": 0, + "time": 1640865600000, + }, + "refreshInterval": Object { + "pause": true, + "value": 5000, + }, + "targetPosition": Object { + "tiebreaker": 0, + "time": 1640865600000, + }, + "timeRange": Object { + "expression": Object { + "from": "now-2d", + "to": "now-1d", + }, + "lastChangedCompletely": 1640995200000, + }, + "timestamps": Object { + "endTimestamp": 1640908800000, + "lastChangedTimestamp": 1640995200000, + "startTimestamp": 1640822400000, + }, + "visiblePositions": Object { + "endKey": null, + "middleKey": null, + "pagesAfterEnd": Infinity, + "pagesBeforeStart": Infinity, + "startKey": null, + }, + } + `); + }); + + it('initializes state from from url state with just a time range', () => { + const initialState = createInitialLogPositionState({ + initialStateFromUrl: { + start: 'now-2d', + end: 'now-1d', + }, + initialStateFromTimefilter: null, + now: getTestMoment().toDate(), + }); + + expect(initialState).toMatchInlineSnapshot(` + Object { + "latestPosition": null, + "refreshInterval": Object { + "pause": true, + "value": 5000, + }, + "targetPosition": null, + "timeRange": Object { + "expression": Object { + "from": "now-2d", + "to": "now-1d", + }, + "lastChangedCompletely": 1640995200000, + }, + "timestamps": Object { + "endTimestamp": 1640908800000, + "lastChangedTimestamp": 1640995200000, + "startTimestamp": 1640822400000, + }, + "visiblePositions": Object { + "endKey": null, + "middleKey": null, + "pagesAfterEnd": Infinity, + "pagesBeforeStart": Infinity, + "startKey": null, + }, + } + `); + }); + + it('initializes state from from url state with just a position', () => { + const initialState = createInitialLogPositionState({ + initialStateFromUrl: { + position: { + time: getTestMoment().subtract(36, 'hours').valueOf(), + }, + }, + initialStateFromTimefilter: null, + now: getTestMoment().toDate(), + }); + + expect(initialState).toMatchInlineSnapshot(` + Object { + "latestPosition": Object { + "tiebreaker": 0, + "time": 1640865600000, + }, + "refreshInterval": Object { + "pause": true, + "value": 5000, + }, + "targetPosition": Object { + "tiebreaker": 0, + "time": 1640865600000, + }, + "timeRange": Object { + "expression": Object { + "from": "2021-12-30T11:00:00.000Z", + "to": "2021-12-30T13:00:00.000Z", + }, + "lastChangedCompletely": 1640995200000, + }, + "timestamps": Object { + "endTimestamp": 1640869200000, + "lastChangedTimestamp": 1640995200000, + "startTimestamp": 1640862000000, + }, + "visiblePositions": Object { + "endKey": null, + "middleKey": null, + "pagesAfterEnd": Infinity, + "pagesBeforeStart": Infinity, + "startKey": null, + }, + } + `); + }); +}); + +describe('function updateStateFromUrlState', () => { + it('applies a new target position that is within the date range', () => { + const initialState = createInitialTestState(); + const newState = updateStateFromUrlState({ + position: { + time: initialState.timestamps.startTimestamp + 1, + tiebreaker: 2, + }, + })(initialState); + + expect(newState).toEqual({ + ...initialState, + targetPosition: { + time: initialState.timestamps.startTimestamp + 1, + tiebreaker: 2, + }, + latestPosition: { + time: initialState.timestamps.startTimestamp + 1, + tiebreaker: 2, + }, + }); + }); + + it('applies a new partial target position that is within the date range', () => { + const initialState = createInitialTestState(); + const newState = updateStateFromUrlState({ + position: { + time: initialState.timestamps.startTimestamp + 1, + }, + })(initialState); + + expect(newState).toEqual({ + ...initialState, + targetPosition: { + time: initialState.timestamps.startTimestamp + 1, + tiebreaker: 0, + }, + latestPosition: { + time: initialState.timestamps.startTimestamp + 1, + tiebreaker: 0, + }, + }); + }); + + it('rejects a target position that is outside the date range', () => { + const initialState = createInitialTestState(); + const newState = updateStateFromUrlState({ + position: { + time: initialState.timestamps.startTimestamp - 1, + }, + })(initialState); + + expect(newState).toEqual({ + ...initialState, + targetPosition: null, + latestPosition: null, + }); + }); + + it('applies a new time range and updates timestamps', () => { + const initialState = createInitialTestState(); + const updateDate = getTestMoment().add(1, 'hour').toDate(); + const newState = updateStateFromUrlState( + { + start: 'now-2d', + end: 'now-1d', + }, + updateDate + )(initialState); + + expect(newState).toEqual({ + ...initialState, + timeRange: { + expression: { + from: 'now-2d', + to: 'now-1d', + }, + lastChangedCompletely: updateDate.valueOf(), + }, + timestamps: { + startTimestamp: moment(updateDate).subtract(2, 'day').valueOf(), + endTimestamp: moment(updateDate).subtract(1, 'day').valueOf(), + lastChangedTimestamp: updateDate.valueOf(), + }, + }); + }); +}); + +const getTestMoment = () => moment.utc('2022-01-01T00:00:00.000Z'); + +const createInitialTestState = () => + createInitialLogPositionState({ + initialStateFromUrl: null, + initialStateFromTimefilter: null, + now: getTestMoment().toDate(), + }); diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts index 521a5bf8562fc8..cd5f11346c56aa 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_state.ts @@ -5,249 +5,304 @@ * 2.0. */ -import { useState, useMemo, useEffect, useCallback } from 'react'; -import createContainer from 'constate'; -import useSetState from 'react-use/lib/useSetState'; -import useInterval from 'react-use/lib/useInterval'; -import { TimeKey } from '../../../../common/time'; -import { datemathToEpochMillis, isValidDatemath } from '../../../utils/datemath'; -import { useKibanaTimefilterTime } from '../../../hooks/use_kibana_timefilter_time'; - -type TimeKeyOrNull = TimeKey | null; - -interface DateRange { - startDateExpression: string; - endDateExpression: string; - startTimestamp: number; - endTimestamp: number; - timestampsLastUpdate: number; - lastCompleteDateRangeExpressionUpdate: number; -} +import { RefreshInterval } from '@kbn/data-plugin/public'; +import { TimeRange } from '@kbn/es-query'; +import { createStateContainer } from '@kbn/kibana-utils-plugin/public'; +import { identity, pipe } from 'fp-ts/lib/function'; +import produce, { Draft, original } from 'immer'; +import moment, { DurationInputObject } from 'moment'; +import { isSameTimeKey, MinimalTimeKey, pickTimeKey, TimeKey } from '../../../../common/time'; +import { datemathToEpochMillis } from '../../../utils/datemath'; +import { TimefilterState } from '../../../utils/timefilter_state_storage'; +import { LogPositionUrlState } from './use_log_position_url_state_sync'; interface VisiblePositions { - startKey: TimeKeyOrNull; - middleKey: TimeKeyOrNull; - endKey: TimeKeyOrNull; + startKey: TimeKey | null; + middleKey: TimeKey | null; + endKey: TimeKey | null; pagesAfterEnd: number; pagesBeforeStart: number; } -export interface LogPositionStateParams { - isInitialized: boolean; - targetPosition: TimeKeyOrNull; - isStreaming: boolean; - firstVisiblePosition: TimeKeyOrNull; - pagesBeforeStart: number; - pagesAfterEnd: number; - visibleMidpoint: TimeKeyOrNull; - visibleMidpointTime: number | null; - visibleTimeInterval: { start: number; end: number } | null; - startDateExpression: string; - endDateExpression: string; - startTimestamp: number | null; - endTimestamp: number | null; - timestampsLastUpdate: number; - lastCompleteDateRangeExpressionUpdate: number; +export interface LogPositionState { + timeRange: { + expression: TimeRange; + lastChangedCompletely: number; + }; + timestamps: { + startTimestamp: number; + endTimestamp: number; + lastChangedTimestamp: number; + }; + refreshInterval: RefreshInterval; + latestPosition: TimeKey | null; + targetPosition: TimeKey | null; + visiblePositions: VisiblePositions; } -export interface LogPositionCallbacks { - initialize: () => void; - jumpToTargetPosition: (pos: TimeKeyOrNull) => void; - jumpToTargetPositionTime: (time: number) => void; - reportVisiblePositions: (visPos: VisiblePositions) => void; - startLiveStreaming: () => void; - stopLiveStreaming: () => void; - updateDateRange: (newDateRage: Partial) => void; +export interface InitialLogPositionArguments { + initialStateFromUrl: LogPositionUrlState | null; + initialStateFromTimefilter: TimefilterState | null; + now?: Date; } -const DESIRED_BUFFER_PAGES = 2; - -const useVisibleMidpoint = (middleKey: TimeKeyOrNull, targetPosition: TimeKeyOrNull) => { - // Of the two dependencies `middleKey` and `targetPosition`, return - // whichever one was the most recently updated. This allows the UI controls - // to display a newly-selected `targetPosition` before loading new data; - // otherwise the previous `middleKey` would linger in the UI for the entirety - // of the loading operation, which the user could perceive as unresponsiveness - const [store, update] = useState({ - middleKey, - targetPosition, - currentValue: middleKey || targetPosition, - }); - useEffect(() => { - if (middleKey !== store.middleKey) { - update({ targetPosition, middleKey, currentValue: middleKey }); - } else if (targetPosition !== store.targetPosition) { - update({ targetPosition, middleKey, currentValue: targetPosition }); - } - }, [middleKey, targetPosition]); // eslint-disable-line react-hooks/exhaustive-deps +/** + * Initial state + */ - return store.currentValue; +const initialTimeRangeExpression: TimeRange = { + from: 'now-1d', + to: 'now', }; -const TIME_DEFAULTS = { from: 'now-1d', to: 'now' }; -const STREAMING_INTERVAL = 5000; +const initialRefreshInterval: RefreshInterval = { + pause: true, + value: 5000, +}; -export const useLogPositionState: () => LogPositionStateParams & LogPositionCallbacks = () => { - const [getTime, setTime] = useKibanaTimefilterTime(TIME_DEFAULTS); - const { from: start, to: end } = getTime(); +const initialVisiblePositions: VisiblePositions = { + endKey: null, + middleKey: null, + startKey: null, + pagesBeforeStart: Infinity, + pagesAfterEnd: Infinity, +}; - const DEFAULT_DATE_RANGE = { - startDateExpression: start, - endDateExpression: end, - }; +export const createInitialLogPositionState = ({ + initialStateFromUrl, + initialStateFromTimefilter, + now, +}: InitialLogPositionArguments): LogPositionState => { + const nowTimestamp = now?.valueOf() ?? Date.now(); + + return pipe( + { + timeRange: { + expression: initialTimeRangeExpression, + lastChangedCompletely: nowTimestamp, + }, + timestamps: { + startTimestamp: datemathToEpochMillis(initialTimeRangeExpression.from, 'down', now) ?? 0, + endTimestamp: datemathToEpochMillis(initialTimeRangeExpression.to, 'up', now) ?? 0, + lastChangedTimestamp: nowTimestamp, + }, + refreshInterval: initialRefreshInterval, + targetPosition: null, + latestPosition: null, + visiblePositions: initialVisiblePositions, + }, + initialStateFromUrl != null + ? initializeStateFromUrlState(initialStateFromUrl, now) + : initialStateFromTimefilter != null + ? updateStateFromTimefilterState(initialStateFromTimefilter, now) + : identity + ); +}; - // Flag to determine if `LogPositionState` has been fully initialized. - // - // When the page loads, there might be initial state in the URL. We want to - // prevent the entries from showing until we have processed that initial - // state. That prevents double fetching. - const [isInitialized, setInitialized] = useState(false); - const initialize = useCallback(() => { - setInitialized(true); - }, [setInitialized]); - - const [targetPosition, jumpToTargetPosition] = useState(null); - const [isStreaming, setIsStreaming] = useState(false); - const [visiblePositions, reportVisiblePositions] = useState({ - endKey: null, - middleKey: null, - startKey: null, - pagesBeforeStart: Infinity, - pagesAfterEnd: Infinity, +export const createLogPositionStateContainer = (initialArguments: InitialLogPositionArguments) => + createStateContainer(createInitialLogPositionState(initialArguments), { + updateTimeRange: (state: LogPositionState) => (timeRange: Partial) => + updateTimeRange(timeRange)(state), + updateRefreshInterval: + (state: LogPositionState) => (refreshInterval: Partial) => + updateRefreshInterval(refreshInterval)(state), + startLiveStreaming: (state: LogPositionState) => () => + updateRefreshInterval({ pause: false })(state), + stopLiveStreaming: (state: LogPositionState) => () => + updateRefreshInterval({ pause: true })(state), + jumpToTargetPosition: (state: LogPositionState) => (targetPosition: TimeKey | null) => + updateTargetPosition(targetPosition)(state), + jumpToTargetPositionTime: (state: LogPositionState) => (time: number) => + updateTargetPosition({ time })(state), + reportVisiblePositions: (state: LogPositionState) => (visiblePositions: VisiblePositions) => + updateVisiblePositions(visiblePositions)(state), }); - // We group the `startDate` and `endDate` values in the same object to be able - // to set both at the same time, saving a re-render - const [dateRange, setDateRange] = useSetState({ - ...DEFAULT_DATE_RANGE, - startTimestamp: datemathToEpochMillis(DEFAULT_DATE_RANGE.startDateExpression)!, - endTimestamp: datemathToEpochMillis(DEFAULT_DATE_RANGE.endDateExpression, 'up')!, - timestampsLastUpdate: Date.now(), - lastCompleteDateRangeExpressionUpdate: Date.now(), - }); +/** + * Common updaters + */ - useEffect(() => { - if (isInitialized) { - if ( - TIME_DEFAULTS.from !== dateRange.startDateExpression || - TIME_DEFAULTS.to !== dateRange.endDateExpression - ) { - setTime({ from: dateRange.startDateExpression, to: dateRange.endDateExpression }); - } - } - }, [isInitialized, dateRange.startDateExpression, dateRange.endDateExpression, setTime]); +const updateVisiblePositions = (visiblePositions: VisiblePositions) => + produce((draftState) => { + draftState.visiblePositions = visiblePositions; - const { startKey, middleKey, endKey, pagesBeforeStart, pagesAfterEnd } = visiblePositions; + updateLatestPositionDraft(draftState); + }); - const visibleMidpoint = useVisibleMidpoint(middleKey, targetPosition); +const updateTargetPosition = (targetPosition: Partial | null) => + produce((draftState) => { + if (targetPosition?.time != null) { + draftState.targetPosition = { + time: targetPosition.time, + tiebreaker: targetPosition.tiebreaker ?? 0, + }; + } else { + draftState.targetPosition = null; + } - const visibleTimeInterval = useMemo( - () => (startKey && endKey ? { start: startKey.time, end: endKey.time } : null), - [startKey, endKey] - ); + updateLatestPositionDraft(draftState); + }); - // Allow setting `startDate` and `endDate` separately, or together - const updateDateRange = useCallback( - (newDateRange: Partial) => { - // Prevent unnecessary re-renders - if (!('startDateExpression' in newDateRange) && !('endDateExpression' in newDateRange)) { - return; - } +const updateLatestPositionDraft = (draftState: Draft) => { + const previousState = original(draftState); + const previousVisibleMiddleKey = previousState?.visiblePositions?.middleKey ?? null; + const previousTargetPosition = previousState?.targetPosition ?? null; - const nextStartDateExpression = - newDateRange.startDateExpression || dateRange.startDateExpression; - const nextEndDateExpression = newDateRange.endDateExpression || dateRange.endDateExpression; + if (!isSameTimeKey(previousVisibleMiddleKey, draftState.visiblePositions.middleKey)) { + draftState.latestPosition = draftState.visiblePositions.middleKey; + } else if (!isSameTimeKey(previousTargetPosition, draftState.targetPosition)) { + draftState.latestPosition = draftState.targetPosition; + } +}; - if (!isValidDatemath(nextStartDateExpression) || !isValidDatemath(nextEndDateExpression)) { - return; +const updateTimeRange = (timeRange: Partial, now?: Date) => + produce((draftState) => { + const newFrom = timeRange?.from; + const newTo = timeRange?.to; + const nowTimestamp = now?.valueOf() ?? Date.now(); + + // Update expression and timestamps + if (newFrom != null) { + draftState.timeRange.expression.from = newFrom; + const newStartTimestamp = datemathToEpochMillis(newFrom, 'down', now); + if (newStartTimestamp != null) { + draftState.timestamps.startTimestamp = newStartTimestamp; + draftState.timestamps.lastChangedTimestamp = nowTimestamp; } - - // Dates are valid, so the function cannot return `null` - const nextStartTimestamp = datemathToEpochMillis(nextStartDateExpression)!; - const nextEndTimestamp = datemathToEpochMillis(nextEndDateExpression, 'up')!; - - // Reset the target position if it doesn't fall within the new range. - if ( - targetPosition && - (nextStartTimestamp > targetPosition.time || nextEndTimestamp < targetPosition.time) - ) { - jumpToTargetPosition(null); + } + if (newTo != null) { + draftState.timeRange.expression.to = newTo; + const newEndTimestamp = datemathToEpochMillis(newTo, 'up', now); + if (newEndTimestamp != null) { + draftState.timestamps.endTimestamp = newEndTimestamp; + draftState.timestamps.lastChangedTimestamp = nowTimestamp; } - - setDateRange((prevState) => ({ - ...newDateRange, - startTimestamp: nextStartTimestamp, - endTimestamp: nextEndTimestamp, - timestampsLastUpdate: Date.now(), - // NOTE: Complete refers to the last time an update was requested with both expressions. These require a full refresh (unless streaming). Timerange expansion - // and pagination however do not. - lastCompleteDateRangeExpressionUpdate: - 'startDateExpression' in newDateRange && 'endDateExpression' in newDateRange - ? Date.now() - : prevState.lastCompleteDateRangeExpressionUpdate, - })); - }, - [setDateRange, dateRange, targetPosition] - ); - - // `endTimestamp` update conditions - useEffect(() => { - if (dateRange.endDateExpression !== 'now') { - return; } - - // User is close to the bottom edge of the scroll. - if (visiblePositions.pagesAfterEnd <= DESIRED_BUFFER_PAGES) { - setDateRange({ - endTimestamp: datemathToEpochMillis(dateRange.endDateExpression, 'up')!, - timestampsLastUpdate: Date.now(), - }); + if (newFrom != null && newTo != null) { + draftState.timeRange.lastChangedCompletely = nowTimestamp; } - }, [dateRange.endDateExpression, visiblePositions, setDateRange]); - const startLiveStreaming = useCallback(() => { - setIsStreaming(true); - jumpToTargetPosition(null); - updateDateRange({ startDateExpression: 'now-1d', endDateExpression: 'now' }); - }, [updateDateRange]); + // Reset the target position if it doesn't fall within the new range. + if ( + draftState.targetPosition != null && + (draftState.timestamps.startTimestamp > draftState.targetPosition.time || + draftState.timestamps.endTimestamp < draftState.targetPosition.time) + ) { + draftState.targetPosition = null; - const stopLiveStreaming = useCallback(() => { - setIsStreaming(false); - }, []); - - useInterval( - () => updateDateRange({ startDateExpression: 'now-1d', endDateExpression: 'now' }), - isStreaming ? STREAMING_INTERVAL : null - ); + updateLatestPositionDraft(draftState); + } + }); - const state = { - isInitialized, - targetPosition, - isStreaming, - firstVisiblePosition: startKey, - pagesBeforeStart, - pagesAfterEnd, - visibleMidpoint, - visibleMidpointTime: visibleMidpoint ? visibleMidpoint.time : null, - visibleTimeInterval, - ...dateRange, - }; +const updateRefreshInterval = + (refreshInterval: Partial) => (state: LogPositionState) => + pipe( + state, + produce((draftState) => { + if (refreshInterval.pause != null) { + draftState.refreshInterval.pause = refreshInterval.pause; + } + if (refreshInterval.value != null) { + draftState.refreshInterval.value = refreshInterval.value; + } + + if (!draftState.refreshInterval.pause) { + draftState.targetPosition = null; + + updateLatestPositionDraft(draftState); + } + }), + (currentState) => { + if (!currentState.refreshInterval.pause) { + return updateTimeRange(initialTimeRangeExpression)(currentState); + } else { + return currentState; + } + } + ); - const callbacks = { - initialize, - jumpToTargetPosition, - jumpToTargetPositionTime: useCallback( - (time: number) => jumpToTargetPosition({ tiebreaker: 0, time }), - [jumpToTargetPosition] - ), - reportVisiblePositions, - startLiveStreaming, - stopLiveStreaming, - updateDateRange, - }; +/** + * URL state helpers + */ - return { ...state, ...callbacks }; -}; +export const getUrlState = (state: LogPositionState): LogPositionUrlState => ({ + streamLive: !state.refreshInterval.pause, + start: state.timeRange.expression.from, + end: state.timeRange.expression.to, + position: state.latestPosition ? pickTimeKey(state.latestPosition) : null, +}); + +export const initializeStateFromUrlState = + (urlState: LogPositionUrlState | null, now?: Date) => + (state: LogPositionState): LogPositionState => + pipe( + state, + updateTargetPosition(urlState?.position ?? null), + updateTimeRange( + { + from: urlState?.start ?? getTimeRangeStartFromPosition(urlState?.position), + to: urlState?.end ?? getTimeRangeEndFromPosition(urlState?.position), + }, + now + ), + updateRefreshInterval({ pause: !urlState?.streamLive }) + ); + +export const updateStateFromUrlState = + (urlState: LogPositionUrlState | null, now?: Date) => + (state: LogPositionState): LogPositionState => + pipe( + state, + updateTargetPosition(urlState?.position ?? null), + updateTimeRange( + { + from: urlState?.start, + to: urlState?.end, + }, + now + ), + updateRefreshInterval({ pause: !urlState?.streamLive }) + ); + +/** + * Timefilter helpers + */ -export const [LogPositionStateProvider, useLogPositionStateContext] = - createContainer(useLogPositionState); +export const getTimefilterState = (state: LogPositionState): TimefilterState => ({ + timeRange: state.timeRange.expression, + refreshInterval: state.refreshInterval, +}); + +export const updateStateFromTimefilterState = + (timefilterState: TimefilterState | null, now?: Date) => + (state: LogPositionState): LogPositionState => + pipe( + state, + updateTimeRange( + { + from: timefilterState?.timeRange?.from, + to: timefilterState?.timeRange?.to, + }, + now + ), + updateRefreshInterval({ + pause: timefilterState?.refreshInterval?.pause, + value: Math.max(timefilterState?.refreshInterval?.value ?? 0, initialRefreshInterval.value), + }) + ); + +const defaultTimeRangeFromPositionOffset: DurationInputObject = { hours: 1 }; + +const getTimeRangeStartFromPosition = ( + position: Partial | null | undefined +): string | undefined => + position?.time != null + ? moment(position.time).subtract(defaultTimeRangeFromPositionOffset).toISOString() + : undefined; + +const getTimeRangeEndFromPosition = ( + position: Partial | null | undefined +): string | undefined => + position?.time != null + ? moment(position.time).add(defaultTimeRangeFromPositionOffset).toISOString() + : undefined; diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/log_position_timefilter_state.ts b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_timefilter_state.ts new file mode 100644 index 00000000000000..35c2a2f367c4e6 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_position/log_position_timefilter_state.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { INullableBaseStateContainer, syncState } from '@kbn/kibana-utils-plugin/public'; +import { useCallback, useState } from 'react'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import { + createTimefilterStateStorage, + TimefilterState, + timefilterStateStorageKey, +} from '../../../utils/timefilter_state_storage'; + +export const useLogPositionTimefilterStateSync = () => { + const { + services: { + data: { + query: { + timefilter: { timefilter }, + }, + }, + }, + } = useKibanaContextForPlugin(); + + const [timefilterStateStorage] = useState(() => createTimefilterStateStorage({ timefilter })); + + const [initialStateFromTimefilter] = useState(() => + timefilterStateStorage.get(timefilterStateStorageKey) + ); + + const startSyncingWithTimefilter = useCallback( + (stateContainer: INullableBaseStateContainer) => { + timefilterStateStorage.set(timefilterStateStorageKey, stateContainer.get()); + + const { start, stop } = syncState({ + storageKey: timefilterStateStorageKey, + stateContainer, + stateStorage: timefilterStateStorage, + }); + + start(); + + return stop; + }, + [timefilterStateStorage] + ); + + return { + initialStateFromTimefilter, + startSyncingWithTimefilter, + }; +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/replace_log_position_in_query_string.ts b/x-pack/plugins/infra/public/containers/logs/log_position/replace_log_position_in_query_string.ts new file mode 100644 index 00000000000000..e447c2c1436d29 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_position/replace_log_position_in_query_string.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { replaceStateKeyInQueryString } from '../../../utils/url_state'; +import { LogPositionUrlState, LOG_POSITION_URL_STATE_KEY } from './use_log_position_url_state_sync'; + +const ONE_HOUR = 3600000; + +export const replaceLogPositionInQueryString = (time: number) => + Number.isNaN(time) + ? (value: string) => value + : replaceStateKeyInQueryString(LOG_POSITION_URL_STATE_KEY, { + position: { + time, + tiebreaker: 0, + }, + end: new Date(time + ONE_HOUR).toISOString(), + start: new Date(time - ONE_HOUR).toISOString(), + streamLive: false, + }); diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/use_log_position.ts b/x-pack/plugins/infra/public/containers/logs/log_position/use_log_position.ts new file mode 100644 index 00000000000000..61e543b6b96eac --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_position/use_log_position.ts @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import createContainer from 'constate'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import useInterval from 'react-use/lib/useInterval'; +import useThrottle from 'react-use/lib/useThrottle'; +import { TimeKey } from '../../../../common/time'; +import { withReduxDevTools } from '../../../utils/state_container_devtools'; +import { TimefilterState } from '../../../utils/timefilter_state_storage'; +import { useObservableState } from '../../../utils/use_observable'; +import { wrapStateContainer } from '../../../utils/wrap_state_container'; +import { + createLogPositionStateContainer, + getTimefilterState, + getUrlState, + LogPositionState, + updateStateFromTimefilterState, + updateStateFromUrlState, +} from './log_position_state'; +import { useLogPositionTimefilterStateSync } from './log_position_timefilter_state'; +import { LogPositionUrlState, useLogPositionUrlStateSync } from './use_log_position_url_state_sync'; + +type TimeKeyOrNull = TimeKey | null; + +interface DateRange { + startDateExpression: string; + endDateExpression: string; + startTimestamp: number; + endTimestamp: number; + timestampsLastUpdate: number; + lastCompleteDateRangeExpressionUpdate: number; +} + +interface VisiblePositions { + startKey: TimeKeyOrNull; + middleKey: TimeKeyOrNull; + endKey: TimeKeyOrNull; + pagesAfterEnd: number; + pagesBeforeStart: number; +} + +export type LogPositionStateParams = DateRange & { + targetPosition: TimeKeyOrNull; + isStreaming: boolean; + firstVisiblePosition: TimeKeyOrNull; + pagesBeforeStart: number; + pagesAfterEnd: number; + visibleMidpoint: TimeKeyOrNull; + visibleMidpointTime: number | null; + visibleTimeInterval: { start: number; end: number } | null; +}; + +export interface LogPositionCallbacks { + jumpToTargetPosition: (pos: TimeKeyOrNull) => void; + jumpToTargetPositionTime: (time: number) => void; + reportVisiblePositions: (visPos: VisiblePositions) => void; + startLiveStreaming: () => void; + stopLiveStreaming: () => void; + updateDateRange: UpdateDateRangeFn; +} + +type UpdateDateRangeFn = ( + newDateRange: Partial> +) => void; + +const DESIRED_BUFFER_PAGES = 2; +const RELATIVE_END_UPDATE_DELAY = 1000; + +export const useLogPositionState: () => LogPositionStateParams & LogPositionCallbacks = () => { + const { initialStateFromUrl, startSyncingWithUrl } = useLogPositionUrlStateSync(); + const { initialStateFromTimefilter, startSyncingWithTimefilter } = + useLogPositionTimefilterStateSync(); + + const [logPositionStateContainer] = useState(() => + withReduxDevTools( + createLogPositionStateContainer({ + initialStateFromUrl, + initialStateFromTimefilter, + }), + { + name: 'logPosition', + } + ) + ); + + useEffect(() => { + return startSyncingWithUrl( + wrapStateContainer({ + wrapGet: getUrlState, + wrapSet: updateStateFromUrlState, + })(logPositionStateContainer) + ); + }, [logPositionStateContainer, startSyncingWithUrl]); + + useEffect(() => { + return startSyncingWithTimefilter( + wrapStateContainer({ + wrapGet: getTimefilterState, + wrapSet: updateStateFromTimefilterState, + })(logPositionStateContainer) + ); + }, [logPositionStateContainer, startSyncingWithTimefilter, startSyncingWithUrl]); + + const { latestValue: latestLogPositionState } = useObservableState( + logPositionStateContainer.state$, + () => logPositionStateContainer.get() + ); + + const dateRange = useMemo( + () => getLegacyDateRange(latestLogPositionState), + [latestLogPositionState] + ); + + const { targetPosition, visiblePositions } = latestLogPositionState; + + const isStreaming = useMemo( + () => !latestLogPositionState.refreshInterval.pause, + [latestLogPositionState] + ); + + const updateDateRange = useCallback( + (newDateRange: Partial>) => + logPositionStateContainer.transitions.updateTimeRange({ + from: newDateRange.startDateExpression, + to: newDateRange.endDateExpression, + }), + [logPositionStateContainer] + ); + + const visibleTimeInterval = useMemo( + () => + visiblePositions.startKey && visiblePositions.endKey + ? { start: visiblePositions.startKey.time, end: visiblePositions.endKey.time } + : null, + [visiblePositions.startKey, visiblePositions.endKey] + ); + + // `endTimestamp` update conditions + const throttledPagesAfterEnd = useThrottle( + visiblePositions.pagesAfterEnd, + RELATIVE_END_UPDATE_DELAY + ); + useEffect(() => { + if (dateRange.endDateExpression !== 'now') { + return; + } + + // User is close to the bottom edge of the scroll. + if (throttledPagesAfterEnd <= DESIRED_BUFFER_PAGES) { + logPositionStateContainer.transitions.updateTimeRange({ to: 'now' }); + } + }, [dateRange.endDateExpression, throttledPagesAfterEnd, logPositionStateContainer]); + + useInterval( + () => logPositionStateContainer.transitions.updateTimeRange({ from: 'now-1d', to: 'now' }), + latestLogPositionState.refreshInterval.pause || + latestLogPositionState.refreshInterval.value <= 0 + ? null + : latestLogPositionState.refreshInterval.value + ); + + return { + // position state + targetPosition, + isStreaming, + ...dateRange, + + // visible positions state + firstVisiblePosition: visiblePositions.startKey, + pagesBeforeStart: visiblePositions.pagesBeforeStart, + pagesAfterEnd: visiblePositions.pagesAfterEnd, + visibleMidpoint: latestLogPositionState.latestPosition, + visibleMidpointTime: latestLogPositionState.latestPosition?.time ?? null, + visibleTimeInterval, + + // actions + jumpToTargetPosition: logPositionStateContainer.transitions.jumpToTargetPosition, + jumpToTargetPositionTime: logPositionStateContainer.transitions.jumpToTargetPositionTime, + reportVisiblePositions: logPositionStateContainer.transitions.reportVisiblePositions, + startLiveStreaming: logPositionStateContainer.transitions.startLiveStreaming, + stopLiveStreaming: logPositionStateContainer.transitions.stopLiveStreaming, + updateDateRange, + }; +}; + +export const [LogPositionStateProvider, useLogPositionStateContext] = + createContainer(useLogPositionState); + +const getLegacyDateRange = (logPositionState: LogPositionState): DateRange => ({ + endDateExpression: logPositionState.timeRange.expression.to, + endTimestamp: logPositionState.timestamps.endTimestamp, + lastCompleteDateRangeExpressionUpdate: logPositionState.timeRange.lastChangedCompletely, + startDateExpression: logPositionState.timeRange.expression.from, + startTimestamp: logPositionState.timestamps.startTimestamp, + timestampsLastUpdate: logPositionState.timestamps.lastChangedTimestamp, +}); diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/use_log_position_url_state_sync.ts b/x-pack/plugins/infra/public/containers/logs/log_position/use_log_position_url_state_sync.ts new file mode 100644 index 00000000000000..b9e6a8a5b3eb6a --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_position/use_log_position_url_state_sync.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { INullableBaseStateContainer, syncState } from '@kbn/kibana-utils-plugin/public'; +import { getOrElseW } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as rt from 'io-ts'; +import { useCallback, useState } from 'react'; +import { map } from 'rxjs/operators'; +import { minimalTimeKeyRT } from '../../../../common/time'; +import { datemathStringRT } from '../../../utils/datemath'; +import { useKbnUrlStateStorageFromRouterContext } from '../../../utils/kbn_url_state_context'; + +export const logPositionUrlStateRT = rt.partial({ + streamLive: rt.boolean, + position: rt.union([rt.partial(minimalTimeKeyRT.props), rt.null]), + start: datemathStringRT, + end: datemathStringRT, +}); + +export type LogPositionUrlState = rt.TypeOf; + +export const LOG_POSITION_URL_STATE_KEY = 'logPosition'; + +export const useLogPositionUrlStateSync = () => { + const urlStateStorage = useKbnUrlStateStorageFromRouterContext(); + + const [initialStateFromUrl] = useState(() => + pipe( + logPositionUrlStateRT.decode(urlStateStorage.get(LOG_POSITION_URL_STATE_KEY)), + getOrElseW(() => null) + ) + ); + + const startSyncingWithUrl = useCallback( + (stateContainer: INullableBaseStateContainer) => { + if (initialStateFromUrl == null) { + urlStateStorage.set(LOG_POSITION_URL_STATE_KEY, stateContainer.get(), { + replace: true, + }); + } + + const { start, stop } = syncState({ + storageKey: LOG_POSITION_URL_STATE_KEY, + stateContainer: { + state$: stateContainer.state$.pipe(map(logPositionUrlStateRT.encode)), + set: (value) => + stateContainer.set( + pipe( + logPositionUrlStateRT.decode(value), + getOrElseW(() => null) + ) + ), + get: () => logPositionUrlStateRT.encode(stateContainer.get()), + }, + stateStorage: { + ...urlStateStorage, + set: (key: string, state: State) => + urlStateStorage.set(key, state, { replace: true }), + }, + }); + + start(); + + return stop; + }, + [initialStateFromUrl, urlStateStorage] + ); + + return { + initialStateFromUrl, + startSyncingWithUrl, + }; +}; diff --git a/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx b/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx deleted file mode 100644 index d4d8075a2598fa..00000000000000 --- a/x-pack/plugins/infra/public/containers/logs/log_position/with_log_position_url_state.tsx +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useMemo } from 'react'; - -import { pickTimeKey } from '../../../../common/time'; -import { replaceStateKeyInQueryString, UrlStateContainer } from '../../../utils/url_state'; -import { useLogPositionStateContext, LogPositionStateParams } from './log_position_state'; -import { isValidDatemath, datemathToEpochMillis } from '../../../utils/datemath'; - -/** - * Url State - */ -export interface LogPositionUrlState { - position?: LogPositionStateParams['visibleMidpoint']; - streamLive: boolean; - start?: string; - end?: string; -} - -const ONE_HOUR = 3600000; - -export const WithLogPositionUrlState = () => { - const { - visibleMidpoint, - isStreaming, - jumpToTargetPosition, - startLiveStreaming, - stopLiveStreaming, - startDateExpression, - endDateExpression, - updateDateRange, - initialize, - } = useLogPositionStateContext(); - const urlState = useMemo( - () => ({ - position: visibleMidpoint ? pickTimeKey(visibleMidpoint) : null, - streamLive: isStreaming, - start: startDateExpression, - end: endDateExpression, - }), - [visibleMidpoint, isStreaming, startDateExpression, endDateExpression] - ); - return ( - { - if (!newUrlState) { - return; - } - - if (newUrlState.start || newUrlState.end) { - updateDateRange({ - startDateExpression: newUrlState.start, - endDateExpression: newUrlState.end, - }); - } - - if (newUrlState.position) { - jumpToTargetPosition(newUrlState.position); - } - - if (newUrlState.streamLive) { - startLiveStreaming(); - } else if (typeof newUrlState.streamLive !== 'undefined' && !newUrlState.streamLive) { - stopLiveStreaming(); - } - }} - onInitialize={(initialUrlState: LogPositionUrlState | undefined) => { - if (initialUrlState) { - const initialPosition = initialUrlState.position; - let initialStartDateExpression = initialUrlState.start; - let initialEndDateExpression = initialUrlState.end; - - if (!initialPosition) { - initialStartDateExpression = initialStartDateExpression || 'now-1d'; - initialEndDateExpression = initialEndDateExpression || 'now'; - } else { - const initialStartTimestamp = initialStartDateExpression - ? datemathToEpochMillis(initialStartDateExpression) - : undefined; - const initialEndTimestamp = initialEndDateExpression - ? datemathToEpochMillis(initialEndDateExpression, 'up') - : undefined; - - // Adjust the start-end range if the target position falls outside or if it's not set. - if (!initialStartTimestamp || initialStartTimestamp > initialPosition.time) { - initialStartDateExpression = new Date(initialPosition.time - ONE_HOUR).toISOString(); - } - - if (!initialEndTimestamp || initialEndTimestamp < initialPosition.time) { - initialEndDateExpression = new Date(initialPosition.time + ONE_HOUR).toISOString(); - } - - jumpToTargetPosition(initialPosition); - } - - if (initialStartDateExpression || initialEndDateExpression) { - updateDateRange({ - startDateExpression: initialStartDateExpression, - endDateExpression: initialEndDateExpression, - }); - } - - if (initialUrlState.streamLive) { - startLiveStreaming(); - } - } - - initialize(); - }} - /> - ); -}; - -const mapToUrlState = (value: any): LogPositionUrlState | undefined => - value - ? { - position: mapToPositionUrlState(value.position), - streamLive: mapToStreamLiveUrlState(value.streamLive), - start: mapToDate(value.start), - end: mapToDate(value.end), - } - : undefined; - -const mapToPositionUrlState = (value: any) => - value && typeof value.time === 'number' && typeof value.tiebreaker === 'number' - ? pickTimeKey(value) - : undefined; - -const mapToStreamLiveUrlState = (value: any) => (typeof value === 'boolean' ? value : false); - -const mapToDate = (value: any) => (isValidDatemath(value) ? value : undefined); -export const replaceLogPositionInQueryString = (time: number) => - Number.isNaN(time) - ? (value: string) => value - : replaceStateKeyInQueryString('logPosition', { - position: { - time, - tiebreaker: 0, - }, - end: new Date(time + ONE_HOUR).toISOString(), - start: new Date(time - ONE_HOUR).toISOString(), - streamLive: false, - }); diff --git a/x-pack/plugins/infra/public/pages/logs/stream/components/stream_live_button.tsx b/x-pack/plugins/infra/public/pages/logs/stream/components/stream_live_button.tsx new file mode 100644 index 00000000000000..d97a929aa3a160 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/stream/components/stream_live_button.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +export const StreamLiveButton: React.FC<{ + isStreaming: boolean; + onStartStreaming: () => void; + onStopStreaming: () => void; +}> = ({ isStreaming, onStartStreaming, onStopStreaming }) => + isStreaming ? ( + + + + ) : ( + + + + ); diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx index 026119ff5c74c1..9a726152d9f7c2 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_providers.tsx @@ -16,7 +16,6 @@ import { LogHighlightsStateProvider } from '../../../containers/logs/log_highlig import { LogPositionStateProvider, useLogPositionStateContext, - WithLogPositionUrlState, } from '../../../containers/logs/log_position'; import { LogStreamProvider, useLogStreamContext } from '../../../containers/logs/log_stream'; import { LogViewConfigurationProvider } from '../../../containers/logs/log_view_configuration'; @@ -55,8 +54,7 @@ const ViewLogInContext: React.FC = ({ children }) => { const LogEntriesStateProvider: React.FC = ({ children }) => { const { logViewId } = useLogViewContext(); - const { startTimestamp, endTimestamp, targetPosition, isInitialized } = - useLogPositionStateContext(); + const { startTimestamp, endTimestamp, targetPosition } = useLogPositionStateContext(); const { filterQuery } = useLogFilterStateContext(); // Don't render anything if the date range is incorrect. @@ -64,12 +62,6 @@ const LogEntriesStateProvider: React.FC = ({ children }) => { return null; } - // Don't initialize the entries until the position has been fully intialized. - // See `` - if (!isInitialized) { - return null; - } - return ( { - diff --git a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx index cf30518f78ede3..36c2349b471fdb 100644 --- a/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx +++ b/x-pack/plugins/infra/public/pages/logs/stream/page_toolbar.tsx @@ -5,20 +5,19 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; -import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; +import React, { useMemo } from 'react'; import { LogCustomizationMenu } from '../../../components/logging/log_customization_menu'; -import { LogDatepicker } from '../../../components/logging/log_datepicker'; import { LogHighlightsMenu } from '../../../components/logging/log_highlights_menu'; import { LogTextScaleControls } from '../../../components/logging/log_text_scale_controls'; import { LogTextWrapControls } from '../../../components/logging/log_text_wrap_controls'; import { useLogHighlightsStateContext } from '../../../containers/logs/log_highlights/log_highlights'; import { useLogPositionStateContext } from '../../../containers/logs/log_position'; import { useLogViewConfigurationContext } from '../../../containers/logs/log_view_configuration'; +import { useKibanaContextForPlugin } from '../../../hooks/use_kibana'; import { useLogViewContext } from '../../../hooks/use_log_view'; +import { StreamLiveButton } from './components/stream_live_button'; export const LogsToolbar = () => { const { derivedDataView } = useLogViewContext(); @@ -39,83 +38,72 @@ export const LogsToolbar = () => { goToPreviousHighlight, goToNextHighlight, } = useLogHighlightsStateContext(); - const { - isStreaming, - startLiveStreaming, - stopLiveStreaming, - startDateExpression, - endDateExpression, - updateDateRange, - } = useLogPositionStateContext(); + const { isStreaming, startLiveStreaming, stopLiveStreaming } = useLogPositionStateContext(); + + const dataViews = useMemo( + () => (derivedDataView != null ? [derivedDataView] : undefined), + [derivedDataView] + ); return ( -

+ ); }; - -const QueryBarFlexItem = euiStyled(EuiFlexItem)` - @media (min-width: 1200px) { - flex: 0 0 100% !important; - margin-left: 0 !important; - margin-right: 0 !important; - padding-left: 12px; - padding-right: 12px; - } -`; diff --git a/x-pack/plugins/infra/public/utils/datemath.ts b/x-pack/plugins/infra/public/utils/datemath.ts index 2ed4f68b7a934d..68c77e3c0e7ed6 100644 --- a/x-pack/plugins/infra/public/utils/datemath.ts +++ b/x-pack/plugins/infra/public/utils/datemath.ts @@ -6,6 +6,9 @@ */ import dateMath, { Unit } from '@kbn/datemath'; +import { chain } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as rt from 'io-ts'; const JS_MAX_DATE = 8640000000000000; @@ -14,8 +17,25 @@ export function isValidDatemath(value: string): boolean { return !!(parsedValue && parsedValue.isValid()); } -export function datemathToEpochMillis(value: string, round: 'down' | 'up' = 'down'): number | null { - const parsedValue = dateMath.parse(value, { roundUp: round === 'up' }); +export const datemathStringRT = new rt.Type( + 'datemath', + rt.string.is, + (value, context) => + pipe( + rt.string.validate(value, context), + chain((stringValue) => + isValidDatemath(stringValue) ? rt.success(stringValue) : rt.failure(stringValue, context) + ) + ), + String +); + +export function datemathToEpochMillis( + value: string, + round: 'down' | 'up' = 'down', + forceNow?: Date +): number | null { + const parsedValue = dateMath.parse(value, { roundUp: round === 'up', forceNow }); if (!parsedValue || !parsedValue.isValid()) { return null; } diff --git a/x-pack/plugins/infra/public/utils/kbn_url_state_context.ts b/x-pack/plugins/infra/public/utils/kbn_url_state_context.ts new file mode 100644 index 00000000000000..7a751e30f4082e --- /dev/null +++ b/x-pack/plugins/infra/public/utils/kbn_url_state_context.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IToasts } from '@kbn/core-notifications-browser'; +import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public'; +import createContainer from 'constate'; +import { History } from 'history'; +import { useState } from 'react'; + +const useKbnUrlStateStorageFromRouter = ({ + history, + toastsService, +}: { + history: History; + toastsService: IToasts; +}) => { + const [urlStateStorage] = useState(() => + createKbnUrlStateStorage({ + history, + useHash: false, + useHashQuery: false, + ...withNotifyOnErrors(toastsService), + }) + ); + + return urlStateStorage; +}; + +export const [KbnUrlStateStorageFromRouterProvider, useKbnUrlStateStorageFromRouterContext] = + createContainer(useKbnUrlStateStorageFromRouter); diff --git a/x-pack/plugins/infra/public/utils/state_container_devtools.ts b/x-pack/plugins/infra/public/utils/state_container_devtools.ts new file mode 100644 index 00000000000000..c68936eca7921e --- /dev/null +++ b/x-pack/plugins/infra/public/utils/state_container_devtools.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ReduxLikeStateContainer } from '@kbn/kibana-utils-plugin/public'; +import { EnhancerOptions } from 'redux-devtools-extension'; + +export const withReduxDevTools = >( + stateContainer: StateContainer, + config?: EnhancerOptions +): StateContainer => { + if (process.env.NODE_ENV !== 'production' && (window as any).__REDUX_DEVTOOLS_EXTENSION__) { + const devToolsExtension = (window as any).__REDUX_DEVTOOLS_EXTENSION__; + + const devToolsInstance = devToolsExtension.connect({ + ...config, + serialize: { + ...(typeof config?.serialize === 'object' ? config.serialize : {}), + replacer: (_key: string, value: unknown) => replaceReactSyntheticEvent(value), + }, + features: { + lock: false, + persist: false, + import: false, + jump: false, + skip: false, + reorder: false, + dispatch: false, + ...config?.features, + }, + }); + + devToolsInstance.init(stateContainer.getState()); + + stateContainer.addMiddleware(({ getState }) => (next) => (action) => { + devToolsInstance.send(action, getState()); + return next(action); + }); + } + + return stateContainer; +}; + +const isReactSyntheticEvent = (value: unknown) => + typeof value === 'object' && value != null && (value as any).nativeEvent instanceof Event; + +const replaceReactSyntheticEvent = (value: unknown) => + isReactSyntheticEvent(value) ? '[ReactSyntheticEvent]' : value; diff --git a/x-pack/plugins/infra/public/utils/timefilter_state_storage.ts b/x-pack/plugins/infra/public/utils/timefilter_state_storage.ts new file mode 100644 index 00000000000000..d990ec81649c1b --- /dev/null +++ b/x-pack/plugins/infra/public/utils/timefilter_state_storage.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RefreshInterval, TimefilterContract } from '@kbn/data-plugin/public'; +import { TimeRange } from '@kbn/es-query'; +import { IStateStorage } from '@kbn/kibana-utils-plugin/public'; +import { map, merge, Observable, of } from 'rxjs'; + +export const timefilterStateStorageKey = 'timefilter'; +type TimefilterStateStorageKey = typeof timefilterStateStorageKey; + +interface ITimefilterStateStorage extends IStateStorage { + set(key: TimefilterStateStorageKey, state: TimefilterState): void; + set(key: string, state: State): void; + get(key: TimefilterStateStorageKey): TimefilterState | null; + get(key: string): State | null; + change$(key: TimefilterStateStorageKey): Observable; + change$(key: string): Observable; +} + +export interface TimefilterState { + timeRange?: TimeRange; + refreshInterval?: RefreshInterval; +} + +export const createTimefilterStateStorage = ({ + timefilter, +}: { + timefilter: TimefilterContract; +}): ITimefilterStateStorage => { + return { + set: (key, state) => { + if (key !== timefilterStateStorageKey) { + return; + } + + // TS doesn't narrow the overload arguments correctly + const { timeRange, refreshInterval } = state as TimefilterState; + + if (timeRange != null) { + timefilter.setTime(timeRange); + } + if (refreshInterval != null) { + timefilter.setRefreshInterval(refreshInterval); + } + }, + get: (key) => (key === timefilterStateStorageKey ? getTimefilterState(timefilter) : null), + change$: (key) => + key === timefilterStateStorageKey + ? merge(timefilter.getTimeUpdate$(), timefilter.getRefreshIntervalUpdate$()).pipe( + map(() => getTimefilterState(timefilter)) + ) + : of(null), + }; +}; + +const getTimefilterState = (timefilter: TimefilterContract): TimefilterState => ({ + timeRange: timefilter.getTime(), + refreshInterval: timefilter.getRefreshInterval(), +}); diff --git a/x-pack/plugins/infra/public/utils/wrap_state_container.ts b/x-pack/plugins/infra/public/utils/wrap_state_container.ts new file mode 100644 index 00000000000000..d6ec59ded314b0 --- /dev/null +++ b/x-pack/plugins/infra/public/utils/wrap_state_container.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BaseState, BaseStateContainer } from '@kbn/kibana-utils-plugin/public'; +import { map } from 'rxjs/operators'; + +export const wrapStateContainer = + ({ + wrapSet, + wrapGet, + }: { + wrapSet: (state: StateB | null) => (previousState: StateA) => StateA; + wrapGet: (state: StateA) => StateB; + }) => + (stateContainer: BaseStateContainer) => ({ + get: () => wrapGet(stateContainer.get()), + set: (value: StateB | null) => stateContainer.set(wrapSet(value)(stateContainer.get())), + state$: stateContainer.state$.pipe(map(wrapGet)), + }); diff --git a/x-pack/plugins/observability/public/components/shared/page_template/page_template.tsx b/x-pack/plugins/observability/public/components/shared/page_template/page_template.tsx index aad9aee7af23f4..47a6649e098cd7 100644 --- a/x-pack/plugins/observability/public/components/shared/page_template/page_template.tsx +++ b/x-pack/plugins/observability/public/components/shared/page_template/page_template.tsx @@ -98,6 +98,7 @@ export function ObservabilityPageTemplate({ strict: !entry.ignoreTrailingSlash, }) != null); const badgeLocalStorageId = `observability.nav_item_badge_visible_${entry.app}${entry.path}`; + const navId = entry.label.toLowerCase().split(' ').join('_'); return { id: `${sectionIndex}.${entryIndex}`, name: entry.isNewFeature ? ( @@ -107,7 +108,8 @@ export function ObservabilityPageTemplate({ ), href, isSelected, - 'data-nav-id': entry.label.toLowerCase().split(' ').join('_'), + 'data-nav-id': navId, + 'data-test-subj': `observability-nav-${entry.app}-${navId}`, onClick: (event) => { if (entry.onClick) { entry.onClick(event); diff --git a/x-pack/test/functional/apps/infra/logs_source_configuration.ts b/x-pack/test/functional/apps/infra/logs_source_configuration.ts index 38cc795034a22e..24b7a538d77f65 100644 --- a/x-pack/test/functional/apps/infra/logs_source_configuration.ts +++ b/x-pack/test/functional/apps/infra/logs_source_configuration.ts @@ -54,9 +54,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.infraLogs.navigateToTab('settings'); await pageObjects.header.waitUntilLoadingHasFinished(); - const documentTitle = await browser.getTitle(); - expect(documentTitle).to.contain('Settings - Logs - Observability - Elastic'); + retry.try(async () => { + const documentTitle = await browser.getTitle(); + expect(documentTitle).to.contain('Settings - Logs - Observability - Elastic'); + }); }); it('can change the log indices to a pattern that matches nothing', async () => { diff --git a/x-pack/test/functional/page_objects/infra_logs_page.ts b/x-pack/test/functional/page_objects/infra_logs_page.ts index c1d20c2e977adb..0bcbff031005c4 100644 --- a/x-pack/test/functional/page_objects/infra_logs_page.ts +++ b/x-pack/test/functional/page_objects/infra_logs_page.ts @@ -5,11 +5,10 @@ * 2.0. */ -// import moment from 'moment'; +import { FlyoutOptionsUrlState } from '@kbn/infra-plugin/public/containers/logs/log_flyout'; +import { LogPositionUrlState } from '@kbn/infra-plugin/public/containers/logs/log_position'; import querystring from 'querystring'; import { encode, RisonValue } from 'rison-node'; -import { LogPositionUrlState } from '@kbn/infra-plugin/public/containers/logs/log_position/with_log_position_url_state'; -import { FlyoutOptionsUrlState } from '@kbn/infra-plugin/public/containers/logs/log_flyout'; import { FtrProviderContext } from '../ftr_provider_context'; export interface TabsParams { diff --git a/x-pack/test/functional/page_objects/observability_page.ts b/x-pack/test/functional/page_objects/observability_page.ts index 07cceca4be1229..0177939ec3d15b 100644 --- a/x-pack/test/functional/page_objects/observability_page.ts +++ b/x-pack/test/functional/page_objects/observability_page.ts @@ -13,6 +13,10 @@ export function ObservabilityPageProvider({ getService, getPageObjects }: FtrPro const testSubjects = getService('testSubjects'); return { + async clickSolutionNavigationEntry(appId: string, navId: string) { + await testSubjects.click(`observability-nav-${appId}-${navId}`); + }, + async expectCreateCaseButtonEnabled() { const button = await testSubjects.find('createNewCaseBtn', 20000); const disabledAttr = await button.getAttribute('disabled'); @@ -54,5 +58,10 @@ export function ObservabilityPageProvider({ getService, getPageObjects }: FtrPro const text = await h2.getVisibleText(); expect(text).to.contain('Kibana feature privileges required'); }, + + async getDatePickerRangeText() { + const datePickerButton = await testSubjects.find('superDatePickerShowDatesButton'); + return await datePickerButton.getVisibleText(); + }, }; } diff --git a/x-pack/test/observability_functional/apps/observability/pages/alerts/state_synchronization.ts b/x-pack/test/observability_functional/apps/observability/pages/alerts/state_synchronization.ts index fe9751dc9c738f..8f4bcbb237620a 100644 --- a/x-pack/test/observability_functional/apps/observability/pages/alerts/state_synchronization.ts +++ b/x-pack/test/observability_functional/apps/observability/pages/alerts/state_synchronization.ts @@ -17,7 +17,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const find = getService('find'); const testSubjects = getService('testSubjects'); const observability = getService('observability'); - const pageObjects = getPageObjects(['common']); + const pageObjects = getPageObjects(['common', 'observability', 'timePicker']); before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/observability/alerts'); @@ -45,9 +45,14 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('should not sync URL state to shared time range on page load ', async () => { - await (await find.byLinkText('Stream')).click(); + await pageObjects.observability.clickSolutionNavigationEntry( + 'observability-overview', + 'overview' + ); + + const observabilityPageDateRange = await pageObjects.observability.getDatePickerRangeText(); - await assertLogsStreamPageTimeRange('Last 1 day'); + expect(observabilityPageDateRange).to.be('Last 15 minutes'); }); it('should apply defaults if URL state is missing', async () => { @@ -61,18 +66,29 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); it('should use shared time range if set', async () => { - await (await find.byLinkText('Stream')).click(); + await pageObjects.observability.clickSolutionNavigationEntry( + 'observability-overview', + 'overview' + ); await setTimeRangeToXDaysAgo(10); - await (await find.byLinkText('Alerts')).click(); + await pageObjects.observability.clickSolutionNavigationEntry( + 'observability-overview', + 'alerts' + ); expect(await observability.alerts.common.getTimeRange()).to.be('Last 10 days'); }); it('should set the shared time range', async () => { await setTimeRangeToXDaysAgo(100); - await (await find.byLinkText('Stream')).click(); + await pageObjects.observability.clickSolutionNavigationEntry( + 'observability-overview', + 'overview' + ); + + const observabilityPageDateRange = await pageObjects.observability.getDatePickerRangeText(); - await assertLogsStreamPageTimeRange('Last 100 days'); + expect(observabilityPageDateRange).to.be('Last 100 days'); }); async function assertAlertsPageState(expected: { @@ -90,18 +106,13 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(timeRange).to.be(expected.timeRange); } - async function assertLogsStreamPageTimeRange(expected: string) { - // Only handles relative time ranges - const datePickerButton = await testSubjects.find('superDatePickerShowDatesButton'); - const timerange = await datePickerButton.getVisibleText(); - expect(timerange).to.be(expected); - } - async function setTimeRangeToXDaysAgo(numberOfDays: number) { await (await testSubjects.find('superDatePickerToggleQuickMenuButton')).click(); - const numerOfDaysField = await find.byCssSelector('[aria-label="Time value"]'); - await numerOfDaysField.clearValueWithKeyboard(); - await numerOfDaysField.type(numberOfDays.toString()); + const numberField = await find.byCssSelector('[aria-label="Time value"]'); + await numberField.clearValueWithKeyboard(); + await numberField.type(numberOfDays.toString()); + const unitField = await find.byCssSelector('[aria-label="Time unit"]'); + await unitField.type('Days'); await find.clickByButtonText('Apply'); } }); From 64c3c63e21009719a99e5cebe47859d0c63ce93b Mon Sep 17 00:00:00 2001 From: Kaarina Tungseth Date: Wed, 9 Nov 2022 12:00:25 -0600 Subject: [PATCH 13/18] [Lens] Updates the UI copy for Random sampling (#144265) ## Summary Updates the Random sampling copy. Original PR: #143929 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../lens/public/datasources/form_based/layer_settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/lens/public/datasources/form_based/layer_settings.tsx b/x-pack/plugins/lens/public/datasources/form_based/layer_settings.tsx index ec161ef9967371..566d381ba9a4c9 100644 --- a/x-pack/plugins/lens/public/datasources/form_based/layer_settings.tsx +++ b/x-pack/plugins/lens/public/datasources/form_based/layer_settings.tsx @@ -42,7 +42,7 @@ export function LayerSettingsPanel({

Date: Wed, 9 Nov 2022 19:27:36 +0100 Subject: [PATCH 14/18] [Profiling] Small improvements to the differential function view (#144824) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improved the visualization of data in profiling by showing more details and using more nicely formatted numbers. Fixes https://github.com/elastic/prodfiler/issues/2772 Co-authored-by: Tim Rühsen --- .../public/components/topn_functions.tsx | 160 +++++++++++++----- 1 file changed, 122 insertions(+), 38 deletions(-) diff --git a/x-pack/plugins/profiling/public/components/topn_functions.tsx b/x-pack/plugins/profiling/public/components/topn_functions.tsx index 151fcd501931cd..30d7ede63140a9 100644 --- a/x-pack/plugins/profiling/public/components/topn_functions.tsx +++ b/x-pack/plugins/profiling/public/components/topn_functions.tsx @@ -32,19 +32,100 @@ interface Row { inclusiveCPU: number; diff?: { rank: number; + samples: number; exclusiveCPU: number; inclusiveCPU: number; }; } +function getColorLabel(percent: number) { + const color = percent < 0 ? 'success' : 'danger'; + const prefix = percent < 0 ? '-' : '+'; + const label = + Math.abs(percent) <= 0.01 ? '<0.01' : ' ' + prefix + Math.abs(percent).toFixed(2) + '%'; + + return [color, label] as const; +} + +function TotalSamplesStat({ + totalSamples, + newSamples, +}: { + totalSamples: number; + newSamples: number | undefined; +}) { + const sampleHeader = i18n.translate('xpack.profiling.functionsView.totalSampleCountLabel', { + defaultMessage: ' Total sample estimate: ', + }); + + if (newSamples === undefined || newSamples === 0) { + return ( + + {sampleHeader} + {' ' + totalSamples.toLocaleString()} + + ); + } + + const diffSamples = totalSamples - newSamples; + const percentDelta = (diffSamples / (totalSamples - diffSamples)) * 100; + const [color, label] = getColorLabel(percentDelta); + + return ( + + {sampleHeader} + {' ' + totalSamples.toLocaleString() + ' '} + ({label}) + + ); +} + +function SampleStat({ + samples, + diffSamples, + totalSamples, +}: { + samples: number; + diffSamples: number | undefined; + totalSamples: number; +}) { + const samplesLabel = `${samples.toLocaleString()}`; + + if (diffSamples === undefined || diffSamples === 0 || totalSamples === 0) { + return <>{samplesLabel}; + } + + const percentDelta = (diffSamples / (samples - diffSamples)) * 100; + const [color, label] = getColorLabel(percentDelta); + + const totalPercentDelta = (diffSamples / totalSamples) * 100; + const [totalColor, totalLabel] = getColorLabel(totalPercentDelta); + + return ( + + {samplesLabel} + + + {label} rel + + + + + {totalLabel} abs + + + + ); +} + function CPUStat({ cpu, diffCPU }: { cpu: number; diffCPU: number | undefined }) { const cpuLabel = `${cpu.toFixed(2)}%`; if (diffCPU === undefined || diffCPU === 0) { return <>{cpuLabel}; } - const color = diffCPU < 0 ? 'success' : 'danger'; - const label = Math.abs(diffCPU) <= 0.01 ? '<0.01' : Math.abs(diffCPU).toFixed(2); + + const [color, label] = getColorLabel(diffCPU); return ( @@ -58,18 +139,6 @@ function CPUStat({ cpu, diffCPU }: { cpu: number; diffCPU: number | undefined }) ); } -function TotalDiff({ samples1, samples2 }: { samples1: number; samples2: number }) { - if (samples1 === samples2 || samples1 === 0) { - return <>; - } - - const diff = Math.abs(1 - samples2 / samples1) * 100; - const text = (samples1 < samples2 ? '+' : '-') + `${diff.toFixed(2)}%`; - const color = samples1 < samples2 ? 'danger' : 'success'; - - return ({text}); -} - export const TopNFunctionsTable = ({ sortDirection, sortField, @@ -87,7 +156,7 @@ export const TopNFunctionsTable = ({ comparisonTopNFunctions?: TopNFunctions; }) => { const totalCount: number = useMemo(() => { - if (!topNFunctions || !topNFunctions.TotalCount || topNFunctions.TotalCount === 0) { + if (!topNFunctions || !topNFunctions.TotalCount) { return 0; } @@ -113,6 +182,7 @@ export const TopNFunctionsTable = ({ comparisonTopNFunctions && comparisonRow ? { rank: topN.Rank - comparisonRow.Rank, + samples: topN.CountExclusive - comparisonRow.CountExclusive, exclusiveCPU: exclusiveCPU - (comparisonRow.CountExclusive / comparisonTopNFunctions.TotalCount) * 100, @@ -154,18 +224,31 @@ export const TopNFunctionsTable = ({ { field: TopNFunctionSortField.Samples, name: i18n.translate('xpack.profiling.functionsView.samplesColumnLabel', { - defaultMessage: 'Samples', + defaultMessage: 'Samples (estd.)', }), align: 'right', - render: (_, { samples }) => { - return {samples}; + render: (_, { samples, diff }) => { + return ( + + ); }, }, { field: TopNFunctionSortField.ExclusiveCPU, - name: i18n.translate('xpack.profiling.functionsView.exclusiveCpuColumnLabel', { - defaultMessage: 'Exclusive CPU', - }), + name: ( + + + {i18n.translate('xpack.profiling.functionsView.cpuColumnLabel1Exclusive', { + defaultMessage: 'CPU excl.', + })} + + + {i18n.translate('xpack.profiling.functionsView.cpuColumnLabel2Exclusive', { + defaultMessage: 'subfunctions', + })} + + + ), render: (_, { exclusiveCPU, diff }) => { return ; }, @@ -173,9 +256,20 @@ export const TopNFunctionsTable = ({ }, { field: TopNFunctionSortField.InclusiveCPU, - name: i18n.translate('xpack.profiling.functionsView.inclusiveCpuColumnLabel', { - defaultMessage: 'Inclusive CPU', - }), + name: ( + + + {i18n.translate('xpack.profiling.functionsView.cpuColumnLabel1Inclusive', { + defaultMessage: 'CPU incl.', + })} + + + {i18n.translate('xpack.profiling.functionsView.cpuColumnLabel2Inclusive', { + defaultMessage: 'subfunctions', + })} + + + ), render: (_, { inclusiveCPU, diff }) => { return ; }, @@ -220,13 +314,6 @@ export const TopNFunctionsTable = ({ }); } - const totalSampleCountLabel = i18n.translate( - 'xpack.profiling.functionsView.totalSampleCountLabel', - { - defaultMessage: 'Total sample count', - } - ); - const sortedRows = orderBy( rows, (row) => { @@ -239,13 +326,10 @@ export const TopNFunctionsTable = ({ return ( <> - - {totalSampleCountLabel}: {totalCount} - {TotalDiff({ - samples1: comparisonTopNFunctions?.TotalCount ?? 0, - samples2: totalCount, - })} - + Date: Wed, 9 Nov 2022 20:46:56 +0100 Subject: [PATCH 15/18] Update dependency react-hook-form to ^7.39.0 (main) (#144874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Patryk Kopyciński --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 5ea68680babb41..44a3d668b6a62b 100644 --- a/package.json +++ b/package.json @@ -604,7 +604,7 @@ "react-fast-compare": "^2.0.4", "react-focus-on": "^3.6.0", "react-grid-layout": "^1.3.4", - "react-hook-form": "^7.38.0", + "react-hook-form": "^7.39.1", "react-intl": "^2.8.0", "react-is": "^17.0.2", "react-markdown": "^6.0.3", diff --git a/yarn.lock b/yarn.lock index 2b5cfe80382f1a..f618ddaf5732e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22335,10 +22335,10 @@ react-grid-layout@^1.3.4: react-draggable "^4.0.0" react-resizable "^3.0.4" -react-hook-form@^7.38.0: - version "7.38.0" - resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.38.0.tgz#53d6a68df587ce4ce88352f63e0ecc7fc8779320" - integrity sha512-gxWW1kMeru9xR1GoR+Iw4hA+JBOM3SHfr4DWCUKY0xc7Vv1MLsF109oHtBeWl9shcyPFx67KHru44DheN0XY5A== +react-hook-form@^7.39.1: + version "7.39.1" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.39.1.tgz#ded87d4b3f6692d1f9219515f78ca282b6e1ebf7" + integrity sha512-MiF9PCILN5KulhSGbnjohMiTOrB47GerDTichMNP0y2cPUu1GTRFqbunOxCE9N1499YTLMV/ne4gFzqCp1rxrQ== react-input-autosize@^3.0.0: version "3.0.0" From f7758e0ada6dd716dd83d9ca21ecebbd18afba40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20R=C3=BChsen?= Date: Wed, 9 Nov 2022 20:58:35 +0100 Subject: [PATCH 16/18] [Profiling] Improve the differential flamegraph tooltip (#144895) Improve the root tooltip for the differential flamegraph: - remove the superfluous CPU usages (it's always 100% inclusive and 0% exclusive) - add the change rate of the samples as colored text **Before** ![Screenshot_20221109_130516](https://user-images.githubusercontent.com/2087964/200828112-045196e2-88cb-4f5c-906a-4e61d73be9c7.png) **After** ![Screenshot_20221109_125746](https://user-images.githubusercontent.com/2087964/200828152-cb2f0cc7-28cc-4e54-800f-d5c9df0448c4.png) Improve the non-root tooltips for the differential flamegraph: - remove the 'no change' label if there is no change - add the change rate of the samples as colored text **Before** ![Screenshot_20221109_130600](https://user-images.githubusercontent.com/2087964/200828108-c6c78866-4aff-4617-ac0f-e78aeba78a54.png) **After** ![Screenshot_20221109_125916](https://user-images.githubusercontent.com/2087964/200828149-8caac941-3c58-4389-ac7f-bda2e687faaa.png) Fixes https://github.com/elastic/prodfiler/issues/2714 Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Joseph Crail --- .../public/components/flamegraph.tsx | 141 +++++++++++------- 1 file changed, 86 insertions(+), 55 deletions(-) diff --git a/x-pack/plugins/profiling/public/components/flamegraph.tsx b/x-pack/plugins/profiling/public/components/flamegraph.tsx index 4b6ed5a3b8e01c..09eba11eb6cd2f 100644 --- a/x-pack/plugins/profiling/public/components/flamegraph.tsx +++ b/x-pack/plugins/profiling/public/components/flamegraph.tsx @@ -6,7 +6,16 @@ */ import { Chart, Datum, Flame, FlameLayerValue, PartialTheme, Settings } from '@elastic/charts'; -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSwitch, useEuiTheme } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiSpacer, + EuiSwitch, + EuiText, + EuiTextColor, + useEuiTheme, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { Maybe } from '@kbn/observability-plugin/common/typings'; import { isNumber } from 'lodash'; @@ -29,46 +38,62 @@ function TooltipRow({ formatAsPercentage: boolean; showChange: boolean; }) { - const valueLabel = formatAsPercentage ? asPercentage(value) : value.toString(); + const valueLabel = formatAsPercentage ? asPercentage(Math.abs(value)) : value.toString(); const comparisonLabel = formatAsPercentage && isNumber(comparison) ? asPercentage(comparison) : comparison?.toString(); - const diff = showChange && isNumber(comparison) ? comparison - value : undefined; + let diff: number | undefined; + let diffLabel = ''; + let color = ''; - let diffLabel: string | undefined = diff?.toString(); - - if (diff === 0) { - diffLabel = i18n.translate('xpack.profiling.flameGraphToolTip.diffNoChange', { - defaultMessage: 'no change', - }); - } else if (formatAsPercentage && diff !== undefined) { - diffLabel = asPercentage(diff); + if (isNumber(comparison)) { + if (showChange) { + color = value < comparison ? 'danger' : 'success'; + if (formatAsPercentage) { + // CPU percent values + diff = comparison - value; + diffLabel = + '(' + (diff > 0 ? '+' : diff < 0 ? '-' : '') + asPercentage(Math.abs(diff)) + ')'; + } else { + // Sample counts + diff = 1 - comparison / value; + diffLabel = + '(' + (diff > 0 ? '-' : diff < 0 ? '+' : '') + asPercentage(Math.abs(diff)) + ')'; + } + if (Math.abs(diff) < 0.0001) { + diffLabel = ''; + } + } } return ( - + - - {label} - - {comparison - ? i18n.translate('xpack.profiling.flameGraphTooltip.valueLabel', { - defaultMessage: `{value} vs {comparison}`, - values: { - value: valueLabel, - comparison: comparisonLabel, - }, - }) - : valueLabel} - {diffLabel ? ` (${diffLabel})` : ''} + + {label} + + + {comparison !== undefined + ? i18n.translate('xpack.profiling.flameGraphTooltip.valueLabel', { + defaultMessage: `{value} vs {comparison}`, + values: { + value: valueLabel, + comparison: comparisonLabel, + }, + }) + : valueLabel} + {diffLabel} + + ); } function FlameGraphTooltip({ + isRoot, label, countInclusive, countExclusive, @@ -79,6 +104,7 @@ function FlameGraphTooltip({ comparisonSamples, comparisonTotalSamples, }: { + isRoot: boolean; samples: number; label: string; countInclusive: number; @@ -101,40 +127,44 @@ function FlameGraphTooltip({ {label} - - + {isRoot === false && ( + <> + + + + )} @@ -251,6 +281,7 @@ export const FlameGraph: React.FC = ({ return ( Date: Wed, 9 Nov 2022 20:59:50 +0100 Subject: [PATCH 17/18] [Security Solution] [Exceptions] Add ListExceptionItem Component and its components with implementing the logic + restructuring exceptions under security (#144622) ## Summary **Shared List Collapsed** image **One Shared list expanded** image **Shared List with no Exceptions** image **Add Exceptions from Shared List Card** image **Exit Exception from Shared List Card** image **Delete Endpoint is disabled** image 1. **New components** a. `list_details_link_anchor` => This component should be removed and moved to @kbn/securitysolution-exception-list-components once all the building components get moved b. `exceptions_utility` => This component should be removed and moved to @kbn/securitysolution-exception-list-components once all the building components get moved c. `list_exception_items ` a wrapper over the ExceptionItem from the `@kbn/securitysolution-exception-list-components` added to pass the missing above components, should be removed soon once everything gets moved to the kbn package 2. **New Hooks** a. `use_list_exception_items` => holds all the Exceptions' items' logic b. `use_exceptions_list.card` => hold all the exception card logic 4. Apply Designs to the Shared Lists 5. **Restructure folders under the `x-pack=>security_solution=>exceptions`** a. components b. hooks c. pages d. translations e. utils 6. Added excluded files in `jest.config` 7. Renamed the `shared_list` components in `routes` ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../src/header_menu/header_menu.test.tsx | 15 +- .../src/header_menu/index.tsx | 9 +- .../exceptions_table.cy.ts | 8 +- .../cypress/screens/exceptions.ts | 8 +- .../components/add_exception_flyout/index.tsx | 29 ++- .../edit_exception_flyout/index.tsx | 22 +- .../flyout_components/utils.test.tsx | 8 +- .../components/flyout_components/utils.tsx | 4 +- .../components/rules_table/helpers.test.ts | 2 +- .../public/exceptions/api/exception_api.ts | 160 +++++++++++++ .../{translations.ts => api/index.ts} | 10 +- .../public/exceptions/api/types.ts | 37 +++ .../create_shared_exception_list/index.tsx} | 4 +- .../components/exceptions_list_card/index.tsx | 219 ++++++++++++++++++ .../exceptions_utility.test.tsx | 51 ++++ .../components/exceptions_utility/index.tsx | 104 +++++++++ .../import_exceptions_list_flyout/index.tsx} | 4 +- .../list_details_link_anchor/index.tsx | 40 ++++ .../components/list_exception_items/index.tsx | 100 ++++++++ .../list_search_bar/index.tsx} | 2 +- .../shared_list_utilty_bar/index.tsx} | 4 +- .../shared_lists_utility_bar.test.tsx} | 4 +- .../title_badge/index.tsx} | 28 ++- .../public/exceptions/config/index.ts | 7 + .../use_all_exception_lists/index.tsx} | 4 +- .../use_create_shared_list/index.tsx} | 2 +- .../hooks/use_exceptions_list.card/index.tsx | 172 ++++++++++++++ .../use_import_exception_list/index.tsx} | 0 .../hooks/use_list_exception_items/index.ts | 143 ++++++++++++ .../public/exceptions/jest.config.js | 10 + .../exceptions_list_card.tsx | 150 ------------ .../manage_exceptions/translations.ts | 161 ------------- .../shared_lists/index.tsx} | 54 ++--- .../shared_lists/shared_lists.test.tsx} | 61 +++-- .../public/exceptions/routes.tsx | 4 +- .../public/exceptions/translations/index.ts | 9 + .../exceptions/translations/list_details.ts | 133 +++++++++++ .../translations/list_exception_items.ts | 27 +++ .../shared_list.ts} | 125 +++++++++- .../public/exceptions/utils/list.utils.ts | 13 ++ .../public/exceptions/utils/translations.ts | 143 ++++++++++++ .../exceptions/utils/ui.helpers.test.tsx | 55 +++++ .../public/exceptions/utils/ui.helpers.tsx | 37 +++ 43 files changed, 1737 insertions(+), 445 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/exceptions/api/exception_api.ts rename x-pack/plugins/security_solution/public/exceptions/{translations.ts => api/index.ts} (52%) create mode 100644 x-pack/plugins/security_solution/public/exceptions/api/types.ts rename x-pack/plugins/security_solution/public/exceptions/{manage_exceptions/create_shared_exception_list.tsx => components/create_shared_exception_list/index.tsx} (98%) create mode 100644 x-pack/plugins/security_solution/public/exceptions/components/exceptions_list_card/index.tsx create mode 100644 x-pack/plugins/security_solution/public/exceptions/components/exceptions_utility/exceptions_utility.test.tsx create mode 100644 x-pack/plugins/security_solution/public/exceptions/components/exceptions_utility/index.tsx rename x-pack/plugins/security_solution/public/exceptions/{manage_exceptions/import_exceptions_list_flyout.tsx => components/import_exceptions_list_flyout/index.tsx} (98%) create mode 100644 x-pack/plugins/security_solution/public/exceptions/components/list_details_link_anchor/index.tsx create mode 100644 x-pack/plugins/security_solution/public/exceptions/components/list_exception_items/index.tsx rename x-pack/plugins/security_solution/public/exceptions/{manage_exceptions/exceptions_search_bar.tsx => components/list_search_bar/index.tsx} (95%) rename x-pack/plugins/security_solution/public/exceptions/{manage_exceptions/exceptions_table_utility_bar.tsx => components/shared_list_utilty_bar/index.tsx} (92%) rename x-pack/plugins/security_solution/public/exceptions/{manage_exceptions/exceptions_table_utility_bar.test.tsx => components/shared_list_utilty_bar/shared_lists_utility_bar.test.tsx} (91%) rename x-pack/plugins/security_solution/public/exceptions/{manage_exceptions/title_badge.tsx => components/title_badge/index.tsx} (59%) create mode 100644 x-pack/plugins/security_solution/public/exceptions/config/index.ts rename x-pack/plugins/security_solution/public/exceptions/{manage_exceptions/use_all_exception_lists.tsx => hooks/use_all_exception_lists/index.tsx} (95%) rename x-pack/plugins/security_solution/public/exceptions/{manage_exceptions/use_create_shared_list.tsx => hooks/use_create_shared_list/index.tsx} (94%) create mode 100644 x-pack/plugins/security_solution/public/exceptions/hooks/use_exceptions_list.card/index.tsx rename x-pack/plugins/security_solution/public/exceptions/{manage_exceptions/use_import_exception_list.tsx => hooks/use_import_exception_list/index.tsx} (100%) create mode 100644 x-pack/plugins/security_solution/public/exceptions/hooks/use_list_exception_items/index.ts delete mode 100644 x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_list_card.tsx delete mode 100644 x-pack/plugins/security_solution/public/exceptions/manage_exceptions/translations.ts rename x-pack/plugins/security_solution/public/exceptions/{manage_exceptions/exceptions_table.tsx => pages/shared_lists/index.tsx} (89%) rename x-pack/plugins/security_solution/public/exceptions/{manage_exceptions/exceptions_table.test.tsx => pages/shared_lists/shared_lists.test.tsx} (63%) create mode 100644 x-pack/plugins/security_solution/public/exceptions/translations/index.ts create mode 100644 x-pack/plugins/security_solution/public/exceptions/translations/list_details.ts create mode 100644 x-pack/plugins/security_solution/public/exceptions/translations/list_exception_items.ts rename x-pack/plugins/security_solution/public/exceptions/{manage_exceptions/translations_exceptions_table.ts => translations/shared_list.ts} (63%) create mode 100644 x-pack/plugins/security_solution/public/exceptions/utils/list.utils.ts create mode 100644 x-pack/plugins/security_solution/public/exceptions/utils/translations.ts create mode 100644 x-pack/plugins/security_solution/public/exceptions/utils/ui.helpers.test.tsx create mode 100644 x-pack/plugins/security_solution/public/exceptions/utils/ui.helpers.tsx diff --git a/packages/kbn-securitysolution-exception-list-components/src/header_menu/header_menu.test.tsx b/packages/kbn-securitysolution-exception-list-components/src/header_menu/header_menu.test.tsx index 8b5775264e230e..509c9e81b3d7dc 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/header_menu/header_menu.test.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/header_menu/header_menu.test.tsx @@ -5,7 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ -import { fireEvent, render } from '@testing-library/react'; +import { createEvent, fireEvent, render } from '@testing-library/react'; import React from 'react'; import { HeaderMenu } from '.'; import { actions, actionsWithDisabledDelete } from '../mocks/header.mock'; @@ -90,7 +90,6 @@ describe('HeaderMenu', () => { expect(wrapper.queryByTestId('ActionItemedit')).not.toBeInTheDocument(); expect(wrapper.queryByTestId('MenuPanel')).not.toBeInTheDocument(); }); - it('should call onEdit if action has onClick', () => { const onEdit = jest.fn(); const customAction = [...actions]; @@ -113,4 +112,16 @@ describe('HeaderMenu', () => { fireEvent.click(wrapper.getByTestId('EmptyButton')); expect(wrapper.queryByTestId('MenuPanel')).toBeInTheDocument(); }); + it('should stop propagation when clicking on the menu', () => { + const onEdit = jest.fn(); + const customAction = [...actions]; + customAction[0].onClick = onEdit; + const wrapper = render( + + ); + const headerMenu = wrapper.getByTestId('headerMenuItems'); + const click = createEvent.click(headerMenu); + const result = fireEvent(headerMenu, click); + expect(result).toBe(true); + }); }); diff --git a/packages/kbn-securitysolution-exception-list-components/src/header_menu/index.tsx b/packages/kbn-securitysolution-exception-list-components/src/header_menu/index.tsx index ba6f3c7beff5d0..a8e44a03473c52 100644 --- a/packages/kbn-securitysolution-exception-list-components/src/header_menu/index.tsx +++ b/packages/kbn-securitysolution-exception-list-components/src/header_menu/index.tsx @@ -20,12 +20,12 @@ import { } from '@elastic/eui'; import { ButtonContentIconSide } from '@elastic/eui/src/components/button/_button_content_deprecated'; -interface Action { +export interface Action { key: string; icon: string; label: string | boolean; disabled?: boolean; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; } interface HeaderMenuComponentProps { disableActions: boolean; @@ -66,9 +66,9 @@ const HeaderMenuComponent: FC = ({ icon={action.icon} disabled={action.disabled} layoutAlign="center" - onClick={() => { + onClick={(e) => { onClosePopover(); - if (typeof action.onClick === 'function') action.onClick(); + if (typeof action.onClick === 'function') action.onClick(e); }} > {action.label} @@ -103,6 +103,7 @@ const HeaderMenuComponent: FC = ({ ) } + onClick={(e) => e.stopPropagation()} panelPaddingSize={panelPaddingSize} isOpen={isPopoverOpen} closePopover={onClosePopover} diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_management_flow/exceptions_table.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_management_flow/exceptions_table.cy.ts index 4a806eae4d53dc..42ad3c20962aa7 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_management_flow/exceptions_table.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/exceptions_management_flow/exceptions_table.cy.ts @@ -20,10 +20,9 @@ import { searchForExceptionList, waitForExceptionsTableToBeLoaded, clearSearchSelection, - expandExceptionActions, } from '../../../tasks/exceptions_table'; import { - EXCEPTIONS_TABLE_DELETE_BTN, + EXCEPTIONS_OVERFLOW_ACTIONS_BTN, EXCEPTIONS_TABLE_LIST_NAME, EXCEPTIONS_TABLE_SHOWING_LISTS, } from '../../../screens/exceptions'; @@ -182,8 +181,7 @@ describe('Exceptions Table - read only', () => { cy.get(EXCEPTIONS_TABLE_SHOWING_LISTS).should('have.text', `Showing 1 list`); }); - it('Delete icon is not shown', () => { - expandExceptionActions(); - cy.get(EXCEPTIONS_TABLE_DELETE_BTN).should('be.disabled'); + it('Card menu actions should be disabled', () => { + cy.get(EXCEPTIONS_OVERFLOW_ACTIONS_BTN).first().should('be.disabled'); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts index 00f06777bd1110..fcd82827c05e44 100644 --- a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts @@ -35,7 +35,7 @@ export const ENTRY_DELETE_BTN = '[data-test-subj="builderItemEntryDeleteButton"] export const CANCEL_BTN = '[data-test-subj="cancelExceptionAddButton"]'; export const EXCEPTIONS_OVERFLOW_ACTIONS_BTN = - '[data-test-subj="exceptionsListCardOverflowActions"]'; + '[data-test-subj="sharedListOverflowCardButtonIcon"]'; export const EXCEPTIONS_TABLE = '[data-test-subj="pageContainer"]'; @@ -43,9 +43,11 @@ export const EXCEPTIONS_TABLE_SEARCH = '[data-test-subj="exceptionsHeaderSearchI export const EXCEPTIONS_TABLE_SHOWING_LISTS = '[data-test-subj="showingExceptionLists"]'; -export const EXCEPTIONS_TABLE_DELETE_BTN = '[data-test-subj="exceptionsTableDeleteButton"]'; +export const EXCEPTIONS_TABLE_DELETE_BTN = + '[data-test-subj="sharedListOverflowCardActionItemDelete"]'; -export const EXCEPTIONS_TABLE_EXPORT_BTN = '[data-test-subj="exceptionsTableExportButton"]'; +export const EXCEPTIONS_TABLE_EXPORT_BTN = + '[data-test-subj="sharedListOverflowCardActionItemExport"]'; export const EXCEPTIONS_TABLE_SEARCH_CLEAR = '[data-test-subj="allExceptionListsPanel"] button.euiFormControlLayoutClearButton'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx index 413973faff7663..ad0876736fce70 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/add_exception_flyout/index.tsx @@ -50,7 +50,7 @@ import type { Rule } from '../../../rule_management/logic/types'; import { ExceptionItemsFlyoutAlertsActions } from '../flyout_components/alerts_actions'; import { ExceptionsAddToRulesOrLists } from '../flyout_components/add_exception_to_rule_or_list'; import { useAddNewExceptionItems } from './use_add_new_exceptions'; -import { entrichNewExceptionItems } from '../flyout_components/utils'; +import { enrichNewExceptionItems } from '../flyout_components/utils'; import { useCloseAlertsFromExceptions } from '../../logic/use_close_alerts'; import { ruleTypesThatAllowLargeValueLists } from '../../utils/constants'; @@ -74,6 +74,7 @@ export interface AddExceptionFlyoutProps { */ isAlertDataLoading?: boolean; alertStatus?: Status; + sharedListToAddTo?: ExceptionListSchema[]; onCancel: (didRuleChange: boolean) => void; onConfirm: (didRuleChange: boolean, didCloseAlert: boolean, didBulkCloseAlert: boolean) => void; } @@ -106,6 +107,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ showAlertCloseOptions, isAlertDataLoading, alertStatus, + sharedListToAddTo, onCancel, onConfirm, }: AddExceptionFlyoutProps) { @@ -125,6 +127,12 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ } }, [rules]); + const getListType = useMemo(() => { + if (isEndpointItem) return ExceptionListTypeEnum.ENDPOINT; + if (sharedListToAddTo) return ExceptionListTypeEnum.DETECTION; + + return ExceptionListTypeEnum.RULE_DEFAULT; + }, [isEndpointItem, sharedListToAddTo]); const [ { exceptionItemMeta: { name: exceptionItemName }, @@ -151,10 +159,9 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ : rules != null && rules.length === 1 ? 'add_to_rule' : 'select_rules_to_add_to', - listType: isEndpointItem ? ExceptionListTypeEnum.ENDPOINT : ExceptionListTypeEnum.RULE_DEFAULT, + listType: getListType, selectedRulesToAddTo: rules != null ? rules : [], }); - const hasAlertData = useMemo((): boolean => { return alertData != null; }, [alertData]); @@ -320,14 +327,17 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ try { const ruleDefaultOptions = ['add_to_rule', 'add_to_rules', 'select_rules_to_add_to']; const addToRules = ruleDefaultOptions.includes(addExceptionToRadioSelection); - const addToSharedLists = addExceptionToRadioSelection === 'add_to_lists'; + const addToSharedLists = + !!sharedListToAddTo?.length || + (addExceptionToRadioSelection === 'add_to_lists' && !isEmpty(exceptionListsToAddTo)); + const sharedLists = sharedListToAddTo?.length ? sharedListToAddTo : exceptionListsToAddTo; - const items = entrichNewExceptionItems({ + const items = enrichNewExceptionItems({ itemName: exceptionItemName, commentToAdd: newComment, addToRules, addToSharedLists, - sharedLists: exceptionListsToAddTo, + sharedLists, listType, selectedOs: osTypesSelection, items: exceptionItems, @@ -338,8 +348,8 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ selectedRulesToAddTo, listType, addToRules: addToRules && !isEmpty(selectedRulesToAddTo), - addToSharedLists: addToSharedLists && !isEmpty(exceptionListsToAddTo), - sharedLists: exceptionListsToAddTo, + addToSharedLists, + sharedLists, }); const alertIdToClose = closeSingleAlert && alertData ? alertData._id : undefined; @@ -358,6 +368,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ setErrorSubmitting(e); } }, [ + sharedListToAddTo, submitNewExceptionItems, addExceptionToRadioSelection, exceptionItemName, @@ -463,7 +474,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({ onFilterIndexPatterns={filterIndexPatterns} /> - {listType !== ExceptionListTypeEnum.ENDPOINT && ( + {listType !== ExceptionListTypeEnum.ENDPOINT && !sharedListToAddTo?.length && ( <> void; onConfirm: (arg: boolean) => void; } @@ -97,6 +98,7 @@ const EditExceptionFlyoutComponent: React.FC = ({ itemToEdit, rule, showAlertCloseOptions, + openedFromListDetailPage, onCancel, onConfirm, }): JSX.Element => { @@ -244,7 +246,7 @@ const EditExceptionFlyoutComponent: React.FC = ({ if (submitEditExceptionItems == null) return; try { - const items = entrichExceptionItemsForUpdate({ + const items = enrichExceptionItemsForUpdate({ itemName: exceptionItemName, commentToAdd: newComment, listType, @@ -339,7 +341,7 @@ const EditExceptionFlyoutComponent: React.FC = ({ onSetErrorExists={setConditionsValidationError} onFilterIndexPatterns={filterIndexPatterns} /> - {listType === ExceptionListTypeEnum.DETECTION && ( + {!openedFromListDetailPage && listType === ExceptionListTypeEnum.DETECTION && ( <> = ({ /> )} - {listType === ExceptionListTypeEnum.RULE_DEFAULT && rule != null && ( - <> - - - - )} + {!openedFromListDetailPage && + listType === ExceptionListTypeEnum.RULE_DEFAULT && + rule != null && ( + <> + + + + )} [ @@ -30,7 +30,7 @@ describe('add_exception_flyout#utils', () => { const items = getExceptionItems(); expect( - entrichNewExceptionItems({ + enrichNewExceptionItems({ itemName: 'My item', commentToAdd: 'New comment', addToRules: true, @@ -66,7 +66,7 @@ describe('add_exception_flyout#utils', () => { ]; expect( - entrichNewExceptionItems({ + enrichNewExceptionItems({ itemName: 'My item', commentToAdd: 'New comment', addToRules: false, @@ -105,7 +105,7 @@ describe('add_exception_flyout#utils', () => { ]; expect( - entrichNewExceptionItems({ + enrichNewExceptionItems({ itemName: 'My item', commentToAdd: 'New comment', addToRules: false, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/utils.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/utils.tsx index a8674883253c00..51da06de63bf9d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/utils.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_exceptions/components/flyout_components/utils.tsx @@ -111,7 +111,7 @@ export const enrichItemsForSharedLists = * @param listType exception list type * @param items exception items to be modified */ -export const entrichNewExceptionItems = ({ +export const enrichNewExceptionItems = ({ itemName, commentToAdd, addToRules, @@ -152,7 +152,7 @@ export const entrichNewExceptionItems = ({ * @param listType exception list type * @param items exception items to be modified */ -export const entrichExceptionItemsForUpdate = ({ +export const enrichExceptionItemsForUpdate = ({ itemName, commentToAdd, selectedOs, diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/helpers.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/helpers.test.ts index 1ef07451103e6f..ba537eaefb35c8 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/helpers.test.ts @@ -6,7 +6,7 @@ */ import { Query } from '@elastic/eui'; -import { EXCEPTIONS_SEARCH_SCHEMA } from '../../../../exceptions/manage_exceptions/exceptions_search_bar'; +import { EXCEPTIONS_SEARCH_SCHEMA } from '../../../../exceptions/components/list_search_bar'; import { caseInsensitiveSort, getSearchFilters } from './helpers'; describe('AllRulesTable Helpers', () => { diff --git a/x-pack/plugins/security_solution/public/exceptions/api/exception_api.ts b/x-pack/plugins/security_solution/public/exceptions/api/exception_api.ts new file mode 100644 index 00000000000000..45c44fc61b5f16 --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/api/exception_api.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { + Pagination as ServerPagination, + ExceptionListSchema, + ListArray, + NamespaceType, +} from '@kbn/securitysolution-io-ts-list-types'; +import { + deleteExceptionListItemById, + fetchExceptionListsItemsByListIds, + updateExceptionListItem, + addExceptionListItem, +} from '@kbn/securitysolution-list-api'; +import { transformInput } from '@kbn/securitysolution-list-hooks'; +import type { + GetExceptionItemProps, + RuleReferences, +} from '@kbn/securitysolution-exception-list-components'; +import type { HttpSetup } from '@kbn/core-http-browser'; +import { findRuleExceptionReferences } from '../../detection_engine/rule_management/api/api'; +import type { AddExceptionItem, DeleteExceptionItem, EditExceptionItem, FetchItems } from './types'; + +// Some of the APIs here are already defined in Kbn packages, need to be refactored + +export const prepareFetchExceptionItemsParams = ( + exceptions: ListArray | null, + list: ExceptionListSchema | null, + options?: GetExceptionItemProps | null +) => { + const { pagination, search, filters } = options || {}; + let listIds: string[] = []; + let namespaceTypes: NamespaceType[] = []; + + if (Array.isArray(exceptions) && exceptions.length) { + listIds = exceptions.map((excList) => excList.list_id); + namespaceTypes = exceptions.map((excList) => excList.namespace_type); + } else if (list) { + listIds = [list.list_id]; + namespaceTypes = [list.namespace_type]; + } + + return { + listIds, + namespaceTypes, + pagination, + search, + filters, + }; +}; + +export const fetchListExceptionItems = async ({ + namespaceTypes, + listIds, + http, + pagination, + search, +}: FetchItems) => { + try { + const abortCtrl = new AbortController(); + const { + pageIndex: inputPageIndex, + pageSize: inputPageSize, + totalItemCount: inputTotalItemCount, + } = pagination || {}; + + // TODO transform Pagination object from Frontend=>Backend & <= + const { + page: pageIndex, + per_page: pageSize, + total: totalItemCount, + data, + } = await fetchExceptionListsItemsByListIds({ + filter: undefined, + http: http as HttpSetup, + listIds: listIds ?? [], + namespaceTypes: namespaceTypes ?? [], + search, + pagination: { + perPage: inputPageSize, + page: (inputPageIndex || 0) + 1, + total: inputTotalItemCount, + } as ServerPagination, + signal: abortCtrl.signal, + }); + + const transformedData = data.map((item) => transformInput(item)); + + return { + data: transformedData, + pagination: { pageIndex: pageIndex - 1, pageSize, totalItemCount }, + }; + } catch (error) { + throw new Error(error); + } +}; + +export const getExceptionItemsReferences = async (list: ExceptionListSchema) => { + try { + const abortCtrl = new AbortController(); + + const { references } = await findRuleExceptionReferences({ + lists: [list].map((listInput) => ({ + id: listInput.id, + listId: listInput.list_id, + namespaceType: listInput.namespace_type, + })), + signal: abortCtrl.signal, + }); + + return references.reduce((acc, reference) => { + return { ...acc, ...reference } as RuleReferences; + }, {}); + } catch (error) { + throw new Error(error); + } +}; + +export const deleteException = async ({ id, namespaceType, http }: DeleteExceptionItem) => { + try { + const abortCtrl = new AbortController(); + await deleteExceptionListItemById({ + http: http as HttpSetup, + id, + namespaceType, + signal: abortCtrl.signal, + }); + } catch (error) { + throw new Error(error); + } +}; + +export const editException = async ({ http, exception }: EditExceptionItem) => { + try { + const abortCtrl = new AbortController(); + await updateExceptionListItem({ + http: http as HttpSetup, + listItem: exception, + signal: abortCtrl.signal, + }); + } catch (error) { + throw new Error(error); + } +}; +export const addException = async ({ http, exception }: AddExceptionItem) => { + try { + const abortCtrl = new AbortController(); + await addExceptionListItem({ + http: http as HttpSetup, + listItem: exception, + signal: abortCtrl.signal, + }); + } catch (error) { + throw new Error(error); + } +}; diff --git a/x-pack/plugins/security_solution/public/exceptions/translations.ts b/x-pack/plugins/security_solution/public/exceptions/api/index.ts similarity index 52% rename from x-pack/plugins/security_solution/public/exceptions/translations.ts rename to x-pack/plugins/security_solution/public/exceptions/api/index.ts index 780ed23a64ffe5..cda467ea629249 100644 --- a/x-pack/plugins/security_solution/public/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/exceptions/api/index.ts @@ -4,12 +4,4 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { i18n } from '@kbn/i18n'; - -export const READ_ONLY_BADGE_TOOLTIP = i18n.translate( - 'xpack.securitySolution.exceptions.badge.readOnly.tooltip', - { - defaultMessage: 'Unable to create, edit or delete exceptions', - } -); +export * from './exception_api'; diff --git a/x-pack/plugins/security_solution/public/exceptions/api/types.ts b/x-pack/plugins/security_solution/public/exceptions/api/types.ts new file mode 100644 index 00000000000000..51a2c4266a59aa --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/api/types.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + CreateExceptionListItemSchema, + NamespaceType, + UpdateExceptionListItemSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import type { Pagination } from '@elastic/eui'; +import type { HttpSetup } from '@kbn/core-http-browser'; + +export interface FetchItems { + http: HttpSetup | undefined; + listIds: string[]; + namespaceTypes: NamespaceType[]; + pagination: Pagination | undefined; + search?: string; + filter?: string; +} +export interface DeleteExceptionItem { + id: string; + namespaceType: NamespaceType; + http: HttpSetup | undefined; +} +export interface EditExceptionItem { + http: HttpSetup | undefined; + exception: UpdateExceptionListItemSchema; +} + +export interface AddExceptionItem { + http: HttpSetup | undefined; + exception: CreateExceptionListItemSchema; +} diff --git a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/create_shared_exception_list.tsx b/x-pack/plugins/security_solution/public/exceptions/components/create_shared_exception_list/index.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/exceptions/manage_exceptions/create_shared_exception_list.tsx rename to x-pack/plugins/security_solution/public/exceptions/components/create_shared_exception_list/index.tsx index c500b02ccb344b..e99fadf7b1e996 100644 --- a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/create_shared_exception_list.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/components/create_shared_exception_list/index.tsx @@ -27,7 +27,7 @@ import type { ErrorToastOptions, Toast, ToastInput } from '@kbn/core-notificatio import { i18n as translate } from '@kbn/i18n'; import type { ListDetails } from '@kbn/securitysolution-exception-list-components'; -import { useCreateSharedExceptionListWithOptionalSignal } from './use_create_shared_list'; +import { useCreateSharedExceptionListWithOptionalSignal } from '../../hooks/use_create_shared_list'; import { CREATE_SHARED_LIST_TITLE, CREATE_SHARED_LIST_NAME_FIELD, @@ -38,7 +38,7 @@ import { CREATE_SHARED_LIST_NAME_FIELD_PLACEHOLDER, SUCCESS_TITLE, getSuccessText, -} from './translations'; +} from '../../translations'; export const CreateSharedListFlyout = memo( ({ diff --git a/x-pack/plugins/security_solution/public/exceptions/components/exceptions_list_card/index.tsx b/x-pack/plugins/security_solution/public/exceptions/components/exceptions_list_card/index.tsx new file mode 100644 index 00000000000000..7ade4b41cb0ebd --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/components/exceptions_list_card/index.tsx @@ -0,0 +1,219 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; + +import { + EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiTextColor, + EuiPanel, + EuiText, + EuiAccordion, + EuiButtonIcon, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import type { HttpSetup } from '@kbn/core-http-browser'; +import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types'; +import type { NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; +import { HeaderMenu } from '@kbn/securitysolution-exception-list-components'; +import styled from 'styled-components'; +import { euiThemeVars } from '@kbn/ui-theme'; +import { EditExceptionFlyout } from '../../../detection_engine/rule_exceptions/components/edit_exception_flyout'; +import { AddExceptionFlyout } from '../../../detection_engine/rule_exceptions/components/add_exception_flyout'; +import type { ExceptionListInfo } from '../../hooks/use_all_exception_lists'; +import { TitleBadge } from '../title_badge'; +import * as i18n from '../../translations'; +import { ListExceptionItems } from '../list_exception_items'; +import { useExceptionsListCard } from '../../hooks/use_exceptions_list.card'; + +interface ExceptionsListCardProps { + exceptionsList: ExceptionListInfo; + http: HttpSetup; + handleDelete: ({ + id, + listId, + namespaceType, + }: { + id: string; + listId: string; + namespaceType: NamespaceType; + }) => () => Promise; + handleExport: ({ + id, + listId, + namespaceType, + }: { + id: string; + listId: string; + namespaceType: NamespaceType; + }) => () => Promise; + readOnly: boolean; +} +const buttonCss = css` + // Ask KIBANA Team why Emotion is not working fully under xpack + width: 100%; + z-index: 100; + span { + cursor: pointer; + display: block; + } +`; +const ExceptionPanel = styled(EuiPanel)` + margin: -${euiThemeVars.euiSizeS} ${euiThemeVars.euiSizeM} 0 ${euiThemeVars.euiSizeM}; +`; +const ListHeaderContainer = styled(EuiFlexGroup)` + padding: ${euiThemeVars.euiSizeS}; +`; +export const ExceptionsListCard = memo( + ({ exceptionsList, handleDelete, handleExport, readOnly }) => { + const { + listId, + listName, + listType, + createdAt, + createdBy, + exceptions, + pagination, + ruleReferences, + toggleAccordion, + openAccordionId, + menuActionItems, + listRulesCount, + listDescription, + exceptionItemsCount, + onEditExceptionItem, + onDeleteException, + onPaginationChange, + setToggleAccordion, + exceptionViewerStatus, + showAddExceptionFlyout, + showEditExceptionFlyout, + exceptionToEdit, + onAddExceptionClick, + handleConfirmExceptionFlyout, + handleCancelExceptionItemFlyout, + } = useExceptionsListCard({ + exceptionsList, + handleExport, + handleDelete, + }); + + return ( + + + + setToggleAccordion(!toggleAccordion)} + buttonContent={ + + + + + + + + + + {listName} + + + + + {listDescription} + + + + + + + + + + + + + + + + + + + + + + + + + + + } + > + + + + + + + {showAddExceptionFlyout ? ( + + ) : null} + {showEditExceptionFlyout && exceptionToEdit ? ( + + ) : null} + + ); + } +); + +ExceptionsListCard.displayName = 'ExceptionsListCard'; diff --git a/x-pack/plugins/security_solution/public/exceptions/components/exceptions_utility/exceptions_utility.test.tsx b/x-pack/plugins/security_solution/public/exceptions/components/exceptions_utility/exceptions_utility.test.tsx new file mode 100644 index 00000000000000..8290a1efc0ae92 --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/components/exceptions_utility/exceptions_utility.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { TestProviders } from '../../../common/mock'; + +import { ExceptionsUtility } from '.'; + +describe('ExceptionsUtility', () => { + it('it renders correct item counts', () => { + const wrapper = render( + + + + ); + expect(wrapper.getByTestId('exceptionUtilityShowingText')).toHaveTextContent( + 'Showing 1-50 of 105' + ); + }); + it('it renders last updated message', () => { + const wrapper = render( + + + + ); + expect(wrapper.getByTestId('exceptionUtilityLastUpdated')).toHaveTextContent('Updated now'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/exceptions/components/exceptions_utility/index.tsx b/x-pack/plugins/security_solution/public/exceptions/components/exceptions_utility/index.tsx new file mode 100644 index 00000000000000..029c92e371fc96 --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/components/exceptions_utility/index.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 type { FC } from 'react'; +import styled from 'styled-components'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { Pagination } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; + +import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; +import { + UtilityBar, + UtilityBarSection, + UtilityBarGroup, + UtilityBarText, +} from '../../../common/components/utility_bar'; + +const StyledText = styled.span` + font-weight: bold; + color: ${({ theme }) => theme.eui.euiColorDarkestShade}; +`; + +const MyUtilities = styled(EuiFlexGroup)` + height: 50px; +`; + +const StyledCondition = styled.span` + display: inline-block !important; + vertical-align: middle !important; +`; +interface ExceptionsUtilityComponentProps { + dataTestSubj?: string; + exceptionsTitle?: string; + pagination: Pagination; + // Corresponds to last time exception items were fetched + lastUpdated: string | number | null; +} +// This component should be removed and moved to @kbn/securitysolution-exception-list-components +// once all the building components get moved + +const ExceptionsUtilityComponent: FC = ({ + dataTestSubj, + pagination, + lastUpdated, + exceptionsTitle, +}) => { + const { pageSize, totalItemCount } = pagination; + return ( + + + + + + + {`1-${Math.min(pageSize, totalItemCount)}`}, + partTwo: {`${totalItemCount}`}, + }} + /> + + {exceptionsTitle && ( + + {exceptionsTitle} + + )} + + + + + + + + + + ), + }} + /> + + + + ); +}; + +ExceptionsUtilityComponent.displayName = 'ExceptionsUtilityComponent'; + +export const ExceptionsUtility = React.memo(ExceptionsUtilityComponent); + +ExceptionsUtility.displayName = 'ExceptionsUtility'; diff --git a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/import_exceptions_list_flyout.tsx b/x-pack/plugins/security_solution/public/exceptions/components/import_exceptions_list_flyout/index.tsx similarity index 98% rename from x-pack/plugins/security_solution/public/exceptions/manage_exceptions/import_exceptions_list_flyout.tsx rename to x-pack/plugins/security_solution/public/exceptions/components/import_exceptions_list_flyout/index.tsx index ab19d153181b7e..0858ee37c23fd2 100644 --- a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/import_exceptions_list_flyout.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/components/import_exceptions_list_flyout/index.tsx @@ -31,9 +31,9 @@ import type { import type { HttpSetup } from '@kbn/core-http-browser'; import type { ToastInput, Toast, ErrorToastOptions } from '@kbn/core-notifications-browser'; -import { useImportExceptionList } from './use_import_exception_list'; +import { useImportExceptionList } from '../../hooks/use_import_exception_list'; -import * as i18n from './translations'; +import * as i18n from '../../translations'; export const ImportExceptionListFlyout = React.memo( ({ diff --git a/x-pack/plugins/security_solution/public/exceptions/components/list_details_link_anchor/index.tsx b/x-pack/plugins/security_solution/public/exceptions/components/list_details_link_anchor/index.tsx new file mode 100644 index 00000000000000..d1cb24e8d066be --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/components/list_details_link_anchor/index.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { FC } from 'react'; +import { SecurityPageName } from '../../../../common/constants'; +import { getRuleDetailsTabUrl } from '../../../common/components/link_to/redirect_to_detection_engine'; +import { RuleDetailTabs } from '../../../detection_engine/rule_details_ui/pages/rule_details'; +import { SecuritySolutionLinkAnchor } from '../../../common/components/links'; + +interface LinkAnchorProps { + referenceName: string; + referenceId: string; + external?: boolean; +} +// This component should be removed and moved to @kbn/securitysolution-exception-list-components +// once all the building components get moved + +const LinkAnchor: FC = ({ referenceName, referenceId, external }) => { + return ( + + {referenceName} + + ); +}; + +LinkAnchor.displayName = 'LinkAnchor'; + +export const ListDetailsLinkAnchor = React.memo(LinkAnchor); + +ListDetailsLinkAnchor.displayName = 'ListDetailsLinkAnchor'; diff --git a/x-pack/plugins/security_solution/public/exceptions/components/list_exception_items/index.tsx b/x-pack/plugins/security_solution/public/exceptions/components/list_exception_items/index.tsx new file mode 100644 index 00000000000000..68bd02dd9f542c --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/components/list_exception_items/index.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import type { FC } from 'react'; +import type { + ExceptionListItemIdentifiers, + GetExceptionItemProps, + RuleReferences, + ViewerStatus, +} from '@kbn/securitysolution-exception-list-components'; +import { ExceptionItems } from '@kbn/securitysolution-exception-list-components'; +import type { + ExceptionListItemSchema, + ExceptionListTypeEnum, +} from '@kbn/securitysolution-io-ts-list-types'; + +import type { Pagination } from '@elastic/eui'; +import { FormattedDate } from '../../../common/components/formatted_date'; +import { getFormattedComments } from '../../utils/ui.helpers'; +import { ListDetailsLinkAnchor } from '../list_details_link_anchor'; +import { ExceptionsUtility } from '../exceptions_utility'; +import * as i18n from '../../translations/list_exception_items'; + +interface ListExceptionItemsProps { + isReadOnly: boolean; + exceptions: ExceptionListItemSchema[]; + listType: ExceptionListTypeEnum; + lastUpdated: string | number | null; + pagination: Pagination; + emptyViewerTitle?: string; + emptyViewerBody?: string; + viewerStatus: ViewerStatus | ''; + ruleReferences: RuleReferences; + hideUtility?: boolean; + onDeleteException: (arg: ExceptionListItemIdentifiers) => void; + onEditExceptionItem: (item: ExceptionListItemSchema) => void; + onPaginationChange: (arg: GetExceptionItemProps) => void; + onCreateExceptionListItem: () => void; +} + +const ListExceptionItemsComponent: FC = ({ + isReadOnly, + exceptions, + listType, + lastUpdated, + pagination, + emptyViewerTitle, + emptyViewerBody, + viewerStatus, + ruleReferences, + hideUtility = false, + onDeleteException, + onEditExceptionItem, + onPaginationChange, + onCreateExceptionListItem, +}) => { + return ( + <> + + hideUtility ? null : ( + + ) + } + /> + + ); +}; + +ListExceptionItemsComponent.displayName = 'ListExceptionItemsComponent'; + +export const ListExceptionItems = React.memo(ListExceptionItemsComponent); + +ListExceptionItems.displayName = 'ListExceptionItems'; diff --git a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_search_bar.tsx b/x-pack/plugins/security_solution/public/exceptions/components/list_search_bar/index.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_search_bar.tsx rename to x-pack/plugins/security_solution/public/exceptions/components/list_search_bar/index.tsx index bcd9e99498f583..dcc559e4f62ef2 100644 --- a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_search_bar.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/components/list_search_bar/index.tsx @@ -9,7 +9,7 @@ import React from 'react'; import type { EuiSearchBarProps } from '@elastic/eui'; import { EuiSearchBar } from '@elastic/eui'; -import * as i18n from './translations_exceptions_table'; +import * as i18n from '../../translations'; interface ExceptionListsTableSearchProps { onSearch: (args: Parameters>[0]) => void; diff --git a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_table_utility_bar.tsx b/x-pack/plugins/security_solution/public/exceptions/components/shared_list_utilty_bar/index.tsx similarity index 92% rename from x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_table_utility_bar.tsx rename to x-pack/plugins/security_solution/public/exceptions/components/shared_list_utilty_bar/index.tsx index d42b3e7c89a4e2..e4c3653a2693ad 100644 --- a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_table_utility_bar.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/components/shared_list_utilty_bar/index.tsx @@ -13,8 +13,8 @@ import { UtilityBarGroup, UtilityBarSection, UtilityBarText, -} from '../../common/components/utility_bar'; -import * as i18n from './translations_exceptions_table'; +} from '../../../common/components/utility_bar'; +import * as i18n from '../../translations'; interface ExceptionsTableUtilityBarProps { onRefresh?: () => void; diff --git a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_table_utility_bar.test.tsx b/x-pack/plugins/security_solution/public/exceptions/components/shared_list_utilty_bar/shared_lists_utility_bar.test.tsx similarity index 91% rename from x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_table_utility_bar.test.tsx rename to x-pack/plugins/security_solution/public/exceptions/components/shared_list_utilty_bar/shared_lists_utility_bar.test.tsx index 7d71fe78989a42..df38a4b5fee797 100644 --- a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_table_utility_bar.test.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/components/shared_list_utilty_bar/shared_lists_utility_bar.test.tsx @@ -6,9 +6,9 @@ */ import React from 'react'; -import { TestProviders } from '../../common/mock'; +import { TestProviders } from '../../../common/mock'; import { render, screen, within } from '@testing-library/react'; -import { ExceptionsTableUtilityBar } from './exceptions_table_utility_bar'; +import { ExceptionsTableUtilityBar } from '.'; describe('ExceptionsTableUtilityBar', () => { it('displays correct exception lists label and refresh rules action button', () => { diff --git a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/title_badge.tsx b/x-pack/plugins/security_solution/public/exceptions/components/title_badge/index.tsx similarity index 59% rename from x-pack/plugins/security_solution/public/exceptions/manage_exceptions/title_badge.tsx rename to x-pack/plugins/security_solution/public/exceptions/components/title_badge/index.tsx index a4572cc65788b8..c8956db897689d 100644 --- a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/title_badge.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/components/title_badge/index.tsx @@ -9,6 +9,7 @@ import React, { memo } from 'react'; import styled from 'styled-components'; import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { euiThemeVars } from '@kbn/ui-theme'; interface TitleBadgeProps { title: string; @@ -17,20 +18,25 @@ interface TitleBadgeProps { const StyledFlexItem = styled(EuiFlexItem)` border-right: 1px solid #d3dae6; - padding: 4px 12px 4px 0; + padding: ${euiThemeVars.euiSizeXS} ${euiThemeVars.euiSizeS} ${euiThemeVars.euiSizeXS} 0; `; + +const TextContainer = styled(EuiText)` + width: max-content; +`; + export const TitleBadge = memo(({ title, badgeString }) => { return ( - - - - {title} - - - {badgeString}{' '} - - - + + + + {`${title}:`} + + + + {badgeString} + + ); }); diff --git a/x-pack/plugins/security_solution/public/exceptions/config/index.ts b/x-pack/plugins/security_solution/public/exceptions/config/index.ts new file mode 100644 index 00000000000000..d738670ec26510 --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/config/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor 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 listIDsCannotBeEdited = ['endpoint_list']; diff --git a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/use_all_exception_lists.tsx b/x-pack/plugins/security_solution/public/exceptions/hooks/use_all_exception_lists/index.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/exceptions/manage_exceptions/use_all_exception_lists.tsx rename to x-pack/plugins/security_solution/public/exceptions/hooks/use_all_exception_lists/index.tsx index 688ad352a147ff..36f7fc132cc845 100644 --- a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/use_all_exception_lists.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/hooks/use_all_exception_lists/index.tsx @@ -8,8 +8,8 @@ import { useCallback, useEffect, useState } from 'react'; import type { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types'; -import type { Rule } from '../../detection_engine/rule_management/logic'; -import { fetchRules } from '../../detection_engine/rule_management/api/api'; +import type { Rule } from '../../../detection_engine/rule_management/logic'; +import { fetchRules } from '../../../detection_engine/rule_management/api/api'; export interface ExceptionListInfo extends ExceptionListSchema { rules: Rule[]; } diff --git a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/use_create_shared_list.tsx b/x-pack/plugins/security_solution/public/exceptions/hooks/use_create_shared_list/index.tsx similarity index 94% rename from x-pack/plugins/security_solution/public/exceptions/manage_exceptions/use_create_shared_list.tsx rename to x-pack/plugins/security_solution/public/exceptions/hooks/use_create_shared_list/index.tsx index ada52f39a8aa57..4789a762bf71b5 100644 --- a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/use_create_shared_list.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/hooks/use_create_shared_list/index.tsx @@ -10,7 +10,7 @@ import type { ExceptionListSchema } from '@kbn/securitysolution-io-ts-list-types import type { HttpStart } from '@kbn/core/public'; import { useAsync, withOptionalSignal } from '@kbn/securitysolution-hook-utils'; -import { SHARED_EXCEPTION_LIST_URL } from '../../../common/constants'; +import { SHARED_EXCEPTION_LIST_URL } from '../../../../common/constants'; export const createSharedExceptionList = async ({ name, diff --git a/x-pack/plugins/security_solution/public/exceptions/hooks/use_exceptions_list.card/index.tsx b/x-pack/plugins/security_solution/public/exceptions/hooks/use_exceptions_list.card/index.tsx new file mode 100644 index 00000000000000..0602431f469010 --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/hooks/use_exceptions_list.card/index.tsx @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, useEffect, useMemo, useState } from 'react'; +import type { + ExceptionListItemSchema, + NamespaceType, +} from '@kbn/securitysolution-io-ts-list-types'; +import { ViewerStatus } from '@kbn/securitysolution-exception-list-components'; +import { useGeneratedHtmlId } from '@elastic/eui'; +import type { ExceptionListInfo } from '../use_all_exception_lists'; +import { useListExceptionItems } from '../use_list_exception_items'; +import * as i18n from '../../translations/list_details'; +import { checkIfListCannotBeEdited } from '../../utils/list.utils'; + +interface ListAction { + id: string; + listId: string; + namespaceType: NamespaceType; +} +export const useExceptionsListCard = ({ + exceptionsList, + handleExport, + handleDelete, +}: { + exceptionsList: ExceptionListInfo; + handleExport: ({ id, listId, namespaceType }: ListAction) => () => Promise; + handleDelete: ({ id, listId, namespaceType }: ListAction) => () => Promise; +}) => { + const [viewerStatus, setViewerStatus] = useState(ViewerStatus.LOADING); + const [exceptionToEdit, setExceptionToEdit] = useState(); + const [showAddExceptionFlyout, setShowAddExceptionFlyout] = useState(false); + const [showEditExceptionFlyout, setShowEditExceptionFlyout] = useState(false); + + const { + name: listName, + list_id: listId, + rules: listRules, + type: listType, + created_by: createdBy, + created_at: createdAt, + description: listDescription, + } = exceptionsList; + + const onFinishFetchingExceptions = useCallback(() => { + setViewerStatus(''); + }, [setViewerStatus]); + + const onEditExceptionItem = (exception: ExceptionListItemSchema) => { + setExceptionToEdit(exception); + setShowEditExceptionFlyout(true); + }; + const { + lastUpdated, + exceptionViewerStatus, + exceptions, + pagination, + ruleReferences, + fetchItems, + onDeleteException, + onPaginationChange, + } = useListExceptionItems({ + list: exceptionsList, + deleteToastTitle: i18n.EXCEPTION_ITEM_DELETE_TITLE, + deleteToastBody: (name) => i18n.EXCEPTION_ITEM_DELETE_TEXT(name), + errorToastBody: i18n.EXCEPTION_ERROR_DESCRIPTION, + errorToastTitle: i18n.EXCEPTION_ERROR_TITLE, + onEditListExceptionItem: onEditExceptionItem, + onFinishFetchingExceptions, + }); + + useEffect(() => { + fetchItems(null, ViewerStatus.LOADING); + }, [fetchItems]); + + const [toggleAccordion, setToggleAccordion] = useState(false); + const openAccordionId = useGeneratedHtmlId({ prefix: 'openAccordion' }); + + const listCannotBeEdited = checkIfListCannotBeEdited(exceptionsList); + + const menuActionItems = useMemo( + () => [ + { + key: 'Export', + icon: 'exportAction', + label: i18n.EXPORT_EXCEPTION_LIST, + onClick: (e: React.MouseEvent) => { + handleExport({ + id: exceptionsList.id, + listId: exceptionsList.list_id, + namespaceType: exceptionsList.namespace_type, + })(); + }, + }, + { + key: 'Delete', + icon: 'trash', + disabled: listCannotBeEdited, + label: i18n.DELETE_EXCEPTION_LIST, + onClick: (e: React.MouseEvent) => { + handleDelete({ + id: exceptionsList.id, + listId: exceptionsList.list_id, + namespaceType: exceptionsList.namespace_type, + })(); + }, + }, + ], + [ + exceptionsList.id, + exceptionsList.list_id, + exceptionsList.namespace_type, + handleDelete, + handleExport, + listCannotBeEdited, + ] + ); + + // Once details Page is added all of these methods will be used from it as well + // as their own states + const onAddExceptionClick = useCallback(() => { + setShowAddExceptionFlyout(true); + }, [setShowAddExceptionFlyout]); + + const handleCancelExceptionItemFlyout = () => { + setShowAddExceptionFlyout(false); + setShowEditExceptionFlyout(false); + }; + const handleConfirmExceptionFlyout = useCallback( + (didExceptionChange: boolean): void => { + setShowAddExceptionFlyout(false); + setShowEditExceptionFlyout(false); + if (!didExceptionChange) return; + fetchItems(); + }, + [fetchItems, setShowAddExceptionFlyout, setShowEditExceptionFlyout] + ); + + return { + listId, + listName, + listDescription, + createdAt: new Date(createdAt).toDateString(), + createdBy, + listRulesCount: listRules.length.toString(), + exceptionItemsCount: pagination.totalItemCount.toString(), + listType, + menuActionItems, + showAddExceptionFlyout, + toggleAccordion, + openAccordionId, + viewerStatus, + exceptionToEdit, + showEditExceptionFlyout, + lastUpdated, + exceptions, + ruleReferences, + pagination, + exceptionViewerStatus, + onEditExceptionItem, + onDeleteException, + onPaginationChange, + setToggleAccordion, + onAddExceptionClick, + handleConfirmExceptionFlyout, + handleCancelExceptionItemFlyout, + }; +}; diff --git a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/use_import_exception_list.tsx b/x-pack/plugins/security_solution/public/exceptions/hooks/use_import_exception_list/index.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/exceptions/manage_exceptions/use_import_exception_list.tsx rename to x-pack/plugins/security_solution/public/exceptions/hooks/use_import_exception_list/index.tsx diff --git a/x-pack/plugins/security_solution/public/exceptions/hooks/use_list_exception_items/index.ts b/x-pack/plugins/security_solution/public/exceptions/hooks/use_list_exception_items/index.ts new file mode 100644 index 00000000000000..0da8169baf2f43 --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/hooks/use_list_exception_items/index.ts @@ -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 { useCallback, useState } from 'react'; +import type { Pagination } from '@elastic/eui'; +import { ViewerStatus } from '@kbn/securitysolution-exception-list-components'; +import type { RuleReferences } from '@kbn/securitysolution-exception-list-components'; +import type { + ExceptionListItemSchema, + ExceptionListSchema, +} from '@kbn/securitysolution-io-ts-list-types'; +import { useKibana, useToasts } from '../../../common/lib/kibana'; +import { + deleteException, + fetchListExceptionItems, + getExceptionItemsReferences, + prepareFetchExceptionItemsParams, +} from '../../api'; + +export interface UseListExceptionItemsProps { + list: ExceptionListSchema; + deleteToastTitle: string; + deleteToastBody: (exceptionName: string) => string; + errorToastTitle: string; + errorToastBody: string; + onEditListExceptionItem: (exceptionItem: ExceptionListItemSchema) => void; + onFinishFetchingExceptions?: () => void; +} + +export const useListExceptionItems = ({ + list, + deleteToastTitle, + deleteToastBody, + errorToastTitle, + errorToastBody, + onEditListExceptionItem, + onFinishFetchingExceptions, +}: UseListExceptionItemsProps) => { + const { services } = useKibana(); + const { http } = services; + const toasts = useToasts(); + + const [exceptions, setExceptions] = useState([]); + const [exceptionListReferences, setExceptionListReferences] = useState({}); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 0, + totalItemCount: 0, + }); + const [lastUpdated, setLastUpdated] = useState(null); + const [viewerStatus, setViewerStatus] = useState(''); + + const handleErrorStatus = useCallback( + (error: Error, errorTitle?: string, errorDescription?: string) => { + toasts?.addError(error, { + title: errorTitle || errorToastTitle, + toastMessage: errorDescription || errorToastBody, + }); + setViewerStatus(ViewerStatus.ERROR); + }, + [errorToastBody, errorToastTitle, toasts] + ); + + const getReferences = useCallback(async () => { + try { + const result: RuleReferences = await getExceptionItemsReferences(list); + setExceptionListReferences(result); + } catch (error) { + handleErrorStatus(error); + } + }, [handleErrorStatus, list, setExceptionListReferences]); + + const updateViewer = useCallback((paginationResult, dataLength, viewStatus) => { + setPagination(paginationResult); + setLastUpdated(Date.now()); + setTimeout(() => { + if (viewStatus === ViewerStatus.EMPTY_SEARCH) + return setViewerStatus(!dataLength ? viewStatus : ''); + + setViewerStatus(!dataLength ? ViewerStatus.EMPTY : ''); + }, 200); + }, []); + + const fetchItems = useCallback( + async (options?, viewStatus?) => { + try { + setViewerStatus(ViewerStatus.LOADING); + const { data, pagination: paginationResult } = await fetchListExceptionItems({ + http, + ...prepareFetchExceptionItemsParams(null, list, options), + }); + setExceptions(data); + getReferences(); + updateViewer(paginationResult, data.length, viewStatus); + if (typeof onFinishFetchingExceptions === 'function') onFinishFetchingExceptions(); + } catch (error) { + handleErrorStatus(error); + } + }, + [http, list, getReferences, updateViewer, onFinishFetchingExceptions, handleErrorStatus] + ); + + const onDeleteException = useCallback( + async ({ id, name, namespaceType }) => { + try { + setViewerStatus(ViewerStatus.LOADING); + await deleteException({ id, http, namespaceType }); + toasts?.addSuccess({ + title: deleteToastTitle, + text: typeof deleteToastBody === 'function' ? deleteToastBody(name) : '', + }); + fetchItems(); + } catch (error) { + handleErrorStatus(error); + } + }, + [http, toasts, deleteToastTitle, deleteToastBody, fetchItems, handleErrorStatus] + ); + const onEditExceptionItem = (exception: ExceptionListItemSchema) => { + if (typeof onEditListExceptionItem === 'function') onEditListExceptionItem(exception); + }; + const onPaginationChange = useCallback( + async (options) => { + fetchItems(options); + }, + [fetchItems] + ); + return { + exceptions, + lastUpdated, + pagination, + exceptionViewerStatus: viewerStatus, + + ruleReferences: exceptionListReferences, + fetchItems, + onDeleteException, + onEditExceptionItem, + onPaginationChange, + }; +}; diff --git a/x-pack/plugins/security_solution/public/exceptions/jest.config.js b/x-pack/plugins/security_solution/public/exceptions/jest.config.js index f4b96b3f07e7a0..cce8582ebdd865 100644 --- a/x-pack/plugins/security_solution/public/exceptions/jest.config.js +++ b/x-pack/plugins/security_solution/public/exceptions/jest.config.js @@ -14,6 +14,16 @@ module.exports = { coverageReporters: ['text', 'html'], collectCoverageFrom: [ '/x-pack/plugins/security_solution/public/exceptions/**/*.{ts,tsx}', + '!/x-pack/plugins/security_solution/public/exceptions/*.test.{ts,tsx}', + '!/x-pack/plugins/security_solution/public/exceptions/*.constants.{ts}', + '!/x-pack/plugins/security_solution/public/exceptions/{__test__,__snapshots__,__examples__,*mock*,tests,test_helpers,integration_tests,types}/**/*', + '!/x-pack/plugins/security_solution/public/exceptions/*mock*.{ts,tsx}', + '!/x-pack/plugins/security_solution/public/exceptions/*.test.{ts,tsx}', + '!/x-pack/plugins/security_solution/public/exceptions/*.d.ts', + '!/x-pack/plugins/security_solution/public/exceptions/*.config.ts', + '!/x-pack/plugins/security_solution/public/exceptions/index.{js,ts,tsx}', + '!/x-pack/plugins/security_solution/public/exceptions/translations/*', + '!/x-pack/plugins/security_solution/public/exceptions/*.translations', ], // See: https://github.com/elastic/kibana/issues/117255, the moduleNameMapper creates mocks to avoid memory leaks from kibana core. moduleNameMapper: { diff --git a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_list_card.tsx b/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_list_card.tsx deleted file mode 100644 index cc2552b4f9fd77..00000000000000 --- a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_list_card.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { memo, useState } from 'react'; - -import { - EuiLink, - EuiButtonIcon, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiFlexGroup, - EuiFlexItem, - EuiTextColor, - EuiPanel, - EuiPopover, - EuiText, -} from '@elastic/eui'; -import type { HttpSetup } from '@kbn/core-http-browser'; -import type { NamespaceType } from '@kbn/securitysolution-io-ts-list-types'; -import type { ExceptionListInfo } from './use_all_exception_lists'; -import { TitleBadge } from './title_badge'; -import * as i18n from './translations'; - -interface ExceptionsListCardProps { - exceptionsList: ExceptionListInfo; - http: HttpSetup; - handleDelete: ({ - id, - listId, - namespaceType, - }: { - id: string; - listId: string; - namespaceType: NamespaceType; - }) => () => Promise; - handleExport: ({ - id, - listId, - namespaceType, - }: { - id: string; - listId: string; - namespaceType: NamespaceType; - }) => () => Promise; - readOnly: boolean; -} - -export const ExceptionsListCard = memo( - ({ exceptionsList, http, handleDelete, handleExport, readOnly }) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const onItemActionsClick = () => setIsPopoverOpen((isOpen) => !isOpen); - const onClosePopover = () => setIsPopoverOpen(false); - - return ( - - - - - - - - - - - {exceptionsList.name.toString()} - - - - - {exceptionsList.description} - - - - - - - - - - - - - - - } - panelPaddingSize="none" - isOpen={isPopoverOpen} - closePopover={onClosePopover} - > - { - onClosePopover(); - handleDelete({ - id: exceptionsList.id, - listId: exceptionsList.list_id, - namespaceType: exceptionsList.namespace_type, - })(); - }} - > - {i18n.DELETE_EXCEPTION_LIST} - , - { - onClosePopover(); - handleExport({ - id: exceptionsList.id, - listId: exceptionsList.list_id, - namespaceType: exceptionsList.namespace_type, - })(); - }} - > - {i18n.EXPORT_EXCEPTION_LIST} - , - ]} - /> - - - - - - - ); - } -); - -ExceptionsListCard.displayName = 'ExceptionsListCard'; diff --git a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/translations.ts b/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/translations.ts deleted file mode 100644 index 0ef2345da14b34..00000000000000 --- a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/translations.ts +++ /dev/null @@ -1,161 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const uploadSuccessMessage = (fileName: string) => - i18n.translate('xpack.securitySolution.lists.exceptionListImportSuccess', { - defaultMessage: "Exception list '{fileName}' was imported", - values: { fileName }, - }); - -export const CREATED_BY = i18n.translate('xpack.securitySolution.exceptionsTable.createdBy', { - defaultMessage: 'Created By', -}); - -export const CREATED_AT = i18n.translate('xpack.securitySolution.exceptionsTable.createdAt', { - defaultMessage: 'Created At', -}); - -export const DELETE_EXCEPTION_LIST = i18n.translate( - 'xpack.securitySolution.exceptionsTable.deleteExceptionList', - { - defaultMessage: 'Delete Exception List', - } -); - -export const EXPORT_EXCEPTION_LIST = i18n.translate( - 'xpack.securitySolution.exceptionsTable.exportExceptionList', - { - defaultMessage: 'Export Exception List', - } -); - -export const IMPORT_EXCEPTION_LIST_HEADER = i18n.translate( - 'xpack.securitySolution.exceptionsTable.importExceptionListHeader', - { - defaultMessage: 'Import shared exception list', - } -); - -export const IMPORT_EXCEPTION_LIST_BODY = i18n.translate( - 'xpack.securitySolution.exceptionsTable.importExceptionListFlyoutBody', - { - defaultMessage: 'Select shared exception lists to import', - } -); - -export const IMPORT_EXCEPTION_LIST_WARNING = i18n.translate( - 'xpack.securitySolution.exceptionsTable.importExceptionListWarning', - { - defaultMessage: 'We found a pre-existing list with that id', - } -); - -export const IMPORT_EXCEPTION_LIST_OVERWRITE = i18n.translate( - 'xpack.securitySolution.exceptionsTable.importExceptionListOverwrite', - { - defaultMessage: 'Overwrite the existing list', - } -); - -export const IMPORT_EXCEPTION_LIST_AS_NEW_LIST = i18n.translate( - 'xpack.securitySolution.exceptionsTable.importExceptionListAsNewList', - { - defaultMessage: 'Create new list', - } -); - -export const UPLOAD_SUCCESS_TITLE = i18n.translate( - 'xpack.securitySolution.lists.exceptionListImportSuccessTitle', - { - defaultMessage: 'Exception list imported', - } -); - -export const UPLOAD_ERROR = i18n.translate( - 'xpack.securitySolution.lists.exceptionListUploadError', - { - defaultMessage: 'There was an error uploading the exception list.', - } -); - -export const UPLOAD_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionListsImportButton', - { - defaultMessage: 'Import list', - } -); - -export const CLOSE_FLYOUT = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionListsCloseImportFlyout', - { - defaultMessage: 'Close', - } -); - -export const IMPORT_PROMPT = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionListsFilePickerPrompt', - { - defaultMessage: 'Select or drag and drop multiple files', - } -); - -export const CREATE_SHARED_LIST_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.createSharedExceptionListTitle', - { - defaultMessage: 'Create shared exception list', - } -); - -export const CREATE_SHARED_LIST_NAME_FIELD = i18n.translate( - 'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutNameField', - { - defaultMessage: 'Shared exception list name', - } -); - -export const CREATE_SHARED_LIST_NAME_FIELD_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutNameFieldPlaceholder', - { - defaultMessage: 'New exception list', - } -); - -export const CREATE_SHARED_LIST_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutDescription', - { - defaultMessage: 'Description (optional)', - } -); - -export const CREATE_SHARED_LIST_DESCRIPTION_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutDescriptionPlaceholder', - { - defaultMessage: 'New exception list', - } -); - -export const CREATE_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutCreateButton', - { - defaultMessage: 'Create shared exception list', - } -); - -export const getSuccessText = (listName: string) => - i18n.translate('xpack.securitySolution.exceptions.createSharedExceptionListSuccessDescription', { - defaultMessage: 'list with name ${listName} was created!', - values: { listName }, - }); - -export const SUCCESS_TITLE = i18n.translate( - 'xpack.securitySolution.exceptions.createSharedExceptionListSuccessTitle', - { - defaultMessage: 'created list', - } -); diff --git a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx similarity index 89% rename from x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_table.tsx rename to x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx index 620208e9012cff..584cc5d032a44e 100644 --- a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_table.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/index.tsx @@ -27,28 +27,28 @@ import { import type { NamespaceType, ExceptionListFilter } from '@kbn/securitysolution-io-ts-list-types'; import { useApi, useExceptionLists } from '@kbn/securitysolution-list-hooks'; -import { AutoDownload } from '../../common/components/auto_download/auto_download'; -import { Loader } from '../../common/components/loader'; -import { useKibana } from '../../common/lib/kibana'; -import { useAppToasts } from '../../common/hooks/use_app_toasts'; - -import * as i18n from './translations_exceptions_table'; -import { ExceptionsTableUtilityBar } from './exceptions_table_utility_bar'; -import { useAllExceptionLists } from './use_all_exception_lists'; -import { ReferenceErrorModal } from '../../detections/components/value_lists_management_flyout/reference_error_modal'; -import { patchRule } from '../../detection_engine/rule_management/api/api'; -import { ExceptionsSearchBar } from './exceptions_search_bar'; -import { getSearchFilters } from '../../detection_engine/rule_management_ui/components/rules_table/helpers'; -import { useUserData } from '../../detections/components/user_info'; -import { useListsConfig } from '../../detections/containers/detection_engine/lists/use_lists_config'; -import { MissingPrivilegesCallOut } from '../../detections/components/callouts/missing_privileges_callout'; -import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../../common/endpoint/service/artifacts/constants'; -import { ExceptionsListCard } from './exceptions_list_card'; - -import { ImportExceptionListFlyout } from './import_exceptions_list_flyout'; -import { CreateSharedListFlyout } from './create_shared_exception_list'; - -import { AddExceptionFlyout } from '../../detection_engine/rule_exceptions/components/add_exception_flyout'; +import { AutoDownload } from '../../../common/components/auto_download/auto_download'; +import { Loader } from '../../../common/components/loader'; +import { useKibana } from '../../../common/lib/kibana'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; + +import * as i18n from '../../translations/shared_list'; +import { ExceptionsTableUtilityBar } from '../../components/shared_list_utilty_bar'; +import { useAllExceptionLists } from '../../hooks/use_all_exception_lists'; +import { ReferenceErrorModal } from '../../../detections/components/value_lists_management_flyout/reference_error_modal'; +import { patchRule } from '../../../detection_engine/rule_management/api/api'; +import { ExceptionsSearchBar } from '../../components/list_search_bar'; +import { getSearchFilters } from '../../../detection_engine/rule_management_ui/components/rules_table/helpers'; +import { useUserData } from '../../../detections/components/user_info'; +import { useListsConfig } from '../../../detections/containers/detection_engine/lists/use_lists_config'; +import { MissingPrivilegesCallOut } from '../../../detections/components/callouts/missing_privileges_callout'; +import { ALL_ENDPOINT_ARTIFACT_LIST_IDS } from '../../../../common/endpoint/service/artifacts/constants'; +import { ExceptionsListCard } from '../../components/exceptions_list_card'; + +import { ImportExceptionListFlyout } from '../../components/import_exceptions_list_flyout'; +import { CreateSharedListFlyout } from '../../components/create_shared_exception_list'; + +import { AddExceptionFlyout } from '../../../detection_engine/rule_exceptions/components/add_exception_flyout'; export type Func = () => Promise; @@ -68,7 +68,7 @@ const exceptionReferenceModalInitialState: ReferenceModalState = { listNamespaceType: 'single', }; -export const ExceptionListsTable = React.memo(() => { +export const SharedLists = React.memo(() => { const [{ loading: userInfoLoading, canUserCRUD, canUserREAD }] = useUserData(); const { loading: listsConfigLoading } = useListsConfig(); @@ -304,6 +304,7 @@ export const ExceptionListsTable = React.memo(() => { iconSide="right" onClick={onRowSizeButtonClick} > + {/* TODO move to translations */} {`Rows per page: ${rowSize}`} ); @@ -479,9 +480,8 @@ export const ExceptionListsTable = React.memo(() => { totalExceptionLists={exceptionListsWithRuleRefs.length} onRefresh={handleRefresh} /> - {exceptionListsWithRuleRefs.length > 0 && canUserCRUD !== null && canUserREAD !== null && ( - +

{exceptionListsWithRuleRefs.map((excList) => ( { handleExport={handleExport} /> ))} - +
)} )} @@ -546,4 +546,4 @@ export const ExceptionListsTable = React.memo(() => { ); }); -ExceptionListsTable.displayName = 'ExceptionListsTable'; +SharedLists.displayName = 'SharedLists'; diff --git a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_table.test.tsx b/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/shared_lists.test.tsx similarity index 63% rename from x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_table.test.tsx rename to x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/shared_lists.test.tsx index 03196264b79c29..3665b825a712cb 100644 --- a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/exceptions_table.test.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/pages/shared_lists/shared_lists.test.tsx @@ -6,21 +6,21 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import { TestProviders } from '../../common/mock'; +import { TestProviders } from '../../../common/mock'; import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock'; -import { useUserData } from '../../detections/components/user_info'; +import { useUserData } from '../../../detections/components/user_info'; -import { ExceptionListsTable } from './exceptions_table'; +import { SharedLists } from '.'; import { useApi, useExceptionLists } from '@kbn/securitysolution-list-hooks'; -import { useAllExceptionLists } from './use_all_exception_lists'; +import { useAllExceptionLists } from '../../hooks/use_all_exception_lists'; import { useHistory } from 'react-router-dom'; -import { generateHistoryMock } from '../../common/utils/route/mocks'; +import { generateHistoryMock } from '../../../common/utils/route/mocks'; +import { fireEvent, render, waitFor } from '@testing-library/react'; -jest.mock('../../detections/components/user_info'); -jest.mock('../../common/lib/kibana'); -jest.mock('./use_all_exception_lists'); +jest.mock('../../../detections/components/user_info'); +jest.mock('../../../common/utils/route/mocks'); +jest.mock('../../hooks/use_all_exception_lists'); jest.mock('@kbn/securitysolution-list-hooks'); jest.mock('react-router-dom', () => { const originalModule = jest.requireActual('react-router-dom'); @@ -39,11 +39,11 @@ jest.mock('@kbn/i18n-react', () => { }; }); -jest.mock('../../detections/containers/detection_engine/lists/use_lists_config', () => ({ +jest.mock('../../../detections/containers/detection_engine/lists/use_lists_config', () => ({ useListsConfig: jest.fn().mockReturnValue({ loading: false }), })); -describe('ExceptionListsTable', () => { +describe('SharedLists', () => { const mockHistory = generateHistoryMock(); const exceptionList1 = getExceptionListSchemaMock(); const exceptionList2 = { ...getExceptionListSchemaMock(), list_id: 'not_endpoint_list', id: '2' }; @@ -91,21 +91,18 @@ describe('ExceptionListsTable', () => { }); it('renders delete option as disabled if list is "endpoint_list"', async () => { - const wrapper = mount( + const wrapper = render( - + ); + const allMenuActions = wrapper.getAllByTestId('sharedListOverflowCardButtonIcon'); + fireEvent.click(allMenuActions[0]); - wrapper - .find('[data-test-subj="exceptionsListCardOverflowActions"] button') - .at(0) - .simulate('click'); - - expect(wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button')).toHaveLength(1); - expect( - wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button').at(0).prop('disabled') - ).toBeTruthy(); + await waitFor(() => { + const allDeleteActions = wrapper.getAllByTestId('sharedListOverflowCardActionItemDelete'); + expect(allDeleteActions[0]).toBeDisabled(); + }); }); it('renders delete option as disabled if user is read only', async () => { @@ -117,17 +114,19 @@ describe('ExceptionListsTable', () => { }, ]); - const wrapper = mount( + const wrapper = render( - + ); - wrapper - .find('[data-test-subj="exceptionsListCardOverflowActions"] button') - .at(0) - .simulate('click'); - expect( - wrapper.find('[data-test-subj="exceptionsTableDeleteButton"] button').at(0).prop('disabled') - ).toBeTruthy(); + const allMenuActions = wrapper.getAllByTestId('sharedListOverflowCardButtonIcon'); + fireEvent.click(allMenuActions[1]); + + await waitFor(() => { + const allDeleteActions = wrapper.queryAllByTestId('sharedListOverflowCardActionItemDelete'); + expect(allDeleteActions).toEqual([]); + const allExportActions = wrapper.queryAllByTestId('sharedListOverflowCardActionItemExport'); + expect(allExportActions).toEqual([]); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/exceptions/routes.tsx b/x-pack/plugins/security_solution/public/exceptions/routes.tsx index 367474aa12ae75..d91669f4f6c892 100644 --- a/x-pack/plugins/security_solution/public/exceptions/routes.tsx +++ b/x-pack/plugins/security_solution/public/exceptions/routes.tsx @@ -11,7 +11,7 @@ import { Route } from '@kbn/kibana-react-plugin/public'; import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; import * as i18n from './translations'; import { EXCEPTIONS_PATH, SecurityPageName } from '../../common/constants'; -import { ExceptionListsTable } from './manage_exceptions/exceptions_table'; +import { SharedLists } from './pages/shared_lists'; import { SpyRoute } from '../common/utils/route/spy_routes'; import { NotFoundPage } from '../app/404'; import { useReadonlyHeader } from '../use_readonly_header'; @@ -20,7 +20,7 @@ import { PluginTemplateWrapper } from '../common/components/plugin_template_wrap const ExceptionsRoutes = () => ( - + diff --git a/x-pack/plugins/security_solution/public/exceptions/translations/index.ts b/x-pack/plugins/security_solution/public/exceptions/translations/index.ts new file mode 100644 index 00000000000000..9aa34e16e6054c --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/translations/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export * from './list_details'; +export * from './list_exception_items'; +export * from './shared_list'; diff --git a/x-pack/plugins/security_solution/public/exceptions/translations/list_details.ts b/x-pack/plugins/security_solution/public/exceptions/translations/list_details.ts new file mode 100644 index 00000000000000..8c027a9c8102e1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/translations/list_details.ts @@ -0,0 +1,133 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const EXCEPTION_LIST_EMPTY_VIEWER_TITLE = i18n.translate( + 'xpack.securitySolution.exception.list.empty.viewer_title', + { + defaultMessage: 'Create exceptions to this list', + } +); + +export const EXCEPTION_LIST_EMPTY_VIEWER_BODY = (listName: string) => + i18n.translate('xpack.securitySolution.exception.list.empty.viewer_body', { + values: { listName }, + defaultMessage: + 'There is no exception in your [{listName}]. Create rule exceptions to this list.', + }); + +export const EXCEPTION_LIST_EMPTY_VIEWER_BUTTON = i18n.translate( + 'xpack.securitySolution.exception.list.empty.viewer_button', + { + defaultMessage: 'Create rule exception', + } +); + +export const EXCEPTION_LIST_EMPTY_SEARCH_BAR_BUTTON = i18n.translate( + 'xpack.securitySolution.exception.list.search_bar_button', + { + defaultMessage: 'Add rule exception to list', + } +); + +export const EXCEPTION_LIST_SEARCH_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.exceptions.list.exceptionItemSearchErrorTitle', + { + defaultMessage: 'Error searching', + } +); + +export const EXCEPTION_LIST_SEARCH_ERROR_BODY = i18n.translate( + 'xpack.securitySolution.exceptions.list.exceptionItemSearchErrorBody', + { + defaultMessage: 'An error occurred searching for exception items. Please try again.', + } +); + +export const EXCEPTION_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.exceptions.list.exceptionItemsFetchError', + { + defaultMessage: 'Unable to load exception items', + } +); + +export const EXCEPTION_ERROR_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.exceptions.list.exceptionItemsFetchErrorDescription', + { + defaultMessage: + 'There was an error loading the exception items. Contact your administrator for help.', + } +); + +export const EXCEPTION_ITEM_DELETE_TITLE = i18n.translate( + 'xpack.securitySolution.exceptions.list.exception.item.card.exceptionItemDeleteSuccessTitle', + { + defaultMessage: 'Exception deleted', + } +); + +export const EXCEPTION_ITEM_DELETE_TEXT = (itemName: string) => + i18n.translate( + 'xpack.securitySolution.exceptions.list.exception.item.card.exceptionItemDeleteSuccessText', + { + values: { itemName }, + defaultMessage: '"{itemName}" deleted successfully.', + } + ); + +export const EXCEPTION_LIST_EXPORTED_SUCCESSFULLY = (listName: string) => + i18n.translate('xpack.securitySolution.exceptions.list.exported_successfully', { + values: { listName }, + defaultMessage: '{listName} exported successfully', + }); + +export const EXCEPTION_LIST_DELETED_SUCCESSFULLY = (listName: string) => + i18n.translate('xpack.securitySolution.exceptions.list.deleted_successfully', { + values: { listName }, + defaultMessage: '{listName} deleted successfully', + }); +export const MANAGE_RULES_CANCEL = i18n.translate( + 'xpack.securitySolution.exceptions.list.manage_rules_cancel', + { + defaultMessage: 'Cancel', + } +); + +export const MANAGE_RULES_SAVE = i18n.translate( + 'xpack.securitySolution.exceptions.list.manage_rules_save', + { + defaultMessage: 'Save', + } +); +export const MANAGE_RULES_HEADER = i18n.translate( + 'xpack.securitySolution.exceptions.list.manage_rules_header', + { + defaultMessage: 'Manege rules', + } +); + +export const MANAGE_RULES_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.exceptions.list.manage_rules_description', + { + defaultMessage: 'Link or unlink rules to this exception list.', + } +); + +export const DELETE_EXCEPTION_LIST = i18n.translate( + 'xpack.securitySolution.exceptionsTable.deleteExceptionList', + { + defaultMessage: 'Delete Exception List', + } +); + +export const EXPORT_EXCEPTION_LIST = i18n.translate( + 'xpack.securitySolution.exceptionsTable.exportExceptionList', + { + defaultMessage: 'Export Exception List', + } +); diff --git a/x-pack/plugins/security_solution/public/exceptions/translations/list_exception_items.ts b/x-pack/plugins/security_solution/public/exceptions/translations/list_exception_items.ts new file mode 100644 index 00000000000000..1310226ef5d54a --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/translations/list_exception_items.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; +export const EXCEPTION_ITEM_CARD_EDIT_LABEL = i18n.translate( + 'xpack.securitySolution.exceptions.list.exception.item.card.edit.label', + { + defaultMessage: 'Edit rule exception', + } +); + +export const EXCEPTION_ITEM_CARD_DELETE_LABEL = i18n.translate( + 'xpack.securitySolution.exceptions.list.exception.item.card.delete.label', + { + defaultMessage: 'Delete rule exception', + } +); + +export const EXCEPTION_UTILITY_TITLE = i18n.translate( + 'xpack.securitySolution.exceptions.list.utility.title', + { + defaultMessage: 'rule exceptions', + } +); diff --git a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/translations_exceptions_table.ts b/x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts similarity index 63% rename from x-pack/plugins/security_solution/public/exceptions/manage_exceptions/translations_exceptions_table.ts rename to x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts index 0263452120a830..5eaf096400db7e 100644 --- a/x-pack/plugins/security_solution/public/exceptions/manage_exceptions/translations_exceptions_table.ts +++ b/x-pack/plugins/security_solution/public/exceptions/translations/shared_list.ts @@ -202,10 +202,74 @@ export const IMPORT_EXCEPTION_LIST_BUTTON = i18n.translate( } ); -export const CREATE_BUTTON = i18n.translate( - 'xpack.securitySolution.exceptions.manageExceptions.create', +export const IMPORT_EXCEPTION_LIST_HEADER = i18n.translate( + 'xpack.securitySolution.exceptionsTable.importExceptionListFlyoutHeader', + { + defaultMessage: 'Import shared exception list', + } +); + +export const IMPORT_EXCEPTION_LIST_BODY = i18n.translate( + 'xpack.securitySolution.exceptionsTable.importExceptionListFlyoutBody', + { + defaultMessage: 'Select shared exception lists to import', + } +); +export const IMPORT_EXCEPTION_LIST_WARNING = i18n.translate( + 'xpack.securitySolution.exceptionsTable.importExceptionListWarning', + { + defaultMessage: 'We found a pre-existing list with that id', + } +); + +export const IMPORT_EXCEPTION_LIST_OVERWRITE = i18n.translate( + 'xpack.securitySolution.exceptionsTable.importExceptionListOverwrite', + { + defaultMessage: 'Overwrite the existing list', + } +); + +export const IMPORT_EXCEPTION_LIST_AS_NEW_LIST = i18n.translate( + 'xpack.securitySolution.exceptionsTable.importExceptionListAsNewList', + { + defaultMessage: 'Create new list', + } +); + +export const READ_ONLY_BADGE_TOOLTIP = i18n.translate( + 'xpack.securitySolution.exceptions.badge.readOnly.tooltip', + { + defaultMessage: 'Unable to create, edit or delete exceptions', + } +); + +export const CLOSE_FLYOUT = i18n.translate( + 'xpack.securitySolution.exceptions.exceptionListsCloseImportFlyout', + { + defaultMessage: 'Close', + } +); +export const IMPORT_PROMPT = i18n.translate( + 'xpack.securitySolution.exceptions.exceptionListsFilePickerPrompt', + { + defaultMessage: 'Select or drag and drop multiple files', + } +); + +export const RULES = i18n.translate('xpack.securitySolution.exceptionsTable.rulesCountLabel', { + defaultMessage: 'Rules', +}); +export const CREATED_BY = i18n.translate('xpack.securitySolution.exceptionsTable.createdBy', { + defaultMessage: 'Created By', +}); + +export const DATE_CREATED = i18n.translate('xpack.securitySolution.exceptionsTable.createdAt', { + defaultMessage: 'Date created', +}); +export const EXCEPTIONS = i18n.translate( + 'xpack.securitySolution.exceptionsTable.exceptionsCountLabel', { - defaultMessage: 'Create', + defaultMessage: 'Exceptions', } ); @@ -222,3 +286,58 @@ export const CREATE_BUTTON_ITEM_BUTTON = i18n.translate( defaultMessage: 'create exception item', } ); + +export const CREATE_SHARED_LIST_TITLE = i18n.translate( + 'xpack.securitySolution.exceptions.createSharedExceptionListTitle', + { + defaultMessage: 'Create shared exception list', + } +); + +export const CREATE_SHARED_LIST_NAME_FIELD = i18n.translate( + 'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutNameField', + { + defaultMessage: 'Shared exception list name', + } +); + +export const CREATE_SHARED_LIST_NAME_FIELD_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutNameFieldPlaceholder', + { + defaultMessage: 'New exception list', + } +); + +export const CREATE_SHARED_LIST_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutDescription', + { + defaultMessage: 'Description (optional)', + } +); + +export const CREATE_SHARED_LIST_DESCRIPTION_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutDescriptionPlaceholder', + { + defaultMessage: 'New exception list', + } +); + +export const CREATE_BUTTON = i18n.translate( + 'xpack.securitySolution.exceptions.createSharedExceptionListFlyoutCreateButton', + { + defaultMessage: 'Create shared exception list', + } +); + +export const getSuccessText = (listName: string) => + i18n.translate('xpack.securitySolution.exceptions.createSharedExceptionListSuccessDescription', { + defaultMessage: 'list with name ${listName} was created!', + values: { listName }, + }); + +export const SUCCESS_TITLE = i18n.translate( + 'xpack.securitySolution.exceptions.createSharedExceptionListSuccessTitle', + { + defaultMessage: 'created list', + } +); diff --git a/x-pack/plugins/security_solution/public/exceptions/utils/list.utils.ts b/x-pack/plugins/security_solution/public/exceptions/utils/list.utils.ts new file mode 100644 index 00000000000000..adc7cb012c6b4c --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/utils/list.utils.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { listIDsCannotBeEdited } from '../config'; +import type { ExceptionListInfo } from '../hooks/use_all_exception_lists'; + +export const checkIfListCannotBeEdited = (list: ExceptionListInfo) => { + return !!listIDsCannotBeEdited.find((id) => id === list.list_id); +}; diff --git a/x-pack/plugins/security_solution/public/exceptions/utils/translations.ts b/x-pack/plugins/security_solution/public/exceptions/utils/translations.ts new file mode 100644 index 00000000000000..012f4e677a5b27 --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/utils/translations.ts @@ -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 { i18n } from '@kbn/i18n'; + +export const COMMENTS_SHOW = (comments: number) => + i18n.translate('xpack.securitySolution.exceptions.showCommentsLabel', { + values: { comments }, + defaultMessage: 'Show ({comments}) {comments, plural, =1 {Comment} other {Comments}}', + }); + +export const COMMENTS_HIDE = (comments: number) => + i18n.translate('xpack.securitySolution.exceptions.hideCommentsLabel', { + values: { comments }, + defaultMessage: 'Hide ({comments}) {comments, plural, =1 {Comment} other {Comments}}', + }); + +export const COMMENT_EVENT = i18n.translate('xpack.securitySolution.exceptions.commentEventLabel', { + defaultMessage: 'added a comment', +}); + +export const OPERATING_SYSTEM_LABEL = i18n.translate( + 'xpack.securitySolution.exceptions.operatingSystemFullLabel', + { + defaultMessage: 'Operating System', + } +); + +export const ADD_TO_ENDPOINT_LIST = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.addToEndpointListLabel', + { + defaultMessage: 'Add endpoint exception', + } +); + +export const ADD_TO_DETECTIONS_LIST = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.addToDetectionsListLabel', + { + defaultMessage: 'Add rule exception', + } +); + +export const ADD_COMMENT_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.addCommentPlaceholder', + { + defaultMessage: 'Add a new comment...', + } +); + +export const ADD_TO_CLIPBOARD = i18n.translate( + 'xpack.securitySolution.exceptions.viewer.addToClipboard', + { + defaultMessage: 'Comment', + } +); + +export const CLEAR_EXCEPTIONS_LABEL = i18n.translate( + 'xpack.securitySolution.exceptions.clearExceptionsLabel', + { + defaultMessage: 'Remove Exception List', + } +); + +export const ADD_EXCEPTION_FETCH_404_ERROR = (listId: string) => + i18n.translate('xpack.securitySolution.exceptions.fetch404Error', { + values: { listId }, + defaultMessage: + 'The associated exception list ({listId}) no longer exists. Please remove the missing exception list to add additional exceptions to the detection rule.', + }); + +export const ADD_EXCEPTION_FETCH_ERROR = i18n.translate( + 'xpack.securitySolution.exceptions.fetchError', + { + defaultMessage: 'Error fetching exception list', + } +); + +export const ERROR = i18n.translate('xpack.securitySolution.exceptions.errorLabel', { + defaultMessage: 'Error', +}); + +export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.cancelLabel', { + defaultMessage: 'Cancel', +}); + +export const MODAL_ERROR_ACCORDION_TEXT = i18n.translate( + 'xpack.securitySolution.exceptions.modalErrorAccordionText', + { + defaultMessage: 'Show rule reference information:', + } +); + +export const DISASSOCIATE_LIST_SUCCESS = (id: string) => + i18n.translate('xpack.securitySolution.exceptions.disassociateListSuccessText', { + values: { id }, + defaultMessage: 'Exception list ({id}) has successfully been removed', + }); + +export const DISASSOCIATE_EXCEPTION_LIST_ERROR = i18n.translate( + 'xpack.securitySolution.exceptions.disassociateExceptionListError', + { + defaultMessage: 'Failed to remove exception list', + } +); + +export const OPERATING_SYSTEM_WINDOWS = i18n.translate( + 'xpack.securitySolution.exceptions.operatingSystemWindows', + { + defaultMessage: 'Windows', + } +); + +export const OPERATING_SYSTEM_MAC = i18n.translate( + 'xpack.securitySolution.exceptions.operatingSystemMac', + { + defaultMessage: 'macOS', + } +); + +export const OPERATING_SYSTEM_WINDOWS_AND_MAC = i18n.translate( + 'xpack.securitySolution.exceptions.operatingSystemWindowsAndMac', + { + defaultMessage: 'Windows and macOS', + } +); + +export const OPERATING_SYSTEM_LINUX = i18n.translate( + 'xpack.securitySolution.exceptions.operatingSystemLinux', + { + defaultMessage: 'Linux', + } +); + +export const ERROR_FETCHING_REFERENCES_TITLE = i18n.translate( + 'xpack.securitySolution.exceptions.fetchingReferencesErrorToastTitle', + { + defaultMessage: 'Error fetching exception references', + } +); diff --git a/x-pack/plugins/security_solution/public/exceptions/utils/ui.helpers.test.tsx b/x-pack/plugins/security_solution/public/exceptions/utils/ui.helpers.test.tsx new file mode 100644 index 00000000000000..8b571d82981755 --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/utils/ui.helpers.test.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type React from 'react'; +import { mount } from 'enzyme'; +import moment from 'moment-timezone'; + +import { getFormattedComments } from './ui.helpers'; +import { getCommentsArrayMock } from '@kbn/lists-plugin/common/schemas/types/comment.mock'; + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('123'), +})); + +describe('Exception helpers', () => { + beforeEach(() => { + moment.tz.setDefault('UTC'); + }); + + afterEach(() => { + moment.tz.setDefault('Browser'); + }); + + describe('#getFormattedComments', () => { + test('it returns formatted comment object with username and timestamp', () => { + const payload = getCommentsArrayMock(); + const result = getFormattedComments(payload); + + expect(result[0].username).toEqual('some user'); + expect(result[0].timestamp).toEqual('on Apr 20th 2020 @ 15:25:31'); + }); + + test('it returns formatted timeline icon with comment users initial', () => { + const payload = getCommentsArrayMock(); + const result = getFormattedComments(payload); + + const wrapper = mount(result[0].timelineAvatar as React.ReactElement); + + expect(wrapper.text()).toEqual('SU'); + }); + + test('it returns comment text', () => { + const payload = getCommentsArrayMock(); + const result = getFormattedComments(payload); + + const wrapper = mount(result[0].children as React.ReactElement); + + expect(wrapper.text()).toEqual('some old comment'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/exceptions/utils/ui.helpers.tsx b/x-pack/plugins/security_solution/public/exceptions/utils/ui.helpers.tsx new file mode 100644 index 00000000000000..979d21e300fc85 --- /dev/null +++ b/x-pack/plugins/security_solution/public/exceptions/utils/ui.helpers.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { EuiCommentProps } from '@elastic/eui'; +import { EuiText, EuiAvatar } from '@elastic/eui'; + +import type { CommentsArray } from '@kbn/securitysolution-io-ts-list-types'; + +import moment from 'moment'; +import * as i18n from './translations'; +import { WithCopyToClipboard } from '../../common/lib/clipboard/with_copy_to_clipboard'; + +/** + * Formats ExceptionItem.comments into EuiCommentList format + * + * @param comments ExceptionItem.comments + */ +export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[] => + comments.map((commentItem) => ({ + username: commentItem.created_by, + timestamp: moment(commentItem.created_at).format('on MMM Do YYYY @ HH:mm:ss'), + event: i18n.COMMENT_EVENT, + timelineAvatar: , + children: {commentItem.comment}, + actions: ( + + ), + })); From b721fdcf4288e5a26b39672a178f12c582624585 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 9 Nov 2022 14:13:10 -0700 Subject: [PATCH 18/18] [Security solution] Guided onboarding, alerts & cases design updates (#144249) --- .github/CODEOWNERS | 1 + .../create/flyout/create_case_flyout.tsx | 6 +- .../public/components/create/form.test.tsx | 32 +++- .../cases/public/components/create/form.tsx | 6 +- .../public/components/create/form_context.tsx | 5 +- .../components/create/submit_button.tsx | 1 + .../public/cases/pages/index.test.tsx | 93 +++++++++++ .../public/cases/pages/index.tsx | 17 +- .../event_details/event_details.tsx | 10 +- .../insights/insight_accordion.tsx | 6 +- .../insights/related_cases.test.tsx | 115 ++++++++----- .../event_details/insights/related_cases.tsx | 61 ++++--- .../guided_onboarding_tour/README.md | 20 ++- .../cases_tour_steps.test.tsx | 34 ++++ .../cases_tour_steps.tsx | 48 ++++++ .../guided_onboarding_tour/tour.test.tsx | 44 +++-- .../guided_onboarding_tour/tour.tsx | 40 ++--- .../guided_onboarding_tour/tour_config.ts | 72 ++++++++- .../guided_onboarding_tour/tour_step.test.tsx | 106 +++++++----- .../guided_onboarding_tour/tour_step.tsx | 60 ++++--- .../public/common/components/links/index.tsx | 45 ++++-- .../use_add_to_case_actions.test.tsx | 152 ++++++++++++++++++ .../use_add_to_case_actions.tsx | 38 +++-- .../use_responder_action_item.tsx | 1 + .../components/take_action_dropdown/index.tsx | 15 +- .../render_cell_value.tsx | 14 +- .../security_solution/public/helpers.tsx | 14 +- .../timeline/body/actions/index.tsx | 17 +- 28 files changed, 856 insertions(+), 217 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/cases/pages/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/cases_tour_steps.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/cases_tour_steps.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_add_to_case_actions.test.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ca96fa8a0905b0..30827ee5182795 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -486,6 +486,7 @@ /x-pack/plugins/security_solution/cypress/tasks/network @elastic/security-threat-hunting-explore /x-pack/plugins/security_solution/cypress/upgrade_e2e/threat_hunting/cases @elastic/security-threat-hunting-explore +/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour @elastic/security-threat-hunting-explore /x-pack/plugins/security_solution/public/common/components/charts @elastic/security-threat-hunting-explore /x-pack/plugins/security_solution/public/common/components/header_page @elastic/security-threat-hunting-explore /x-pack/plugins/security_solution/public/common/components/header_section @elastic/security-threat-hunting-explore diff --git a/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx b/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx index 8f5e420f6b79d0..d20d14c5746988 100644 --- a/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx +++ b/x-pack/plugins/cases/public/components/create/flyout/create_case_flyout.tsx @@ -10,6 +10,7 @@ import styled, { createGlobalStyle } from 'styled-components'; import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui'; import { QueryClientProvider } from '@tanstack/react-query'; +import type { CasePostRequest } from '../../../../common/api'; import * as i18n from '../translations'; import type { Case } from '../../../../common/ui/types'; import { CreateCaseForm } from '../form'; @@ -26,6 +27,7 @@ export interface CreateCaseFlyoutProps { onSuccess?: (theCase: Case) => Promise; attachments?: CaseAttachmentsWithoutOwner; headerContent?: React.ReactNode; + initialValue?: Pick; } const StyledFlyout = styled(EuiFlyout)` @@ -72,7 +74,7 @@ const FormWrapper = styled.div` `; export const CreateCaseFlyout = React.memo( - ({ afterCaseCreated, onClose, onSuccess, attachments, headerContent }) => { + ({ afterCaseCreated, attachments, headerContent, initialValue, onClose, onSuccess }) => { const handleCancel = onClose || function () {}; const handleOnSuccess = onSuccess || async function () {}; @@ -81,6 +83,7 @@ export const CreateCaseFlyout = React.memo( ( onCancel={handleCancel} onSuccess={handleOnSuccess} withSteps={false} + initialValue={initialValue} /> diff --git a/x-pack/plugins/cases/public/components/create/form.test.tsx b/x-pack/plugins/cases/public/components/create/form.test.tsx index ddc65f443bdb30..0f05f7a25ad212 100644 --- a/x-pack/plugins/cases/public/components/create/form.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { mount } from 'enzyme'; -import { act, render } from '@testing-library/react'; +import { act, render, within } from '@testing-library/react'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import { NONE_CONNECTOR_ID } from '../../../common/api'; @@ -182,4 +182,34 @@ describe('CreateCaseForm', () => { expect(result.getByTestId('createCaseAssigneesComboBox')).toBeInTheDocument(); }); + + it('should not prefill the form when no initialValue provided', () => { + const { getByTestId } = render( + + + + ); + + const titleInput = within(getByTestId('caseTitle')).getByTestId('input'); + const descriptionInput = within(getByTestId('caseDescription')).getByRole('textbox'); + expect(titleInput).toHaveValue(''); + expect(descriptionInput).toHaveValue(''); + }); + + it('should prefill the form when provided with initialValue', () => { + const { getByTestId } = render( + + + + ); + + const titleInput = within(getByTestId('caseTitle')).getByTestId('input'); + const descriptionInput = within(getByTestId('caseDescription')).getByRole('textbox'); + + expect(titleInput).toHaveValue('title'); + expect(descriptionInput).toHaveValue('description'); + }); }); diff --git a/x-pack/plugins/cases/public/components/create/form.tsx b/x-pack/plugins/cases/public/components/create/form.tsx index e1a0c4f3b1cea1..4ec587667e18d9 100644 --- a/x-pack/plugins/cases/public/components/create/form.tsx +++ b/x-pack/plugins/cases/public/components/create/form.tsx @@ -23,7 +23,7 @@ import { Tags } from './tags'; import { Connector } from './connector'; import * as i18n from './translations'; import { SyncAlertsToggle } from './sync_alerts_toggle'; -import type { ActionConnector } from '../../../common/api'; +import type { ActionConnector, CasePostRequest } from '../../../common/api'; import type { Case } from '../../containers/types'; import type { CasesTimelineIntegration } from '../timeline_context'; import { CasesTimelineIntegrationProvider } from '../timeline_context'; @@ -70,6 +70,7 @@ export interface CreateCaseFormProps extends Pick Promise; timelineIntegration?: CasesTimelineIntegration; attachments?: CaseAttachmentsWithoutOwner; + initialValue?: Pick; } const empty: ActionConnector[] = []; @@ -79,6 +80,7 @@ export const CreateCaseFormFields: React.FC = React.m const { isSyncAlertsEnabled, caseAssignmentAuthorized } = useCasesFeatures(); const { owner } = useCasesContext(); + const availableOwners = useAvailableCasesOwners(); const canShowCaseSolutionSelection = !owner.length && availableOwners.length; @@ -181,12 +183,14 @@ export const CreateCaseForm: React.FC = React.memo( onSuccess, timelineIntegration, attachments, + initialValue, }) => ( Promise; attachments?: CaseAttachmentsWithoutOwner; + initialValue?: Pick; } export const FormContext: React.FC = ({ @@ -51,6 +53,7 @@ export const FormContext: React.FC = ({ children, onSuccess, attachments, + initialValue, }) => { const { data: connectors = [], isLoading: isLoadingConnectors } = useGetConnectors(); const { owner, appId } = useCasesContext(); @@ -128,7 +131,7 @@ export const FormContext: React.FC = ({ ); const { form } = useForm({ - defaultValue: initialCaseValue, + defaultValue: { ...initialCaseValue, ...initialValue }, options: { stripEmptyFields: false }, schema, onSubmit: submitCase, diff --git a/x-pack/plugins/cases/public/components/create/submit_button.tsx b/x-pack/plugins/cases/public/components/create/submit_button.tsx index 2c3ecd563df731..b3eb4724fd1c9d 100644 --- a/x-pack/plugins/cases/public/components/create/submit_button.tsx +++ b/x-pack/plugins/cases/public/components/create/submit_button.tsx @@ -16,6 +16,7 @@ const SubmitCaseButtonComponent: React.FC = () => { return ( { + const endTourStep = jest.fn(); + beforeEach(() => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: AlertsCasesTourSteps.viewCase, + incrementStep: () => null, + endTourStep, + isTourShown: () => true, + }); + jest.clearAllMocks(); + }); + it('calls endTour on cases details page when SecurityStepId.alertsCases tour is active and step is AlertsCasesTourSteps.viewCase', () => { + render( + + + , + { wrapper: TestProviders } + ); + expect(endTourStep).toHaveBeenCalledWith(SecurityStepId.alertsCases); + }); + it('does not call endTour on cases details page when SecurityStepId.alertsCases tour is not active', () => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: AlertsCasesTourSteps.viewCase, + incrementStep: () => null, + endTourStep, + isTourShown: () => false, + }); + render( + + + , + { wrapper: TestProviders } + ); + expect(endTourStep).not.toHaveBeenCalled(); + }); + it('does not call endTour on cases details page when SecurityStepId.alertsCases tour is active and step is not AlertsCasesTourSteps.viewCase', () => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: AlertsCasesTourSteps.expandEvent, + incrementStep: () => null, + endTourStep, + isTourShown: () => true, + }); + render( + + + , + { wrapper: TestProviders } + ); + expect(endTourStep).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/pages/index.tsx b/x-pack/plugins/security_solution/public/cases/pages/index.tsx index 5db5185679ab33..26f600bf7e0cb1 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/index.tsx @@ -5,9 +5,14 @@ * 2.0. */ -import React, { useCallback, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import type { CaseViewRefreshPropInterface } from '@kbn/cases-plugin/common'; +import { useTourContext } from '../../common/components/guided_onboarding_tour'; +import { + AlertsCasesTourSteps, + SecurityStepId, +} from '../../common/components/guided_onboarding_tour/tour_config'; import { TimelineId } from '../../../common/types/timeline'; import { getRuleDetailsUrl, useFormatUrl } from '../../common/components/link_to'; @@ -91,6 +96,16 @@ const CaseContainerComponent: React.FC = () => { }, [dispatch]); const refreshRef = useRef(null); + const { activeStep, endTourStep, isTourShown } = useTourContext(); + + const isTourActive = useMemo( + () => activeStep === AlertsCasesTourSteps.viewCase && isTourShown(SecurityStepId.alertsCases), + [activeStep, isTourShown] + ); + + useEffect(() => { + if (isTourActive) endTourStep(SecurityStepId.alertsCases); + }, [endTourStep, isTourActive]); return ( diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 173001dc42aff5..622d3035a7de67 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -25,7 +25,11 @@ import type { SearchHit } from '../../../../common/search_strategy'; import { getMitreComponentParts } from '../../../detections/mitre/get_mitre_threat_component'; import { GuidedOnboardingTourStep } from '../guided_onboarding_tour/tour_step'; import { isDetectionsAlertsTable } from '../top_n/helpers'; -import { getTourAnchor, SecurityStepId } from '../guided_onboarding_tour/tour_config'; +import { + AlertsCasesTourSteps, + getTourAnchor, + SecurityStepId, +} from '../guided_onboarding_tour/tour_config'; import type { AlertRawEventData } from './osquery_tab'; import { useOsqueryTab } from './osquery_tab'; import { EventFieldsBrowser } from './event_fields_browser'; @@ -448,8 +452,8 @@ const EventDetailsComponent: React.FC = ({ return ( ReactNode; extraAction?: EuiAccordionProps['extraAction']; onToggle?: EuiAccordionProps['onToggle']; + forceState?: EuiAccordionProps['forceState']; } /** @@ -34,7 +35,7 @@ interface Props { * It wraps logic and custom styling around the loading, error and success states of an insight section. */ export const InsightAccordion = React.memo( - ({ prefix, state, text, renderContent, onToggle = noop, extraAction }) => { + ({ prefix, state, text, renderContent, onToggle = noop, extraAction, forceState }) => { const accordionId = useGeneratedHtmlId({ prefix }); switch (state) { @@ -62,11 +63,14 @@ export const InsightAccordion = React.memo( // The accordion can display the content now return ( {renderContent()} diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx index d9b83f8e66388c..8e6bc304e1a381 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import React from 'react'; import { TestProviders } from '../../../mock'; @@ -14,10 +14,12 @@ import { useGetUserCasesPermissions } from '../../../lib/kibana'; import { RelatedCases } from './related_cases'; import { noCasesPermissions, readCasesPermissions } from '../../../../cases_test_utils'; import { CASES_LOADING, CASES_COUNT } from './translations'; +import { useTourContext } from '../../guided_onboarding_tour'; +import { AlertsCasesTourSteps } from '../../guided_onboarding_tour/tour_config'; const mockedUseKibana = mockUseKibana(); const mockGetRelatedCases = jest.fn(); - +jest.mock('../../guided_onboarding_tour'); jest.mock('../../../lib/kibana', () => { const original = jest.requireActual('../../../lib/kibana'); @@ -40,70 +42,76 @@ jest.mock('../../../lib/kibana', () => { }); const eventId = '1c84d9bff4884dabe6aa1bb15f08433463b848d9269e587078dc56669550d27a'; +const scrollToMock = jest.fn(); +window.HTMLElement.prototype.scrollIntoView = scrollToMock; describe('Related Cases', () => { + beforeEach(() => { + (useGetUserCasesPermissions as jest.Mock).mockReturnValue(readCasesPermissions()); + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: AlertsCasesTourSteps.viewCase, + incrementStep: () => null, + endTourStep: () => null, + isTourShown: () => false, + }); + jest.clearAllMocks(); + }); describe('When user does not have cases read permissions', () => { - test('should not show related cases when user does not have permissions', () => { + beforeEach(() => { (useGetUserCasesPermissions as jest.Mock).mockReturnValue(noCasesPermissions()); - render( - - - - ); - + }); + test('should not show related cases when user does not have permissions', async () => { + await act(async () => { + render( + + + + ); + }); expect(screen.queryByText('cases')).toBeNull(); }); }); describe('When user does have case read permissions', () => { - beforeEach(() => { - (useGetUserCasesPermissions as jest.Mock).mockReturnValue(readCasesPermissions()); - }); - - describe('When related cases are loading', () => { - test('should show the loading message', () => { + test('Should show the loading message', async () => { + await act(async () => { mockGetRelatedCases.mockReturnValue([]); render( ); - expect(screen.getByText(CASES_LOADING)).toBeInTheDocument(); }); }); - describe('When related cases are unable to be retrieved', () => { - test('should show 0 related cases when there are none', async () => { + test('Should show 0 related cases when there are none', async () => { + await act(async () => { mockGetRelatedCases.mockReturnValue([]); render( ); - - await waitFor(() => { - expect(screen.getByText(CASES_COUNT(0))).toBeInTheDocument(); - }); }); + + expect(screen.getByText(CASES_COUNT(0))).toBeInTheDocument(); }); - describe('When 1 related case is retrieved', () => { - test('should show 1 related case', async () => { + test('Should show 1 related case', async () => { + await act(async () => { mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]); render( ); - await waitFor(() => { - expect(screen.getByText(CASES_COUNT(1))).toBeInTheDocument(); - expect(screen.getByTestId('case-details-link')).toHaveTextContent('Test Case'); - }); }); + expect(screen.getByText(CASES_COUNT(1))).toBeInTheDocument(); + expect(screen.getByTestId('case-details-link')).toHaveTextContent('Test Case'); }); - describe('When 2 related cases are retrieved', () => { - test('should show 2 related cases', async () => { + test('Should show 2 related cases', async () => { + await act(async () => { mockGetRelatedCases.mockReturnValue([ { id: '789', title: 'Test Case 1' }, { id: '456', title: 'Test Case 2' }, @@ -113,15 +121,48 @@ describe('Related Cases', () => { ); + }); + expect(screen.getByText(CASES_COUNT(2))).toBeInTheDocument(); + const cases = screen.getAllByTestId('case-details-link'); + expect(cases).toHaveLength(2); + expect(cases[0]).toHaveTextContent('Test Case 1'); + expect(cases[1]).toHaveTextContent('Test Case 2'); + }); - await waitFor(() => { - expect(screen.getByText(CASES_COUNT(2))).toBeInTheDocument(); - const cases = screen.getAllByTestId('case-details-link'); - expect(cases).toHaveLength(2); - expect(cases[0]).toHaveTextContent('Test Case 1'); - expect(cases[1]).toHaveTextContent('Test Case 2'); - }); + test('Should not open the related cases accordion when isTourActive=false', async () => { + mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]); + await act(async () => { + render( + + + + ); + }); + expect(scrollToMock).not.toHaveBeenCalled(); + expect( + screen.getByTestId('RelatedCases-accordion').classList.contains('euiAccordion-isOpen') + ).toBe(false); + }); + + test('Should automatically open the related cases accordion when isTourActive=true', async () => { + (useTourContext as jest.Mock).mockReturnValue({ + activeStep: AlertsCasesTourSteps.viewCase, + incrementStep: () => null, + endTourStep: () => null, + isTourShown: () => true, + }); + mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]); + await act(async () => { + render( + + + + ); }); + expect(scrollToMock).toHaveBeenCalled(); + expect( + screen.getByTestId('RelatedCases-accordion').classList.contains('euiAccordion-isOpen') + ).toBe(true); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.tsx index 19742a9b0a915d..8444e10d11cfd1 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/insights/related_cases.tsx @@ -5,9 +5,11 @@ * 2.0. */ -import React, { useCallback, useState, useEffect } from 'react'; +import React, { useCallback, useState, useEffect, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { AlertsCasesTourSteps, SecurityStepId } from '../../guided_onboarding_tour/tour_config'; +import { useTourContext } from '../../guided_onboarding_tour'; import { useKibana, useToasts } from '../../../lib/kibana'; import { CaseDetailsLink } from '../../links'; import { APP_ID } from '../../../../../common/constants'; @@ -33,27 +35,49 @@ export const RelatedCases = React.memo(({ eventId }) => { const [relatedCases, setRelatedCases] = useState(undefined); const [hasError, setHasError] = useState(false); + const { activeStep, isTourShown } = useTourContext(); + const isTourActive = useMemo( + () => activeStep === AlertsCasesTourSteps.viewCase && isTourShown(SecurityStepId.alertsCases), + [activeStep, isTourShown] + ); const renderContent = useCallback(() => renderCaseContent(relatedCases), [relatedCases]); - const getRelatedCases = useCallback(async () => { - let relatedCaseList: RelatedCaseList = []; - try { - if (eventId) { - relatedCaseList = - (await cases.api.getRelatedCases(eventId, { - owner: APP_ID, - })) ?? []; - } - } catch (error) { - setHasError(true); - toasts.addWarning(CASES_ERROR_TOAST(error)); + const [shouldFetch, setShouldFetch] = useState(false); + + useEffect(() => { + if (!shouldFetch) { + return; } - setRelatedCases(relatedCaseList); - }, [eventId, cases.api, toasts]); + let ignore = false; + const fetch = async () => { + let relatedCaseList: RelatedCaseList = []; + try { + if (eventId) { + relatedCaseList = + (await cases.api.getRelatedCases(eventId, { + owner: APP_ID, + })) ?? []; + } + } catch (error) { + if (!ignore) { + setHasError(true); + } + toasts.addWarning(CASES_ERROR_TOAST(error)); + } + if (!ignore) { + setRelatedCases(relatedCaseList); + setShouldFetch(false); + } + }; + fetch(); + return () => { + ignore = true; + }; + }, [cases.api, eventId, shouldFetch, toasts]); useEffect(() => { - getRelatedCases(); - }, [eventId, getRelatedCases]); + setShouldFetch(true); + }, [eventId]); let state: InsightAccordionState = 'loading'; if (hasError) { @@ -68,6 +92,7 @@ export const RelatedCases = React.memo(({ eventId }) => { state={state} text={getTextFromState(state, relatedCases?.length)} renderContent={renderContent} + forceState={isTourActive ? 'open' : undefined} /> ); }); @@ -95,7 +120,7 @@ function renderCaseContent(relatedCases: RelatedCaseList = []) { id && title ? ( {' '} - + {title} {relatedCases[index + 1] ? ',' : ''} diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/README.md b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/README.md index 483d9c30cb82ca..a369669613bb42 100644 --- a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/README.md +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/README.md @@ -1,5 +1,4 @@ ## Security Guided Onboarding Tour -This work required some creativity for reasons. Allow me to explain some weirdness The [`EuiTourStep`](https://elastic.github.io/eui/#/display/tour) component needs an **anchor** to attach on in the DOM. This can be defined in 2 ways: ``` @@ -47,7 +46,7 @@ It was important that the `EuiTourStep` **anchor** is in the DOM when the tour s 1 - The component for this anchor is `RenderCellValue` which returns `DefaultCellRenderer`. We wrap `DefaultCellRenderer` with `GuidedOnboardingTourStep`, passing `step={1} stepId={SecurityStepId.alertsCases}` to indicate the step. Since there are many other iterations of this component on the page, we also need to pass the `isTourAnchor` property to determine which of these components should be the anchor. In the code, this looks something like: + The component for this anchor is `RenderCellValue` which returns `DefaultCellRenderer`. We wrap `DefaultCellRenderer` with `GuidedOnboardingTourStep`, passing `step={AlertsCasesTourSteps.pointToAlertName} tourId={SecurityStepId.alertsCases}` to indicate the step. Since there are many other iterations of this component on the page, we also need to pass the `isTourAnchor` property to determine which of these components should be the anchor. In the code, this looks something like: ``` export const RenderCellValue = (props) => { @@ -63,8 +62,8 @@ It was important that the `EuiTourStep` **anchor** is in the DOM when the tour s return ( @@ -87,14 +86,13 @@ It was important that the `EuiTourStep` **anchor** is in the DOM when the tour s defaultMessage: `In addition to the alert, you can add any relevant information you need to the case.`, } ), - anchor: `[data-test-subj="create-case-flyout"]`, + anchor: `[tour-step="create-case-flyout"]`, anchorPosition: 'leftUp', dataTestSubj: getTourAnchor(5, SecurityStepId.alertsCases), - hideNextButton: true, } ``` - Notice that the **anchor prop is defined** as `[data-test-subj="create-case-flyout"]` in the step 5 config. There is also a `hideNextButton` boolean utilized here. + Notice that the **anchor prop is defined** as `[tour-step="create-case-flyout"]` in the step 5 config. As you can see pictured below, the tour step anchor is the create case flyout and the next button is hidden. 5 @@ -110,7 +108,7 @@ It was important that the `EuiTourStep` **anchor** is in the DOM when the tour s headerContent: ( // isTourAnchor=true no matter what in order to // force active guide step outside of security solution (cases) - + ), } : {}), @@ -121,9 +119,9 @@ It was important that the `EuiTourStep` **anchor** is in the DOM when the tour s ``` export interface TourContextValue { activeStep: number; - endTourStep: (stepId: SecurityStepId) => void; - incrementStep: (stepId: SecurityStepId, step?: number) => void; - isTourShown: (stepId: SecurityStepId) => boolean; + endTourStep: (tourId: SecurityStepId) => void; + incrementStep: (tourId: SecurityStepId, step?: number) => void; + isTourShown: (tourId: SecurityStepId) => boolean; } ``` When the tour step does not have a next button, the anchor component will need to call `incrementStep` after an action is taken. For example, in `SecurityStepId.alertsCases` step 4, the user needs to click the "Add to case" button to advance the tour. diff --git a/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/cases_tour_steps.test.tsx b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/cases_tour_steps.test.tsx new file mode 100644 index 00000000000000..3cf8d696833b3a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour/cases_tour_steps.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 '@testing-library/react'; +import { CasesTourSteps } from './cases_tour_steps'; +import { AlertsCasesTourSteps } from './tour_config'; +import { TestProviders } from '../../mock'; + +jest.mock('./tour_step', () => ({ + GuidedOnboardingTourStep: jest + .fn() + .mockImplementation(({ step, onClick }: { onClick: () => void; step: number }) => ( +