From 1df780803b57d5911830b0810576c3148fd03779 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Fri, 6 May 2022 17:01:59 -0600 Subject: [PATCH] ## [Security Solution] Alerts Treemap and Multi-Dimensional Alert Grouping This PR introduces the new _Treemap_ and _Multi-Dimensional Alert Grouping_ to the Alerts page. The initial commit was developed as an _ON week_ proof of concept (POC). It has since been updated to incorporate product and UX feedback. ### Alerts treemap The new _Alerts_ page treemap is shown in the screenshot below: ![treemap](https://user-images.githubusercontent.com/4459398/178233664-c45be7ca-b03e-40b9-b423-aeeaa47461c0.png) _Above: The new `treemap` in the Alerts page_ - Alerts are colored by risk score - Clicking on a cell instantly filters the alerts page - Treemap legend items may be added to filters and Timeline investigations - The new treemap supports multi-dimensional grouping and filtering - Alerts are grouped by `kibana.alert.rule.name` and `host.name` by default ### Multi-Dimensional Alert Grouping The table on the Alerts page, which previously supported grouping by a single field, has also been enhanced to support multi-dimensional grouping, per the screenshot below: ![alerts-table-multi-dimensional-grouping](https://user-images.githubusercontent.com/4459398/178240710-ecf66799-35a8-4874-8882-5ccfcccc86fe.png) _Above: The table in the Alerts page, enhanced to support multi-dimensional grouping_ ## Filtering the Alerts page by risk score Every rule, including prebuilt Elastic rules and custom rules created by users, must specify a risk score at rule creation time, per the screenshot below: ![rule_risk_score_configuration](https://user-images.githubusercontent.com/4459398/156712042-19b71f53-f337-4aed-bebf-ce10ea2b9f63.png) _Above: Every rule has a risk score specified when it's created_ The colors of the alerts displayed in the treemap are determined by the rule's risk score. This makes it easy to quickly filter the entire alerts page by clicking on the riskiest alerts. Clicking on a cell in the treemap adds two filters, one for each group by field, per the screenshot below: ![two-filters](https://user-images.githubusercontent.com/4459398/178252768-7c66dc5e-8a3c-41d8-95e2-eeca20133127.png) _Above: Two filters, (one for each group by field), are added to the page when a cell is clicked_ The Alerts page updates instantly when filters are added or removed. In the screenshot below, the 2nd filter was removed to filter the page to all `mimikatz process started` alerts: ![second-filter-removed](https://user-images.githubusercontent.com/4459398/178253726-66905d60-99da-4d76-9ea1-744cb53abd6f.png) _Above: Removing the 2nd filter, a specific `host.name`, revealed all the hosts in the `mimikatz process started` alerts_ ### Switching views Users may switch between the following views: - Table - Trend (default) - Treemap per the screenshot below: ![view-selection](https://user-images.githubusercontent.com/4459398/178242001-6868c751-ffa6-486f-b81a-81a4d5912877.png) _Above: View selection_ The default _Trend_ view is shown in the screenshot below: ![trend-view](https://user-images.githubusercontent.com/4459398/178242769-58d6c800-69db-4e14-87e4-9232f3e35427.png) _Above: The (default) Trend view_ - The Trend chart's legend has been enhanced to display counts, per the design detailed in issue - The Trend view only supports visualizing a single dimension. Hovering over the disabled `Group by top` select in the Trend view displays the tooltip shown in the screenshot below: ![tooltip](https://user-images.githubusercontent.com/4459398/178243356-9bfe7f54-5b31-4f61-a795-0cfa0a70285b.png) _Above: The Trend view only supports visualizing a single dimension_ ### Collapsing the panel The panel may optionally be collapsed to save space, per the screenshot below: ![collapsed](https://user-images.githubusercontent.com/4459398/178244282-525d4c9f-a23b-4ec4-8f72-7e813e771687.png) _Above: The panel (optionally) collapsed_ ### User preferences are persisted to local storage Previously, the _Group by_ selections on the Alerts page were always forgotten when users navigated away from the Alerts page. As a result, users had to re-select their preferred Group by fields every time they visited the page. We now store all of the following preferences in local storage: - View selection (Table, Trend, Treemap) - Group by selections - Panel collapse state The preferences above are now restored the next time users return to the Alerts page. ### Group by field selection is synchronized between views Group by field selection is synchronized between views. For example, if a user changes the Group by fields in the Treemap view and then switches to the Table view, the same Group by fields will be displayed in the Table view. ### Resetting Group by fields to their defaults Users may reset the Group by fields to their defaults (`kibana.alert.rule.name` and `host.name`) for any visualization via the menu shown in the screenshot below: ![reset-group-by-fields](https://user-images.githubusercontent.com/4459398/178246997-70d89763-40d4-4c93-b0b6-b439fc3e22cd.png) _Above: Resetting Group by fields to defaults via the menu_ --- .../common/experimental_features.ts | 3 - .../changing_alert_status.spec.ts | 10 + .../cypress/screens/alerts.ts | 10 +- .../security_solution/cypress/tasks/alerts.ts | 13 + .../alerts_treemap/component/index.tsx | 179 --------- .../component/labels/index.test.ts | 31 -- .../component/limit_message/index.tsx | 25 -- .../components/alerts_treemap/index.test.tsx | 67 ++++ .../components/alerts_treemap/index.tsx | 376 +++++++++--------- .../lib/chart_palette/index.test.ts} | 81 ++-- .../alerts_treemap/lib/chart_palette/index.ts | 66 +++ .../{ => lib}/flatten/flatten_bucket.test.ts | 0 .../{ => lib}/flatten/flatten_bucket.ts | 2 +- .../flatten/get_flattened_buckets.test.ts | 0 .../flatten/get_flattened_buckets.ts | 2 +- .../{ => lib}/flatten/mocks/mock_buckets.ts | 2 +- .../flatten/mocks/mock_flattened_buckets.ts | 2 +- .../{component => lib}/helpers.test.ts | 4 +- .../{component => lib}/helpers.ts | 11 +- .../alerts_treemap/lib/labels/index.test.ts | 33 ++ .../{component => lib}/labels/index.ts | 0 .../layers/index.test.ts} | 106 +++-- .../layers.ts => lib/layers/index.ts} | 41 +- .../get_flattened_legend_items.test.ts | 4 +- .../legend}/get_flattened_legend_items.ts | 10 +- .../legend/index.test.ts} | 21 +- .../legend.ts => lib/legend/index.ts} | 31 +- .../lib/mocks/mock_alert_search_response.ts | 140 +++++++ .../alerts_treemap/no_data/index.test.tsx | 19 + .../{component => }/no_data/index.tsx | 10 +- .../components/alerts_treemap/query/index.ts | 2 +- .../components/alerts_treemap/translations.ts | 7 - .../common/components/alerts_treemap/types.ts | 2 +- .../alerts_treemap_panel/index.test.tsx | 247 ++++++++++++ .../components/alerts_treemap_panel/index.tsx | 204 ++++++++++ .../authentications_host_table.test.tsx.snap | 4 +- .../authentications_user_table.test.tsx.snap | 4 +- .../configurations/default/index.test.tsx | 78 ++++ .../configurations/default/index.tsx | 108 +---- .../configurations/default/translations.ts | 55 +-- .../chart_settings_popover/index.test.tsx | 44 ++ .../chart_settings_popover/index.tsx | 23 +- .../chart_settings_popover/translations.ts | 15 + .../charts/draggable_legend.test.tsx | 24 +- .../components/charts/draggable_legend.tsx | 28 +- .../charts/draggable_legend_item.test.tsx | 14 +- .../charts/draggable_legend_item.tsx | 9 +- .../components/field_selection/index.test.tsx | 77 ++++ .../components/field_selection/index.tsx | 69 ++++ .../__snapshots__/index.test.tsx.snap | 9 +- .../components/header_section/index.test.tsx | 111 +++++- .../components/header_section/index.tsx | 35 +- .../common/components/inspect/index.test.tsx | 27 ++ .../common/components/inspect/index.tsx | 6 +- .../components/local_storage/helpers.test.ts | 16 +- .../components/local_storage/helpers.ts | 2 +- .../components/local_storage/index.test.tsx | 32 +- .../matrix_histogram/index.test.tsx | 10 +- .../alerts_count_panel/alerts_count.test.tsx | 6 +- .../alerts_count_panel/alerts_count.tsx | 116 +++--- .../alerts_count_panel/columns.test.tsx | 77 ++++ .../alerts_count_panel/columns.tsx | 9 +- .../alerts_count_panel/helpers.test.tsx | 132 ++++++ .../alerts_count_panel/index.test.tsx | 53 +++ .../alerts_kpis/alerts_count_panel/index.tsx | 65 ++- .../mocks/mock_response_empty_field0.ts | 4 +- .../mocks/mock_response_multi_group.ts | 6 +- .../mocks/mock_response_single_group.ts | 4 +- .../alerts_count_panel/translations.ts | 8 +- .../alerts_histogram.test.tsx | 71 +++- .../alerts_histogram.tsx | 8 +- .../alerts_histogram_panel/index.test.tsx | 339 +++++++++++++++- .../alerts_histogram_panel/index.tsx | 53 ++- .../alerts_histogram_panel/mock_data.ts | 351 ++++++++++++++++ .../alerts_histogram_panel/translations.ts | 7 + .../alerts_kpis/common/components.test.tsx | 182 +++++++++ .../alerts_kpis/common/components.tsx | 26 +- .../alerts_kpis/common/translations.ts | 12 +- .../alerts_in_memory_storage.tsx | 82 ---- .../alerts_local_storage/index.tsx | 209 ---------- .../detection_engine/chart_options/index.tsx | 102 ----- .../chart_options_tours/chart_select_tour.tsx | 101 ----- .../chart_options_tours/helpers.test.ts | 46 --- .../chart_options_tours/helpers.ts | 24 -- .../chart_options_tours/translations.ts | 64 --- .../view_chart_toggle_tour.tsx | 90 ----- .../alerts_local_storage/constants.ts | 22 +- .../alerts_local_storage/index.test.tsx | 36 ++ .../alerts_local_storage/index.tsx | 125 ++++++ .../alerts_local_storage/types.ts | 18 +- .../chart_context_menu/index.test.tsx | 82 ++++ .../chart_context_menu/index.tsx | 38 +- .../chart_panels/chart_select/helpers.test.ts | 111 ++++++ .../chart_panels/chart_select/helpers.ts | 70 ++++ .../chart_panels/chart_select/index.test.tsx | 42 ++ .../chart_panels/chart_select/index.tsx | 71 ++++ .../chart_panels/chart_select/translations.ts | 23 ++ .../chart_panels/index.test.tsx | 233 +++++++++++ .../detection_engine/chart_panels/index.tsx | 196 ++++----- .../chart_select/helpers.test.ts | 111 ------ .../detection_engine/chart_select/helpers.ts | 40 -- .../detection_engine/chart_select/index.tsx | 158 -------- .../chart_select/translations.ts | 68 ---- .../detection_engine.test.tsx | 14 + .../detection_engine/detection_engine.tsx | 163 +------- .../pages/detection_engine/get_fill_color.ts | 50 --- .../pages/detection_engine/translations.ts | 21 - .../view_chart_toggle/helpers.test.ts | 218 ---------- .../view_chart_toggle/helpers.ts | 71 ---- .../view_chart_toggle/index.tsx | 70 ---- .../view_chart_toggle/translations.ts | 20 - 111 files changed, 4093 insertions(+), 2786 deletions(-) delete mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/labels/index.test.ts delete mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/limit_message/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.test.tsx rename x-pack/plugins/security_solution/public/{detections/pages/detection_engine/get_fill_color.test.ts => common/components/alerts_treemap/lib/chart_palette/index.test.ts} (56%) create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/chart_palette/index.ts rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{ => lib}/flatten/flatten_bucket.test.ts (100%) rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{ => lib}/flatten/flatten_bucket.ts (91%) rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{ => lib}/flatten/get_flattened_buckets.test.ts (100%) rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{ => lib}/flatten/get_flattened_buckets.ts (91%) rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{ => lib}/flatten/mocks/mock_buckets.ts (98%) rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{ => lib}/flatten/mocks/mock_flattened_buckets.ts (98%) rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{component => lib}/helpers.test.ts (98%) rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{component => lib}/helpers.ts (88%) create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/labels/index.test.ts rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{component => lib}/labels/index.ts (100%) rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{component/layers.test.ts => lib/layers/index.test.ts} (54%) rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{component/layers.ts => lib/layers/index.ts} (74%) rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{component => lib/legend}/get_flattened_legend_items.test.ts (94%) rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{component => lib/legend}/get_flattened_legend_items.ts (87%) rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{component/legend.test.ts => lib/legend/index.test.ts} (93%) rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{component/legend.ts => lib/legend/index.ts} (79%) create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/mocks/mock_alert_search_response.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/no_data/index.test.tsx rename x-pack/plugins/security_solution/public/common/components/alerts_treemap/{component => }/no_data/index.tsx (78%) create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/chart_settings_popover/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/chart_settings_popover/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/field_selection/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/field_selection/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/columns.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/alerts_local_storage/alerts_in_memory_storage.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/alerts_local_storage/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/chart_select_tour.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/helpers.test.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/helpers.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/translations.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/view_chart_toggle_tour.tsx rename x-pack/plugins/security_solution/public/detections/pages/detection_engine/{ => chart_panels}/alerts_local_storage/constants.ts (59%) create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.tsx rename x-pack/plugins/security_solution/public/detections/pages/detection_engine/{ => chart_panels}/alerts_local_storage/types.ts (57%) create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.test.tsx rename x-pack/plugins/security_solution/public/detections/pages/detection_engine/{ => chart_panels}/chart_context_menu/index.tsx (57%) create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/translations.ts create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/helpers.test.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/helpers.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/translations.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/get_fill_color.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/helpers.test.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/helpers.ts delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/translations.ts diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index fe002e9206236..d1a6348204a5d 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -12,9 +12,6 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues; * This object is then used to validate and parse the value entered. */ export const allowedExperimentalValues = Object.freeze({ - alertsTreemapEnabled: true, - showAlertsPageTitle: true, - showChartsToggle: false, tGridEnabled: true, tGridEventRenderedViewEnabled: true, excludePoliciesInFilterEnabled: false, diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/changing_alert_status.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/changing_alert_status.spec.ts index 45910fede0686..9500e19be545f 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_alerts/changing_alert_status.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_alerts/changing_alert_status.spec.ts @@ -20,12 +20,14 @@ import { waitForAlerts, markAcknowledgedFirstAlert, goToAcknowledgedAlerts, + clearGroupByTopInput, closeAlerts, closeFirstAlert, goToClosedAlerts, goToOpenedAlerts, openAlerts, openFirstAlert, + selectCountTable, } from '../../tasks/alerts'; import { createCustomRuleEnabled } from '../../tasks/api_calls/rules'; import { cleanKibana, deleteAlertsAndRules } from '../../tasks/common'; @@ -53,6 +55,8 @@ describe('Changing alert status', () => { cy.get(SELECTED_ALERTS).should('have.text', `Selected 3 alerts`); closeAlerts(); waitForAlerts(); + selectCountTable(); + clearGroupByTopInput(); }); it('Open one alert when more than one closed alerts are selected', () => { @@ -110,6 +114,8 @@ describe('Changing alert status', () => { createCustomRuleEnabled(getNewRule()); visit(ALERTS_URL); waitForAlertsToPopulate(); + selectCountTable(); + clearGroupByTopInput(); }); it('Mark one alert as acknowledged when more than one open alerts are selected', () => { cy.get(ALERTS_COUNT) @@ -148,6 +154,8 @@ describe('Changing alert status', () => { createCustomRuleEnabled(getNewRule(), '1', '100m', 100); visit(ALERTS_URL); waitForAlertsToPopulate(); + selectCountTable(); + clearGroupByTopInput(); }); it('Closes and opens alerts', () => { const numberOfAlertsToBeClosed = 3; @@ -298,6 +306,8 @@ describe('Changing alert status', () => { createCustomRuleEnabled(getNewRule()); visit(ALERTS_URL); waitForAlertsToPopulate(); + selectCountTable(); + clearGroupByTopInput(); }); it('Mark one alert as acknowledged when more than one open alerts are selected', () => { cy.get(ALERTS_COUNT) diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts index 607c26d5c155c..064f9bba70925 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -8,7 +8,7 @@ export const ADD_EXCEPTION_BTN = '[data-test-subj="add-exception-menu-item"]'; export const ALERT_COUNT_TABLE_FIRST_ROW_COUNT = - '[data-test-subj="alertsCountTable"] tr:nth-child(1) td:nth-child(3) .euiTableCellContent__text'; + '[data-test-subj="alertsCountTable"] tr:nth-child(1) td:nth-child(2) .euiTableCellContent__text'; export const ALERT_CHECKBOX = '[data-test-subj~="select-event"].euiCheckbox__input'; @@ -33,6 +33,8 @@ export const ALERTS_COUNT = export const ALERTS_TREND_SIGNAL_RULE_NAME_PANEL = '[data-test-subj="render-content-kibana.alert.rule.name"]'; +export const CHART_SELECT = '[data-test-subj="chartSelect"]'; + export const CLOSE_ALERT_BTN = '[data-test-subj="close-alert-status"]'; export const CLOSE_SELECTED_ALERTS_BTN = '[data-test-subj="close-alert-status"]'; @@ -45,13 +47,15 @@ export const EMPTY_ALERT_TABLE = '[data-test-subj="tGridEmptyState"]'; export const EXPAND_ALERT_BTN = '[data-test-subj="expand-event"]'; +export const GROUP_BY_TOP_INPUT = '[data-test-subj="groupByTop"] [data-test-subj="comboBoxInput"]'; + export const HOST_NAME = '[data-test-subj^=formatted-field][data-test-subj$=host\\.name]'; export const ACKNOWLEDGED_ALERTS_FILTER_BTN = '[data-test-subj="acknowledgedAlerts"]'; export const LOADING_ALERTS_PANEL = '[data-test-subj="loading-alerts-panel"]'; -export const MANAGE_ALERT_DETECTION_RULES_BTN = '[data-test-subj="navigation-rules"]'; +export const MANAGE_ALERT_DETECTION_RULES_BTN = '[data-test-subj="manage-alert-detection-rules"]'; export const MARK_ALERT_ACKNOWLEDGED_BTN = '[data-test-subj="acknowledged-alert-status"]'; @@ -74,6 +78,8 @@ export const RULE_NAME = '[data-test-subj^=formatted-field][data-test-subj$=rule export const SELECTED_ALERTS = '[data-test-subj="selectedShowBulkActionsButton"]'; +export const SELECT_TABLE = '[data-test-subj="selectTable"]'; + export const SEND_ALERT_TO_TIMELINE_BTN = '[data-test-subj="send-alert-to-timeline-button"]'; export const SEVERITY = '[data-test-subj^=formatted-field][data-test-subj$=severity]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts index ea86f1ba96559..421b09b6280d7 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts @@ -9,10 +9,12 @@ import { ADD_EXCEPTION_BTN, ALERT_RISK_SCORE_HEADER, ALERT_CHECKBOX, + CHART_SELECT, CLOSE_ALERT_BTN, CLOSE_SELECTED_ALERTS_BTN, CLOSED_ALERTS_FILTER_BTN, EXPAND_ALERT_BTN, + GROUP_BY_TOP_INPUT, ACKNOWLEDGED_ALERTS_FILTER_BTN, LOADING_ALERTS_PANEL, MANAGE_ALERT_DETECTION_RULES_BTN, @@ -20,6 +22,7 @@ import { OPEN_ALERT_BTN, OPENED_ALERTS_FILTER_BTN, SEND_ALERT_TO_TIMELINE_BTN, + SELECT_TABLE, TAKE_ACTION_POPOVER_BTN, TIMELINE_CONTEXT_MENU_BTN, } from '../screens/alerts'; @@ -125,6 +128,16 @@ export const openAlerts = () => { cy.get(OPEN_ALERT_BTN).click(); }; +export const selectCountTable = () => { + cy.get(CHART_SELECT).click({ force: true }); + cy.get(SELECT_TABLE).click(); +}; + +export const clearGroupByTopInput = () => { + cy.get(GROUP_BY_TOP_INPUT).focus(); + cy.get(GROUP_BY_TOP_INPUT).type('{backspace}'); +}; + export const goToAcknowledgedAlerts = () => { cy.get(ACKNOWLEDGED_ALERTS_FILTER_BTN).click(); cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/index.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/index.tsx deleted file mode 100644 index cda98e9c9a633..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/index.tsx +++ /dev/null @@ -1,179 +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 { - Chart, - Datum, - ElementClickListener, - Partition, - PartitionLayout, - Settings, -} from '@elastic/charts'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { isEmpty } from 'lodash/fp'; -import React, { useCallback, useMemo } from 'react'; -import styled from 'styled-components'; - -import { DraggableLegend } from '../../charts/draggable_legend'; -import { LegendItem } from '../../charts/draggable_legend_item'; -import { AlertSearchResponse } from '../../../../detections/containers/detection_engine/alerts/types'; -import { getFlattenedBuckets } from '../flatten/get_flattened_buckets'; -import { getFlattenedLegendItems } from './get_flattened_legend_items'; -import { - getGroupByFieldsOnClick, - getMaxRiskSubAggregations, - getUpToMaxBuckets, - hasOptionalStackByField, -} from './helpers'; -import { getLayersMultiDimensional, getLayersOneDimension } from './layers'; -import { getFirstGroupLegendItems } from './legend'; -import { NoData } from './no_data'; -import type { AlertsTreeMapAggregation, FlattenedBucket, RawBucket } from '../types'; - -export const DEFAULT_MIN_CHART_HEIGHT = 370; // px -const DEFAULT_LEGEND_WIDTH = 300; // px - -interface TreemapProps { - addFilter?: ({ field, value }: { field: string; value: string | number }) => void; - data: AlertSearchResponse; - maxBuckets: number; - minChartHeight?: number; - stackByField0: string; - stackByField1: string | undefined; -} - -const Wrapper = styled.div` - margin-top: -${({ theme }) => theme.eui.euiSizeS}; -`; - -const LegendContainer = styled.div` - margin-left: ${({ theme }) => theme.eui.euiSizeS}; -`; - -const ChartFlexItem = styled(EuiFlexItem)<{ minChartHeight: number }>` - min-height: ${({ minChartHeight }) => `${minChartHeight}px`}; -`; - -const AlertsTreemapComponent = ({ - addFilter, - data, - maxBuckets, - minChartHeight = DEFAULT_MIN_CHART_HEIGHT, - stackByField0, - stackByField1, -}: TreemapProps) => { - const buckets: RawBucket[] = useMemo( - () => - getUpToMaxBuckets({ - buckets: data.aggregations?.stackByField0?.buckets, - maxItems: maxBuckets, - }), - [data.aggregations?.stackByField0?.buckets, maxBuckets] - ); - - const maxRiskSubAggregations = useMemo(() => getMaxRiskSubAggregations(buckets), [buckets]); - - const flattenedBuckets: FlattenedBucket[] = useMemo( - () => - getFlattenedBuckets({ - buckets, - maxRiskSubAggregations, - stackByField0, - }), - [buckets, maxRiskSubAggregations, stackByField0] - ); - - const legendItems: LegendItem[] = useMemo( - () => - flattenedBuckets == null - ? getFirstGroupLegendItems({ - buckets, - maxRiskSubAggregations, - stackByField0, - }) - : getFlattenedLegendItems({ - buckets, - flattenedBuckets, - maxRiskSubAggregations, - stackByField0, - stackByField1, - }), - [buckets, flattenedBuckets, maxRiskSubAggregations, stackByField0, stackByField1] - ); - - const onElementClick: ElementClickListener = useCallback( - (event) => { - const { groupByField0, groupByField1 } = getGroupByFieldsOnClick(event); - - if (addFilter != null && !isEmpty(groupByField0.trim())) { - addFilter({ field: stackByField0, value: groupByField0 }); - } - - if (addFilter != null && !isEmpty(stackByField1?.trim()) && !isEmpty(groupByField1.trim())) { - addFilter({ field: `${stackByField1}`, value: groupByField1 }); - } - }, - [addFilter, stackByField0, stackByField1] - ); - - const layers = useMemo( - () => - hasOptionalStackByField(stackByField1) - ? getLayersMultiDimensional(maxRiskSubAggregations) - : getLayersOneDimension(maxRiskSubAggregations), - [maxRiskSubAggregations, stackByField1] - ); - - const valueAccessor = useMemo( - () => - hasOptionalStackByField(stackByField1) - ? (d: Datum) => d.stackByField1DocCount - : (d: Datum) => d.doc_count, - [stackByField1] - ); - - const normalizedData: FlattenedBucket[] = hasOptionalStackByField(stackByField1) - ? flattenedBuckets - : buckets; - - if (buckets.length === 0) { - return ; - } - - return ( - - - - - - - - - - - - {legendItems.length > 0 && ( - - )} - - - - - ); -}; - -export const AlertsTreemap = React.memo(AlertsTreemapComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/labels/index.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/labels/index.test.ts deleted file mode 100644 index c5909bfedaee2..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/labels/index.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { getLabel } from '.'; - -describe('getLabel', () => { - it('returns the expected label when risk score is a number', () => { - const baseLabel = 'mimikatz process started'; - const riskScore = 99; - - expect(getLabel({ baseLabel, riskScore })).toBe('mimikatz process started (Risk 99)'); - }); - - it('returns the expected label when risk score is null', () => { - const baseLabel = 'mimikatz process started'; - const riskScore = null; - - expect(getLabel({ baseLabel, riskScore })).toBe(baseLabel); - }); - - it('returns the expected label when risk score is undefined', () => { - const baseLabel = 'mimikatz process started'; - const riskScore = undefined; - - expect(getLabel({ baseLabel, riskScore })).toBe(baseLabel); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/limit_message/index.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/limit_message/index.tsx deleted file mode 100644 index 136b9f9ffc93a..0000000000000 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/limit_message/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiText } from '@elastic/eui'; -import React from 'react'; - -import * as i18n from '../../translations'; - -interface Props { - maxItems: number; -} - -const LimitMessageComponent = ({ maxItems }: Props) => ( - - {i18n.SUBTITLE(maxItems)} - -); - -LimitMessageComponent.displayName = 'LimitMessageComponent'; - -export const LimitMessage = React.memo(LimitMessageComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.test.tsx new file mode 100644 index 0000000000000..86b9a2ee4ce57 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../mock'; +import { + mockAlertSearchResponse, + mockNoDataAlertSearchResponse, +} from './lib/mocks/mock_alert_search_response'; +import type { Props } from '.'; +import { AlertsTreemap } from '.'; + +const defaultProps: Props = { + data: mockAlertSearchResponse, + maxBuckets: 1000, + minChartHeight: 370, + stackByField0: 'kibana.alert.rule.name', + stackByField1: 'host.name', +}; + +describe('AlertsTreemap', () => { + describe('when the response has data', () => { + beforeEach(() => { + render( + + + + ); + }); + + test('it renders the treemap', () => { + expect(screen.getByTestId('treemap').querySelector('.echChart')).toBeInTheDocument(); + }); + + test('it renders the legend', () => { + expect(screen.getByTestId('draggable-legend')).toBeInTheDocument(); + }); + }); + + describe('when the response does NOT have data', () => { + beforeEach(() => { + render( + + + + ); + }); + + test('it does NOT render the treemap', () => { + expect(screen.queryByTestId('treemap')).toBeNull(); + }); + + test('it does NOT render the legend', () => { + expect(screen.queryByTestId('draggable-legend')).toBeNull(); + }); + + test('it renders the "no data" message', () => { + expect(screen.getByTestId('noDataLabel')).toHaveTextContent('No data to display'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.tsx index e421893b27004..8166fb6c07d94 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.tsx @@ -5,215 +5,203 @@ * 2.0. */ -import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; -import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiSpacer } from '@elastic/eui'; -import type { Filter, Query } from '@kbn/es-query'; -import { buildEsQuery } from '@kbn/es-query'; -import React, { useEffect, useMemo } from 'react'; -import uuid from 'uuid'; - -import { useGlobalTime } from '../../containers/use_global_time'; -import { AlertsTreemap, DEFAULT_MIN_CHART_HEIGHT } from './component'; +import type { Datum, ElementClickListener, PartialTheme } from '@elastic/charts'; +import { Chart, Partition, PartitionLayout, Settings } from '@elastic/charts'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import React, { useCallback, useMemo } from 'react'; +import styled from 'styled-components'; + +import { useTheme } from '../charts/common'; +import { DraggableLegend } from '../charts/draggable_legend'; +import type { LegendItem } from '../charts/draggable_legend_item'; +import type { AlertSearchResponse } from '../../../detections/containers/detection_engine/alerts/types'; +import { getRiskScorePalette, RISK_SCORE_STEPS } from './lib/chart_palette'; +import { getFlattenedBuckets } from './lib/flatten/get_flattened_buckets'; +import { getFlattenedLegendItems } from './lib/legend/get_flattened_legend_items'; import { - KpiPanel, - StackByComboBox, -} from '../../../detections/components/alerts_kpis/common/components'; -import { useInspectButton } from '../../../detections/components/alerts_kpis/common/hooks'; -import { - GROUP_BY_TOP_LABEL, - THEN_GROUP_BY_TOP_LABEL, -} from '../../../detections/components/alerts_kpis/common/translations'; -import { AlertSearchResponse } from '../../../detections/containers/detection_engine/alerts/types'; -import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query'; -import { ChartOptionsFlexItem } from '../../../detections/pages/detection_engine/chart_context_menu'; -import { HeaderSection } from '../header_section'; -import { InspectButtonContainer } from '../inspect'; -import { DEFAULT_STACK_BY_FIELD0_SIZE, getAlertsRiskQuery } from './query'; -import * as i18n from './translations'; -import type { AlertsTreeMapAggregation } from './types'; - -const DEFAULT_HEIGHT = DEFAULT_MIN_CHART_HEIGHT + 122; // px - -const COLLAPSED_HEIGHT = 64; // px - -const ALERTS_TREEMAP_ID = 'alerts-treemap'; - -interface AlertsTreemapPanelProps { + getGroupByFieldsOnClick, + getMaxRiskSubAggregations, + getUpToMaxBuckets, + hasOptionalStackByField, +} from './lib/helpers'; +import { getLayersMultiDimensional, getLayersOneDimension } from './lib/layers'; +import { getFirstGroupLegendItems } from './lib/legend'; +import { NoData } from './no_data'; +import type { AlertsTreeMapAggregation, FlattenedBucket, RawBucket } from './types'; + +export const DEFAULT_MIN_CHART_HEIGHT = 370; // px +const DEFAULT_LEGEND_WIDTH = 300; // px + +export interface Props { addFilter?: ({ field, value }: { field: string; value: string | number }) => void; - chartOptionsContextMenu?: React.ReactNode; - expandRiskChart: boolean; - filters?: Filter[]; - height?: number; - query?: Query; - riskSubAggregationField: string; - runtimeMappings?: MappingRuntimeFields; - setExpandRiskChart: (value: boolean) => void; - setStackByField0: (stackBy: string) => void; - setStackByField1: (stackBy: string | undefined) => void; - signalIndexName: string | null; + data: AlertSearchResponse; + maxBuckets: number; + minChartHeight?: number; stackByField0: string; stackByField1: string | undefined; - stackByWidth?: number; } -export const getBucketsCount = ( - data: AlertSearchResponse | null -): number => data?.aggregations?.stackByField0?.buckets?.length ?? 0; +const Wrapper = styled.div` + margin-top: -${({ theme }) => theme.eui.euiSizeS}; +`; + +const LegendContainer = styled.div` + margin-left: ${({ theme }) => theme.eui.euiSizeS}; +`; + +const ChartFlexItem = styled(EuiFlexItem)<{ $minChartHeight: number }>` + min-height: ${({ $minChartHeight }) => `${$minChartHeight}px`}; +`; -const AlertsTreemapPanelComponent = ({ +const AlertsTreemapComponent: React.FC = ({ addFilter, - chartOptionsContextMenu, - expandRiskChart, - filters, - height = DEFAULT_HEIGHT, - query, - riskSubAggregationField, - runtimeMappings, - setExpandRiskChart, - setStackByField0, - setStackByField1, - signalIndexName, + data, + maxBuckets, + minChartHeight = DEFAULT_MIN_CHART_HEIGHT, stackByField0, stackByField1, - stackByWidth, -}: AlertsTreemapPanelProps) => { - const { to, from, deleteQuery, setQuery } = useGlobalTime(); - - // create a unique, but stable (across re-renders) query id - const uniqueQueryId = useMemo(() => `${ALERTS_TREEMAP_ID}-${uuid.v4()}`, []); - - const additionalFilters = useMemo(() => { - try { - return [ - buildEsQuery( - undefined, - query != null ? [query] : [], - filters?.filter((f) => f.meta.disabled === false) ?? [] - ), - ]; - } catch (e) { - return []; - } - }, [query, filters]); - - const { - data: alertsData, - loading: isLoadingAlerts, - refetch, - request, - response, - setQuery: setAlertsQuery, - } = useQueryAlerts<{}, AlertsTreeMapAggregation>({ - query: getAlertsRiskQuery({ - additionalFilters, - from, - riskSubAggregationField, - runtimeMappings, - stackByField0, - stackByField1, - to, - }), - skip: !expandRiskChart, - indexName: signalIndexName, - }); - - useEffect(() => { - setAlertsQuery( - getAlertsRiskQuery({ - additionalFilters, - from, - riskSubAggregationField, - runtimeMappings, +}: Props) => { + const theme = useTheme(); + const fillColor = useMemo(() => theme.background.color, [theme.background.color]); + + const treemapTheme: PartialTheme[] = useMemo( + () => [ + { + partition: { + fillLabel: { valueFont: { fontWeight: 700 } }, + idealFontSizeJump: 1.15, + maxFontSize: 16, + minFontSize: 8, + sectorLineStroke: fillColor, // draws the light or dark "lines" between partitions + sectorLineWidth: 1.5, + }, + }, + ], + [fillColor] + ); + + const buckets: RawBucket[] = useMemo( + () => + getUpToMaxBuckets({ + buckets: data.aggregations?.stackByField0?.buckets, + maxItems: maxBuckets, + }), + [data.aggregations?.stackByField0?.buckets, maxBuckets] + ); + + const maxRiskSubAggregations = useMemo(() => getMaxRiskSubAggregations(buckets), [buckets]); + + const flattenedBuckets: FlattenedBucket[] = useMemo( + () => + getFlattenedBuckets({ + buckets, + maxRiskSubAggregations, stackByField0, - stackByField1, - to, - }) - ); - }, [ - additionalFilters, - from, - riskSubAggregationField, - runtimeMappings, - setAlertsQuery, - stackByField0, - stackByField1, - to, - ]); - - useInspectButton({ - deleteQuery, - loading: isLoadingAlerts, - response, - setQuery, - refetch, - request, - uniqueQueryId, - }); + }), + [buckets, maxRiskSubAggregations, stackByField0] + ); + + const colorPalette = useMemo(() => getRiskScorePalette(RISK_SCORE_STEPS), []); + + const legendItems: LegendItem[] = useMemo( + () => + flattenedBuckets.length === 0 + ? getFirstGroupLegendItems({ + buckets, + colorPalette, + maxRiskSubAggregations, + stackByField0, + }) + : getFlattenedLegendItems({ + buckets, + colorPalette, + flattenedBuckets, + maxRiskSubAggregations, + stackByField0, + stackByField1, + }), + [buckets, colorPalette, flattenedBuckets, maxRiskSubAggregations, stackByField0, stackByField1] + ); + + const onElementClick: ElementClickListener = useCallback( + (event) => { + const { groupByField0, groupByField1 } = getGroupByFieldsOnClick(event); + + if (addFilter != null && !isEmpty(groupByField0.trim())) { + addFilter({ field: stackByField0, value: groupByField0 }); + } + + if (addFilter != null && !isEmpty(stackByField1?.trim()) && !isEmpty(groupByField1.trim())) { + addFilter({ field: `${stackByField1}`, value: groupByField1 }); + } + }, + [addFilter, stackByField0, stackByField1] + ); + + const layers = useMemo( + () => + hasOptionalStackByField(stackByField1) + ? getLayersMultiDimensional({ + colorPalette, + layer0FillColor: fillColor, + maxRiskSubAggregations, + }) + : getLayersOneDimension({ colorPalette, maxRiskSubAggregations }), + [colorPalette, fillColor, maxRiskSubAggregations, stackByField1] + ); + + const valueAccessor = useMemo( + () => + hasOptionalStackByField(stackByField1) + ? (d: Datum) => d.stackByField1DocCount + : (d: Datum) => d.doc_count, + [stackByField1] + ); + + const normalizedData: FlattenedBucket[] = hasOptionalStackByField(stackByField1) + ? flattenedBuckets + : buckets; + + if (buckets.length === 0) { + return ; + } return ( - - - - {expandRiskChart && ( - - - - - - - - {chartOptionsContextMenu != null && ( - - {chartOptionsContextMenu} - - )} - - - )} - - - {isLoadingAlerts ? ( - - ) : ( - <> - {alertsData != null && expandRiskChart && ( - + + + + + + + + + + + {legendItems.length > 0 && ( + )} - - )} - - + + + + ); }; -AlertsTreemapPanelComponent.displayName = 'AlertsTreemapPanelComponent'; - -export const AlertsTreemapPanel = React.memo(AlertsTreemapPanelComponent); +export const AlertsTreemap = React.memo(AlertsTreemapComponent); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/get_fill_color.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/chart_palette/index.test.ts similarity index 56% rename from x-pack/plugins/security_solution/public/detections/pages/detection_engine/get_fill_color.test.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/chart_palette/index.test.ts index 7cfbb0a16eda8..8cc9e45d43b6e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/get_fill_color.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/chart_palette/index.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { euiPaletteWarm } from '@elastic/eui'; import { RISK_COLOR_LOW, RISK_COLOR_MEDIUM, @@ -13,95 +14,95 @@ import { RISK_SCORE_MEDIUM, RISK_SCORE_HIGH, RISK_SCORE_CRITICAL, -} from '../../components/rules/step_about_rule/data'; -import { getFillColor } from './get_fill_color'; +} from '../../../../../detections/components/rules/step_about_rule/data'; +import { getFillColor, getRiskScorePalette, RISK_SCORE_STEPS } from '.'; describe('getFillColor', () => { - describe('when useWarmPalette is true', () => { - const useWarmPalette = true; + describe('when using the Risk Score palette', () => { + const colorPalette = getRiskScorePalette(RISK_SCORE_STEPS); it('returns the expected fill color', () => { - expect(getFillColor({ riskScore: 50, useWarmPalette })).toEqual('#efb685'); + expect(getFillColor({ riskScore: 50, colorPalette })).toEqual('#d6bf57'); }); it('returns the expected fill color when risk score is zero', () => { - expect(getFillColor({ riskScore: 0, useWarmPalette })).toEqual('#fbfada'); + expect(getFillColor({ riskScore: 0, colorPalette })).toEqual('#54b399'); }); it('returns the expected fill color when risk score is less than zero', () => { - expect(getFillColor({ riskScore: -1, useWarmPalette })).toEqual('#fbfada'); + expect(getFillColor({ riskScore: -1, colorPalette })).toEqual('#54b399'); }); it('returns the expected fill color when risk score is 100', () => { - expect(getFillColor({ riskScore: 100, useWarmPalette })).toEqual('#e7664c'); + expect(getFillColor({ riskScore: 100, colorPalette })).toEqual('#e7664c'); }); it('returns the expected fill color when risk score is greater than 100', () => { - expect(getFillColor({ riskScore: 101, useWarmPalette })).toEqual('#e7664c'); - }); - }); - - describe('when useWarmPalette is false', () => { - const useWarmPalette = false; - - it('returns the expected fill color', () => { - expect(getFillColor({ riskScore: 50, useWarmPalette })).toEqual('#d6bf57'); - }); - - it('returns the expected fill color when risk score is zero', () => { - expect(getFillColor({ riskScore: 0, useWarmPalette })).toEqual('#54b399'); - }); - - it('returns the expected fill color when risk score is less than zero', () => { - expect(getFillColor({ riskScore: -1, useWarmPalette })).toEqual('#54b399'); - }); - - it('returns the expected fill color when risk score is 100', () => { - expect(getFillColor({ riskScore: 100, useWarmPalette })).toEqual('#e7664c'); - }); - - it('returns the expected fill color when risk score is greater than 100', () => { - expect(getFillColor({ riskScore: 101, useWarmPalette })).toEqual('#e7664c'); + expect(getFillColor({ riskScore: 101, colorPalette })).toEqual('#e7664c'); }); it('returns the expected fill color when risk score is greater than RISK_SCORE_CRITICAL', () => { - expect(getFillColor({ riskScore: RISK_SCORE_CRITICAL + 1, useWarmPalette })).toEqual( + expect(getFillColor({ riskScore: RISK_SCORE_CRITICAL + 1, colorPalette })).toEqual( RISK_COLOR_CRITICAL ); }); it('returns the expected fill color when risk score is equal to RISK_SCORE_CRITICAL', () => { - expect(getFillColor({ riskScore: RISK_SCORE_CRITICAL, useWarmPalette })).toEqual( + expect(getFillColor({ riskScore: RISK_SCORE_CRITICAL, colorPalette })).toEqual( RISK_COLOR_CRITICAL ); }); it('returns the expected fill color when risk score is greater than RISK_SCORE_HIGH', () => { - expect(getFillColor({ riskScore: RISK_SCORE_HIGH + 1, useWarmPalette })).toEqual( + expect(getFillColor({ riskScore: RISK_SCORE_HIGH + 1, colorPalette })).toEqual( RISK_COLOR_HIGH ); }); it('returns the expected fill color when risk score is equal to RISK_SCORE_HIGH', () => { - expect(getFillColor({ riskScore: RISK_SCORE_HIGH, useWarmPalette })).toEqual(RISK_COLOR_HIGH); + expect(getFillColor({ riskScore: RISK_SCORE_HIGH, colorPalette })).toEqual(RISK_COLOR_HIGH); }); it('returns the expected fill color when risk score is greater than RISK_SCORE_MEDIUM', () => { - expect(getFillColor({ riskScore: RISK_SCORE_MEDIUM + 1, useWarmPalette })).toEqual( + expect(getFillColor({ riskScore: RISK_SCORE_MEDIUM + 1, colorPalette })).toEqual( RISK_COLOR_MEDIUM ); }); it('returns the expected fill color when risk score is equal to RISK_SCORE_MEDIUM', () => { - expect(getFillColor({ riskScore: RISK_SCORE_MEDIUM, useWarmPalette })).toEqual( + expect(getFillColor({ riskScore: RISK_SCORE_MEDIUM, colorPalette })).toEqual( RISK_COLOR_MEDIUM ); }); it('returns the expected fill color when risk score is less than RISK_SCORE_MEDIUM', () => { - expect(getFillColor({ riskScore: RISK_SCORE_MEDIUM - 1, useWarmPalette })).toEqual( + expect(getFillColor({ riskScore: RISK_SCORE_MEDIUM - 1, colorPalette })).toEqual( RISK_COLOR_LOW ); }); }); + + describe('when using an EUI palette', () => { + const colorPalette = euiPaletteWarm(RISK_SCORE_STEPS); + + it('returns the expected fill color', () => { + expect(getFillColor({ riskScore: 50, colorPalette })).toEqual('#efb685'); + }); + + it('returns the expected fill color when risk score is zero', () => { + expect(getFillColor({ riskScore: 0, colorPalette })).toEqual('#fbfada'); + }); + + it('returns the expected fill color when risk score is less than zero', () => { + expect(getFillColor({ riskScore: -1, colorPalette })).toEqual('#fbfada'); + }); + + it('returns the expected fill color when risk score is 100', () => { + expect(getFillColor({ riskScore: 100, colorPalette })).toEqual('#e7664c'); + }); + + it('returns the expected fill color when risk score is greater than 100', () => { + expect(getFillColor({ riskScore: 101, colorPalette })).toEqual('#e7664c'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/chart_palette/index.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/chart_palette/index.ts new file mode 100644 index 0000000000000..56c474522172a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/chart_palette/index.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { clamp } from 'lodash/fp'; + +import { + RISK_COLOR_LOW, + RISK_COLOR_MEDIUM, + RISK_COLOR_HIGH, + RISK_COLOR_CRITICAL, + RISK_SCORE_MEDIUM, + RISK_SCORE_HIGH, + RISK_SCORE_CRITICAL, +} from '../../../../../detections/components/rules/step_about_rule/data'; + +/** + * The detection engine creates risk scores in the range 1 - 100. + * These steps also include a score of "zero", to enable lookups + * via an array index. + */ +export const RISK_SCORE_STEPS = 101; + +/** + * Returns a color palette that maps a risk score to the risk score colors + * defined by the Security Solution. + * + * The pallet defines values for a risk score between 0 and 100 (inclusive), + * but in practice, the detection engine only generates scores between 1-100. + * + * This pallet has the same type as `EuiPalette`, which is not exported by + * EUI at the time of this writing. + */ +export const getRiskScorePalette = (steps: number): string[] => + Array(steps) + .fill(0) + .map((_, i) => { + if (i >= RISK_SCORE_CRITICAL) { + return RISK_COLOR_CRITICAL; + } else if (i >= RISK_SCORE_HIGH) { + return RISK_COLOR_HIGH; + } else if (i >= RISK_SCORE_MEDIUM) { + return RISK_COLOR_MEDIUM; + } else { + return RISK_COLOR_LOW; + } + }); + +/** Returns a fill color based on the index of the risk score in the color palette */ +export const getFillColor = ({ + riskScore, + colorPalette, +}: { + riskScore: number; + colorPalette: string[]; +}): string => { + const MIN_RISK_SCORE = 0; + const MAX_RISK_SCORE = Math.min(100, colorPalette.length); + + const clampedScore = clamp(MIN_RISK_SCORE, MAX_RISK_SCORE, riskScore); + + return colorPalette[clampedScore]; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/flatten_bucket.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/flatten_bucket.test.ts similarity index 100% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/flatten_bucket.test.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/flatten_bucket.test.ts diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/flatten_bucket.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/flatten_bucket.ts similarity index 91% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/flatten_bucket.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/flatten_bucket.ts index 17a3c51c325ae..aa7966e7f79cd 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/flatten_bucket.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/flatten_bucket.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { RawBucket, FlattenedBucket } from '../types'; +import type { RawBucket, FlattenedBucket } from '../../types'; export const flattenBucket = ({ bucket, diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/get_flattened_buckets.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/get_flattened_buckets.test.ts similarity index 100% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/get_flattened_buckets.test.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/get_flattened_buckets.test.ts diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/get_flattened_buckets.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/get_flattened_buckets.ts similarity index 91% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/get_flattened_buckets.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/get_flattened_buckets.ts index 650e0a052c990..14fb905eb66f7 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/get_flattened_buckets.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/get_flattened_buckets.ts @@ -6,7 +6,7 @@ */ import { flattenBucket } from './flatten_bucket'; -import { RawBucket, FlattenedBucket } from '../types'; +import type { RawBucket, FlattenedBucket } from '../../types'; export const getFlattenedBuckets = ({ buckets, diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/mocks/mock_buckets.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/mocks/mock_buckets.ts similarity index 98% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/mocks/mock_buckets.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/mocks/mock_buckets.ts index 2f1820a021773..59f1cb6de0997 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/mocks/mock_buckets.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/mocks/mock_buckets.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { RawBucket } from '../../types'; +import type { RawBucket } from '../../../types'; export const bucketsWithStackByField1: RawBucket[] = [ { diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/mocks/mock_flattened_buckets.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/mocks/mock_flattened_buckets.ts similarity index 98% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/mocks/mock_flattened_buckets.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/mocks/mock_flattened_buckets.ts index d326c3db0be7a..0c465d6af116f 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/flatten/mocks/mock_flattened_buckets.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/mocks/mock_flattened_buckets.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { FlattenedBucket } from '../../types'; +import type { FlattenedBucket } from '../../../types'; export const flattenedBuckets: FlattenedBucket[] = [ { diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/helpers.test.ts similarity index 98% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/helpers.test.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/helpers.test.ts index 653fdc15ea651..9155c396390ca 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/helpers.test.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { PartitionElementEvent } from '@elastic/charts'; +import type { PartitionElementEvent } from '@elastic/charts'; import { omit } from 'lodash/fp'; -import { bucketsWithStackByField1, maxRiskSubAggregations } from '../flatten/mocks/mock_buckets'; +import { bucketsWithStackByField1, maxRiskSubAggregations } from './flatten/mocks/mock_buckets'; import { getGroupByFieldsOnClick, getMaxRiskSubAggregations, diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/helpers.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/helpers.ts similarity index 88% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/helpers.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/helpers.ts index 99f9ff0e5bb7a..1b6a526d8ebdd 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/helpers.ts @@ -5,14 +5,15 @@ * 2.0. */ -import { +import type { + FlameElementEvent, HeatmapElementEvent, PartitionElementEvent, WordCloudElementEvent, XYChartElementEvent, } from '@elastic/charts'; -import { RawBucket } from '../types'; +import type { RawBucket } from '../types'; export const getUpToMaxBuckets = ({ buckets, @@ -37,7 +38,11 @@ interface GetGroupByFieldsResult { export const getGroupByFieldsOnClick = ( elements: Array< - XYChartElementEvent | PartitionElementEvent | HeatmapElementEvent | WordCloudElementEvent + | XYChartElementEvent + | PartitionElementEvent + | FlameElementEvent + | HeatmapElementEvent + | WordCloudElementEvent > ): GetGroupByFieldsResult => { const flattened = elements.flat(2); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/labels/index.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/labels/index.test.ts new file mode 100644 index 0000000000000..33a4b62db5896 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/labels/index.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getLabel } from '.'; + +describe('labels', () => { + describe('getLabel', () => { + it('returns the expected label when risk score is a number', () => { + const baseLabel = 'mimikatz process started'; + const riskScore = 99; + + expect(getLabel({ baseLabel, riskScore })).toBe('mimikatz process started (Risk 99)'); + }); + + it('returns the expected label when risk score is null', () => { + const baseLabel = 'mimikatz process started'; + const riskScore = null; + + expect(getLabel({ baseLabel, riskScore })).toBe(baseLabel); + }); + + it('returns the expected label when risk score is undefined', () => { + const baseLabel = 'mimikatz process started'; + const riskScore = undefined; + + expect(getLabel({ baseLabel, riskScore })).toBe(baseLabel); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/labels/index.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/labels/index.ts similarity index 100% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/labels/index.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/labels/index.ts diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/layers.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/layers/index.test.ts similarity index 54% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/layers.test.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/layers/index.test.ts index e46051ba8c5d7..0cd5ccf63a475 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/layers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/layers/index.test.ts @@ -5,16 +5,14 @@ * 2.0. */ +import { getRiskScorePalette, RISK_SCORE_STEPS } from '../chart_palette'; import { maxRiskSubAggregations } from '../flatten/mocks/mock_buckets'; -import { - DataName, - FillColorDatum, - getGroupFromPath, - getLayersOneDimension, - getLayersMultiDimensional, -} from './layers'; +import type { DataName, FillColorDatum, Path } from '.'; +import { getGroupFromPath, getLayersOneDimension, getLayersMultiDimensional } from '.'; describe('layers', () => { + const colorPalette = getRiskScorePalette(RISK_SCORE_STEPS); + describe('getGroupFromPath', () => { it('returns the expected group from the path', () => { expect( @@ -38,62 +36,81 @@ describe('layers', () => { describe('getLayersOneDimension', () => { it('returns the expected number of layers', () => { - expect(getLayersOneDimension(maxRiskSubAggregations).length).toEqual(1); + expect(getLayersOneDimension({ colorPalette, maxRiskSubAggregations }).length).toEqual(1); }); it('returns the expected fillLabel valueFormatter function', () => { expect( - getLayersOneDimension(maxRiskSubAggregations)[0].fillLabel.valueFormatter(123) + getLayersOneDimension({ colorPalette, maxRiskSubAggregations })[0].fillLabel.valueFormatter( + 123 + ) ).toEqual('123'); }); it('returns the expected groupByRollup function', () => { expect( - getLayersOneDimension(maxRiskSubAggregations)[0].groupByRollup({ key: 'keystone' }) + getLayersOneDimension({ colorPalette, maxRiskSubAggregations })[0].groupByRollup({ + key: 'keystone', + }) ).toEqual('keystone'); }); it('returns the expected nodeLabel function', () => { expect( - getLayersOneDimension(maxRiskSubAggregations)[0].nodeLabel('matches everything') + getLayersOneDimension({ colorPalette, maxRiskSubAggregations })[0].nodeLabel( + 'matches everything' + ) ).toEqual('matches everything (Risk 21)'); }); it('returns the expected shape fillColor function', () => { const dataName: DataName = { dataName: 'mimikatz process started' }; - expect(getLayersOneDimension(maxRiskSubAggregations)[0].shape.fillColor(dataName)).toEqual( - '#e7664c' - ); + expect( + getLayersOneDimension({ colorPalette, maxRiskSubAggregations })[0].shape.fillColor(dataName) + ).toEqual('#e7664c'); }); it('return the default fill color when dataName is not found in the maxRiskSubAggregations', () => { const dataName: DataName = { dataName: 'this does not exist' }; - expect(getLayersOneDimension(maxRiskSubAggregations)[0].shape.fillColor(dataName)).toEqual( - '#54b399' - ); + expect( + getLayersOneDimension({ colorPalette, maxRiskSubAggregations })[0].shape.fillColor(dataName) + ).toEqual('#54b399'); }); }); describe('getLayersMultiDimensional', () => { + const layer0FillColor = 'transparent'; it('returns the expected number of layers', () => { - expect(getLayersMultiDimensional(maxRiskSubAggregations).length).toEqual(2); + expect( + getLayersMultiDimensional({ colorPalette, layer0FillColor, maxRiskSubAggregations }).length + ).toEqual(2); }); it('returns the expected fillLabel valueFormatter function', () => { - getLayersMultiDimensional(maxRiskSubAggregations).forEach((x) => - expect(x.fillLabel.valueFormatter(123)).toEqual('123') + getLayersMultiDimensional({ colorPalette, layer0FillColor, maxRiskSubAggregations }).forEach( + (x) => expect(x.fillLabel.valueFormatter(123)).toEqual('123') ); }); it('returns the expected groupByRollup function for layer 0', () => { expect( - getLayersMultiDimensional(maxRiskSubAggregations)[0].groupByRollup({ key: 'keystone' }) + getLayersMultiDimensional({ + colorPalette, + layer0FillColor, + maxRiskSubAggregations, + })[0].groupByRollup({ + key: 'keystone', + }) ).toEqual('keystone'); }); it('returns the expected groupByRollup function for layer 1, which has a different implementation', () => { expect( - getLayersMultiDimensional(maxRiskSubAggregations)[1].groupByRollup({ + getLayersMultiDimensional({ + colorPalette, + layer0FillColor, + maxRiskSubAggregations, + })[1].groupByRollup({ stackByField1Key: 'host.name', }) ).toEqual('host.name'); @@ -101,33 +118,40 @@ describe('layers', () => { it('returns the expected nodeLabel function for layer 0', () => { expect( - getLayersMultiDimensional(maxRiskSubAggregations)[0].nodeLabel('matches everything') + getLayersMultiDimensional({ + colorPalette, + layer0FillColor, + maxRiskSubAggregations, + })[0].nodeLabel('matches everything') ).toEqual('matches everything (Risk 21)'); }); it('returns the expected nodeLabel function for layer 1, which has a different implementation', () => { expect( - getLayersMultiDimensional(maxRiskSubAggregations)[1].nodeLabel('Host-k8iyfzraq9') + getLayersMultiDimensional({ + colorPalette, + layer0FillColor, + maxRiskSubAggregations, + })[1].nodeLabel('Host-k8iyfzraq9') ).toEqual('Host-k8iyfzraq9'); }); - it('returns the expected shape fillColor function for layer 0', () => { - const dataName: DataName = { dataName: 'mimikatz process started' }; + it('returns the expected shape fillColor for layer 0', () => { expect( - getLayersMultiDimensional(maxRiskSubAggregations)[0].shape.fillColor(dataName) - ).toEqual('#e7664c'); + getLayersMultiDimensional({ colorPalette, layer0FillColor, maxRiskSubAggregations })[0] + .shape.fillColor + ).toEqual(layer0FillColor); }); - it('returns the default fillColor function for layer 0 when dataName is not found in the maxRiskSubAggregations', () => { - const dataName: DataName = { dataName: 'this will not be found' }; - expect( - getLayersMultiDimensional(maxRiskSubAggregations)[0].shape.fillColor(dataName) - ).toEqual('#54b399'); - }); + it('returns the expected shape fill color function for layer 1, which has a different implementation', () => { + const fillColorFn = getLayersMultiDimensional({ + colorPalette, + layer0FillColor, + maxRiskSubAggregations, + })[1].shape.fillColor as ({ dataName, path }: { dataName: string; path: Path[] }) => string; - it('returns the expected shape fillColor function for layer 1, which has a different implementation', () => { expect( - getLayersMultiDimensional(maxRiskSubAggregations)[1].shape.fillColor({ + fillColorFn({ dataName: 'Host-k8iyfzraq9', path: [ { index: 0, value: '__null_small_multiples_key__' }, @@ -139,9 +163,15 @@ describe('layers', () => { ).toEqual('#e7664c'); }); - it('returns the default fillColor function for layer 1 when the group from path is not found', () => { + it('returns the default fillColor for layer 1 when the group from path is not found', () => { + const fillColorFn = getLayersMultiDimensional({ + colorPalette, + layer0FillColor, + maxRiskSubAggregations, + })[1].shape.fillColor as ({ dataName, path }: { dataName: string; path: Path[] }) => string; + expect( - getLayersMultiDimensional(maxRiskSubAggregations)[1].shape.fillColor({ + fillColorFn({ dataName: 'nope', path: [ { index: 0, value: '__null_small_multiples_key__' }, diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/layers.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/layers/index.ts similarity index 74% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/layers.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/layers/index.ts index 8c329f20c7a79..09a4d95bdcb0f 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/layers.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/layers/index.ts @@ -5,16 +5,16 @@ * 2.0. */ -import { Datum } from '@elastic/charts'; +import type { Datum } from '@elastic/charts'; -import { getFillColor } from '../../../../detections/pages/detection_engine/get_fill_color'; -import { getLabel } from './labels'; +import { getFillColor } from '../chart_palette'; +import { getLabel } from '../labels'; export interface DataName { dataName: string; } -interface Path { +export interface Path { index: number; value: string; } @@ -39,9 +39,13 @@ export const getGroupFromPath = (datum: FillColorDatum): string | undefined => { return Array.isArray(datum.path) && groupIndex > 0 ? datum.path[groupIndex].value : undefined; }; -export const getLayersOneDimension = ( - maxRiskSubAggregations: Record -) => [ +export const getLayersOneDimension = ({ + colorPalette, + maxRiskSubAggregations, +}: { + colorPalette: string[]; + maxRiskSubAggregations: Record; +}) => [ { fillLabel: { valueFormatter, @@ -52,15 +56,21 @@ export const getLayersOneDimension = ( fillColor: (d: DataName) => getFillColor({ riskScore: maxRiskSubAggregations[d.dataName] ?? 0, - useWarmPalette: false, + colorPalette, }), }, }, ]; -export const getLayersMultiDimensional = ( - maxRiskSubAggregations: Record -) => [ +export const getLayersMultiDimensional = ({ + colorPalette, + layer0FillColor, + maxRiskSubAggregations, +}: { + colorPalette: string[]; + layer0FillColor: string; + maxRiskSubAggregations: Record; +}) => [ { fillLabel: { valueFormatter, @@ -68,11 +78,7 @@ export const getLayersMultiDimensional = ( groupByRollup, nodeLabel: (d: Datum) => getLabel({ baseLabel: d, riskScore: maxRiskSubAggregations[d] }), shape: { - fillColor: (d: DataName) => - getFillColor({ - riskScore: maxRiskSubAggregations[d.dataName] ?? 0, - useWarmPalette: false, - }), + fillColor: layer0FillColor, }, }, { @@ -84,9 +90,10 @@ export const getLayersMultiDimensional = ( shape: { fillColor: (d: FillColorDatum) => { const groupFromPath = getGroupFromPath(d) ?? ''; + return getFillColor({ riskScore: maxRiskSubAggregations[groupFromPath] ?? 0, - useWarmPalette: false, + colorPalette, }); }, }, diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/get_flattened_legend_items.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/get_flattened_legend_items.test.ts similarity index 94% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/get_flattened_legend_items.test.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/get_flattened_legend_items.test.ts index 2bc37ac78fad1..325e2bab84d6f 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/get_flattened_legend_items.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/get_flattened_legend_items.test.ts @@ -7,7 +7,8 @@ import { omit } from 'lodash/fp'; -import { LegendItem } from '../../charts/draggable_legend_item'; +import type { LegendItem } from '../../../charts/draggable_legend_item'; +import { getRiskScorePalette, RISK_SCORE_STEPS } from '../chart_palette'; import { getFlattenedLegendItems } from './get_flattened_legend_items'; import { bucketsWithStackByField1, maxRiskSubAggregations } from '../flatten/mocks/mock_buckets'; import { flattenedBuckets } from '../flatten/mocks/mock_flattened_buckets'; @@ -140,6 +141,7 @@ describe('getFlattenedLegendItems', () => { const legendItems = getFlattenedLegendItems({ buckets: bucketsWithStackByField1, + colorPalette: getRiskScorePalette(RISK_SCORE_STEPS), flattenedBuckets, maxRiskSubAggregations, stackByField0: 'kibana.alert.rule.name', diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/get_flattened_legend_items.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/get_flattened_legend_items.ts similarity index 87% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/get_flattened_legend_items.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/get_flattened_legend_items.ts index ff8e9bf2d11c3..a904d6ef90bd0 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/get_flattened_legend_items.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/get_flattened_legend_items.ts @@ -5,18 +5,20 @@ * 2.0. */ -import { LegendItem } from '../../charts/draggable_legend_item'; -import { getLegendMap, getLegendItemFromFlattenedBucket } from './legend'; -import { FlattenedBucket, RawBucket } from '../types'; +import type { LegendItem } from '../../../charts/draggable_legend_item'; +import { getLegendMap, getLegendItemFromFlattenedBucket } from '.'; +import type { FlattenedBucket, RawBucket } from '../../types'; export const getFlattenedLegendItems = ({ buckets, + colorPalette, flattenedBuckets, maxRiskSubAggregations, stackByField0, stackByField1, }: { buckets: RawBucket[]; + colorPalette: string[]; flattenedBuckets: FlattenedBucket[]; maxRiskSubAggregations: Record; stackByField0: string; @@ -25,6 +27,7 @@ export const getFlattenedLegendItems = ({ // create a map of bucket.key -> LegendItem[] from the raw buckets: const legendMap: Record = getLegendMap({ buckets, + colorPalette, maxRiskSubAggregations, stackByField0, }); @@ -38,6 +41,7 @@ export const getFlattenedLegendItems = ({ [flattenedBucket.key]: [ ...(acc[flattenedBucket.key] ?? []), getLegendItemFromFlattenedBucket({ + colorPalette, flattenedBucket, maxRiskSubAggregations, stackByField0, diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/legend.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/index.test.ts similarity index 93% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/legend.test.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/index.test.ts index b3d0041ce2269..514b2743504d4 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/legend.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/index.test.ts @@ -7,22 +7,26 @@ import { omit } from 'lodash/fp'; -import { LegendItem } from '../../charts/draggable_legend_item'; +import type { LegendItem } from '../../../charts/draggable_legend_item'; +import { getRiskScorePalette, RISK_SCORE_STEPS } from '../chart_palette'; import { bucketsWithStackByField1, maxRiskSubAggregations } from '../flatten/mocks/mock_buckets'; import { getFirstGroupLegendItems, getLegendItemFromRawBucket, getLegendItemFromFlattenedBucket, getLegendMap, -} from './legend'; -import { FlattenedBucket } from '../types'; +} from '.'; +import type { FlattenedBucket } from '../../types'; describe('legend', () => { + const colorPalette = getRiskScorePalette(RISK_SCORE_STEPS); + describe('getLegendItemFromRawBucket', () => { it('returns an undefined color when showColor is false', () => { expect( getLegendItemFromRawBucket({ bucket: bucketsWithStackByField1[0], + colorPalette, maxRiskSubAggregations, showColor: false, stackByField0: 'kibana.alert.rule.name', @@ -34,6 +38,7 @@ describe('legend', () => { expect( getLegendItemFromRawBucket({ bucket: bucketsWithStackByField1[0], + colorPalette, maxRiskSubAggregations, showColor: true, stackByField0: 'kibana.alert.rule.name', @@ -45,6 +50,7 @@ describe('legend', () => { expect( getLegendItemFromRawBucket({ bucket: bucketsWithStackByField1[0], + colorPalette, maxRiskSubAggregations, showColor: true, stackByField0: 'kibana.alert.rule.name', @@ -56,6 +62,7 @@ describe('legend', () => { expect( getLegendItemFromRawBucket({ bucket: bucketsWithStackByField1[0], + colorPalette, maxRiskSubAggregations, showColor: true, stackByField0: 'kibana.alert.rule.name', @@ -67,6 +74,7 @@ describe('legend', () => { expect( getLegendItemFromRawBucket({ bucket: bucketsWithStackByField1[0], + colorPalette, maxRiskSubAggregations, showColor: true, stackByField0: 'kibana.alert.rule.name', @@ -78,6 +86,7 @@ describe('legend', () => { expect( getLegendItemFromRawBucket({ bucket: bucketsWithStackByField1[0], + colorPalette, maxRiskSubAggregations, showColor: true, stackByField0: 'kibana.alert.rule.name', @@ -100,6 +109,7 @@ describe('legend', () => { omit( ['render', 'dataProviderId'], getLegendItemFromFlattenedBucket({ + colorPalette, flattenedBucket, maxRiskSubAggregations, stackByField0: 'kibana.alert.rule.name', @@ -116,6 +126,7 @@ describe('legend', () => { it('returns the expected render function', () => { const legendItem = getLegendItemFromFlattenedBucket({ + colorPalette, flattenedBucket, maxRiskSubAggregations, stackByField0: 'kibana.alert.rule.name', @@ -127,6 +138,7 @@ describe('legend', () => { it('returns the expected dataProviderId', () => { const legendItem = getLegendItemFromFlattenedBucket({ + colorPalette, flattenedBucket, maxRiskSubAggregations, stackByField0: 'kibana.alert.rule.name', @@ -144,6 +156,7 @@ describe('legend', () => { expect( getFirstGroupLegendItems({ buckets: bucketsWithStackByField1, + colorPalette, maxRiskSubAggregations, stackByField0: 'kibana.alert.rule.name', }).map((x) => omit(['render', 'dataProviderId'], x)) @@ -185,6 +198,7 @@ describe('legend', () => { expect( getFirstGroupLegendItems({ buckets: bucketsWithStackByField1, + colorPalette, maxRiskSubAggregations, stackByField0: 'kibana.alert.rule.name', }).map((x) => (x.render != null ? x.render() : null)) @@ -248,6 +262,7 @@ describe('legend', () => { const legendMap = getLegendMap({ buckets: bucketsWithStackByField1, + colorPalette, maxRiskSubAggregations, stackByField0: 'kibana.alert.rule.name', }); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/legend.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/index.ts similarity index 79% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/legend.ts rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/index.ts index 16d10eff272d9..77865b7d55013 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/legend.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/index.ts @@ -7,27 +7,29 @@ import uuid from 'uuid'; -import { LegendItem } from '../../charts/draggable_legend_item'; -import { getFillColor } from '../../../../detections/pages/detection_engine/get_fill_color'; -import { escapeDataProviderId } from '../../drag_and_drop/helpers'; -import { getLabel } from './labels'; -import type { FlattenedBucket, RawBucket } from '../types'; +import type { LegendItem } from '../../../charts/draggable_legend_item'; +import { getFillColor } from '../chart_palette'; +import { escapeDataProviderId } from '../../../drag_and_drop/helpers'; +import { getLabel } from '../labels'; +import type { FlattenedBucket, RawBucket } from '../../types'; export const getLegendItemFromRawBucket = ({ bucket, + colorPalette, maxRiskSubAggregations, - showColor = true, + showColor, stackByField0, }: { bucket: RawBucket; + colorPalette: string[]; maxRiskSubAggregations: Record; - showColor?: boolean; + showColor: boolean; stackByField0: string; }): LegendItem => ({ color: showColor ? getFillColor({ riskScore: maxRiskSubAggregations[bucket.key] ?? 0, - useWarmPalette: false, + colorPalette, }) : undefined, count: bucket.doc_count, @@ -44,11 +46,13 @@ export const getLegendItemFromRawBucket = ({ }); export const getLegendItemFromFlattenedBucket = ({ + colorPalette, flattenedBucket: { key, stackByField1Key, stackByField1DocCount }, maxRiskSubAggregations, stackByField0, stackByField1, }: { + colorPalette: string[]; flattenedBucket: FlattenedBucket; maxRiskSubAggregations: Record; stackByField0: string; @@ -56,7 +60,7 @@ export const getLegendItemFromFlattenedBucket = ({ }): LegendItem => ({ color: getFillColor({ riskScore: maxRiskSubAggregations[key] ?? 0, - useWarmPalette: false, + colorPalette, }), count: stackByField1DocCount, dataProviderId: escapeDataProviderId( @@ -69,27 +73,33 @@ export const getLegendItemFromFlattenedBucket = ({ export const getFirstGroupLegendItems = ({ buckets, + colorPalette, maxRiskSubAggregations, stackByField0, }: { buckets: RawBucket[]; + colorPalette: string[]; maxRiskSubAggregations: Record; stackByField0: string; }): LegendItem[] => buckets.map((bucket) => getLegendItemFromRawBucket({ bucket, + colorPalette, maxRiskSubAggregations, + showColor: true, stackByField0, }) ); export const getLegendMap = ({ buckets, + colorPalette, maxRiskSubAggregations, stackByField0, }: { buckets: RawBucket[]; + colorPalette: string[]; maxRiskSubAggregations: Record; stackByField0: string; }): Record => @@ -99,8 +109,9 @@ export const getLegendMap = ({ [bucket.key]: [ getLegendItemFromRawBucket({ bucket, + colorPalette, maxRiskSubAggregations, - showColor: false, + showColor: false, // don't show colors for stackByField0 stackByField0, }), ], diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/mocks/mock_alert_search_response.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/mocks/mock_alert_search_response.ts new file mode 100644 index 0000000000000..b28b55a396ad1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/mocks/mock_alert_search_response.ts @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { AlertSearchResponse } from '../../../../../detections/containers/detection_engine/alerts/types'; +import type { AlertsTreeMapAggregation } from '../../types'; + +export const mockAlertSearchResponse: AlertSearchResponse = { + took: 1, + timeout: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 75, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + stackByField0: { + buckets: [ + { + key: 'Endpoint Security', + doc_count: 50, + maxRiskSubAggregation: { + value: 47, + }, + stackByField1: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Host-p3afpacfut', + doc_count: 30, + }, + { + key: 'Host-wgrua1nhzb', + doc_count: 20, + }, + ], + }, + }, + { + key: 'matches everything', + doc_count: 23, + maxRiskSubAggregation: { + value: 21, + }, + stackByField1: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Host-p3afpacfut', + doc_count: 15, + }, + { + key: 'Host-wgrua1nhzb', + doc_count: 7, + }, + { + key: 'Host-bnrf4ss7ez', + doc_count: 1, + }, + ], + }, + }, + { + key: 'Threshold rule', + doc_count: 1, + maxRiskSubAggregation: { + value: 99, + }, + stackByField1: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Host-p3afpacfut', + doc_count: 1, + }, + ], + }, + }, + { + key: 'mimikatz process started', + doc_count: 1, + maxRiskSubAggregation: { + value: 99, + }, + stackByField1: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Host-wgrua1nhzb', + doc_count: 1, + }, + ], + }, + }, + ], + }, + }, +}; + +export const mockNoDataAlertSearchResponse = { + took: 1, + timeout: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 80, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + stackByField0: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], // <-- empty buckets + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/no_data/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/no_data/index.test.tsx new file mode 100644 index 0000000000000..f4c9845c90b90 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/no_data/index.test.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { NoData } from '.'; + +describe('NoData', () => { + test('renders the expected "no data" message', () => { + render(); + + expect(screen.getByTestId('noDataLabel')).toHaveTextContent('No data to display'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/no_data/index.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/no_data/index.tsx similarity index 78% rename from x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/no_data/index.tsx rename to x-pack/plugins/security_solution/public/common/components/alerts_treemap/no_data/index.tsx index aba46eae33132..2dba94d3c12fa 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/component/no_data/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/no_data/index.tsx @@ -9,13 +9,13 @@ import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; -import * as i18n from '../../translations'; +import * as i18n from '../translations'; const NoDataLabel = styled(EuiText)` text-align: center; `; -export const NoData = React.memo(() => ( +const NoDataComponent: React.FC = () => ( @@ -23,6 +23,8 @@ export const NoData = React.memo(() => ( -)); +); -NoData.displayName = 'NoData'; +NoDataComponent.displayName = 'NoDataComponent'; + +export const NoData = React.memo(NoDataComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/query/index.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/query/index.ts index 141d61ed1a46e..cd73b9a5af434 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/query/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/query/index.ts @@ -6,7 +6,7 @@ */ import { isEmpty } from 'lodash/fp'; -import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; /** The maximum number of items to render */ export const DEFAULT_STACK_BY_FIELD0_SIZE = 1000; diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/translations.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/translations.ts index b1d42f542eb58..c5566e62506a8 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/translations.ts @@ -30,13 +30,6 @@ export const SUBTITLE = (maxItems: number) => defaultMessage: 'Showing the top {maxItems} most frequently occurring alerts', }); -export const ALERTS_BY_RISK_SCORE_TITLE = i18n.translate( - 'xpack.securitySolution.components.alertsTreemap.aletsByRiskScoreTitle', - { - defaultMessage: 'Alerts by risk score', - } -); - export const SHOW_ALL = i18n.translate( 'xpack.securitySolution.components.alertsTreemap.showAllButton', { diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/types.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/types.ts index 7fe250b311cb1..b0316952487d3 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { GenericBuckets } from '../../../../common/search_strategy/common'; +import type { GenericBuckets } from '../../../../common/search_strategy/common'; export type RawBucket = GenericBuckets & { maxRiskSubAggregation?: { diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.test.tsx new file mode 100644 index 0000000000000..018fc2927367a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.test.tsx @@ -0,0 +1,247 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import { useLocation } from 'react-router-dom'; + +import { SecurityPageName } from '../../../../common/constants'; +import { + DEFAULT_STACK_BY_FIELD, + DEFAULT_STACK_BY_FIELD1, +} from '../../../detections/components/alerts_kpis/common/config'; +import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query'; +import { ChartContextMenu } from '../../../detections/pages/detection_engine/chart_panels/chart_context_menu'; +import { ChartSelect } from '../../../detections/pages/detection_engine/chart_panels/chart_select'; +import { TestProviders } from '../../mock/test_providers'; +import type { Props } from '.'; +import { AlertsTreemapPanel } from '.'; +import { mockAlertSearchResponse } from '../alerts_treemap/lib/mocks/mock_alert_search_response'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +jest.mock('../../lib/kibana', () => { + const originalModule = jest.requireActual('../../lib/kibana'); + return { + ...originalModule, + useUiSetting$: () => ['0,0.[000]'], + }; +}); + +jest.mock('../../../detections/containers/detection_engine/alerts/use_query', () => ({ + useQueryAlerts: jest.fn(), +})); + +const defaultProps: Props = { + addFilter: jest.fn(), + alignHeader: 'flexStart', + chartOptionsContextMenu: (queryId: string) => ( + + ), + + isPanelExpanded: true, + filters: [ + { + meta: { + alias: null, + negate: true, + disabled: false, + type: 'exists', + key: 'kibana.alert.building_block_type', + value: 'exists', + }, + query: { + exists: { + field: 'kibana.alert.building_block_type', + }, + }, + }, + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'kibana.alert.workflow_status', + params: { + query: 'open', + }, + }, + query: { + term: { + 'kibana.alert.workflow_status': 'open', + }, + }, + }, + ], + query: { + query: '', + language: 'kuery', + }, + riskSubAggregationField: 'signal.rule.risk_score', + runtimeMappings: { + test_via_alerts_table: { + type: 'keyword', + script: { + source: 'emit("Hello World!");', + }, + }, + }, + setIsPanelExpanded: jest.fn(), + setStackByField0: jest.fn(), + setStackByField1: jest.fn(), + signalIndexName: '.alerts-security.alerts-default', + stackByField0: 'kibana.alert.rule.name', + stackByField1: 'host.name', + title: , +}; + +describe('AlertsTreemapPanel', () => { + beforeEach(() => { + jest.resetAllMocks(); + + (useLocation as jest.Mock).mockReturnValue([ + { pageName: SecurityPageName.alerts, detailName: undefined }, + ]); + + (useQueryAlerts as jest.Mock).mockReturnValue({ + loading: false, + data: mockAlertSearchResponse, + setQuery: () => {}, + response: '', + request: '', + refetch: () => {}, + }); + }); + + it('renders the panel', async () => { + render( + + + + ); + + await waitFor(() => expect(screen.getByTestId('treemapPanel')).toBeInTheDocument()); + }); + + it('renders the panel with a hidden overflow-x', async () => { + render( + + + + ); + + await waitFor(() => + expect(screen.getByTestId('treemapPanel')).toHaveStyleRule('overflow-x', 'hidden') + ); + }); + + it('renders the panel with an auto overflow-y to allow vertical scrolling when necessary', async () => { + render( + + + + ); + + await waitFor(() => + expect(screen.getByTestId('treemapPanel')).toHaveStyleRule('overflow-y', 'auto') + ); + }); + + it('renders the chart selector as a custom header title', async () => { + render( + + + + ); + + await waitFor(() => expect(screen.getByTestId('chartSelect')).toBeInTheDocument()); + }); + + it('renders field selection when `isPanelExpanded` is true', async () => { + render( + + + + ); + + await waitFor(() => expect(screen.getByTestId('fieldSelection')).toBeInTheDocument()); + }); + + it('does NOT render field selection when `isPanelExpanded` is false', async () => { + render( + + + + ); + + await waitFor(() => expect(screen.queryByTestId('fieldSelection')).toBeNull()); + }); + + it('renders the progress bar when data is loading', async () => { + (useQueryAlerts as jest.Mock).mockReturnValue({ + loading: true, + data: mockAlertSearchResponse, + setQuery: () => {}, + response: '', + request: '', + refetch: () => {}, + }); + + render( + + + + ); + + await waitFor(() => expect(screen.getByTestId('progress')).toBeInTheDocument()); + }); + + it('does NOT render the progress bar when data has loaded', async () => { + render( + + + + ); + + await waitFor(() => expect(screen.queryByTestId('progress')).toBeNull()); + }); + + it('renders the treemap when data is available and `isPanelExpanded` is true', async () => { + jest.mock('../../../detections/containers/detection_engine/alerts/use_query', () => { + return { + useQueryAlerts: () => ({ + loading: true, + data: mockAlertSearchResponse, + setQuery: () => {}, + response: '', + request: '', + refetch: () => {}, + }), + }; + }); + + render( + + + + ); + + await waitFor(() => + expect(screen.getByTestId('treemap').querySelector('.echChart')).toBeInTheDocument() + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.tsx new file mode 100644 index 0000000000000..d3dadc20e4ace --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.tsx @@ -0,0 +1,204 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; +import { EuiProgress } from '@elastic/eui'; +import type { Filter, Query } from '@kbn/es-query'; +import { buildEsQuery } from '@kbn/es-query'; +import React, { useEffect, useMemo } from 'react'; +import uuid from 'uuid'; + +import { useGlobalTime } from '../../containers/use_global_time'; +import { AlertsTreemap, DEFAULT_MIN_CHART_HEIGHT } from '../alerts_treemap'; +import { KpiPanel } from '../../../detections/components/alerts_kpis/common/components'; +import { useInspectButton } from '../../../detections/components/alerts_kpis/common/hooks'; +import type { AlertSearchResponse } from '../../../detections/containers/detection_engine/alerts/types'; +import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query'; +import { FieldSelection } from '../field_selection'; +import { HeaderSection } from '../header_section'; +import { InspectButtonContainer } from '../inspect'; +import { DEFAULT_STACK_BY_FIELD0_SIZE, getAlertsRiskQuery } from '../alerts_treemap/query'; +import type { AlertsTreeMapAggregation } from '../alerts_treemap/types'; + +const DEFAULT_HEIGHT = DEFAULT_MIN_CHART_HEIGHT + 122; // px + +const COLLAPSED_HEIGHT = 64; // px + +const ALERTS_TREEMAP_ID = 'alerts-treemap'; + +export interface Props { + addFilter?: ({ field, value }: { field: string; value: string | number }) => void; + alignHeader?: 'center' | 'baseline' | 'stretch' | 'flexStart' | 'flexEnd'; + chartOptionsContextMenu?: (queryId: string) => React.ReactNode; + isPanelExpanded: boolean; + filters?: Filter[]; + height?: number; + query?: Query; + riskSubAggregationField: string; + runtimeMappings?: MappingRuntimeFields; + setIsPanelExpanded: (value: boolean) => void; + setStackByField0: (stackBy: string) => void; + setStackByField1: (stackBy: string | undefined) => void; + signalIndexName: string | null; + stackByField0: string; + stackByField1: string | undefined; + stackByWidth?: number; + title: React.ReactNode; +} + +export const getBucketsCount = ( + data: AlertSearchResponse | null +): number => data?.aggregations?.stackByField0?.buckets?.length ?? 0; + +const AlertsTreemapPanelComponent: React.FC = ({ + addFilter, + alignHeader, + chartOptionsContextMenu, + isPanelExpanded, + filters, + height = DEFAULT_HEIGHT, + query, + riskSubAggregationField, + runtimeMappings, + setIsPanelExpanded, + setStackByField0, + setStackByField1, + signalIndexName, + stackByField0, + stackByField1, + stackByWidth, + title, +}: Props) => { + const { to, from, deleteQuery, setQuery } = useGlobalTime(); + + // create a unique, but stable (across re-renders) query id + const uniqueQueryId = useMemo(() => `${ALERTS_TREEMAP_ID}-${uuid.v4()}`, []); + + const additionalFilters = useMemo(() => { + try { + return [ + buildEsQuery( + undefined, + query != null ? [query] : [], + filters?.filter((f) => f.meta.disabled === false) ?? [] + ), + ]; + } catch (e) { + return []; + } + }, [query, filters]); + + const { + data: alertsData, + loading: isLoadingAlerts, + refetch, + request, + response, + setQuery: setAlertsQuery, + } = useQueryAlerts<{}, AlertsTreeMapAggregation>({ + query: getAlertsRiskQuery({ + additionalFilters, + from, + riskSubAggregationField, + runtimeMappings, + stackByField0, + stackByField1, + to, + }), + skip: !isPanelExpanded, + indexName: signalIndexName, + }); + + useEffect(() => { + setAlertsQuery( + getAlertsRiskQuery({ + additionalFilters, + from, + riskSubAggregationField, + runtimeMappings, + stackByField0, + stackByField1, + to, + }) + ); + }, [ + additionalFilters, + from, + riskSubAggregationField, + runtimeMappings, + setAlertsQuery, + stackByField0, + stackByField1, + to, + ]); + + useInspectButton({ + deleteQuery, + loading: isLoadingAlerts, + response, + setQuery, + refetch, + request, + uniqueQueryId, + }); + + return ( + + + + {isPanelExpanded && ( + + )} + + + {isLoadingAlerts ? ( + + ) : ( + <> + {alertsData != null && isPanelExpanded && ( + + )} + + )} + + + ); +}; + +AlertsTreemapPanelComponent.displayName = 'AlertsTreemapPanelComponent'; + +export const AlertsTreemapPanel = React.memo(AlertsTreemapPanelComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/authentication/__snapshots__/authentications_host_table.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/authentication/__snapshots__/authentications_host_table.test.tsx.snap index 985c98e50e508..3445bc360b521 100644 --- a/x-pack/plugins/security_solution/public/common/components/authentication/__snapshots__/authentications_host_table.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/authentication/__snapshots__/authentications_host_table.test.tsx.snap @@ -65,13 +65,15 @@ exports[`Authentication Host Table Component rendering it renders the host authe data-test-subj="header-section" >
({ + useInspect: () => ({ handleClick: mockHandleClick }), +})); + +describe('useChartSettingsPopoverConfiguration', () => { + const onResetStackByFields = jest.fn(); + const queryId = 'abcd'; + + const state: State = mockGlobalState; + const { storage } = createSecuritySolutionStorageMock(); + const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + beforeEach(() => jest.resetAllMocks()); + + test('it returns the expected defaultInitialPanelId', () => { + const { result } = renderHook( + () => useChartSettingsPopoverConfiguration({ onResetStackByFields, queryId }), + { wrapper } + ); + + expect(result.current.defaultInitialPanelId).toEqual('default-initial-panel'); + }); + + test('it invokes handleClick when the Inspect menu item is clicked', () => { + const { result } = renderHook( + () => useChartSettingsPopoverConfiguration({ onResetStackByFields, queryId }), + { wrapper } + ); + + ( + result.current.defaultMenuItems[0].items?.find((x) => x.name === i18n.INSPECT) + ?.onClick as () => void + )(); + + expect(mockHandleClick).toBeCalled(); + }); + + test('it invokes onResetStackByFields when the Reset menu item is clicked', () => { + const { result } = renderHook( + () => useChartSettingsPopoverConfiguration({ onResetStackByFields, queryId }), + { wrapper } + ); + + ( + result.current.defaultMenuItems[0].items?.find((x) => x.name === i18n.RESET_GROUP_BY_FIELDS) + ?.onClick as () => void + )(); + + expect(onResetStackByFields).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/index.tsx b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/index.tsx index bb1f6628fc327..b6f13a5416d9e 100644 --- a/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/index.tsx @@ -5,50 +5,34 @@ * 2.0. */ -import { noop } from 'lodash/fp'; -import { EuiContextMenuPanelDescriptor, EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; +import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import type { Dispatch, SetStateAction } from 'react'; import { useMemo, useState } from 'react'; +import { useInspect } from '../../../inspect/use_inspect'; + import * as i18n from './translations'; const defaultInitialPanelId = 'default-initial-panel'; interface Props { - defaultStackByField1?: string; onResetStackByFields: () => void; - setShowCountsInChartLegend?: (value: boolean) => void; - setStackBy: (value: string) => void; - setStackByField1?: (stackBy: string | undefined) => void; - showCountsInChartLegend?: boolean; + queryId: string; } export const useChartSettingsPopoverConfiguration = ({ onResetStackByFields, - setShowCountsInChartLegend, - setStackBy, - setStackByField1 = noop, - showCountsInChartLegend, -}: Props) => { + queryId, +}: Props): { + defaultInitialPanelId: string; + defaultMenuItems: EuiContextMenuPanelDescriptor[]; + isPopoverOpen: boolean; + setIsPopoverOpen: Dispatch>; +} => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const showCountsInChartLegendMenuItem: EuiContextMenuPanelItemDescriptor[] = useMemo( - () => - setShowCountsInChartLegend != null - ? [ - { - name: showCountsInChartLegend - ? i18n.HIDE_COUNTS_IN_LEGEND - : i18n.SHOW_COUNTS_IN_LEGEND, - icon: 'number', - onClick: () => { - setIsPopoverOpen(false); - setShowCountsInChartLegend(!showCountsInChartLegend); - }, - }, - ] - : [], - [setShowCountsInChartLegend, showCountsInChartLegend] - ); + const { handleClick } = useInspect({ + queryId, + }); const defaultMenuItems: EuiContextMenuPanelDescriptor[] = useMemo( () => [ @@ -56,82 +40,32 @@ export const useChartSettingsPopoverConfiguration = ({ id: defaultInitialPanelId, items: [ { - name: i18n.RESET_STACK_BY_FIELD, - icon: 'kqlField', + 'data-test-subj': 'inspectMenuItem', + icon: 'inspect', + name: i18n.INSPECT, onClick: () => { setIsPopoverOpen(false); - onResetStackByFields(); + handleClick(); }, }, - ...showCountsInChartLegendMenuItem, - ], - title: i18n.OPTIONS, - }, - ], - [onResetStackByFields, showCountsInChartLegendMenuItem] - ); - - const riskMenuItems: EuiContextMenuPanelDescriptor[] = useMemo( - () => [ - { - id: defaultInitialPanelId, - items: [ { + 'data-test-subj': 'resetGroupByFieldsMenuItem', name: i18n.RESET_GROUP_BY_FIELDS, - icon: 'kqlField', onClick: () => { setIsPopoverOpen(false); onResetStackByFields(); }, }, - { - name: i18n.GROUP_BY_RULE_AND_USER_NAME, - icon: 'kqlField', - onClick: () => { - setIsPopoverOpen(false); - setStackBy('kibana.alert.rule.name'); - setStackByField1('user.name'); - }, - }, - { - name: i18n.GROUP_BY_PARENT_AND_CHILD_PROCESS, - icon: 'kqlField', - onClick: () => { - setIsPopoverOpen(false); - setStackBy('process.parent.name'); - setStackByField1('process.name'); - }, - }, - { - name: i18n.GROUP_BY_PROCESS_AND_FILE_NAME, - icon: 'kqlField', - onClick: () => { - setIsPopoverOpen(false); - setStackBy('process.name'); - setStackByField1('file.name'); - }, - }, - { - name: i18n.GROUP_BY_HOST_AND_USER_NAME, - icon: 'kqlField', - onClick: () => { - setIsPopoverOpen(false); - setStackBy('host.name'); - setStackByField1('user.name'); - }, - }, ], - title: i18n.OPTIONS, }, ], - [onResetStackByFields, setStackBy, setStackByField1] + [handleClick, onResetStackByFields] ); return { defaultInitialPanelId, defaultMenuItems, isPopoverOpen, - riskMenuItems, setIsPopoverOpen, }; }; diff --git a/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/translations.ts b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/translations.ts index 47bf638dde60a..61a0e6d0904b4 100644 --- a/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/translations.ts @@ -7,45 +7,10 @@ import { i18n } from '@kbn/i18n'; -export const GROUP_BY_HOST_AND_USER_NAME = i18n.translate( - 'xpack.securitySolution.components.chartSettingsPopover.contextMenuItems.groupByHostAndUserNameMenuItem', +export const INSPECT = i18n.translate( + 'xpack.securitySolution.components.chartSettingsPopover.contextMenuItems.inspectTitle', { - defaultMessage: 'Group by host and user name', - } -); - -export const GROUP_BY_PARENT_AND_CHILD_PROCESS = i18n.translate( - 'xpack.securitySolution.components.chartSettingsPopover.contextMenuItems.groupByParentAndChildProcessMenuItem', - { - defaultMessage: 'Group by parent and child process', - } -); - -export const GROUP_BY_PROCESS_AND_FILE_NAME = i18n.translate( - 'xpack.securitySolution.components.chartSettingsPopover.contextMenuItems.groupByProcessAndFileNameMenuItem', - { - defaultMessage: 'Group by process and file name', - } -); - -export const GROUP_BY_RULE_AND_USER_NAME = i18n.translate( - 'xpack.securitySolution.components.chartSettingsPopover.contextMenuItems.groupByRuleAndUserNameMenuItem', - { - defaultMessage: 'Group by rule and user name', - } -); - -export const HIDE_COUNTS_IN_LEGEND = i18n.translate( - 'xpack.securitySolution.components.chartSettingsPopover.contextMenuItems.hideCountsInLegend', - { - defaultMessage: 'Hide counts in legend', - } -); - -export const OPTIONS = i18n.translate( - 'xpack.securitySolution.components.chartSettingsPopover.contextMenuPanel.optionsTitle', - { - defaultMessage: 'Options', + defaultMessage: 'Inspect', } ); @@ -55,17 +20,3 @@ export const RESET_GROUP_BY_FIELDS = i18n.translate( defaultMessage: 'Reset group by fields', } ); - -export const RESET_STACK_BY_FIELD = i18n.translate( - 'xpack.securitySolution.components.chartSettingsPopover.contextMenuItems.resetStackByFieldMenuItem', - { - defaultMessage: 'Reset stack by field', - } -); - -export const SHOW_COUNTS_IN_LEGEND = i18n.translate( - 'xpack.securitySolution.components.chartSettingsPopover.contextMenuItems.showCountsInLegend', - { - defaultMessage: 'Show counts in legend', - } -); diff --git a/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/index.test.tsx new file mode 100644 index 0000000000000..34ba62104dc0b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/index.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { ChartSettingsPopover } from '.'; + +describe('ChartSettingsPopover', () => { + const setIsPopoverOpen = jest.fn(); + const initialPanelId = 'default-initial-panel'; + + const panels = [ + { + id: initialPanelId, + items: [ + { + icon: 'inspect', + name: 'Inspect', + }, + { + name: 'Reset group by fields', + }, + ], + }, + ]; + + it('renders the chart settings popover', () => { + render( + + ); + + expect(screen.getByTestId('chartSettingsPopoverButton')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/index.tsx b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/index.tsx index 10c9af4748552..2ba1d931da0c9 100644 --- a/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/index.tsx @@ -5,14 +5,13 @@ * 2.0. */ -import { - EuiButtonIcon, - EuiContextMenu, - EuiContextMenuPanelDescriptor, - EuiPopover, -} from '@elastic/eui'; +import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import { EuiButtonIcon, EuiContextMenu, EuiPopover } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; +import { BUTTON_CLASS } from '../inspect'; +import * as i18n from './translations'; + interface Props { initialPanelId: string; isPopoverOpen: boolean; @@ -20,7 +19,7 @@ interface Props { setIsPopoverOpen: React.Dispatch>; } -const ChartSettingsPopoverComponent = ({ +const ChartSettingsPopoverComponent: React.FC = ({ initialPanelId, isPopoverOpen, panels, @@ -35,7 +34,14 @@ const ChartSettingsPopoverComponent = ({ const button = useMemo( () => ( - + ), [onButtonClick] ); @@ -44,6 +50,7 @@ const ChartSettingsPopoverComponent = ({ { ); }); + it(`renders a container with the default 'min-width'`, () => { + expect(wrapper.find('[data-test-subj="draggable-legend"]').first()).toHaveStyleRule( + 'min-width', + `${DEFAULT_WIDTH}px` + ); + }); + + it(`renders a container with the specified 'min-width'`, () => { + const width = 1234; + + wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="draggable-legend"]').first()).toHaveStyleRule( + 'min-width', + `${width}px` + ); + }); + it('scrolls when necessary', () => { expect(wrapper.find('[data-test-subj="draggable-legend"]').first()).toHaveStyleRule( 'overflow', diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.tsx index 1ffb345dc9a60..00be24188d5dc 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; import { rgba } from 'polished'; import React from 'react'; import styled from 'styled-components'; @@ -14,9 +14,9 @@ import type { LegendItem } from './draggable_legend_item'; import { DraggableLegendItem } from './draggable_legend_item'; export const MIN_LEGEND_HEIGHT = 175; -const DEFAULT_WIDTH = 165; // px +export const DEFAULT_WIDTH = 165; // px -const DraggableLegendContainer = styled.div<{ height: number; width: number }>` +const DraggableLegendContainer = styled.div<{ height: number; $minWidth: number }>` height: ${({ height }) => `${height}px`}; overflow: auto; scrollbar-width: thin; @@ -24,12 +24,11 @@ const DraggableLegendContainer = styled.div<{ height: number; width: number }>` @media only screen and (min-width: ${({ theme }) => theme.eui.euiBreakpoints.m}) { width: 165px; } - min-width: ${({ width }) => `${width}px`}; - padding-right: ${({ theme }) => theme.eui.paddingSizes.s}; + min-width: ${({ $minWidth }) => `${$minWidth}px`}; &::-webkit-scrollbar { height: ${({ theme }) => theme.eui.euiScrollBar}; - width: ${({ theme }) => theme.eui.euiSizeM}; + width: ${({ theme }) => theme.eui.euiScrollBar}; } &::-webkit-scrollbar-thumb { @@ -47,9 +46,8 @@ const DraggableLegendContainer = styled.div<{ height: number; width: number }>` const DraggableLegendComponent: React.FC<{ height: number; legendItems: LegendItem[]; - showCountsInLegend?: boolean; - width?: number; -}> = ({ height, legendItems, showCountsInLegend = false, width = DEFAULT_WIDTH }) => { + minWidth?: number; +}> = ({ height, legendItems, minWidth = DEFAULT_WIDTH }) => { if (legendItems.length === 0) { return null; } @@ -58,22 +56,14 @@ const DraggableLegendComponent: React.FC<{ {legendItems.map((item) => ( - {showCountsInLegend ? ( - - ) : ( - - )} + ))} diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx index f816aac786639..aa1d1e57760f1 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx @@ -56,7 +56,7 @@ describe('DraggableLegendItem', () => { ).toEqual(legendItem.value); }); - it('renders a custom legend item via `render`', () => { + it('renders a custom legend item via the `render` prop when provided', () => { const render = (fieldValuePair?: { field: string; value: string | number }) => (
{`${fieldValuePair?.field} - ${fieldValuePair?.value}`}
); @@ -74,6 +74,18 @@ describe('DraggableLegendItem', () => { ); }); + it('renders an item count via the `count` prop when provided', () => { + const customLegendItem = { ...legendItem, count: 1234 }; + + wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="legendItemCount"]`).first().exists()).toBe(true); + }); + it('always hides the Top N action for legend items', () => { expect( wrapper.find(`[data-test-subj="legend-item-${legendItem.dataProviderId}"]`).prop('hideTopN') diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx index 0bad18ada3d92..96770082f0e3e 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.tsx @@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiHealth, EuiText } from '@elastic/eui'; import numeral from '@elastic/numeral'; import React from 'react'; +import styled from 'styled-components'; import { DEFAULT_NUMBER_FORMAT } from '../../../../common/constants'; import { DefaultDraggable } from '../draggables'; @@ -15,6 +16,10 @@ import { useUiSetting$ } from '../../lib/kibana'; import { EMPTY_VALUE_LABEL } from './translation'; import { hasValueToDisplay } from '../../utils/validators'; +const CountFlexItem = styled(EuiFlexItem)` + ${({ theme }) => `margin-right: ${theme.eui.euiSizeS};`} +`; + export interface LegendItem { color?: string; dataProviderId: string; @@ -79,7 +84,9 @@ const DraggableLegendItemComponent: React.FC<{ {count != null && ( - {numeral(count).format(defaultNumberFormat)} + + {numeral(count).format(defaultNumberFormat)} + )} diff --git a/x-pack/plugins/security_solution/public/common/components/field_selection/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/field_selection/index.test.tsx new file mode 100644 index 0000000000000..c236e35e6d0e4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/field_selection/index.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../mock'; +import type { Props } from '.'; +import { FieldSelection } from '.'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +const defaultProps: Props = { + setStackByField0: jest.fn(), + setStackByField1: jest.fn(), + stackByField0: 'kibana.alert.rule.name', + stackByField1: 'host.name', + uniqueQueryId: 'alerts-treemap-7cc69a83-1cd0-4d6e-89fa-f9010e9073db', +}; + +describe('FieldSelection', () => { + test('it renders the (first) "Group by" selection', () => { + render( + + + + ); + + expect(screen.getAllByTestId('comboBoxInput')[0]).toHaveTextContent(defaultProps.stackByField0); + }); + + test('it renders the (second) "Group by top" selection', () => { + render( + + + + ); + + expect(screen.getAllByTestId('comboBoxInput')[1]).toHaveTextContent( + defaultProps.stackByField1 ?? '' + ); + }); + + test('it renders the chart options context menu using the provided `uniqueQueryId`', () => { + const propsWithContextMenu = { + ...defaultProps, + chartOptionsContextMenu: (queryId: string) => ( +
{queryId}
+ ), + }; + + render( + + + + ); + + expect(screen.getByTestId('mock-context-menu')).toHaveTextContent(defaultProps.uniqueQueryId); + }); + + test('it does NOT the chart options context menu when `chartOptionsContextMenu` is undefined', () => { + render( + + + + ); + + expect(screen.queryByTestId('mock-context-menu')).toBeNull(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/field_selection/index.tsx b/x-pack/plugins/security_solution/public/common/components/field_selection/index.tsx new file mode 100644 index 0000000000000..4c9f855814609 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/field_selection/index.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +import { StackByComboBox } from '../../../detections/components/alerts_kpis/common/components'; +import { + GROUP_BY_LABEL, + GROUP_BY_TOP_LABEL, +} from '../../../detections/components/alerts_kpis/common/translations'; + +const ChartOptionsFlexItem = styled(EuiFlexItem)` + margin-left: ${({ theme }) => theme.eui.euiSizeS}; +`; + +export interface Props { + chartOptionsContextMenu?: (queryId: string) => React.ReactNode; + setStackByField0: (stackBy: string) => void; + setStackByField1: (stackBy: string | undefined) => void; + stackByField0: string; + stackByField1: string | undefined; + stackByWidth?: number; + uniqueQueryId: string; +} + +const FieldSelectionComponent: React.FC = ({ + chartOptionsContextMenu, + setStackByField0, + setStackByField1, + stackByField0, + stackByField1, + stackByWidth, + uniqueQueryId, +}: Props) => ( + + + + + + + + {chartOptionsContextMenu != null && ( + + {chartOptionsContextMenu(uniqueQueryId)} + + )} + + +); + +FieldSelectionComponent.displayName = 'FieldSelectionComponent'; + +export const FieldSelection = React.memo(FieldSelectionComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap index 122fe929b6176..00732ec7b82e8 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap @@ -7,14 +7,17 @@ exports[`HeaderSection it renders 1`] = ` data-test-subj="header-section" > - + diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx index d026e3c15ea35..9e89acc20b3f1 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx @@ -10,7 +10,7 @@ import { mount, shallow } from 'enzyme'; import React from 'react'; import { TestProviders } from '../../mock'; -import { HeaderSection } from '.'; +import { getHeaderAlignment, HeaderSection } from '.'; describe('HeaderSection', () => { test('it renders', () => { @@ -205,6 +205,90 @@ describe('HeaderSection', () => { expect(wrapper.find('[data-test-subj="query-toggle-header"]').first().exists()).toBe(true); }); + test('it does NOT align items to flex start in the outer flex group when stackHeader is true', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect( + wrapper.find('[data-test-subj="headerSectionOuterFlexGroup"]').first().getDOMNode() + ).not.toHaveClass('euiFlexGroup--alignItemsFlexStart'); + }); + + test(`it uses the 'column' direction in the outer flex group by default`, () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect( + wrapper.find('[data-test-subj="headerSectionOuterFlexGroup"]').first().getDOMNode() + ).toHaveClass('euiFlexGroup--directionColumn'); + }); + + test('it uses the `outerDirection` prop to specify the direction of the outer flex group when it is provided', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect( + wrapper.find('[data-test-subj="headerSectionOuterFlexGroup"]').first().getDOMNode() + ).toHaveClass('euiFlexGroup--directionRow'); + }); + + test('it defaults to center alignment in the inner flex group', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect( + wrapper.find('[data-test-subj="headerSectionInnerFlexGroup"]').first().getDOMNode() + ).toHaveClass('euiFlexGroup--alignItemsCenter'); + }); + + test('it aligns items using the value of the `alignHeader` prop in the inner flex group when specified', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect( + wrapper.find('[data-test-subj="headerSectionInnerFlexGroup"]').first().getDOMNode() + ).toHaveClass('euiFlexGroup--alignItemsFlexEnd'); + }); + + test('it does NOT default to center alignment in the inner flex group when the `stackHeader` prop is true', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect( + wrapper.find('[data-test-subj="headerSectionInnerFlexGroup"]').first().getDOMNode() + ).not.toHaveClass('euiFlexGroup--alignItemsCenter'); + }); + test('it does render everything but title when toggleStatus = true', () => { const wrapper = mount( @@ -292,4 +376,29 @@ describe('HeaderSection', () => { wrapper.find('[data-test-subj="query-toggle-header"]').first().simulate('click'); expect(mockToggle).toBeCalledWith(false); }); + + describe('getHeaderAlignment', () => { + test(`it always returns the value of alignHeader when it's provided`, () => { + const alignHeader = 'flexStart'; + const stackHeader = true; + + expect(getHeaderAlignment({ alignHeader, stackHeader })).toEqual(alignHeader); + }); + + test(`it returns undefined when stackHeader is true`, () => { + const stackHeader = true; + + expect(getHeaderAlignment({ stackHeader })).toBeUndefined(); + }); + + test(`it returns 'center' when stackHeader is false`, () => { + const stackHeader = false; + + expect(getHeaderAlignment({ stackHeader })).toEqual('center'); + }); + + test(`it returns 'center' by default`, () => { + expect(getHeaderAlignment({})).toEqual('center'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx index 41df43f1bbe0c..e8fe65e52d60c 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx @@ -51,8 +51,9 @@ const Header = styled.header` Header.displayName = 'Header'; export interface HeaderSectionProps extends HeaderProps { + alignHeader?: 'center' | 'baseline' | 'stretch' | 'flexStart' | 'flexEnd'; children?: React.ReactNode; - fullWidthContent?: React.ReactNode; + outerDirection?: 'row' | 'rowReverse' | 'column' | 'columnReverse' | undefined; growLeftSplit?: boolean; headerFilters?: string | React.ReactNode; height?: number; @@ -71,10 +72,27 @@ export interface HeaderSectionProps extends HeaderProps { tooltip?: string; } +export const getHeaderAlignment = ({ + alignHeader, + stackHeader, +}: { + alignHeader?: 'center' | 'baseline' | 'stretch' | 'flexStart' | 'flexEnd'; + stackHeader?: boolean; +}) => { + if (alignHeader != null) { + return alignHeader; + } else if (stackHeader) { + return undefined; + } else { + return 'center'; + } +}; + const HeaderSectionComponent: React.FC = ({ + alignHeader, border, children, - fullWidthContent, + outerDirection = 'column', growLeftSplit = true, headerFilters, height, @@ -111,14 +129,15 @@ const HeaderSectionComponent: React.FC = ({ $hideSubtitle={hideSubtitle} > @@ -165,13 +184,14 @@ const HeaderSectionComponent: React.FC = ({ - {id && showInspectButton && toggleStatus && ( + {id && toggleStatus && ( )} @@ -197,7 +217,6 @@ const HeaderSectionComponent: React.FC = ({
)}
- {fullWidthContent != null && fullWidthContent} ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/inspect/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/inspect/index.test.tsx index 7e92f8e9d3931..8cc5951d5701a 100644 --- a/x-pack/plugins/security_solution/public/common/components/inspect/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/inspect/index.test.tsx @@ -70,6 +70,22 @@ describe('Inspect Button', () => { ); }); + test('it does NOT render the Empty Button when showInspectButton is false', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('button[data-test-subj="inspect-empty-button"]').first().exists()).toBe( + false + ); + }); + test('Eui Icon Button', () => { const wrapper = mount( @@ -92,6 +108,17 @@ describe('Inspect Button', () => { ); }); + test('it does NOT render the Icon Button when showInspectButton is false', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('button[data-test-subj="inspect-icon-button"]').first().exists()).toBe( + false + ); + }); + test('Eui Empty Button disabled', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/inspect/index.tsx b/x-pack/plugins/security_solution/public/common/components/inspect/index.tsx index fb827fd222731..0c9fc02478f92 100644 --- a/x-pack/plugins/security_solution/public/common/components/inspect/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/inspect/index.tsx @@ -40,6 +40,7 @@ interface InspectButtonProps { multiple?: boolean; onCloseInspect?: () => void; queryId: string; + showInspectButton?: boolean; title: string | React.ReactElement | React.ReactNode; } @@ -51,6 +52,7 @@ const InspectButtonComponent: React.FC = ({ multiple = false, // If multiple = true we ignore the inspectIndex and pass all requests and responses to the inspect modal onCloseInspect, queryId = '', + showInspectButton = true, title = '', }) => { const { @@ -74,7 +76,7 @@ const InspectButtonComponent: React.FC = ({ return ( <> - {inputId === 'timeline' && !compact && ( + {inputId === 'timeline' && !compact && showInspectButton && ( = ({ {i18n.INSPECT} )} - {(inputId === 'global' || compact) && ( + {(inputId === 'global' || compact) && showInspectButton && ( { describe('getSettingKey', () => { it('returns the expected key', () => { expect( getSettingKey({ - category: RISK_CHART_CATEGORY, + category: TREEMAP_CATEGORY, page: ALERTS_PAGE, setting: STACK_BY_SETTING_NAME, }) - ).toEqual(`${ALERTS_PAGE}.${RISK_CHART_CATEGORY}.${STACK_BY_SETTING_NAME}`); + ).toEqual(`${ALERTS_PAGE}.${TREEMAP_CATEGORY}.${STACK_BY_SETTING_NAME}`); }); }); - describe('useDefaultWhenEmptyString', () => { + describe('isDefaultWhenEmptyString', () => { it('returns true when value is empty', () => { - expect(useDefaultWhenEmptyString('')).toBe(true); + expect(isDefaultWhenEmptyString('')).toBe(true); }); it('returns false when value is non-empty', () => { - expect(useDefaultWhenEmptyString('foozle')).toBe(false); + expect(isDefaultWhenEmptyString('foozle')).toBe(false); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/local_storage/helpers.ts b/x-pack/plugins/security_solution/public/common/components/local_storage/helpers.ts index 0aed5647114cf..2297fe2652286 100644 --- a/x-pack/plugins/security_solution/public/common/components/local_storage/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/local_storage/helpers.ts @@ -18,5 +18,5 @@ export const getSettingKey = ({ setting: string; }): string => `${page}.${category}.${setting}`; -export const useDefaultWhenEmptyString = (value: T): boolean => +export const isDefaultWhenEmptyString = (value: T): boolean => typeof value !== 'string' || isEmpty(value.trim()); diff --git a/x-pack/plugins/security_solution/public/common/components/local_storage/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/local_storage/index.test.tsx index 831733d9155ab..d7dbfdeb5d026 100644 --- a/x-pack/plugins/security_solution/public/common/components/local_storage/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/local_storage/index.test.tsx @@ -10,12 +10,12 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { APP_ID } from '../../../../common/constants'; import { DEFAULT_STACK_BY_FIELD } from '../../../detections/components/alerts_kpis/common/config'; import { - RISK_CHART_CATEGORY, ALERTS_PAGE, + EXPAND_SETTING_NAME, STACK_BY_SETTING_NAME, - SHOW_SETTING_NAME, -} from '../../../detections/pages/detection_engine/alerts_local_storage/constants'; -import { getSettingKey, useDefaultWhenEmptyString } from './helpers'; + TREEMAP_CATEGORY, +} from '../../../detections/pages/detection_engine/chart_panels/alerts_local_storage/constants'; +import { getSettingKey, isDefaultWhenEmptyString } from './helpers'; import { useKibana as mockUseKibana } from '../../lib/kibana/__mocks__'; import { useLocalStorage } from '.'; @@ -50,7 +50,7 @@ describe('useLocalStorage', () => { useLocalStorage({ defaultValue: DEFAULT_STACK_BY_FIELD, key: getSettingKey({ - category: RISK_CHART_CATEGORY, + category: TREEMAP_CATEGORY, page: ALERTS_PAGE, setting: STACK_BY_SETTING_NAME, }), @@ -68,9 +68,9 @@ describe('useLocalStorage', () => { useLocalStorage({ defaultValue: true, key: getSettingKey({ - category: RISK_CHART_CATEGORY, + category: TREEMAP_CATEGORY, page: ALERTS_PAGE, - setting: SHOW_SETTING_NAME, + setting: EXPAND_SETTING_NAME, }), plugin: APP_ID, }) @@ -89,9 +89,9 @@ describe('useLocalStorage', () => { useLocalStorage({ defaultValue: 1234, key: getSettingKey({ - category: RISK_CHART_CATEGORY, + category: TREEMAP_CATEGORY, page: ALERTS_PAGE, - setting: SHOW_SETTING_NAME, + setting: EXPAND_SETTING_NAME, }), plugin: APP_ID, }) @@ -110,12 +110,12 @@ describe('useLocalStorage', () => { useLocalStorage({ defaultValue: DEFAULT_STACK_BY_FIELD, key: getSettingKey({ - category: RISK_CHART_CATEGORY, + category: TREEMAP_CATEGORY, page: ALERTS_PAGE, setting: STACK_BY_SETTING_NAME, }), plugin: APP_ID, - isInvalidDefault: useDefaultWhenEmptyString, + isInvalidDefault: isDefaultWhenEmptyString, }) ); @@ -125,7 +125,7 @@ describe('useLocalStorage', () => { expect(mockedUseKibana.services.storage.set).toBeCalledWith( `${APP_ID}.${getSettingKey({ - category: RISK_CHART_CATEGORY, + category: TREEMAP_CATEGORY, page: ALERTS_PAGE, setting: STACK_BY_SETTING_NAME, })}`, @@ -143,12 +143,12 @@ describe('useLocalStorage', () => { useLocalStorage({ defaultValue: DEFAULT_STACK_BY_FIELD, key: getSettingKey({ - category: RISK_CHART_CATEGORY, + category: TREEMAP_CATEGORY, page: ALERTS_PAGE, setting: STACK_BY_SETTING_NAME, }), plugin: APP_ID, - isInvalidDefault: useDefaultWhenEmptyString, + isInvalidDefault: isDefaultWhenEmptyString, }) ); @@ -165,12 +165,12 @@ describe('useLocalStorage', () => { useLocalStorage({ defaultValue: DEFAULT_STACK_BY_FIELD, key: getSettingKey({ - category: RISK_CHART_CATEGORY, + category: TREEMAP_CATEGORY, page: ALERTS_PAGE, setting: STACK_BY_SETTING_NAME, }), plugin: APP_ID, - isInvalidDefault: useDefaultWhenEmptyString, + isInvalidDefault: isDefaultWhenEmptyString, }) ); diff --git a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx index 14593d10f1f89..68ef266b07b25 100644 --- a/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/matrix_histogram/index.test.tsx @@ -36,10 +36,6 @@ jest.mock('../visualization_actions', () => ({ )), })); -jest.mock('../inspect', () => ({ - InspectButton: jest.fn(() =>
), -})); - jest.mock('./utils', () => ({ getBarchartConfigs: jest.fn(), getCustomChartData: jest.fn().mockReturnValue(true), @@ -196,7 +192,7 @@ describe('Matrix Histogram Component', () => { wrapper = mount(, { wrappingComponent: TestProviders, }); - expect(wrapper.find('[data-test-subj="mock-inspect"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').exists()).toBe(false); }); test("it doesn't render Inspect button by default on Network page", () => { @@ -215,7 +211,7 @@ describe('Matrix Histogram Component', () => { wrapper = mount(, { wrappingComponent: TestProviders, }); - expect(wrapper.find('[data-test-subj="mock-inspect"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').exists()).toBe(false); }); test('it render Inspect button by default on other pages', () => { @@ -234,7 +230,7 @@ describe('Matrix Histogram Component', () => { wrapper = mount(, { wrappingComponent: TestProviders, }); - expect(wrapper.find('[data-test-subj="mock-inspect"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').exists()).toBe(true); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.test.tsx index 2950ca3aba43b..56aad747a856d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.test.tsx @@ -17,7 +17,7 @@ import type { AlertsCountAggregation } from './types'; import { emptyStackByField0Response } from './mocks/mock_response_empty_field0'; import { buckets as oneGroupByResponseBuckets, - multiGroupResponse, + mockMultiGroupResponse, } from './mocks/mock_response_multi_group'; import { buckets as twoGroupByResponseBuckets, @@ -78,7 +78,7 @@ describe('AlertsCount', () => { { theme.eui.euiSizeS}; `; -export const AlertsCount = memo( - ({ data, loading, stackByField0, stackByField1 }) => { - const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); +export const AlertsCountComponent: React.FC = ({ + data, + loading, + stackByField0, + stackByField1, +}) => { + const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); - const tableColumns = useMemo( - () => - isEmpty(stackByField1?.trim()) - ? getSingleGroupByAlertsCountTableColumns({ - defaultNumberFormat, - stackByField0, - }) - : getMultiGroupAlertsCountTableColumns({ - defaultNumberFormat, - stackByField0, - stackByField1, - }), - [defaultNumberFormat, stackByField0, stackByField1] - ); + const tableColumns = useMemo( + () => + isEmpty(stackByField1?.trim()) + ? getSingleGroupByAlertsCountTableColumns({ + defaultNumberFormat, + stackByField0, + }) + : getMultiGroupAlertsCountTableColumns({ + defaultNumberFormat, + stackByField0, + stackByField1, + }), + [defaultNumberFormat, stackByField0, stackByField1] + ); - const buckets: RawBucket[] = useMemo( - () => - getUpToMaxBuckets({ - buckets: data.aggregations?.stackByField0?.buckets, - maxItems: DEFAULT_STACK_BY_FIELD0_SIZE, - }), - [data.aggregations?.stackByField0?.buckets] - ); + const buckets: RawBucket[] = useMemo( + () => + getUpToMaxBuckets({ + buckets: data.aggregations?.stackByField0?.buckets, + maxItems: DEFAULT_STACK_BY_FIELD0_SIZE, + }), + [data.aggregations?.stackByField0?.buckets] + ); - const maxRiskSubAggregations = useMemo(() => getMaxRiskSubAggregations(buckets), [buckets]); + const maxRiskSubAggregations = useMemo(() => getMaxRiskSubAggregations(buckets), [buckets]); - const items: FlattenedBucket[] = useMemo( - () => - isEmpty(stackByField1?.trim()) - ? buckets - : getFlattenedBuckets({ - buckets, - maxRiskSubAggregations, - stackByField0, - }), - [buckets, maxRiskSubAggregations, stackByField0, stackByField1] - ); + const items: FlattenedBucket[] = useMemo( + () => + isEmpty(stackByField1?.trim()) + ? buckets + : getFlattenedBuckets({ + buckets, + maxRiskSubAggregations, + stackByField0, + }), + [buckets, maxRiskSubAggregations, stackByField0, stackByField1] + ); - return ( - - - - ); - } -); + return ( + + + + ); +}; -AlertsCount.displayName = 'AlertsCount'; +AlertsCountComponent.displayName = 'AlertsCountComponent'; + +export const AlertsCount = React.memo(AlertsCountComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/columns.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/columns.test.tsx new file mode 100644 index 0000000000000..c5600fe7eda94 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/columns.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { omit } from 'lodash/fp'; +import { + getMultiGroupAlertsCountTableColumns, + getSingleGroupByAlertsCountTableColumns, +} from './columns'; + +describe('columns', () => { + const defaultNumberFormat = '0,0.[000]'; + const stackByField0 = 'kibana.alert.rule.name'; + + describe('getMultiGroupAlertsCountTableColumns', () => { + const stackByField1 = 'host.name'; + + test('it returns the expected columns', () => { + expect( + getMultiGroupAlertsCountTableColumns({ + defaultNumberFormat, + stackByField0, + stackByField1, + }).map((x) => omit('render', x)) + ).toEqual([ + { + 'data-test-subj': 'stackByField0Key', + field: 'key', + name: 'Top 1000 values of kibana.alert.rule.name', + truncateText: false, + }, + { + 'data-test-subj': 'stackByField1Key', + field: 'stackByField1Key', + name: 'Top 1000 values of host.name', + truncateText: false, + }, + { + 'data-test-subj': 'stackByField1DocCount', + dataType: 'number', + field: 'stackByField1DocCount', + name: 'Count of records', + sortable: true, + textOnly: true, + }, + ]); + }); + }); + + describe('getSingleGroupByAlertsCountTableColumns', () => { + test('it returns the expected columns', () => { + expect( + getSingleGroupByAlertsCountTableColumns({ defaultNumberFormat, stackByField0 }).map((x) => + omit('render', x) + ) + ).toEqual([ + { + 'data-test-subj': 'stackByField0Key', + field: 'key', + name: 'kibana.alert.rule.name', + truncateText: false, + }, + { + 'data-test-subj': 'doc_count', + dataType: 'number', + field: 'doc_count', + name: 'Count of records', + sortable: true, + textOnly: true, + }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/columns.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/columns.tsx index 7a3fb3de75b7d..7dfb3170e43e0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/columns.tsx @@ -6,13 +6,14 @@ */ import React from 'react'; -import { EuiBasicTableColumn } from '@elastic/eui'; +import type { EuiBasicTableColumn } from '@elastic/eui'; import numeral from '@elastic/numeral'; import type { FlattenedBucket } from '../../../../common/components/alerts_treemap/types'; import { DefaultDraggable } from '../../../../common/components/draggables'; -import { GenericBuckets } from '../../../../../common/search_strategy/common'; +import type { GenericBuckets } from '../../../../../common/search_strategy/common'; import * as i18n from './translations'; +import { DEFAULT_STACK_BY_FIELD0_SIZE, DEFAULT_STACK_BY_FIELD1_SIZE } from './helpers'; export const getSingleGroupByAlertsCountTableColumns = ({ defaultNumberFormat, @@ -62,7 +63,7 @@ export const getMultiGroupAlertsCountTableColumns = ({ { 'data-test-subj': 'stackByField0Key', field: 'key', - name: stackByField0, + name: i18n.COLUMN_LABEL({ fieldName: stackByField0, topN: DEFAULT_STACK_BY_FIELD0_SIZE }), render: function DraggableStackOptionField(value: string) { return ( { + describe('getAlertsCountQuery', () => { + test('it returns the expected query when stackByField1 is specified', () => { + expect( + getAlertsCountQuery({ + additionalFilters, + from, + runtimeMappings, + stackByField0, + stackByField1, + to, + }) + ).toEqual({ + size: 0, + aggs: { + stackByField0: { + terms: { field: 'kibana.alert.rule.name', order: { _count: 'desc' }, size: 1000 }, + aggs: { + stackByField1: { + terms: { field: 'host.name', order: { _count: 'desc' }, size: 1000 }, + }, + }, + }, + }, + query: { + bool: { + filter: [ + { + bool: { + must: [], + filter: [{ term: { 'kibana.alert.workflow_status': 'open' } }], + should: [], + must_not: [{ exists: { field: 'kibana.alert.building_block_type' } }], + }, + }, + { + range: { + '@timestamp': { + gte: '2022-07-08T06:00:00.000Z', + lte: '2022-07-09T05:59:59.999Z', + }, + }, + }, + ], + }, + }, + runtime_mappings: {}, + }); + }); + + test('it returns the expected query when stackByField1 is `undefined`', () => { + expect( + getAlertsCountQuery({ + additionalFilters, + from, + runtimeMappings, + stackByField0, + stackByField1: undefined, + to, + }) + ).toEqual({ + size: 0, + aggs: { + stackByField0: { + terms: { field: 'kibana.alert.rule.name', order: { _count: 'desc' }, size: 1000 }, + aggs: {}, + }, + }, + query: { + bool: { + filter: [ + { + bool: { + must: [], + filter: [{ term: { 'kibana.alert.workflow_status': 'open' } }], + should: [], + must_not: [{ exists: { field: 'kibana.alert.building_block_type' } }], + }, + }, + { + range: { + '@timestamp': { + gte: '2022-07-08T06:00:00.000Z', + lte: '2022-07-09T05:59:59.999Z', + }, + }, + }, + ], + }, + }, + runtime_mappings: {}, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx index 1f9e0e8ff6d8f..82c9d447879c0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.test.tsx @@ -13,6 +13,7 @@ import { AlertsCountPanel } from '.'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; import { DEFAULT_STACK_BY_FIELD, DEFAULT_STACK_BY_FIELD1 } from '../common/config'; import { TestProviders } from '../../../../common/mock'; +import { ChartContextMenu } from '../../../pages/detection_engine/chart_panels/chart_context_menu'; jest.mock('../../../../common/containers/query_toggle'); jest.mock('react-router-dom', () => { @@ -63,6 +64,58 @@ describe('AlertsCountPanel', () => { }); }); + it('renders with the specified `alignHeader` alignment', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="headerSectionInnerFlexGroup"]').first().getDOMNode() + ).toHaveClass('euiFlexGroup--alignItemsFlexEnd'); + }); + }); + + it('renders the inspect button by default', async () => { + await act(async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('button[data-test-subj="inspect-icon-button"]').first().exists()).toBe( + true + ); + }); + }); + + it('it does NOT render the inspect button when a `chartOptionsContextMenu` is provided', async () => { + const chartOptionsContextMenu = (queryId: string) => ( + + ); + + await act(async () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('button[data-test-subj="inspect-icon-button"]').first().exists()).toBe( + false + ); + }); + }); + describe('Query', () => { it('it render with a illegal KQL', async () => { jest.mock('@kbn/es-query', () => ({ diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx index 7d0efe25ce3d4..a609d388f0f7e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/index.tsx @@ -7,7 +7,6 @@ import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; import React, { memo, useMemo, useState, useEffect, useCallback } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import uuid from 'uuid'; import type { Filter, Query } from '@kbn/es-query'; @@ -22,18 +21,18 @@ import { getAlertsCountQuery } from './helpers'; import * as i18n from './translations'; import { AlertsCount } from './alerts_count'; import type { AlertsCountAggregation } from './types'; -import { KpiPanel, StackByComboBox } from '../common/components'; +import { KpiPanel } from '../common/components'; import { useInspectButton } from '../common/hooks'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import { ChartOptionsFlexItem } from '../../../pages/detection_engine/chart_context_menu'; -import { GROUP_BY_TOP_LABEL, THEN_GROUP_BY_TOP_LABEL } from './translations'; +import { FieldSelection } from '../../../../common/components/field_selection'; export const DETECTIONS_ALERTS_COUNT_ID = 'detections-alerts-count'; interface AlertsCountPanelProps { - chartOptionsContextMenu?: React.ReactNode; + alignHeader?: 'center' | 'baseline' | 'stretch' | 'flexStart' | 'flexEnd'; + chartOptionsContextMenu?: (queryId: string) => React.ReactNode; filters?: Filter[]; + panelHeight?: number; query?: Query; setStackByField0: (stackBy: string) => void; setStackByField1: (stackBy: string | undefined) => void; @@ -41,13 +40,16 @@ interface AlertsCountPanelProps { stackByField0: string; stackByField1: string | undefined; stackByWidth?: number; + title?: React.ReactNode; runtimeMappings?: MappingRuntimeFields; } export const AlertsCountPanel = memo( ({ + alignHeader, chartOptionsContextMenu, filters, + panelHeight, query, runtimeMappings, setStackByField0, @@ -56,8 +58,8 @@ export const AlertsCountPanel = memo( stackByField0, stackByField1, stackByWidth, + title = i18n.COUNT_TABLE_TITLE, }) => { - const alertsTreemapEnabled = useIsExperimentalFeatureEnabled('alertsTreemapEnabled'); // feature flag const { to, from, deleteQuery, setQuery } = useGlobalTime(); // create a unique, but stable (across re-renders) query id @@ -147,43 +149,32 @@ export const AlertsCountPanel = memo( return ( - + - - - - {alertsTreemapEnabled && ( - <> - - - - )} - - - {chartOptionsContextMenu != null && ( - - {chartOptionsContextMenu} - - )} - - + {toggleStatus && alertsData != null && ( = { took: 0, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_multi_group.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_multi_group.ts index 51306f316e7f6..730fded03f88b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_multi_group.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_multi_group.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { AlertSearchResponse } from '../../../../containers/detection_engine/alerts/types'; -import { AlertsCountAggregation } from '../types'; +import type { AlertSearchResponse } from '../../../../containers/detection_engine/alerts/types'; +import type { AlertsCountAggregation } from '../types'; export const buckets = [ { @@ -34,7 +34,7 @@ export const buckets = [ /** * A mock response to a request containing multiple group by fields */ -export const multiGroupResponse: AlertSearchResponse = { +export const mockMultiGroupResponse: AlertSearchResponse = { took: 0, timeout: false, _shards: { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_single_group.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_single_group.ts index fb9c69c00f1ed..e7c0f982be03b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_single_group.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_single_group.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { AlertSearchResponse } from '../../../../containers/detection_engine/alerts/types'; -import { AlertsCountAggregation } from '../types'; +import type { AlertSearchResponse } from '../../../../containers/detection_engine/alerts/types'; +import type { AlertsCountAggregation } from '../types'; export const buckets = [ { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/translations.ts index 6f2e428b6b519..14f4d38003d58 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/translations.ts @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; export const COUNT_TABLE_COLUMN_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.alerts.count.countTableColumnTitle', { - defaultMessage: 'Count', + defaultMessage: 'Count of records', } ); @@ -21,4 +21,10 @@ export const COUNT_TABLE_TITLE = i18n.translate( } ); +export const COLUMN_LABEL = ({ fieldName, topN }: { fieldName: string; topN: number }) => + i18n.translate('xpack.securitySolution.detectionEngine.alerts.count.columnLabel', { + values: { fieldName, topN }, + defaultMessage: 'Top {topN} values of {fieldName}', + }); + export * from '../common/translations'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/alerts_histogram.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/alerts_histogram.test.tsx index 11ab2c49a5dc0..4b64a214bd02b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/alerts_histogram.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/alerts_histogram.test.tsx @@ -6,26 +6,73 @@ */ import React from 'react'; -import { shallow } from 'enzyme'; +import { mount, shallow } from 'enzyme'; -import '../../../../common/mock/match_media'; import { AlertsHistogram } from './alerts_histogram'; +import { TestProviders } from '../../../../common/mock'; jest.mock('../../../../common/lib/kibana'); +const legendItems = [ + { + color: '#1EA593', + count: 77, + dataProviderId: + 'draggable-legend-item-2f890398-548e-4604-b2de-525f0eecd124-kibana_alert_rule_name-matches everything', + field: 'kibana.alert.rule.name', + value: 'matches everything', + }, + { + color: '#2B70F7', + count: 56, + dataProviderId: + 'draggable-legend-item-07aca01b-d334-424d-98c0-6d6bc9f8a886-kibana_alert_rule_name-Endpoint Security', + field: 'kibana.alert.rule.name', + value: 'Endpoint Security', + }, +]; + +const defaultProps = { + legendItems, + loading: false, + data: [], + from: '2020-07-07T08:20:18.966Z', + to: '2020-07-08T08:20:18.966Z', + updateDateRange: jest.fn(), +}; + describe('AlertsHistogram', () => { it('renders correctly', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper.find('Chart').exists()).toBeTruthy(); }); + + it('renders a legend with the default width', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="draggable-legend"]').first()).toHaveStyleRule( + 'min-width', + '165px' + ); + }); + + it('renders a legend with the specified `legendWidth`', () => { + const legendMinWidth = 1234; + + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="draggable-legend"]').first()).toHaveStyleRule( + 'min-width', + `${legendMinWidth}px` + ); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/alerts_histogram.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/alerts_histogram.tsx index 4618e66096b28..3966c9a319582 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/alerts_histogram.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/alerts_histogram.tsx @@ -21,15 +21,14 @@ import { EMPTY_VALUE_LABEL } from '../../../../common/components/charts/translat import type { HistogramData } from './types'; const DEFAULT_CHART_HEIGHT = 174; -const LEGEND_WITH_COUNTS_WIDTH = 300; // px interface AlertsHistogramProps { chartHeight?: number; from: string; legendItems: LegendItem[]; legendPosition?: Position; + legendMinWidth?: number; loading: boolean; - showCountsInLegend?: boolean; showLegend?: boolean; to: string; data: HistogramData[]; @@ -42,8 +41,8 @@ export const AlertsHistogram = React.memo( from, legendItems, legendPosition = Position.Right, + legendMinWidth, loading, - showCountsInLegend = false, showLegend, to, updateDateRange, @@ -104,8 +103,7 @@ export const AlertsHistogram = React.memo( )} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx index 539728291156f..963896f23063c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.test.tsx @@ -5,18 +5,21 @@ * 2.0. */ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { waitFor, act } from '@testing-library/react'; import { mount } from 'enzyme'; - import type { Filter } from '@kbn/es-query'; -import { TestProviders } from '../../../../common/mock'; + import { SecurityPageName } from '../../../../app/types'; +import { DEFAULT_WIDTH } from '../../../../common/components/charts/draggable_legend'; import { MatrixLoader } from '../../../../common/components/matrix_histogram/matrix_loader'; - -import { AlertsHistogramPanel } from '.'; -import * as helpers from './helpers'; +import { DEFAULT_STACK_BY_FIELD, DEFAULT_STACK_BY_FIELD1 } from '../common/config'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { TestProviders } from '../../../../common/mock'; +import * as helpers from './helpers'; +import { mockAlertSearchResponse } from './mock_data'; +import { ChartContextMenu } from '../../../pages/detection_engine/chart_panels/chart_context_menu'; +import { AlertsHistogramPanel, LEGEND_WITH_COUNTS_WIDTH } from '.'; jest.mock('../../../../common/containers/query_toggle'); @@ -74,18 +77,21 @@ jest.mock('../../../../common/lib/kibana', () => { jest.mock('../../../../common/components/navigation/use_get_url_search'); +const defaultUseQueryAlertsReturn = { + loading: true, + setQuery: () => undefined, + data: null, + response: '', + request: '', + refetch: null, +}; +const mockUseQueryAlerts = jest.fn().mockReturnValue(defaultUseQueryAlertsReturn); + jest.mock('../../../containers/detection_engine/alerts/use_query', () => { const original = jest.requireActual('../../../containers/detection_engine/alerts/use_query'); return { ...original, - useQueryAlerts: jest.fn().mockReturnValue({ - loading: true, - setQuery: () => undefined, - data: null, - response: '', - request: '', - refetch: null, - }), + useQueryAlerts: (...props: unknown[]) => mockUseQueryAlerts(...props), }; }); @@ -117,6 +123,311 @@ describe('AlertsHistogramPanel', () => { wrapper.unmount(); }); + describe('legend counts', () => { + beforeEach(() => { + mockUseQueryAlerts.mockReturnValue({ + loading: false, + data: mockAlertSearchResponse, + setQuery: () => {}, + response: '', + request: '', + refetch: () => {}, + }); + }); + + test('it does NOT render counts in the legend by default', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="legendItemCount"]').exists()).toBe(false); + }); + + test('it renders counts in the legend when `showCountsInLegend` is true', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="legendItemCount"]').exists()).toBe(true); + }); + }); + + test('it renders the header with the specified `alignHeader` alignment', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper.find('[data-test-subj="headerSectionInnerFlexGroup"]').first().getDOMNode() + ).toHaveClass('euiFlexGroup--alignItemsFlexEnd'); + }); + + describe('inspect button', () => { + test('it renders the inspect button by default', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(true); + }); + + test('it does NOT render the inspect button when a `chartOptionsContextMenu` is provided', async () => { + const chartOptionsContextMenu = (queryId: string) => ( + + ); + + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(false); + }); + }); + + test('it aligns the panel flex group at flex start to ensure the context menu is displayed at the top of the panel', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="panelFlexGroup"]').first().getDOMNode()).toHaveClass( + 'euiFlexGroup--alignItemsFlexStart' + ); + }); + + test('it invokes onFieldSelected when a field is selected', async () => { + const onFieldSelected = jest.fn(); + const optionToSelect = 'agent.hostname'; + + mockUseQueryAlerts.mockReturnValue({ + loading: false, + data: mockAlertSearchResponse, + setQuery: () => {}, + response: '', + request: '', + refetch: () => {}, + }); + + render( + + + + ); + + const comboBox = screen.getByTestId('comboBoxSearchInput'); + comboBox.focus(); // display the combo box options + + const option = await screen.findByText(optionToSelect); + fireEvent.click(option); + + expect(onFieldSelected).toBeCalledWith(optionToSelect); + }); + + describe('stackByLabel', () => { + test('it renders the default stack by label when `stackByLabel` is NOT provided', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('label.euiFormControlLayout__prepend').first().text()).toEqual( + 'Stack by' + ); + }); + + test('it prepends a custom stack by label when `stackByLabel` is provided', () => { + const stackByLabel = 'Group by'; + + const wrapper = mount( + + + + ); + + expect(wrapper.find('label.euiFormControlLayout__prepend').first().text()).toEqual( + stackByLabel + ); + }); + }); + + describe('stackByWidth', () => { + test('it renders the first StackByComboBox with the specified `stackByWidth`', () => { + const stackByWidth = 1234; + + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="stackByComboBox"]').first()).toHaveStyleRule( + 'width', + `${stackByWidth}px` + ); + }); + + test('it renders the placeholder StackByComboBox with the specified `stackByWidth`', () => { + const stackByWidth = 1234; + + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="stackByPlaceholder"]').first()).toHaveStyleRule( + 'width', + `${stackByWidth}px` + ); + }); + }); + + describe('placeholder spacer', () => { + test('it does NOT render the group by placeholder spacer by default', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="placeholderSpacer"]').exists()).toBe(false); + }); + + test('it renders the placeholder spacer when `showGroupByPlaceholder` is true', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="placeholderSpacer"]').exists()).toBe(true); + }); + }); + + describe('placeholder tooltip', () => { + test('it does NOT render the placeholder tooltip by default', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="placeholderTooltip"]').exists()).toBe(false); + }); + + test('it renders the placeholder tooltip when `showGroupByPlaceholder` is true', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="placeholderTooltip"]').exists()).toBe(true); + }); + }); + + describe('placeholder', () => { + test('it does NOT render the group by placeholder by default', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="stackByPlaceholder"]').exists()).toBe(false); + }); + + test('it renders the placeholder when `showGroupByPlaceholder` is true', () => { + const wrapper = mount( + + + + ); + expect(wrapper.find('[data-test-subj="stackByPlaceholder"]').exists()).toBe(true); + }); + }); + + test('it renders the chart options context menu when a `chartOptionsContextMenu` is provided', async () => { + const chartOptionsContextMenu = (queryId: string) => ( + + ); + + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="chartSettingsPopoverButton"]').first().exists()).toBe( + true + ); + }); + + describe('legend width', () => { + beforeEach(() => { + mockUseQueryAlerts.mockReturnValue({ + loading: false, + data: mockAlertSearchResponse, + setQuery: () => {}, + response: '', + request: '', + refetch: () => {}, + }); + }); + + test('it renders the legend with the expected default min-width', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="draggable-legend"]').first()).toHaveStyleRule( + 'min-width', + `${DEFAULT_WIDTH}px` + ); + }); + + test('it renders the legend with the expected min-width when `showCountsInLegend` is true', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="draggable-legend"]').first()).toHaveStyleRule( + 'min-width', + `${LEGEND_WITH_COUNTS_WIDTH}px` + ); + }); + }); + describe('Button view alerts', () => { it('renders correctly', () => { const props = { ...defaultProps, showLinkToAlerts: true }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx index f0d0c1c89ce67..7a0896b56c5ec 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/index.tsx @@ -8,11 +8,11 @@ import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; import type { Position } from '@elastic/charts'; import type { EuiTitleSize } from '@elastic/eui'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiToolTip } from '@elastic/eui'; import numeral from '@elastic/numeral'; import React, { memo, useCallback, useMemo, useState, useEffect } from 'react'; import styled from 'styled-components'; -import { isEmpty } from 'lodash/fp'; +import { isEmpty, noop } from 'lodash/fp'; import uuid from 'uuid'; import type { Filter, Query } from '@kbn/es-query'; @@ -48,7 +48,7 @@ import { KpiPanel, StackByComboBox } from '../common/components'; import { useInspectButton } from '../common/hooks'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; -import { ChartOptionsFlexItem } from '../../../pages/detection_engine/chart_context_menu'; +import { GROUP_BY_TOP_LABEL } from '../common/translations'; const defaultTotalAlertsObj: AlertsTotal = { value: 0, @@ -61,9 +61,16 @@ const ViewAlertsFlexItem = styled(EuiFlexItem)` margin-left: ${({ theme }) => theme.eui.euiSizeL}; `; +const OptionsFlexItem = styled(EuiFlexItem)` + margin-left: ${({ theme }) => theme.eui.euiSizeS}; +`; + +export const LEGEND_WITH_COUNTS_WIDTH = 300; // px + interface AlertsHistogramPanelProps { + alignHeader?: 'center' | 'baseline' | 'stretch' | 'flexStart' | 'flexEnd'; chartHeight?: number; - chartOptionsContextMenu?: React.ReactNode; + chartOptionsContextMenu?: (queryId: string) => React.ReactNode; combinedQueries?: string; defaultStackByOption?: string; filters?: Filter[]; @@ -78,13 +85,15 @@ interface AlertsHistogramPanelProps { legendPosition?: Position; signalIndexName: string | null; showCountsInLegend?: boolean; + showGroupByPlaceholder?: boolean; showLegend?: boolean; showLinkToAlerts?: boolean; showTotalAlertsCount?: boolean; showStackBy?: boolean; + stackByLabel?: string; stackByWidth?: number; timelineId?: string; - title?: string; + title?: React.ReactNode; updateDateRange: UpdateDateRange; runtimeMappings?: MappingRuntimeFields; } @@ -93,6 +102,7 @@ const NO_LEGEND_DATA: LegendItem[] = []; export const AlertsHistogramPanel = memo( ({ + alignHeader, chartHeight, chartOptionsContextMenu, combinedQueries, @@ -107,10 +117,12 @@ export const AlertsHistogramPanel = memo( legendPosition = 'right', signalIndexName, showCountsInLegend = false, + showGroupByPlaceholder = false, showLegend = true, showLinkToAlerts = false, showTotalAlertsCount = false, showStackBy = true, + stackByLabel, stackByWidth, timelineId, title = i18n.HISTOGRAM_HEADER, @@ -322,30 +334,55 @@ export const AlertsHistogramPanel = memo( $toggleStatus={toggleStatus} > - + {showStackBy && ( <> + {showGroupByPlaceholder && ( + <> + + + + + + )} )} {headerChildren != null && headerChildren} {chartOptionsContextMenu != null && ( - {chartOptionsContextMenu} + + {chartOptionsContextMenu(uniqueQueryId)} + )} {linkButton} @@ -362,9 +399,9 @@ export const AlertsHistogramPanel = memo( from={from} legendItems={legendItems} legendPosition={legendPosition} + legendMinWidth={showCountsInLegend ? LEGEND_WITH_COUNTS_WIDTH : undefined} loading={isLoadingAlerts} to={to} - showCountsInLegend={showCountsInLegend} showLegend={showLegend} updateDateRange={updateDateRange} /> diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/mock_data.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/mock_data.ts index 6e5551eb69201..e2ab1a3ae9f84 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/mock_data.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/mock_data.ts @@ -81,3 +81,354 @@ export const textResult = [ { x: 1652199588074, y: 0, g: 'MacBook-Pro.local' }, { x: 1652202288073, y: 0, g: 'MacBook-Pro.local' }, ]; + +export const mockAlertSearchResponse = { + took: 1, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 1, + relation: 'eq', + }, + max_score: 0, + hits: [ + { + _index: '.internal.alerts-security.alerts-default-000001', + _id: 'cb3bcb63c619cd7f3349d77568cc0bf0406210dce95374b04b9bf1e98b68dcdc', + _score: 0, + _source: { + 'kibana.version': '8.4.0', + 'kibana.alert.rule.category': 'Custom Query Rule', + 'kibana.alert.rule.consumer': 'siem', + 'kibana.alert.rule.execution.uuid': '5240f735-0205-4af4-8e6d-dec17d0f084e', + 'kibana.alert.rule.name': 'matches everything', + 'kibana.alert.rule.producer': 'siem', + 'kibana.alert.rule.rule_type_id': 'siem.queryRule', + 'kibana.alert.rule.uuid': '6a6ecac0-fe4f-11ec-8ccd-258a52cbda02', + 'kibana.space_ids': ['default'], + 'kibana.alert.rule.tags': ['test'], + '@timestamp': '2022-07-08T23:58:11.500Z', + agent: { + id: '4a6c871a-b23e-4e83-9098-5e14e85c3f7b', + type: 'endpoint', + version: '7.6.11', + }, + process: { + Ext: { + ancestry: ['snmviyj5md', '2g4w55131x'], + code_signature: [ + { + trusted: false, + subject_name: 'bad signer', + }, + ], + user: 'SYSTEM', + token: { + integrity_level: 16384, + privileges: [ + { + name: 'SeAssignPrimaryTokenPrivilege', + description: 'Replace a process level token', + enabled: false, + }, + ], + integrity_level_name: 'system', + domain: 'NT AUTHORITY', + type: 'tokenPrimary', + user: 'SYSTEM', + sid: 'S-1-5-18', + }, + }, + parent: { + pid: 1, + entity_id: 'snmviyj5md', + }, + group_leader: { + name: 'fake leader', + pid: 4, + entity_id: 'xq1spmmi2w', + }, + session_leader: { + name: 'fake session', + pid: 26, + entity_id: 'xq1spmmi2w', + }, + entry_leader: { + name: 'fake entry', + pid: 558, + entity_id: 'xq1spmmi2w', + }, + name: 'malware writer', + start: 1657324615198, + pid: 2, + entity_id: 'v6w0s12zn1', + executable: 'C:/malware.exe', + hash: { + sha1: 'fake sha1', + sha256: 'fake sha256', + md5: 'fake md5', + }, + uptime: 0, + }, + file: { + owner: 'SYSTEM', + Ext: { + temp_file_path: 'C:/temp/fake_malware.exe', + code_signature: [ + { + trusted: false, + subject_name: 'bad signer', + }, + ], + quarantine_message: 'fake quarantine message', + quarantine_result: true, + malware_classification: { + identifier: 'endpointpe', + score: 1, + threshold: 0.66, + version: '3.0.33', + }, + }, + path: 'C:/fake_malware.exe', + size: 3456, + created: 1657324615198, + name: 'fake_malware.exe', + accessed: 1657324615198, + mtime: 1657324615198, + hash: { + sha1: 'fake file sha1', + sha256: 'fake file sha256', + md5: 'fake file md5', + }, + }, + Endpoint: { + capabilities: [], + configuration: { + isolation: true, + }, + state: { + isolation: true, + }, + status: 'enrolled', + policy: { + applied: { + name: 'With Eventing', + id: 'C2A9093E-E289-4C0A-AA44-8C32A414FA7A', + endpoint_policy_version: 3, + version: 5, + status: 'failure', + }, + }, + }, + ecs: { + version: '1.4.0', + }, + dll: [ + { + Ext: { + compile_time: 1534424710, + malware_classification: { + identifier: 'Whitelisted', + score: 0, + threshold: 0, + version: '3.0.0', + }, + mapped_address: 5362483200, + mapped_size: 0, + }, + path: 'C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe', + code_signature: { + trusted: true, + subject_name: 'Cybereason Inc', + }, + pe: { + architecture: 'x64', + }, + hash: { + sha1: 'ca85243c0af6a6471bdaa560685c51eefd6dbc0d', + sha256: '8ad40c90a611d36eb8f9eb24fa04f7dbca713db383ff55a03aa0f382e92061a2', + md5: '1f2d082566b0fc5f2c238a5180db7451', + }, + }, + ], + data_stream: { + namespace: 'default', + type: 'logs', + dataset: 'endpoint.alerts', + }, + elastic: { + agent: { + id: '4a6c871a-b23e-4e83-9098-5e14e85c3f7b', + }, + }, + host: { + hostname: 'Host-xh6qyoiujf', + os: { + Ext: { + variant: 'Windows Server', + }, + name: 'Windows', + family: 'windows', + version: '6.2', + platform: 'Windows', + full: 'Windows Server 2012', + }, + ip: ['10.74.191.143'], + name: 'Host-xh6qyoiujf', + id: 'b51c2aad-8371-44e5-8dab-cb92a8a32414', + mac: ['86-b5-5e-e7-99-2d'], + architecture: 'rdf4znaej1', + }, + 'event.agent_id_status': 'auth_metadata_missing', + 'event.sequence': 63, + 'event.ingested': '2022-07-08T21:15:43Z', + 'event.code': 'malicious_file', + 'event.kind': 'signal', + 'event.module': 'endpoint', + 'event.action': 'deletion', + 'event.id': 'c6760bef-1c62-4730-848a-1b2d5f8938f9', + 'event.category': 'malware', + 'event.type': 'creation', + 'event.dataset': 'endpoint', + 'kibana.alert.original_time': '2022-07-08T23:56:55.198Z', + 'kibana.alert.ancestors': [ + { + id: 'N2ir34EB_eEmuUQvINry', + type: 'event', + index: '.ds-logs-endpoint.alerts-default-2022.07.07-000001', + depth: 0, + }, + ], + 'kibana.alert.status': 'active', + 'kibana.alert.workflow_status': 'open', + 'kibana.alert.depth': 1, + 'kibana.alert.reason': + 'malware event with process malware writer, file fake_malware.exe, on Host-xh6qyoiujf created low alert matches everything.', + 'kibana.alert.severity': 'low', + 'kibana.alert.risk_score': 21, + 'kibana.alert.rule.parameters': { + description: 'matches almost everything', + risk_score: 21, + severity: 'low', + license: '', + meta: { + from: '1m', + kibana_siem_app_url: 'http://localhost:5601/app/security', + }, + author: [], + false_positives: [], + from: 'now-360s', + rule_id: 'f544e86c-4d83-496f-9e5b-c60965b1eb83', + max_signals: 100, + risk_score_mapping: [], + severity_mapping: [], + threat: [], + to: 'now', + references: [], + version: 1, + exceptions_list: [], + immutable: false, + related_integrations: [], + required_fields: [], + setup: '', + type: 'query', + language: 'kuery', + index: [ + 'apm-*-transaction*', + 'traces-apm*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + query: '_id: *', + filters: [], + }, + 'kibana.alert.rule.actions': [], + 'kibana.alert.rule.author': [], + 'kibana.alert.rule.created_at': '2022-07-07T23:49:18.761Z', + 'kibana.alert.rule.created_by': 'elastic', + 'kibana.alert.rule.description': 'matches almost everything', + 'kibana.alert.rule.enabled': true, + 'kibana.alert.rule.exceptions_list': [], + 'kibana.alert.rule.false_positives': [], + 'kibana.alert.rule.from': 'now-360s', + 'kibana.alert.rule.immutable': false, + 'kibana.alert.rule.interval': '5m', + 'kibana.alert.rule.indices': [ + 'apm-*-transaction*', + 'traces-apm*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + 'kibana.alert.rule.license': '', + 'kibana.alert.rule.max_signals': 100, + 'kibana.alert.rule.references': [], + 'kibana.alert.rule.risk_score_mapping': [], + 'kibana.alert.rule.rule_id': 'f544e86c-4d83-496f-9e5b-c60965b1eb83', + 'kibana.alert.rule.severity_mapping': [], + 'kibana.alert.rule.threat': [], + 'kibana.alert.rule.to': 'now', + 'kibana.alert.rule.type': 'query', + 'kibana.alert.rule.updated_at': '2022-07-07T23:50:01.437Z', + 'kibana.alert.rule.updated_by': 'elastic', + 'kibana.alert.rule.version': 1, + 'kibana.alert.rule.meta.from': '1m', + 'kibana.alert.rule.meta.kibana_siem_app_url': 'http://localhost:5601/app/security', + 'kibana.alert.rule.risk_score': 21, + 'kibana.alert.rule.severity': 'low', + 'kibana.alert.original_event.agent_id_status': 'auth_metadata_missing', + 'kibana.alert.original_event.sequence': 63, + 'kibana.alert.original_event.ingested': '2022-07-08T21:15:43Z', + 'kibana.alert.original_event.code': 'malicious_file', + 'kibana.alert.original_event.kind': 'alert', + 'kibana.alert.original_event.module': 'endpoint', + 'kibana.alert.original_event.action': 'deletion', + 'kibana.alert.original_event.id': 'c6760bef-1c62-4730-848a-1b2d5f8938f9', + 'kibana.alert.original_event.category': 'malware', + 'kibana.alert.original_event.type': 'creation', + 'kibana.alert.original_event.dataset': 'endpoint', + 'kibana.alert.uuid': 'cb3bcb63c619cd7f3349d77568cc0bf0406210dce95374b04b9bf1e98b68dcdc', + }, + }, + ], + }, + aggregations: { + alertsByGrouping: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'matches everything', + doc_count: 2, + alerts: { + buckets: [ + { + key_as_string: '2022-07-08T05:49:46.200Z', + key: 1657259386200, + doc_count: 0, + }, + { + key_as_string: '2022-07-08T06:34:46.199Z', + key: 1657262086199, + doc_count: 0, + }, + ], + }, + }, + ], + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/translations.ts index 67150926621ab..0f5e48a2399cc 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/translations.ts @@ -20,6 +20,13 @@ export const HISTOGRAM_HEADER = i18n.translate( } ); +export const NOT_AVAILABLE_TOOLTIP = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.histogram.notAvailableTooltip', + { + defaultMessage: 'Not available for trend view', + } +); + export const VIEW_ALERTS = i18n.translate( 'xpack.securitySolution.detectionEngine.alerts.histogram.viewAlertsButtonLabel', { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.test.tsx new file mode 100644 index 0000000000000..b444f5b09cd83 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.test.tsx @@ -0,0 +1,182 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../../../common/mock'; +import { KpiPanel, StackByComboBox } from './components'; + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + return { + ...originalModule, + createHref: jest.fn(), + useHistory: jest.fn(), + useLocation: jest.fn().mockReturnValue({ pathname: '' }), + }; +}); + +const mockNavigateToApp = jest.fn(); +jest.mock('../../../../common/lib/kibana/kibana_react', () => { + const original = jest.requireActual('../../../../common/lib/kibana/kibana_react'); + + return { + ...original, + useKibana: () => ({ + services: { + application: { + navigateToApp: mockNavigateToApp, + getUrlForApp: jest.fn(), + }, + data: { + search: { + search: jest.fn(), + }, + }, + uiSettings: { + get: jest.fn(), + }, + notifications: { + toasts: { + addWarning: jest.fn(), + addError: jest.fn(), + addSuccess: jest.fn(), + remove: jest.fn(), + }, + }, + }, + }), + }; +}); + +describe('components', () => { + describe('KpiPanel', () => { + test('it has a hidden overflow-x', () => { + render( + + + {'test'} + + + ); + + expect(screen.getByTestId('test')).toHaveStyleRule('overflow-x', 'hidden'); + }); + + test('it has a hidden overflow-y by default', () => { + render( + + + {'test'} + + + ); + + expect(screen.getByTestId('test')).toHaveStyleRule('overflow-y', 'hidden'); + }); + + test('it uses the `$overflowY` prop for the value of overflow-y when provided', () => { + render( + + + {'test'} + + + ); + + expect(screen.getByTestId('test')).toHaveStyleRule('overflow-y', 'auto'); + }); + }); + + describe('StackByComboBox', () => { + test('it invokes onSelect when a field is selected', async () => { + const onSelect = jest.fn(); + const optionToSelect = 'agent.hostname'; + + render( + + + + ); + + const comboBox = screen.getByTestId('comboBoxSearchInput'); + comboBox.focus(); // display the combo box options + + const option = await screen.findByText(optionToSelect); + fireEvent.click(option); + + expect(onSelect).toBeCalledWith(optionToSelect); + }); + + test('it does NOT disable the combo box by default', () => { + render( + + + + ); + + expect(screen.getByTestId('comboBoxSearchInput')).not.toHaveAttribute('disabled'); + }); + + test('it disables the combo box when `isDisabled` is true', () => { + render( + + + + ); + + expect(screen.getByTestId('comboBoxSearchInput')).toHaveAttribute('disabled'); + }); + + test('it renders the default label', () => { + const defaultLabel = 'Stack by'; + + render( + + + + ); + + expect(screen.getByLabelText(defaultLabel)).toBeInTheDocument(); + }); + + test('it overrides the default label when `prepend` is specified', () => { + const prepend = 'Group by'; + + render( + + + + ); + + expect(screen.getByLabelText(prepend)).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx index 25121c9b5aa07..c4e34d80af91b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.tsx @@ -14,11 +14,27 @@ import * as i18n from './translations'; const DEFAULT_WIDTH = 400; -export const KpiPanel = styled(EuiPanel)<{ height?: number; $toggleStatus: boolean }>` +export const KpiPanel = styled(EuiPanel)<{ + height?: number; + $overflowY?: + | 'auto' + | 'clip' + | 'hidden' + | 'hidden visible' + | 'inherit' + | 'initial' + | 'revert' + | 'revert-layer' + | 'scroll' + | 'unset' + | 'visible'; + $toggleStatus: boolean; +}>` display: flex; flex-direction: column; position: relative; - overflow: hidden; + overflow-x: hidden; + overflow-y: ${({ $overflowY }) => $overflowY ?? 'hidden'}; @media only screen and (min-width: ${(props) => props.theme.eui.euiBreakpoints.m}) { ${({ height, $toggleStatus }) => $toggleStatus && @@ -33,6 +49,8 @@ export const KpiPanel = styled(EuiPanel)<{ height?: number; $toggleStatus: boole `} `; interface StackedBySelectProps { + 'data-test-subj'?: string; + isDisabled?: boolean; prepend?: string; selected: string; onSelect: (selected: string) => void; @@ -45,6 +63,8 @@ export const StackByComboBoxWrapper = styled.div<{ width: number }>` `; export const StackByComboBox: React.FC = ({ + 'data-test-subj': dataTestSubj, + isDisabled = false, onSelect, prepend = i18n.STACK_BY_LABEL, selected, @@ -70,7 +90,9 @@ export const StackByComboBox: React.FC = ({ return ( { - const [alertViewSelection, setAlertViewSelection] = useState(TREND_ID); - - const [expandRiskChart, setExpandRiskChart] = useState(true); - - const [riskChartStackBy0, setRiskChartStackBy0] = useState(DEFAULT_STACK_BY_FIELD); - - const [riskChartStackBy1, setRiskChartStackBy1] = useState( - DEFAULT_STACK_BY_FIELD1 - ); - - const [countTableStackBy0, setCountTableStackBy0] = useState(DEFAULT_STACK_BY_FIELD); - - const [countTableStackBy1, setCountTableStackBy1] = useState( - DEFAULT_STACK_BY_FIELD1 - ); - - const [showCountsInTrendChartLegend, setShowCountsInTrendChartLegend] = useState(true); - - const [showRiskChart, setShowRiskChart] = useState(false); - - const [showCountTable, setShowCountTable] = useState(true); - - const [showTrendChart, setShowTrendChart] = useState(true); - - const [trendChartStackBy, setTrendChartStackBy] = useState(DEFAULT_STACK_BY_FIELD); - - const [tourStep1Completed, setTourStep1Completed] = useState(false); - - const [tourStep2Completed, setTourStep2Completed] = useState(false); - - return { - alertViewSelection, - countTableStackBy0, - countTableStackBy1, - expandRiskChart, - riskChartStackBy0, - riskChartStackBy1, - setAlertViewSelection, - setCountTableStackBy0, - setCountTableStackBy1, - setExpandRiskChart, - setRiskChartStackBy0, - setRiskChartStackBy1, - setShowCountsInTrendChartLegend, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - setTourStep1Completed, - setTourStep2Completed, - setTrendChartStackBy, - showCountsInTrendChartLegend, - showCountTable, - showRiskChart, - showTrendChart, - trendChartStackBy, - tourStep1Completed, - tourStep2Completed, - }; -}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/alerts_local_storage/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/alerts_local_storage/index.tsx deleted file mode 100644 index 65e5433d6e061..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/alerts_local_storage/index.tsx +++ /dev/null @@ -1,209 +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 { useLocalStorage } from '../../../../common/components/local_storage'; -import { - getSettingKey, - useDefaultWhenEmptyString, -} from '../../../../common/components/local_storage/helpers'; -import { APP_ID } from '../../../../../common/constants'; -import { - ALERTS_PAGE, - ALERT_VIEW_SELECTION_SETTING_NAME, - COUNT_CHART_CATEGORY, - EXPAND_SETTING_NAME, - RISK_CHART_CATEGORY, - SHOW_COUNTS_IN_LEGEND, - SHOW_SETTING_NAME, - STACK_BY_0_SETTING_NAME, - STACK_BY_1_SETTING_NAME, - STACK_BY_SETTING_NAME, - TOUR_STEP_1_COMPLETED_SETTING_NAME, - TOUR_STEP_2_COMPLETED_SETTING_NAME, - TREND_CHART_CATEGORY, - VIEW_CATEGORY, -} from './constants'; -import { - DEFAULT_STACK_BY_FIELD, - DEFAULT_STACK_BY_FIELD1, -} from '../../../components/alerts_kpis/common/config'; -import type { AlertsSettings } from './types'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import { useAlertsInMemoryStorage } from './alerts_in_memory_storage'; -import { AlertViewSelection, TREND_ID } from '../chart_select/helpers'; - -export const useAlertsLocalStorage = (): AlertsSettings => { - const alertsTreemapEnabled = useIsExperimentalFeatureEnabled('alertsTreemapEnabled'); // feature flag - - const [alertViewSelection, setAlertViewSelection] = useLocalStorage({ - defaultValue: TREND_ID, - key: getSettingKey({ - category: VIEW_CATEGORY, - page: ALERTS_PAGE, - setting: ALERT_VIEW_SELECTION_SETTING_NAME, - }), - plugin: APP_ID, - isInvalidDefault: useDefaultWhenEmptyString, - }); - - const [expandRiskChart, setExpandRiskChart] = useLocalStorage({ - defaultValue: true, - key: getSettingKey({ - category: RISK_CHART_CATEGORY, - page: ALERTS_PAGE, - setting: EXPAND_SETTING_NAME, - }), - plugin: APP_ID, - }); - - const [riskChartStackBy0, setRiskChartStackBy0] = useLocalStorage({ - defaultValue: DEFAULT_STACK_BY_FIELD, - key: getSettingKey({ - category: RISK_CHART_CATEGORY, - page: ALERTS_PAGE, - setting: STACK_BY_0_SETTING_NAME, - }), - plugin: APP_ID, - isInvalidDefault: useDefaultWhenEmptyString, - }); - - const [riskChartStackBy1, setRiskChartStackBy1] = useLocalStorage({ - defaultValue: DEFAULT_STACK_BY_FIELD1, - key: getSettingKey({ - category: RISK_CHART_CATEGORY, - page: ALERTS_PAGE, - setting: STACK_BY_1_SETTING_NAME, - }), - plugin: APP_ID, - }); - - const [countTableStackBy0, setCountTableStackBy0] = useLocalStorage({ - defaultValue: DEFAULT_STACK_BY_FIELD, - key: getSettingKey({ - category: COUNT_CHART_CATEGORY, - page: ALERTS_PAGE, - setting: STACK_BY_0_SETTING_NAME, - }), - plugin: APP_ID, - isInvalidDefault: useDefaultWhenEmptyString, - }); - - const [countTableStackBy1, setCountTableStackBy1] = useLocalStorage({ - defaultValue: DEFAULT_STACK_BY_FIELD1, - key: getSettingKey({ - category: COUNT_CHART_CATEGORY, - page: ALERTS_PAGE, - setting: STACK_BY_1_SETTING_NAME, - }), - plugin: APP_ID, - }); - - const [trendChartStackBy, setTrendChartStackBy] = useLocalStorage({ - defaultValue: DEFAULT_STACK_BY_FIELD, - key: getSettingKey({ - category: TREND_CHART_CATEGORY, - page: ALERTS_PAGE, - setting: STACK_BY_SETTING_NAME, - }), - plugin: APP_ID, - isInvalidDefault: useDefaultWhenEmptyString, - }); - - const [showCountsInTrendChartLegend, setShowCountsInTrendChartLegend] = useLocalStorage({ - defaultValue: true, - key: getSettingKey({ - category: TREND_CHART_CATEGORY, - page: ALERTS_PAGE, - setting: SHOW_COUNTS_IN_LEGEND, - }), - plugin: APP_ID, - }); - - const [showRiskChart, setShowRiskChart] = useLocalStorage({ - defaultValue: false, - key: getSettingKey({ - category: RISK_CHART_CATEGORY, - page: ALERTS_PAGE, - setting: SHOW_SETTING_NAME, - }), - plugin: APP_ID, - }); - - const [showCountTable, setShowCountTable] = useLocalStorage({ - defaultValue: true, - key: getSettingKey({ - category: COUNT_CHART_CATEGORY, - page: ALERTS_PAGE, - setting: SHOW_SETTING_NAME, - }), - plugin: APP_ID, - }); - - const [showTrendChart, setShowTrendChart] = useLocalStorage({ - defaultValue: true, - key: getSettingKey({ - category: TREND_CHART_CATEGORY, - page: ALERTS_PAGE, - setting: SHOW_SETTING_NAME, - }), - plugin: APP_ID, - }); - - const [tourStep1Completed, setTourStep1Completed] = useLocalStorage({ - defaultValue: false, - key: getSettingKey({ - category: VIEW_CATEGORY, - page: ALERTS_PAGE, - setting: TOUR_STEP_1_COMPLETED_SETTING_NAME, - }), - plugin: APP_ID, - }); - - const [tourStep2Completed, setTourStep2Completed] = useLocalStorage({ - defaultValue: false, - key: getSettingKey({ - category: VIEW_CATEGORY, - page: ALERTS_PAGE, - setting: TOUR_STEP_2_COMPLETED_SETTING_NAME, - }), - plugin: APP_ID, - }); - - const inMemoryStorage = useAlertsInMemoryStorage(); - - // fallback to in memory storage if the `alertsTreemapEnabled` feature flag is false - return alertsTreemapEnabled - ? { - alertViewSelection, - countTableStackBy0, - countTableStackBy1, - expandRiskChart, - riskChartStackBy0, - riskChartStackBy1, - setAlertViewSelection, - setCountTableStackBy0, - setCountTableStackBy1, - setExpandRiskChart, - setRiskChartStackBy0, - setRiskChartStackBy1, - setShowCountsInTrendChartLegend, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - setTrendChartStackBy, - setTourStep1Completed, - setTourStep2Completed, - showCountsInTrendChartLegend, - showCountTable, - showRiskChart, - showTrendChart, - tourStep1Completed, - tourStep2Completed, - trendChartStackBy, - } - : inMemoryStorage; -}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options/index.tsx deleted file mode 100644 index ba11b52696725..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options/index.tsx +++ /dev/null @@ -1,102 +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 { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; - -import { ChartSelectTour } from '../chart_options_tours/chart_select_tour'; -import { ViewChartToggleTour } from '../chart_options_tours/view_chart_toggle_tour'; -import { ChartSelect } from '../chart_select'; -import { AlertViewSelection } from '../chart_select/helpers'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import { ViewChartToggle } from '../view_chart_toggle'; - -const HeaderButtonContainer = styled.div` - margin-left: ${({ theme }) => theme.eui.euiSizeS}; -`; - -export interface Props { - alertViewSelection: AlertViewSelection; - setAlertViewSelection: (alertViewSelection: AlertViewSelection) => void; - setShowCountTable: (value: boolean) => void; - setShowRiskChart: (value: boolean) => void; - setShowTrendChart: (value: boolean) => void; - setTourStep1Completed: (value: boolean) => void; - setTourStep2Completed: (value: boolean) => void; - showCountTable: boolean; - showRiskChart: boolean; - showTrendChart: boolean; - tourStep1Completed: boolean; - tourStep2Completed: boolean; -} - -const ChartOptionsComponent = ({ - alertViewSelection, - setAlertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - setTourStep1Completed, - setTourStep2Completed, - showCountTable, - showRiskChart, - showTrendChart, - tourStep1Completed, - tourStep2Completed, -}: Props) => { - const showChartsToggle = useIsExperimentalFeatureEnabled('showChartsToggle'); // feature flag - - return ( - - {showChartsToggle && ( - - - - - - - - )} - - - - - - - - - - ); -}; - -ChartOptionsComponent.displayName = 'ChartOptionsComponent'; -export const ChartOptions = React.memo(ChartOptionsComponent); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/chart_select_tour.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/chart_select_tour.tsx deleted file mode 100644 index 1bc266f14bdab..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/chart_select_tour.tsx +++ /dev/null @@ -1,101 +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 { noop } from 'lodash/fp'; -import { EuiTourStep, EuiLink, EuiText } from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useTheme } from 'styled-components'; - -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import { isStep2Open, STEPS_TOTAL } from './helpers'; -import * as i18n from './translations'; - -interface EuiTheme { - eui: { - euiZLevel1: number; - }; -} - -const ChartSelectTourComponent = ({ - children, - setTourStep2Completed, - showRiskChart, - showTrendChart, - tourStep1Completed, - tourStep2Completed, -}: { - children: React.ReactElement; - setTourStep2Completed: (value: boolean) => void; - showRiskChart: boolean; - showTrendChart: boolean; - tourStep1Completed: boolean; - tourStep2Completed: boolean; -}) => { - const showChartsToggle = useIsExperimentalFeatureEnabled('showChartsToggle'); // feature flag - const theme = useTheme() as EuiTheme; - const [hiddenForPositoning, setHiddenForPositoning] = useState(false); - - const onStepCompleted = useCallback(() => { - setTourStep2Completed(true); - }, [setTourStep2Completed]); - - const content = useMemo( - () => ( - - {i18n.SELECT_A_VIEW} - - ), - [] - ); - - const footerAction = useMemo( - () => {i18n.END_TOUR}, - [onStepCompleted] - ); - - // We need to briefly hide, then show the tour step when view selection - // changes to force re-positioning, because the size of the button changes - useEffect(() => { - if ( - isStep2Open({ - tourStep1Completed, - tourStep2Completed, - }) - ) { - setHiddenForPositoning(true); // hide the tour step - setTimeout(() => setHiddenForPositoning(false), 0); // show the tour step on the next tick - } - }, [showRiskChart, showTrendChart, tourStep1Completed, tourStep2Completed]); - - return ( - - {children} - - ); -}; - -ChartSelectTourComponent.displayName = 'ChartSelectTourComponent'; -export const ChartSelectTour = React.memo(ChartSelectTourComponent); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/helpers.test.ts deleted file mode 100644 index 8712ae78f8bf4..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/helpers.test.ts +++ /dev/null @@ -1,46 +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 { isStep1Open, isStep2Open } from './helpers'; - -describe('helpers', () => { - describe('isStep1Open', () => { - it('returns false when the delay has elapsed, and step 1 has completed', () => { - expect(isStep1Open({ delayElapsed: true, tourStep1Completed: true })).toBe(false); - }); - - it('returns true when the delay has elapsed, and step 1 has NOT completed', () => { - expect(isStep1Open({ delayElapsed: true, tourStep1Completed: false })).toBe(true); - }); - - it('returns false when the delay has NOT elapsed, and step 1 has completed', () => { - expect(isStep1Open({ delayElapsed: false, tourStep1Completed: true })).toBe(false); - }); - - it('returns false when the delay has NOT elapsed, and step 1 has NOT completed', () => { - expect(isStep1Open({ delayElapsed: false, tourStep1Completed: false })).toBe(false); - }); - }); - - describe('isStep2Open', () => { - it('returns false when step 1 has completed, and step 2 has completed', () => { - expect(isStep2Open({ tourStep1Completed: true, tourStep2Completed: true })).toBe(false); - }); - - it('returns true when step 1 has completed, and step 2 has NOT completed', () => { - expect(isStep2Open({ tourStep1Completed: true, tourStep2Completed: false })).toBe(true); - }); - - it('returns false when step 1 has NOT completed, and step 2 has completed', () => { - expect(isStep2Open({ tourStep1Completed: false, tourStep2Completed: true })).toBe(false); - }); - - it('returns false when step 1 has NOT completed, and step 2 has NOT completed', () => { - expect(isStep2Open({ tourStep1Completed: false, tourStep2Completed: false })).toBe(false); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/helpers.ts deleted file mode 100644 index 6f638cd165edd..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/helpers.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const STEPS_TOTAL = 2; - -export const isStep1Open = ({ - delayElapsed, - tourStep1Completed, -}: { - delayElapsed: boolean; - tourStep1Completed: boolean; -}): boolean => delayElapsed && !tourStep1Completed; - -export const isStep2Open = ({ - tourStep1Completed, - tourStep2Completed, -}: { - tourStep1Completed: boolean; - tourStep2Completed: boolean; -}): boolean => tourStep1Completed && !tourStep2Completed; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/translations.ts deleted file mode 100644 index 1c0439934c0e2..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/translations.ts +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const CLICK_TO_HIDE_OR_SHOW_CHART = i18n.translate( - 'xpack.securitySolution.components.chartOptionsTours.clickToHideOrShowChartText', - { - defaultMessage: 'Click to hide or show charts', - } -); - -export const END_TOUR = i18n.translate( - 'xpack.securitySolution.components.chartOptionsTours.endTourButton', - { - defaultMessage: 'End tour', - } -); - -export const GOT_IT = i18n.translate( - 'xpack.securitySolution.components.chartOptionsTours.gotItButton', - { - defaultMessage: 'Got it', - } -); - -export const SKIP_TOUR = i18n.translate( - 'xpack.securitySolution.components.chartOptionsTours.skipTourButton', - { - defaultMessage: 'Skip tour', - } -); - -export const SELECT_A_VIEW = i18n.translate( - 'xpack.securitySolution.components.chartOptionsTours.selectAViewText', - { - defaultMessage: 'Select a view', - } -); - -export const STEP_1_TITLE = i18n.translate( - 'xpack.securitySolution.components.chartOptionsTours.step1Title', - { - defaultMessage: 'Step 1', - } -); - -export const STEP_2_TITLE = i18n.translate( - 'xpack.securitySolution.components.chartOptionsTours.step2Title', - { - defaultMessage: 'Step 2', - } -); - -export const SUBTITLE = i18n.translate( - 'xpack.securitySolution.components.chartOptionsTours.subtitle', - { - defaultMessage: 'Chart options tour', - } -); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/view_chart_toggle_tour.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/view_chart_toggle_tour.tsx deleted file mode 100644 index a7c202bde778b..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_options_tours/view_chart_toggle_tour.tsx +++ /dev/null @@ -1,90 +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 { noop } from 'lodash/fp'; -import { EuiButton, EuiLink, EuiSpacer, EuiText, EuiTourStep } from '@elastic/eui'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { useTheme } from 'styled-components'; - -import { isStep1Open, STEPS_TOTAL } from './helpers'; -import * as i18n from './translations'; - -const DELAY = 1000 * 4; - -interface EuiTheme { - eui: { - euiZLevel1: number; - }; -} - -const ViewChartToggleTourComponent = ({ - children, - setTourStep1Completed, - setTourStep2Completed, - tourStep1Completed, -}: { - children: React.ReactElement; - setTourStep1Completed: (value: boolean) => void; - setTourStep2Completed: (value: boolean) => void; - tourStep1Completed: boolean; -}) => { - const [delayElapsed, setDelayElapsed] = useState(false); - const theme = useTheme() as EuiTheme; - - const onStepCompleted = useCallback(() => { - setTourStep1Completed(true); - }, [setTourStep1Completed]); - - const onSkipTour = useCallback(() => { - setTourStep2Completed(true); - setTourStep1Completed(true); - }, [setTourStep1Completed, setTourStep2Completed]); - - const content = useMemo( - () => ( - <> - - {i18n.CLICK_TO_HIDE_OR_SHOW_CHART} - - - {i18n.GOT_IT} - - ), - [onStepCompleted] - ); - - const footerAction = useMemo( - () => {i18n.SKIP_TOUR}, - [onSkipTour] - ); - - useEffect(() => { - setTimeout(() => setDelayElapsed(true), DELAY); - }, []); - - return ( - - {children} - - ); -}; - -ViewChartToggleTourComponent.displayName = 'ViewChartToggleTourComponent'; -export const ViewChartToggleTour = React.memo(ViewChartToggleTourComponent); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/alerts_local_storage/constants.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/constants.ts similarity index 59% rename from x-pack/plugins/security_solution/public/detections/pages/detection_engine/alerts_local_storage/constants.ts rename to x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/constants.ts index 92738382592cc..6df717c6b541e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/alerts_local_storage/constants.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/constants.ts @@ -11,20 +11,14 @@ export const ALERTS_PAGE = 'alerts'; /** This setting persists the value of the view selector, which toggles between chart types */ export const ALERT_VIEW_SELECTION_SETTING_NAME = 'alert-view-selection'; -/** settings for the `Count` visualization are grouped under this category */ -export const COUNT_CHART_CATEGORY = 'count-chart'; +/** settings for the `Count` table visualization are grouped under this category */ +export const TABLE_CATEGORY = 'table'; /** This setting persists the expanded / collapsed state of an expandable panel */ export const EXPAND_SETTING_NAME = 'expand'; -/** settings for the `Risk` visualization are grouped under this category */ -export const RISK_CHART_CATEGORY = 'risk-chart'; - -/** This setting persists the option to show counts in a legend */ -export const SHOW_COUNTS_IN_LEGEND = 'show-counts-in-legend'; - -/** Show a visualization when this setting is true */ -export const SHOW_SETTING_NAME = 'show'; +/** settings for the `Treemap` visualization are grouped under this category */ +export const TREEMAP_CATEGORY = 'treemap'; /** This setting persists the value of the `Stack by` field selector */ export const STACK_BY_SETTING_NAME = 'stack-by'; @@ -36,13 +30,7 @@ export const STACK_BY_0_SETTING_NAME = 'stack-by-0'; export const STACK_BY_1_SETTING_NAME = 'stack-by-1'; /** settings for the `Trend` visualization are grouped under this category */ -export const TREND_CHART_CATEGORY = 'trend-chart'; - -/** This setting is set to true when step 1 of the chart options tour is completed */ -export const TOUR_STEP_1_COMPLETED_SETTING_NAME = 'tour-step-1-completed'; - -/** This setting is set to true when step 2 of the chart options tour is completed */ -export const TOUR_STEP_2_COMPLETED_SETTING_NAME = 'tour-step-2-completed'; +export const TREND_CHART_CATEGORY = 'trend'; /** settings for view selection are grouped under this category */ export const VIEW_CATEGORY = 'view'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.test.tsx new file mode 100644 index 0000000000000..6731bee771a3d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.test.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; + +import { useAlertsLocalStorage } from '.'; +import { TestProviders } from '../../../../../common/mock'; + +describe('useAlertsLocalStorage', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + test('it returns the expected defaults', () => { + const { result } = renderHook(() => useAlertsLocalStorage(), { wrapper }); + + const defaults = Object.fromEntries( + Object.entries(result.current).filter((x) => typeof x[1] !== 'function') + ); + + expect(defaults).toEqual({ + alertViewSelection: 'trend', // default to the trend chart + countTableStackBy0: 'kibana.alert.rule.name', + countTableStackBy1: 'host.name', + isTreemapPanelExpanded: true, + riskChartStackBy0: 'kibana.alert.rule.name', + riskChartStackBy1: 'host.name', + trendChartStackBy: 'kibana.alert.rule.name', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.tsx new file mode 100644 index 0000000000000..18ef61c59a9bd --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useLocalStorage } from '../../../../../common/components/local_storage'; +import { + getSettingKey, + isDefaultWhenEmptyString, +} from '../../../../../common/components/local_storage/helpers'; +import { APP_ID } from '../../../../../../common/constants'; +import { + ALERTS_PAGE, + ALERT_VIEW_SELECTION_SETTING_NAME, + TABLE_CATEGORY, + EXPAND_SETTING_NAME, + TREEMAP_CATEGORY, + STACK_BY_0_SETTING_NAME, + STACK_BY_1_SETTING_NAME, + STACK_BY_SETTING_NAME, + TREND_CHART_CATEGORY, + VIEW_CATEGORY, +} from './constants'; +import { + DEFAULT_STACK_BY_FIELD, + DEFAULT_STACK_BY_FIELD1, +} from '../../../../components/alerts_kpis/common/config'; +import type { AlertsSettings } from './types'; +import type { AlertViewSelection } from '../chart_select/helpers'; +import { TREND_ID } from '../chart_select/helpers'; + +export const useAlertsLocalStorage = (): AlertsSettings => { + const [alertViewSelection, setAlertViewSelection] = useLocalStorage({ + defaultValue: TREND_ID, + key: getSettingKey({ + category: VIEW_CATEGORY, + page: ALERTS_PAGE, + setting: ALERT_VIEW_SELECTION_SETTING_NAME, + }), + plugin: APP_ID, + isInvalidDefault: isDefaultWhenEmptyString, + }); + + const [isTreemapPanelExpanded, setIsTreemapPanelExpanded] = useLocalStorage({ + defaultValue: true, + key: getSettingKey({ + category: TREEMAP_CATEGORY, + page: ALERTS_PAGE, + setting: EXPAND_SETTING_NAME, + }), + plugin: APP_ID, + }); + + const [riskChartStackBy0, setRiskChartStackBy0] = useLocalStorage({ + defaultValue: DEFAULT_STACK_BY_FIELD, + key: getSettingKey({ + category: TREEMAP_CATEGORY, + page: ALERTS_PAGE, + setting: STACK_BY_0_SETTING_NAME, + }), + plugin: APP_ID, + isInvalidDefault: isDefaultWhenEmptyString, + }); + + const [riskChartStackBy1, setRiskChartStackBy1] = useLocalStorage({ + defaultValue: DEFAULT_STACK_BY_FIELD1, + key: getSettingKey({ + category: TREEMAP_CATEGORY, + page: ALERTS_PAGE, + setting: STACK_BY_1_SETTING_NAME, + }), + plugin: APP_ID, + }); + + const [countTableStackBy0, setCountTableStackBy0] = useLocalStorage({ + defaultValue: DEFAULT_STACK_BY_FIELD, + key: getSettingKey({ + category: TABLE_CATEGORY, + page: ALERTS_PAGE, + setting: STACK_BY_0_SETTING_NAME, + }), + plugin: APP_ID, + isInvalidDefault: isDefaultWhenEmptyString, + }); + + const [countTableStackBy1, setCountTableStackBy1] = useLocalStorage({ + defaultValue: DEFAULT_STACK_BY_FIELD1, + key: getSettingKey({ + category: TABLE_CATEGORY, + page: ALERTS_PAGE, + setting: STACK_BY_1_SETTING_NAME, + }), + plugin: APP_ID, + }); + + const [trendChartStackBy, setTrendChartStackBy] = useLocalStorage({ + defaultValue: DEFAULT_STACK_BY_FIELD, + key: getSettingKey({ + category: TREND_CHART_CATEGORY, + page: ALERTS_PAGE, + setting: STACK_BY_SETTING_NAME, + }), + plugin: APP_ID, + isInvalidDefault: isDefaultWhenEmptyString, + }); + + return { + alertViewSelection, + countTableStackBy0, + countTableStackBy1, + isTreemapPanelExpanded, + riskChartStackBy0, + riskChartStackBy1, + setAlertViewSelection, + setCountTableStackBy0, + setCountTableStackBy1, + setIsTreemapPanelExpanded, + setRiskChartStackBy0, + setRiskChartStackBy1, + setTrendChartStackBy, + trendChartStackBy, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/alerts_local_storage/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/types.ts similarity index 57% rename from x-pack/plugins/security_solution/public/detections/pages/detection_engine/alerts_local_storage/types.ts rename to x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/types.ts index 7a60a8b96c887..e909fc66f11db 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/alerts_local_storage/types.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/types.ts @@ -5,33 +5,21 @@ * 2.0. */ -import { AlertViewSelection } from '../chart_select/helpers'; +import type { AlertViewSelection } from '../chart_select/helpers'; export interface AlertsSettings { alertViewSelection: AlertViewSelection; countTableStackBy0: string; countTableStackBy1: string | undefined; - expandRiskChart: boolean; + isTreemapPanelExpanded: boolean; riskChartStackBy0: string; riskChartStackBy1: string | undefined; setAlertViewSelection: (alertViewSelection: AlertViewSelection) => void; setCountTableStackBy0: (value: string) => void; setCountTableStackBy1: (value: string | undefined) => void; - setExpandRiskChart: (value: boolean) => void; + setIsTreemapPanelExpanded: (value: boolean) => void; setRiskChartStackBy0: (value: string) => void; setRiskChartStackBy1: (value: string | undefined) => void; - setShowCountsInTrendChartLegend: (value: boolean) => void; - setShowCountTable: (value: boolean) => void; - setShowRiskChart: (value: boolean) => void; - setShowTrendChart: (value: boolean) => void; - setTourStep1Completed: (value: boolean) => void; - setTourStep2Completed: (value: boolean) => void; setTrendChartStackBy: (value: string) => void; - showCountsInTrendChartLegend: boolean; - showCountTable: boolean; - showRiskChart: boolean; - showTrendChart: boolean; - tourStep1Completed: boolean; - tourStep2Completed: boolean; trendChartStackBy: string; } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.test.tsx new file mode 100644 index 0000000000000..a68ad9996eeda --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { + DEFAULT_STACK_BY_FIELD, + DEFAULT_STACK_BY_FIELD1, +} from '../../../../components/alerts_kpis/common/config'; +import { TestProviders } from '../../../../../common/mock'; +import { ChartContextMenu } from '.'; + +describe('ChartContextMenu', () => { + const queryId = 'abcd'; + beforeEach(() => jest.resetAllMocks()); + + test('it renders the chart context menu button', () => { + render( + + + + ); + + expect(screen.getByTestId('chartSettingsPopoverButton')).toBeInTheDocument(); + }); + + test('it renders the Inspect menu item', () => { + render( + + + + ); + + const menuButton = screen.getByTestId('chartSettingsPopoverButton'); + menuButton.click(); + + expect(screen.getByTestId('inspectMenuItem')).toBeInTheDocument(); + }); + + test('it invokes `setStackBy` and `setStackByField1` when the Reset group by fields menu item selected', () => { + const setStackBy = jest.fn(); + const setStackByField1 = jest.fn(); + + render( + + + + ); + + const menuButton = screen.getByTestId('chartSettingsPopoverButton'); + menuButton.click(); + + const resetMenuItem = screen.getByTestId('resetGroupByFieldsMenuItem'); + resetMenuItem.click(); + + expect(setStackBy).toBeCalledWith('kibana.alert.rule.name'); + expect(setStackByField1).toBeCalledWith('host.name'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_context_menu/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.tsx similarity index 57% rename from x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_context_menu/index.tsx rename to x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.tsx index 3d58623ee38d2..36ac97de09610 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_context_menu/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.tsx @@ -5,33 +5,25 @@ * 2.0. */ -import { EuiFlexItem } from '@elastic/eui'; import React, { useCallback } from 'react'; -import styled from 'styled-components'; -import { ChartSettingsPopover } from '../../../../common/components/chart_settings_popover'; -import { useChartSettingsPopoverConfiguration } from '../../../../common/components/chart_settings_popover/configurations/default'; +import { ChartSettingsPopover } from '../../../../../common/components/chart_settings_popover'; +import { useChartSettingsPopoverConfiguration } from '../../../../../common/components/chart_settings_popover/configurations/default'; interface Props { defaultStackByField: string; defaultStackByField1?: string; - setShowCountsInChartLegend?: (value: boolean) => void; + queryId: string; setStackBy: (value: string) => void; setStackByField1?: (stackBy: string | undefined) => void; - showCountsInChartLegend?: boolean; } -export const ChartOptionsFlexItem = styled(EuiFlexItem)` - margin-left: ${({ theme }) => theme.eui.euiSizeS}; -`; - -const ChartContextMenuComponent = ({ +const ChartContextMenuComponent: React.FC = ({ defaultStackByField, defaultStackByField1, - setShowCountsInChartLegend, + queryId, setStackBy, setStackByField1, - showCountsInChartLegend, }: Props) => { const onResetStackByFields = useCallback(() => { setStackBy(defaultStackByField); @@ -41,25 +33,17 @@ const ChartContextMenuComponent = ({ } }, [defaultStackByField, defaultStackByField1, setStackBy, setStackByField1]); - const { - defaultInitialPanelId, - defaultMenuItems, - isPopoverOpen, - riskMenuItems, - setIsPopoverOpen, - } = useChartSettingsPopoverConfiguration({ - onResetStackByFields, - setShowCountsInChartLegend, - setStackBy, - setStackByField1, - showCountsInChartLegend, - }); + const { defaultInitialPanelId, defaultMenuItems, isPopoverOpen, setIsPopoverOpen } = + useChartSettingsPopoverConfiguration({ + onResetStackByFields, + queryId, + }); return ( ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts new file mode 100644 index 0000000000000..3bc3520b7b701 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { AlertViewSelection } from './helpers'; +import { + getButtonProperties, + getContextMenuPanels, + TABLE_ID, + TREEMAP_ID, + TREND_ID, +} from './helpers'; +import * as i18n from './translations'; + +describe('helpers', () => { + beforeEach(() => jest.resetAllMocks()); + + describe('getButtonProperties', () => { + test('it returns the expected properties when alertViewSelection is Trend', () => { + expect(getButtonProperties(TREND_ID)).toEqual({ + 'data-test-subj': TREND_ID, + icon: 'visBarVerticalStacked', + name: i18n.TREND, + }); + }); + + test('it returns the expected properties when alertViewSelection is Table', () => { + expect(getButtonProperties(TABLE_ID)).toEqual({ + 'data-test-subj': TABLE_ID, + icon: 'visTable', + name: i18n.TABLE, + }); + }); + + test('it returns the expected properties when alertViewSelection is Treemap', () => { + expect(getButtonProperties(TREEMAP_ID)).toEqual({ + 'data-test-subj': TREEMAP_ID, + icon: 'grid', + name: i18n.TREEMAP, + }); + }); + }); + + describe('getContextMenuPanels', () => { + const alertViewSelections: AlertViewSelection[] = ['trend', 'table', 'treemap']; + const setAlertViewSelection = jest.fn(); + + alertViewSelections.forEach((alertViewSelection) => { + test(`it returns the expected panel id when alertViewSelection is '${alertViewSelection}'`, () => { + const panels = getContextMenuPanels({ + alertViewSelection, + setAlertViewSelection, + }); + + expect(panels[0].id).toEqual(0); + }); + + test(`it disables the '${alertViewSelection}' item when alertViewSelection is '${alertViewSelection}'`, () => { + const panels = getContextMenuPanels({ + alertViewSelection, + setAlertViewSelection, + }); + + const item = panels[0].items?.find((x) => x['data-test-subj'] === alertViewSelection); + + expect(item?.disabled).toBe(true); + }); + + test(`it invokes setAlertViewSelection the '${alertViewSelection}' item when alertViewSelection is '${alertViewSelection}'`, () => { + const panels = getContextMenuPanels({ + alertViewSelection, + setAlertViewSelection, + }); + + const item = panels[0].items?.find((x) => x['data-test-subj'] === alertViewSelection); + + expect(item?.disabled).toBe(true); + }); + + test(`it enables all other items when alertViewSelection is '${alertViewSelection}'`, () => { + const panels = getContextMenuPanels({ + alertViewSelection, + setAlertViewSelection, + }); + + const otherItems = panels[0].items?.filter( + (x) => x['data-test-subj'] !== alertViewSelection + ); + + otherItems?.forEach((x) => { + expect(x.disabled).toBe(false); + }); + }); + + test(`onClick invokes setAlertViewSelection with '${alertViewSelection}' item when alertViewSelection is '${alertViewSelection}'`, () => { + const panels = getContextMenuPanels({ + alertViewSelection, + setAlertViewSelection, + }); + + const item = panels[0].items?.find((x) => x['data-test-subj'] === alertViewSelection); + (item?.onClick as () => void)(); + + expect(setAlertViewSelection).toBeCalledWith(alertViewSelection); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.ts new file mode 100644 index 0000000000000..cf78f58dd8ab2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; + +import * as i18n from './translations'; + +export const TABLE_ID = 'table'; +export const TREND_ID = 'trend'; +export const TREEMAP_ID = 'treemap'; + +export type AlertViewSelection = 'trend' | 'table' | 'treemap'; + +export interface ButtonProperties { + 'data-test-subj': string; + icon: string; + name: string; +} + +export const getButtonProperties = (alertViewSelection: AlertViewSelection): ButtonProperties => { + const table = { 'data-test-subj': alertViewSelection, icon: 'visTable', name: i18n.TABLE }; + + switch (alertViewSelection) { + case TABLE_ID: + return table; + case TREND_ID: + return { + 'data-test-subj': alertViewSelection, + icon: 'visBarVerticalStacked', + name: i18n.TREND, + }; + case TREEMAP_ID: + return { 'data-test-subj': alertViewSelection, icon: 'grid', name: i18n.TREEMAP }; + default: + return table; + } +}; + +export const getContextMenuPanels = ({ + alertViewSelection, + setAlertViewSelection, +}: { + alertViewSelection: AlertViewSelection; + setAlertViewSelection: (alertViewSelection: AlertViewSelection) => void; +}): EuiContextMenuPanelDescriptor[] => [ + { + id: 0, + items: [ + { + ...getButtonProperties('table'), + disabled: alertViewSelection === 'table', + onClick: () => setAlertViewSelection('table'), + }, + { + ...getButtonProperties('trend'), + disabled: alertViewSelection === 'trend', + onClick: () => setAlertViewSelection('trend'), + }, + { + ...getButtonProperties('treemap'), + disabled: alertViewSelection === 'treemap', + onClick: () => setAlertViewSelection('treemap'), + }, + ], + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.test.tsx new file mode 100644 index 0000000000000..779069832d44e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TestProviders } from '../../../../../common/mock'; +import { ChartSelect } from '.'; + +describe('ChartSelect', () => { + test('it renders the chart select button', () => { + render( + + + + ); + + expect(screen.getByTestId('chartSelect')).toBeInTheDocument(); + }); + + test('it invokes `setAlertViewSelection` with the expected value when a chart is selected', () => { + const setAlertViewSelection = jest.fn(); + + render( + + + + ); + + const selectButton = screen.getByTestId('chartSelect'); + selectButton.click(); + + const treemapMenuItem = screen.getByTestId('treemap'); + treemapMenuItem.click(); + + expect(setAlertViewSelection).toBeCalledWith('treemap'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.tsx new file mode 100644 index 0000000000000..87f799de9e0e8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; +import { EuiButton, EuiContextMenu, EuiIcon, EuiPopover } from '@elastic/eui'; +import React, { useCallback, useMemo, useState } from 'react'; +import styled from 'styled-components'; + +import type { AlertViewSelection } from './helpers'; +import { getButtonProperties, getContextMenuPanels } from './helpers'; + +interface Props { + alertViewSelection: AlertViewSelection; + setAlertViewSelection: (alertViewSelection: AlertViewSelection) => void; +} + +const ChartTypeIcon = styled(EuiIcon)` + margin-right: ${({ theme }) => theme.eui.euiSizeS}; +`; + +const ChartSelectComponent: React.FC = ({ + alertViewSelection, + setAlertViewSelection, +}: Props) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + const onButtonClick = useCallback(() => setIsPopoverOpen((currentVal) => !currentVal), []); + + const button = useMemo(() => { + const buttonProperties = getButtonProperties(alertViewSelection); + + return ( + + + {buttonProperties.name} + + ); + }, [alertViewSelection, onButtonClick]); + + const panels: EuiContextMenuPanelDescriptor[] = useMemo( + () => getContextMenuPanels({ alertViewSelection, setAlertViewSelection }), + [alertViewSelection, setAlertViewSelection] + ); + + return ( + + + + ); +}; + +ChartSelectComponent.displayName = 'ChartSelectComponent'; + +export const ChartSelect = React.memo(ChartSelectComponent); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/translations.ts new file mode 100644 index 0000000000000..8a3f7d45ab84c --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/translations.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 { i18n } from '@kbn/i18n'; + +export const TABLE = i18n.translate('xpack.securitySolution.components.chartSelect.tableOption', { + defaultMessage: 'Table', +}); + +export const TREND = i18n.translate('xpack.securitySolution.components.chartSelect.trendOption', { + defaultMessage: 'Trend', +}); + +export const TREEMAP = i18n.translate( + 'xpack.securitySolution.components.chartSelect.treemapOption', + { + defaultMessage: 'Treemap', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.test.tsx new file mode 100644 index 0000000000000..dcf77449da7c2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.test.tsx @@ -0,0 +1,233 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { useAlertsLocalStorage } from './alerts_local_storage'; +import { mockBrowserFields } from '../../../../common/containers/source/mock'; +import { useSourcererDataView } from '../../../../common/containers/sourcerer'; +import { TestProviders } from '../../../../common/mock'; +import { ChartPanels } from '.'; + +jest.mock('./alerts_local_storage'); + +jest.mock('../../../../common/containers/sourcerer'); + +jest.mock('react-router-dom', () => { + const originalModule = jest.requireActual('react-router-dom'); + + return { + ...originalModule, + useParams: jest.fn(), + useHistory: jest.fn(), + useLocation: () => ({ pathname: '' }), + }; +}); + +jest.mock('../../../../common/lib/kibana', () => { + const original = jest.requireActual('../../../../common/lib/kibana'); + + return { + ...original, + useUiSetting$: () => ['0,0.[000]'], + useKibana: () => ({ + services: { + application: { + navigateToUrl: jest.fn(), + }, + storage: { + get: jest.fn(), + set: jest.fn(), + }, + }, + }), + }; +}); + +const defaultAlertSettings = { + alertViewSelection: 'trend', + countTableStackBy0: 'kibana.alert.rule.name', + countTableStackBy1: 'host.name', + isTreemapPanelExpanded: true, + riskChartStackBy0: 'kibana.alert.rule.name', + riskChartStackBy1: 'host.name', + setAlertViewSelection: jest.fn(), + setCountTableStackBy0: jest.fn(), + setCountTableStackBy1: jest.fn(), + setIsTreemapPanelExpanded: jest.fn(), + setRiskChartStackBy0: jest.fn(), + setRiskChartStackBy1: jest.fn(), + setTrendChartStackBy: jest.fn(), + trendChartStackBy: 'kibana.alert.rule.name', +}; + +const defaultProps = { + addFilter: jest.fn(), + alertsHistogramDefaultFilters: [ + { + meta: { + alias: null, + negate: true, + disabled: false, + type: 'exists', + key: 'kibana.alert.building_block_type', + value: 'exists', + }, + query: { + exists: { + field: 'kibana.alert.building_block_type', + }, + }, + }, + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'kibana.alert.workflow_status', + params: { + query: 'open', + }, + }, + query: { + term: { + 'kibana.alert.workflow_status': 'open', + }, + }, + }, + ], + isLoadingIndexPattern: false, + query: { + query: '', + language: 'kuery', + }, + runtimeMappings: {}, + signalIndexName: '.alerts-security.alerts-default', + updateDateRangeCallback: jest.fn(), +}; + +describe('ChartPanels', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (useSourcererDataView as jest.Mock).mockReturnValue({ + indicesExist: true, + indexPattern: {}, + browserFields: mockBrowserFields, + }); + + (useAlertsLocalStorage as jest.Mock).mockReturnValue({ + ...defaultAlertSettings, + }); + }); + + test('it renders the chart selector', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('chartSelect')).toBeInTheDocument(); + }); + }); + + test('it renders the trend loading spinner when data is loading and `alertViewSelection` is trend', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('trendLoadingSpinner')).toBeInTheDocument(); + }); + }); + + test('it renders the alert histogram panel when `alertViewSelection` is trend', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('alerts-histogram-panel')).toBeInTheDocument(); + }); + }); + + test('it renders the table loading spinner when data is loading and `alertViewSelection` is table', async () => { + (useAlertsLocalStorage as jest.Mock).mockReturnValue({ + ...defaultAlertSettings, + alertViewSelection: 'table', + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('tableLoadingSpinner')).toBeInTheDocument(); + }); + }); + + test('it renders the alerts count panel when `alertViewSelection` is table', async () => { + (useAlertsLocalStorage as jest.Mock).mockReturnValue({ + ...defaultAlertSettings, + alertViewSelection: 'table', + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('alertsCountPanel')).toBeInTheDocument(); + }); + }); + + test('it renders the treemap loading spinner when data is loading and `alertViewSelection` is treemap', async () => { + (useAlertsLocalStorage as jest.Mock).mockReturnValue({ + ...defaultAlertSettings, + alertViewSelection: 'treemap', + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('treemapLoadingSpinner')).toBeInTheDocument(); + }); + }); + + test('it renders the alerts count panel when `alertViewSelection` is treemap', async () => { + (useAlertsLocalStorage as jest.Mock).mockReturnValue({ + ...defaultAlertSettings, + alertViewSelection: 'treemap', + }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('treemapPanel')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.tsx index f2f433d20e660..c89e69fd83f09 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.tsx @@ -5,22 +5,29 @@ * 2.0. */ -import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import type { Filter, Query } from '@kbn/es-query'; import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; -import { ChartContextMenu } from '../chart_context_menu'; -import { AlertsTreemapPanel } from '../../../../common/components/alerts_treemap'; -import { UpdateDateRange } from '../../../../common/components/charts/common'; +import { useAlertsLocalStorage } from './alerts_local_storage'; +import type { AlertsSettings } from './alerts_local_storage/types'; +import { ChartContextMenu } from './chart_context_menu'; +import { ChartSelect } from './chart_select'; +import { AlertsTreemapPanel } from '../../../../common/components/alerts_treemap_panel'; +import type { UpdateDateRange } from '../../../../common/components/charts/common'; import { AlertsHistogramPanel } from '../../../components/alerts_kpis/alerts_histogram_panel'; import { - CHART_HEIGHT, DEFAULT_STACK_BY_FIELD, DEFAULT_STACK_BY_FIELD1, } from '../../../components/alerts_kpis/common/config'; import { AlertsCountPanel } from '../../../components/alerts_kpis/alerts_count_panel'; +import { GROUP_BY_LABEL } from '../../../components/alerts_kpis/common/translations'; + +const TABLE_PANEL_HEIGHT = 330; // px +const TRENT_CHART_HEIGHT = 127; // px +const TREND_CHART_PANEL_HEIGHT = 256; // px const AlertsCountPanelFlexItem = styled(EuiFlexItem)` margin-left: ${({ theme }) => theme.eui.euiSizeM}; @@ -30,65 +37,53 @@ const FullHeightFlexItem = styled(EuiFlexItem)` height: 100%; `; +const ChartSelectContainer = styled.div` + margin-left: ${({ theme }) => theme.eui.euiSizeS}; +`; + export interface Props { addFilter: ({ field, value }: { field: string; value: string | number }) => void; alertsHistogramDefaultFilters: Filter[]; - countTableStackBy0: string; - countTableStackBy1: string | undefined; - expandRiskChart: boolean; isLoadingIndexPattern: boolean; query: Query; - riskChartStackBy0: string; - riskChartStackBy1: string | undefined; runtimeMappings: MappingRuntimeFields; - setCountTableStackBy0: (value: string) => void; - setCountTableStackBy1: (value: string | undefined) => void; - setExpandRiskChart: (value: boolean) => void; - setRiskChartStackBy0: (value: string) => void; - setRiskChartStackBy1: (value: string | undefined) => void; - setShowCountsInTrendChartLegend: (value: boolean) => void; - setTrendChartStackBy: (value: string) => void; signalIndexName: string | null; - showCountsInTrendChartLegend: boolean; - showCountTable: boolean; - showRiskChart: boolean; - showTrendChart: boolean; - trendChartStackBy: string; updateDateRangeCallback: UpdateDateRange; } -const ChartPanelsComponent = ({ +const ChartPanelsComponent: React.FC = ({ addFilter, alertsHistogramDefaultFilters, - countTableStackBy0, - countTableStackBy1, - expandRiskChart, isLoadingIndexPattern, query, - riskChartStackBy0, - riskChartStackBy1, runtimeMappings, - setCountTableStackBy0, - setCountTableStackBy1, - setExpandRiskChart, - setRiskChartStackBy0, - setRiskChartStackBy1, - setShowCountsInTrendChartLegend, - setTrendChartStackBy, signalIndexName, - showCountsInTrendChartLegend, - showCountTable, - showRiskChart, - showTrendChart, - trendChartStackBy, updateDateRangeCallback, }: Props) => { + const { + alertViewSelection, + countTableStackBy0, + countTableStackBy1, + isTreemapPanelExpanded, + riskChartStackBy0, + riskChartStackBy1, + setAlertViewSelection, + setCountTableStackBy0, + setCountTableStackBy1, + setIsTreemapPanelExpanded, + setRiskChartStackBy0, + setRiskChartStackBy1, + setTrendChartStackBy, + trendChartStackBy, + }: AlertsSettings = useAlertsLocalStorage(); + const updateCommonStackBy0 = useCallback( (value: string) => { + setTrendChartStackBy(value); setCountTableStackBy0(value); setRiskChartStackBy0(value); }, - [setCountTableStackBy0, setRiskChartStackBy0] + [setCountTableStackBy0, setRiskChartStackBy0, setTrendChartStackBy] ); const updateCommonStackBy1 = useCallback( @@ -99,58 +94,54 @@ const ChartPanelsComponent = ({ [setCountTableStackBy1, setRiskChartStackBy1] ); - const CountTableContextMenu = useMemo( - () => ( - - ), - [updateCommonStackBy0, updateCommonStackBy1] - ); - - const RiskChartContextMenu = useMemo( - () => ( - - ), + const chartOptionsContextMenu = useMemo( + // eslint-disable-next-line react/display-name + () => (queryId: string) => + ( + + ), [updateCommonStackBy0, updateCommonStackBy1] ); - const TrendChartContextMenu = useMemo( + const title = useMemo( () => ( - + + + ), - [setShowCountsInTrendChartLegend, setTrendChartStackBy, showCountsInTrendChartLegend] + [alertViewSelection, setAlertViewSelection] ); return ( - <> - {showTrendChart && ( +
+ {alertViewSelection === 'trend' && ( {isLoadingIndexPattern ? ( - + ) : ( )} - {showCountTable && ( + {alertViewSelection === 'table' && ( {isLoadingIndexPattern ? ( - + ) : ( )} )} - {showRiskChart && ( + {alertViewSelection === 'treemap' && ( - + {isLoadingIndexPattern ? ( + + ) : ( + + )} )} - +
); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/helpers.test.ts deleted file mode 100644 index f39d0cff2a41f..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/helpers.test.ts +++ /dev/null @@ -1,111 +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 { getChartCount, RISK_ID, TREND_ID, updateChartVisiblityOnSelection } from './helpers'; - -describe('helpers', () => { - describe('getChartCount', () => { - it('returns the expected count when alertViewSelection is risk', () => { - expect(getChartCount(RISK_ID)).toEqual(1); - }); - - it('returns the expected count when alertViewSelection is trend', () => { - expect(getChartCount(TREND_ID)).toEqual(2); - }); - }); - - describe('updateChartVisiblityOnSelection', () => { - const setAlertViewSelection = jest.fn(); - const setShowCountTable = jest.fn(); - const setShowRiskChart = jest.fn(); - const setShowTrendChart = jest.fn(); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - describe('when alertViewSelection is trend', () => { - const alertViewSelection = TREND_ID; - - it('invokes `setShowRiskChart` with false', () => { - updateChartVisiblityOnSelection({ - alertViewSelection, - setAlertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - }); - - expect(setShowRiskChart).toBeCalledWith(false); - }); - - it('invokes `setShowTrendChart` with true', () => { - updateChartVisiblityOnSelection({ - alertViewSelection, - setAlertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - }); - - expect(setShowTrendChart).toBeCalledWith(true); - }); - - it('invokes `setShowCountTable` with true', () => { - updateChartVisiblityOnSelection({ - alertViewSelection, - setAlertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - }); - - expect(setShowCountTable).toBeCalledWith(true); - }); - }); - - describe('when alertViewSelection is risk', () => { - const alertViewSelection = RISK_ID; - - it('invokes `setShowTrendChart` with false', () => { - updateChartVisiblityOnSelection({ - alertViewSelection, - setAlertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - }); - - expect(setShowTrendChart).toBeCalledWith(false); - }); - - it('invokes `setShowCountTable` with false', () => { - updateChartVisiblityOnSelection({ - alertViewSelection, - setAlertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - }); - - expect(setShowCountTable).toBeCalledWith(false); - }); - - it('invokes `setShowRiskChart` with true', () => { - updateChartVisiblityOnSelection({ - alertViewSelection, - setAlertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - }); - - expect(setShowRiskChart).toBeCalledWith(true); - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/helpers.ts deleted file mode 100644 index 341a0a38c78cc..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/helpers.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const TREND_ID = 'trend'; -export const RISK_ID = 'risk'; - -export type AlertViewSelection = 'trend' | 'risk'; - -export const updateChartVisiblityOnSelection = ({ - alertViewSelection, - setAlertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, -}: { - alertViewSelection: AlertViewSelection; - setAlertViewSelection: (alertViewSelection: AlertViewSelection) => void; - setShowCountTable: (show: boolean) => void; - setShowRiskChart: (show: boolean) => void; - setShowTrendChart: (show: boolean) => void; -}) => { - if (alertViewSelection === TREND_ID) { - setShowRiskChart(false); - setShowTrendChart(true); - setShowCountTable(true); - } else { - setShowTrendChart(false); - setShowCountTable(false); - setShowRiskChart(true); - } - - setAlertViewSelection(alertViewSelection); -}; - -export const getChartCount = (alertViewSelection: AlertViewSelection): number => - alertViewSelection === RISK_ID ? 1 : 2; // the risk view has a single chart, the trend view has two (trend & count) diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/index.tsx deleted file mode 100644 index deb75335d2d01..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/index.tsx +++ /dev/null @@ -1,158 +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 { - EuiButtonEmpty, - EuiPopover, - EuiSelectable, - EuiSelectableOption, - EuiTextColor, - EuiTitle, -} from '@elastic/eui'; -import React, { useCallback, useMemo, useState } from 'react'; -import styled from 'styled-components'; - -import { TREND_ID, RISK_ID, updateChartVisiblityOnSelection, AlertViewSelection } from './helpers'; -import * as i18n from './translations'; - -const ContainerEuiSelectable = styled.div` - width: 300px; - .euiSelectableListItem__text { - white-space: pre-wrap !important; - line-height: normal; - } -`; - -interface Props { - alertViewSelection: AlertViewSelection; - setAlertViewSelection: (alertViewSelection: AlertViewSelection) => void; - setShowCountTable: (show: boolean) => void; - setShowRiskChart: (show: boolean) => void; - setShowTrendChart: (show: boolean) => void; -} - -const ChartSelectComponent = ({ - alertViewSelection, - setAlertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, -}: Props) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const closePopover = useCallback(() => setIsPopoverOpen(false), []); - const onButtonClick = useCallback(() => setIsPopoverOpen((currentVal) => !currentVal), []); - - const button = useMemo( - () => ( - - {alertViewSelection === TREND_ID ? i18n.TREND_VIEW : i18n.ALERTS_BY_RISK_SCORE_VIEW} - - ), - [alertViewSelection, onButtonClick] - ); - - const listProps = useMemo( - () => ({ - rowHeight: 80, - showIcons: true, - }), - [] - ); - - const onChange = useCallback( - (opts: EuiSelectableOption[]) => { - const selected = opts.filter((i) => i.checked === 'on'); - if (selected.length > 0) { - const newView: AlertViewSelection = (selected[0]?.key as AlertViewSelection) ?? TREND_ID; - - updateChartVisiblityOnSelection({ - alertViewSelection: newView, - setAlertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - }); - } - setIsPopoverOpen(false); - }, - [setAlertViewSelection, setShowCountTable, setShowRiskChart, setShowTrendChart] - ); - - const options: EuiSelectableOption[] = useMemo( - () => [ - { - checked: alertViewSelection === TREND_ID ? 'on' : undefined, - key: TREND_ID, - label: i18n.TREND_VIEW, - meta: [ - { - text: i18n.TREND_VIEW_DESCRIPTION, - }, - ], - }, - { - checked: alertViewSelection === RISK_ID ? 'on' : undefined, - key: RISK_ID, - label: i18n.ALERTS_BY_RISK_SCORE_VIEW, - meta: [ - { - text: i18n.ALERTS_BY_RISK_SCORE_VIEW_DESCRIPTION, - }, - ], - }, - ], - [alertViewSelection] - ); - - const renderOption = useCallback((option) => { - return ( - <> - -
{option.label}
-
- - {option.meta[0].text} - - - ); - }, []); - - return ( - - - - {(list) => list} - - - - ); -}; - -ChartSelectComponent.displayName = 'ChartSelectComponent'; - -export const ChartSelect = React.memo(ChartSelectComponent); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/translations.ts deleted file mode 100644 index d8891775134e3..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_select/translations.ts +++ /dev/null @@ -1,68 +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 ALERTS_COUNT_LEGEND = i18n.translate( - 'xpack.securitySolution.components.chartOptions.alertsCountLegend', - { - defaultMessage: 'Alerts count', - } -); - -export const CHART_OPTIONS = i18n.translate( - 'xpack.securitySolution.components.chartOptions.chartOptionsButton', - { - defaultMessage: 'Chart options', - } -); - -export const HIDE_ALERTS_COUNT = i18n.translate( - 'xpack.securitySolution.components.chartOptions.hideAlertsCountOption', - { - defaultMessage: 'Hide alerts count', - } -); - -export const SHOW = i18n.translate('xpack.securitySolution.components.chartOptions.showLabel', { - defaultMessage: 'Show', -}); - -export const TREND_VIEW = i18n.translate( - 'xpack.securitySolution.components.chartOptions.trendViewOption', - { - defaultMessage: 'Trend view', - } -); - -export const TREND_VIEW_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.components.chartOptions.trendViewDescription', - { - defaultMessage: 'View the trend of alerts as a stacked bar chart', - } -); - -export const ALERTS_BY_RISK_SCORE_VIEW = i18n.translate( - 'xpack.securitySolution.components.chartOptions.alertsByRiskScoreViewOption', - { - defaultMessage: 'Alerts by risk score view', - } -); - -export const ALERTS_BY_RISK_SCORE_VIEW_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.components.chartOptions.alertsByRiskScoreViewDescription', - { - defaultMessage: 'View a treemap of alerts, colored by risk score', - } -); - -export const SHOW_TREND_CHART = i18n.translate( - 'xpack.securitySolution.components.chartOptions.showTrendChartLabel', - { - defaultMessage: 'Show trend chart', - } -); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index 575a7b0f4ee97..0e86d6e972f3d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -143,4 +143,18 @@ describe('DetectionEnginePageComponent', () => { expect(wrapper.find('FiltersGlobal').exists()).toBe(true); }); }); + + it('renders the chart panels', async () => { + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + expect(wrapper.find('[data-test-subj="chartPanels"]').exists()).toBe(true); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index 5022d24516161..56555e95e40ef 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -26,8 +26,6 @@ import type { Dispatch } from 'redux'; import { isTab } from '@kbn/timelines-plugin/public'; import type { Status } from '../../../../common/detection_engine/schemas/common/schemas'; -import { useAlertsLocalStorage } from './alerts_local_storage'; -import type { AlertsSettings } from './alerts_local_storage/types'; import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; import { SecurityPageName } from '../../../app/types'; import { TimelineId } from '../../../../common/types/timeline'; @@ -41,7 +39,6 @@ import { inputsSelectors } from '../../../common/store/inputs'; import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; import { AlertsTable } from '../../components/alerts_table'; import { NoApiIntegrationKeyCallOut } from '../../components/callouts/no_api_integration_callout'; -import { AlertsHistogramPanel } from '../../components/alerts_kpis/alerts_histogram_panel'; import { useUserData } from '../../components/user_info'; import { DetectionEngineNoIndex } from './detection_engine_no_index'; import { useListsConfig } from '../../containers/detection_engine/lists/use_lists_config'; @@ -64,7 +61,6 @@ import { buildShowBuildingBlockFilter, buildThreatMatchFilter, } from '../../components/alerts_table/default_config'; -import { ChartOptions } from './chart_options'; import { ChartPanels } from './chart_panels'; import { useSourcererDataView } from '../../../common/containers/sourcerer'; import { useSignalHelpers } from '../../../common/containers/sourcerer/use_signal_helpers'; @@ -73,8 +69,6 @@ import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { NeedAdminForUpdateRulesCallOut } from '../../components/callouts/need_admin_for_update_callout'; import { MissingPrivilegesCallOut } from '../../components/callouts/missing_privileges_callout'; import { useKibana } from '../../../common/lib/kibana'; -import { AlertsCountPanel } from '../../components/alerts_kpis/alerts_count_panel'; -import { CHART_HEIGHT } from '../../components/alerts_kpis/common/config'; import { AlertsTableFilterGroup, FILTER_OPEN, @@ -82,7 +76,6 @@ import { import { EmptyPage } from '../../../common/components/empty_page'; import { HeaderPage } from '../../../common/components/header_page'; import { LandingPageComponent } from '../../../common/components/landing_page'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; /** * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. @@ -280,38 +273,6 @@ const DetectionEnginePageComponent: React.FC = ({ [docLinks] ); - const alertsTreemapEnabled = useIsExperimentalFeatureEnabled('alertsTreemapEnabled'); // feature flag - const showAlertsPageTitle = useIsExperimentalFeatureEnabled('showAlertsPageTitle'); // feature flag - - const { - alertViewSelection, - countTableStackBy0, - countTableStackBy1, - expandRiskChart, - riskChartStackBy0, - riskChartStackBy1, - setAlertViewSelection, - setCountTableStackBy0, - setCountTableStackBy1, - setExpandRiskChart, - setRiskChartStackBy0, - setRiskChartStackBy1, - setShowCountsInTrendChartLegend, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - setTourStep1Completed, - setTourStep2Completed, - setTrendChartStackBy, - showCountsInTrendChartLegend, - showCountTable, - showRiskChart, - showTrendChart, - tourStep1Completed, - tourStep2Completed, - trendChartStackBy, - }: AlertsSettings = useAlertsLocalStorage(); - if (loading) { return ( @@ -370,20 +331,16 @@ const DetectionEnginePageComponent: React.FC = ({ data-test-subj="detectionsAlertsPage" > - {showAlertsPageTitle && ( - <> - - - {i18n.BUTTON_MANAGE_RULES} - - - - - )} + + + {i18n.BUTTON_MANAGE_RULES} + + + = ({ showUpdating, })} - - {alertsTreemapEnabled && ( - - - - )}
- - {alertsTreemapEnabled ? ( - - ) : ( - <> - - {isLoadingIndexPattern ? ( - - ) : ( - - )} - - - - {isLoadingIndexPattern ? ( - - ) : ( - - )} - - - )} - + diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/get_fill_color.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/get_fill_color.ts deleted file mode 100644 index fa920ad73a90b..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/get_fill_color.ts +++ /dev/null @@ -1,50 +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 { euiPaletteWarm } from '@elastic/eui'; - -import { - RISK_COLOR_LOW, - RISK_COLOR_MEDIUM, - RISK_COLOR_HIGH, - RISK_COLOR_CRITICAL, - RISK_SCORE_MEDIUM, - RISK_SCORE_HIGH, - RISK_SCORE_CRITICAL, -} from '../../components/rules/step_about_rule/data'; - -export const getFillColor = ({ - riskScore, - useWarmPalette, -}: { - riskScore: number; - useWarmPalette: boolean; -}): string => { - const MIN_RISK_SCORE = 0; - const MAX_RISK_SCORE = 100; - - const clampedScore = - riskScore < MIN_RISK_SCORE - ? MIN_RISK_SCORE - : riskScore > MAX_RISK_SCORE - ? MAX_RISK_SCORE - : riskScore; - - if (useWarmPalette) { - return euiPaletteWarm(MAX_RISK_SCORE + 1)[clampedScore]; - } - - if (clampedScore >= RISK_SCORE_CRITICAL) { - return RISK_COLOR_CRITICAL; - } else if (clampedScore >= RISK_SCORE_HIGH) { - return RISK_COLOR_HIGH; - } else if (clampedScore >= RISK_SCORE_MEDIUM) { - return RISK_COLOR_MEDIUM; - } else { - return RISK_COLOR_LOW; - } -}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts index 44477d8ad7d87..fedf119025304 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts @@ -130,27 +130,6 @@ export const USER_UNAUTHENTICATED_MSG_BODY = i18n.translate( } ); -export const VIEW_RISK_LABEL = i18n.translate( - 'xpack.securitySolution.detectionEngine.viewRiskLabel', - { - defaultMessage: 'Risk', - } -); - -export const VIEW_COUNT_LABEL = i18n.translate( - 'xpack.securitySolution.detectionEngine.viewCountLabel', - { - defaultMessage: 'Count', - } -); - -export const VIEW_TREND_LABEL = i18n.translate( - 'xpack.securitySolution.detectionEngine.viewTrendLabel', - { - defaultMessage: 'Trend', - } -); - export const ML_RULES_DISABLED_MESSAGE = i18n.translate( 'xpack.securitySolution.detectionEngine.mlRulesDisabledMessageTitle', { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/helpers.test.ts deleted file mode 100644 index 0f8a8a0dedbdf..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/helpers.test.ts +++ /dev/null @@ -1,218 +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 { getButtonText, getIconType, onToggle } from './helpers'; - -describe('helpers', () => { - describe('getIconType', () => { - describe('when alertViewSelection is trend', () => { - const alertViewSelection = 'trend'; - - it('returns the expected icon when showTrendChart is true', () => { - expect( - getIconType({ - alertViewSelection, - showRiskChart: false, - showTrendChart: true, - }) - ).toEqual('eyeClosed'); - }); - - it('returns the expected icon when showTrendChart is false', () => { - expect( - getIconType({ - alertViewSelection, - showRiskChart: false, - showTrendChart: false, - }) - ).toEqual('eye'); - }); - }); - - describe('when alertViewSelection is risk', () => { - const alertViewSelection = 'risk'; - - it('returns the expected icon when showRiskChart is true', () => { - expect( - getIconType({ - alertViewSelection, - showRiskChart: true, - showTrendChart: false, - }) - ).toEqual('eyeClosed'); - }); - - it('returns the expected icon when showRiskChart is false', () => { - expect( - getIconType({ - alertViewSelection, - showRiskChart: false, - showTrendChart: false, - }) - ).toEqual('eye'); - }); - }); - }); - - describe('getButtonText', () => { - describe('when alertViewSelection is trend', () => { - const alertViewSelection = 'trend'; - - it('returns the expected button text when showTrendChart is true', () => { - expect( - getButtonText({ - alertViewSelection, - showRiskChart: false, - showTrendChart: true, - }) - ).toEqual('Hide charts'); - }); - - it('returns the expected button text when showTrendChart is false', () => { - expect( - getButtonText({ - alertViewSelection, - showRiskChart: false, - showTrendChart: false, - }) - ).toEqual('Show charts'); - }); - }); - - describe('when alertViewSelection is risk', () => { - const alertViewSelection = 'risk'; - - it('returns the expected button text when showRiskChart is true', () => { - expect( - getButtonText({ - alertViewSelection, - showRiskChart: true, - showTrendChart: false, - }) - ).toEqual('Hide chart'); - }); - - it('returns the expected button text when showRiskChart is false', () => { - expect( - getButtonText({ - alertViewSelection, - showRiskChart: false, - showTrendChart: false, - }) - ).toEqual('Show chart'); - }); - }); - }); - - describe('onToggle', () => { - const setShowCountTable = jest.fn(); - const setShowRiskChart = jest.fn(); - const setShowTrendChart = jest.fn(); - - beforeEach(() => { - jest.resetAllMocks(); - }); - - describe('when alertViewSelection is trend', () => { - const alertViewSelection = 'trend'; - const showCountTable = false; - const showRiskChart = true; // currently showing - const showTrendChart = false; - - it('invokes `setShowTrendChart` with the opposite value of `showTrendChart`', () => { - onToggle({ - alertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - showCountTable, - showRiskChart, - showTrendChart, - }); - - expect(setShowTrendChart).toBeCalledWith(!showTrendChart); - }); - - it('invokes `setShowCountTable` with the opposite value of `showCountTable`', () => { - onToggle({ - alertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - showCountTable, - showRiskChart, - showTrendChart, - }); - - expect(setShowCountTable).toBeCalledWith(!showCountTable); - }); - - it('invokes `setShowRiskChart` with false', () => { - onToggle({ - alertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - showCountTable, - showRiskChart, - showTrendChart, - }); - - expect(setShowRiskChart).toBeCalledWith(false); - }); - }); - - describe('when alertViewSelection is risk', () => { - const alertViewSelection = 'risk'; - const showCountTable = true; // currently showing - const showRiskChart = false; - const showTrendChart = true; // also currently showing - - it('invokes `setShowTrendChart` with false', () => { - onToggle({ - alertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - showCountTable, - showRiskChart, - showTrendChart, - }); - - expect(setShowTrendChart).toBeCalledWith(false); - }); - - it('invokes `setShowCountTable` with false', () => { - onToggle({ - alertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - showCountTable, - showRiskChart, - showTrendChart, - }); - - expect(setShowCountTable).toBeCalledWith(!showCountTable); - }); - - it('invokes `setShowRiskChart` with the opposite value of `showRiskChart`', () => { - onToggle({ - alertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - showCountTable, - showRiskChart, - showTrendChart, - }); - - expect(setShowRiskChart).toBeCalledWith(!showRiskChart); - }); - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/helpers.ts deleted file mode 100644 index 59dcf0a922fcc..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/helpers.ts +++ /dev/null @@ -1,71 +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 { AlertViewSelection, getChartCount } from '../chart_select/helpers'; -import * as i18n from './translations'; - -export const getIconType = ({ - alertViewSelection, - showRiskChart, - showTrendChart, -}: { - alertViewSelection: AlertViewSelection; - showRiskChart: boolean; - showTrendChart: boolean; -}): 'eyeClosed' | 'eye' => { - if (alertViewSelection === 'trend') { - return showTrendChart ? 'eyeClosed' : 'eye'; - } else { - return showRiskChart ? 'eyeClosed' : 'eye'; - } -}; - -export const getButtonText = ({ - alertViewSelection, - showRiskChart, - showTrendChart, -}: { - alertViewSelection: AlertViewSelection; - showRiskChart: boolean; - showTrendChart: boolean; -}): string => { - const chartCount = getChartCount(alertViewSelection); - - if (alertViewSelection === 'trend') { - return showTrendChart ? i18n.HIDE_CHARTS(chartCount) : i18n.SHOW_CHARTS(chartCount); - } else { - return showRiskChart ? i18n.HIDE_CHARTS(chartCount) : i18n.SHOW_CHARTS(chartCount); - } -}; - -export const onToggle = ({ - alertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - showCountTable, - showRiskChart, - showTrendChart, -}: { - alertViewSelection: AlertViewSelection; - setShowCountTable: (show: boolean) => void; - setShowRiskChart: (show: boolean) => void; - setShowTrendChart: (show: boolean) => void; - showCountTable: boolean; - showRiskChart: boolean; - showTrendChart: boolean; -}) => { - if (alertViewSelection === 'trend') { - setShowTrendChart(!showTrendChart); - setShowCountTable(!showCountTable); - setShowRiskChart(false); - } else { - setShowTrendChart(false); - setShowCountTable(false); - setShowRiskChart(!showRiskChart); - } -}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/index.tsx deleted file mode 100644 index 0219843d5a843..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/index.tsx +++ /dev/null @@ -1,70 +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 { EuiButtonEmpty } from '@elastic/eui'; -import React, { useCallback } from 'react'; - -import { AlertViewSelection } from '../chart_select/helpers'; -import { getButtonText, getIconType, onToggle } from './helpers'; - -interface Props { - alertViewSelection: AlertViewSelection; - setShowCountTable: (show: boolean) => void; - setShowRiskChart: (show: boolean) => void; - setShowTrendChart: (show: boolean) => void; - showCountTable: boolean; - showRiskChart: boolean; - showTrendChart: boolean; -} - -const ViewChartToggleComponent = ({ - alertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - showCountTable, - showRiskChart, - showTrendChart, -}: Props) => { - const onClick = useCallback(() => { - onToggle({ - alertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - showCountTable, - showRiskChart, - showTrendChart, - }); - }, [ - alertViewSelection, - setShowCountTable, - setShowRiskChart, - setShowTrendChart, - showCountTable, - showRiskChart, - showTrendChart, - ]); - - const buttonText = getButtonText({ alertViewSelection, showRiskChart, showTrendChart }); - - return ( - - {buttonText} - - ); -}; - -ViewChartToggleComponent.displayName = 'ViewChartToggleComponent'; - -export const ViewChartToggle = React.memo(ViewChartToggleComponent); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/translations.ts deleted file mode 100644 index a7f830422c2eb..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/view_chart_toggle/translations.ts +++ /dev/null @@ -1,20 +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 HIDE_CHARTS = (chartCount: number) => - i18n.translate('xpack.securitySolution.components.viewChartToggle.hideChartsButtonText', { - values: { chartCount }, - defaultMessage: 'Hide { chartCount, plural, =1 {chart} other {charts}}', - }); - -export const SHOW_CHARTS = (chartCount: number) => - i18n.translate('xpack.securitySolution.components.viewChartToggle.showChartsButtonText', { - values: { chartCount }, - defaultMessage: 'Show { chartCount, plural, =1 {chart} other {charts}}', - });