From 0301d201ee4b6d33dcd4c21794d8a98ac1f02c2f Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Tue, 12 Jul 2022 22:09:17 -0600 Subject: [PATCH] [Security Solution] Alerts Treemap and Multi-Dimensional Alert Grouping (#126896) ## [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/178412669-f437cfe4-f819-45b5-8359-987a2a3c6645.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_ --- .../changing_alert_status.spec.ts | 10 + .../cypress/screens/alerts.ts | 6 + .../security_solution/cypress/tasks/alerts.ts | 14 +- .../cypress/tasks/rule_details.ts | 4 +- .../components/alerts_treemap/index.test.tsx | 68 ++++ .../components/alerts_treemap/index.tsx | 203 ++++++++++ .../lib/chart_palette/index.test.ts | 108 ++++++ .../alerts_treemap/lib/chart_palette/index.ts | 66 ++++ .../lib/flatten/flatten_bucket.test.ts | 54 +++ .../lib/flatten/flatten_bucket.ts | 23 ++ .../lib/flatten/get_flattened_buckets.test.ts | 148 ++++++++ .../lib/flatten/get_flattened_buckets.ts | 20 + .../lib/flatten/mocks/mock_buckets.ts | 96 +++++ .../flatten/mocks/mock_flattened_buckets.ts | 137 +++++++ .../alerts_treemap/lib/helpers.test.ts | 235 ++++++++++++ .../components/alerts_treemap/lib/helpers.ts | 69 ++++ .../alerts_treemap/lib/labels/index.test.ts | 33 ++ .../alerts_treemap/lib/labels/index.ts | 16 + .../alerts_treemap/lib/layers/index.test.ts | 202 ++++++++++ .../alerts_treemap/lib/layers/index.ts | 101 +++++ .../legend/get_flattened_legend_items.test.ts | 153 ++++++++ .../lib/legend/get_flattened_legend_items.ts | 60 +++ .../alerts_treemap/lib/legend/index.test.ts | 275 ++++++++++++++ .../alerts_treemap/lib/legend/index.ts | 120 ++++++ .../lib/mocks/mock_alert_search_response.ts | 140 +++++++ .../alerts_treemap/no_data/index.test.tsx | 21 ++ .../alerts_treemap/no_data/index.tsx | 30 ++ .../alerts_treemap/query/index.test.ts | 122 ++++++ .../components/alerts_treemap/query/index.ts | 110 ++++++ .../components/alerts_treemap/translations.ts | 38 ++ .../common/components/alerts_treemap/types.ts | 31 ++ .../alerts_treemap_panel/index.test.tsx | 245 ++++++++++++ .../components/alerts_treemap_panel/index.tsx | 204 ++++++++++ .../authentications_host_table.test.tsx.snap | 2 + .../authentications_user_table.test.tsx.snap | 2 + .../configurations/default/index.test.tsx | 78 ++++ .../configurations/default/index.tsx | 69 ++++ .../configurations/default/translations.ts | 22 ++ .../chart_settings_popover/index.test.tsx | 47 +++ .../chart_settings_popover/index.tsx | 64 ++++ .../chart_settings_popover/translations.ts | 15 + .../charts/draggable_legend.test.tsx | 24 +- .../components/charts/draggable_legend.tsx | 8 +- .../charts/draggable_legend_item.test.tsx | 30 ++ .../charts/draggable_legend_item.tsx | 54 ++- .../components/field_selection/index.test.tsx | 77 ++++ .../components/field_selection/index.tsx | 73 ++++ .../__snapshots__/index.test.tsx.snap | 6 +- .../components/header_section/index.test.tsx | 111 +++++- .../components/header_section/index.tsx | 37 +- .../common/components/inspect/index.test.tsx | 27 ++ .../common/components/inspect/index.tsx | 6 +- .../components/local_storage/helpers.test.ts | 37 ++ .../components/local_storage/helpers.ts | 22 ++ .../components/local_storage/index.test.tsx | 182 +++++++++ .../common/components/local_storage/index.tsx | 57 +++ .../matrix_histogram/index.test.tsx | 10 +- .../alerts_count_panel/alerts_count.test.tsx | 210 ++++++++--- .../alerts_count_panel/alerts_count.tsx | 137 ++++--- .../alerts_count_panel/columns.test.tsx | 77 ++++ .../alerts_count_panel/columns.tsx | 108 ++++++ .../alerts_count_panel/helpers.test.tsx | 132 +++++++ .../alerts_count_panel/helpers.tsx | 39 +- .../alerts_count_panel/index.test.tsx | 77 +++- .../alerts_kpis/alerts_count_panel/index.tsx | 85 ++++- .../mocks/mock_response_empty_field0.ts | 35 ++ .../mocks/mock_response_multi_group.ts | 61 +++ .../mocks/mock_response_single_group.ts | 146 ++++++++ .../alerts_count_panel/translations.ts | 8 +- .../alerts_kpis/alerts_count_panel/types.ts | 8 +- .../alerts_histogram.test.tsx | 71 +++- .../alerts_histogram.tsx | 8 +- .../alerts_histogram_panel/index.test.tsx | 340 ++++++++++++++++- .../alerts_histogram_panel/index.tsx | 83 ++++- .../alerts_histogram_panel/mock_data.ts | 351 ++++++++++++++++++ .../alerts_histogram_panel/translations.ts | 7 + .../alerts_histogram_panel/types.ts | 1 + .../alerts_kpis/common/components.test.tsx | 205 ++++++++++ .../alerts_kpis/common/components.tsx | 52 ++- .../components/alerts_kpis/common/config.ts | 1 + .../alerts_kpis/common/translations.ts | 14 + .../components/rules/step_about_rule/data.tsx | 30 +- .../detection_engine/alerts/types.ts | 1 + .../alerts_local_storage/constants.ts | 36 ++ .../alerts_local_storage/index.test.tsx | 36 ++ .../alerts_local_storage/index.tsx | 118 ++++++ .../alerts_local_storage/types.ts | 25 ++ .../chart_context_menu/index.test.tsx | 87 +++++ .../chart_panels/chart_context_menu/index.tsx | 54 +++ .../chart_panels/chart_select/helpers.test.ts | 90 +++++ .../chart_panels/chart_select/helpers.ts | 78 ++++ .../chart_panels/chart_select/index.test.tsx | 43 +++ .../chart_panels/chart_select/index.tsx | 73 ++++ .../chart_panels/chart_select/translations.ts | 30 ++ .../chart_panels/index.test.tsx | 233 ++++++++++++ .../detection_engine/chart_panels/index.tsx | 204 ++++++++++ .../detection_engine.test.tsx | 18 + .../detection_engine/detection_engine.tsx | 79 ++-- 98 files changed, 7449 insertions(+), 264 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/chart_palette/index.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/chart_palette/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/flatten_bucket.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/flatten_bucket.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/get_flattened_buckets.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/get_flattened_buckets.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/mocks/mock_buckets.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/mocks/mock_flattened_buckets.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/labels/index.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/labels/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/layers/index.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/layers/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/get_flattened_legend_items.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/get_flattened_legend_items.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/index.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/index.ts 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 create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/no_data/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/query/index.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/query/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/alerts_treemap/types.ts 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/configurations/default/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/translations.ts 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/index.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/common/components/local_storage/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/local_storage/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/local_storage/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/local_storage/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/columns.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/alerts_count_panel/mocks/mock_response_empty_field0.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_multi_group.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_single_group.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/constants.ts 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 create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/types.ts create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.tsx 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 create mode 100644 x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/index.tsx 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 b37cc82e7108b..913ef6bd724b4 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -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,6 +47,8 @@ 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"]'; @@ -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="table"]'; + 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..22e57d8b0c7db 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'; @@ -65,7 +68,6 @@ export const closeAlerts = () => { export const expandFirstAlertActions = () => { cy.get(TIMELINE_CONTEXT_MENU_BTN).should('be.visible'); - cy.get(TIMELINE_CONTEXT_MENU_BTN).find('svg').should('have.attr', 'data-is-loaded'); cy.get(TIMELINE_CONTEXT_MENU_BTN).first().click({ force: true }); }; @@ -125,6 +127,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/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index 7f46061d4b03c..15ef032ca4878 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -99,9 +99,9 @@ export const goToExceptionsTab = () => { }; export const editException = () => { - cy.get(EXCEPTION_ITEM_ACTIONS_BUTTON).click(); + cy.get(EXCEPTION_ITEM_ACTIONS_BUTTON).click({ force: true }); - cy.get(EDIT_EXCEPTION_BTN).click(); + cy.get(EDIT_EXCEPTION_BTN).click({ force: true }); }; export const removeException = () => { 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..1337d9234c3a1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 * as i18n from './translations'; +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')).not.toBeInTheDocument(); + }); + + test('it does NOT render the legend', () => { + expect(screen.queryByTestId('draggable-legend')).not.toBeInTheDocument(); + }); + + test('it renders the "no data" message', () => { + expect(screen.getByText(i18n.NO_DATA_LABEL)).toBeInTheDocument(); + }); + }); +}); 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 new file mode 100644 index 0000000000000..07777f3bdfdcc --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/index.tsx @@ -0,0 +1,203 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { 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 { + 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; + data: AlertSearchResponse; + maxBuckets: number; + minChartHeight?: number; + stackByField0: string; + stackByField1: string | undefined; +} + +const LegendContainer = styled.div` + margin-left: ${({ theme }) => theme.eui.euiSizeS}; +`; + +const ChartFlexItem = styled(EuiFlexItem)<{ $minChartHeight: number }>` + min-height: ${({ $minChartHeight }) => `${$minChartHeight}px`}; +`; + +const AlertsTreemapComponent: React.FC = ({ + addFilter, + data, + maxBuckets, + minChartHeight = DEFAULT_MIN_CHART_HEIGHT, + stackByField0, + stackByField1, +}: 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, + }), + [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 ( +
+ + + + + + + + + + + {legendItems.length > 0 && ( + + )} + + + +
+ ); +}; + +export const AlertsTreemap = React.memo(AlertsTreemapComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/chart_palette/index.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/chart_palette/index.test.ts new file mode 100644 index 0000000000000..8cc9e45d43b6e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/chart_palette/index.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 '../../../../../detections/components/rules/step_about_rule/data'; +import { getFillColor, getRiskScorePalette, RISK_SCORE_STEPS } from '.'; + +describe('getFillColor', () => { + describe('when using the Risk Score palette', () => { + const colorPalette = getRiskScorePalette(RISK_SCORE_STEPS); + + it('returns the expected fill color', () => { + expect(getFillColor({ riskScore: 50, colorPalette })).toEqual('#d6bf57'); + }); + + it('returns the expected fill color when risk score is zero', () => { + expect(getFillColor({ riskScore: 0, colorPalette })).toEqual('#54b399'); + }); + + it('returns the expected fill color when risk score is less than zero', () => { + expect(getFillColor({ riskScore: -1, colorPalette })).toEqual('#54b399'); + }); + + 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'); + }); + + it('returns the expected fill color when risk score is greater than RISK_SCORE_CRITICAL', () => { + 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, 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, 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, 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, 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, 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, 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/lib/flatten/flatten_bucket.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/flatten_bucket.test.ts new file mode 100644 index 0000000000000..141d73c923bb8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/flatten_bucket.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { flattenBucket } from './flatten_bucket'; +import { + bucketsWithStackByField1, + bucketsWithoutStackByField1, + maxRiskSubAggregations, +} from './mocks/mock_buckets'; + +describe('flattenBucket', () => { + it(`returns the expected flattened buckets when stackByField1 has buckets`, () => { + expect(flattenBucket({ bucket: bucketsWithStackByField1[0], maxRiskSubAggregations })).toEqual([ + { + doc_count: 34, + key: 'matches everything', + maxRiskSubAggregation: { value: 21 }, + stackByField1DocCount: 12, + stackByField1Key: 'Host-k8iyfzraq9', + }, + { + doc_count: 34, + key: 'matches everything', + maxRiskSubAggregation: { value: 21 }, + stackByField1DocCount: 10, + stackByField1Key: 'Host-ao1a4wu7vn', + }, + { + doc_count: 34, + key: 'matches everything', + maxRiskSubAggregation: { value: 21 }, + stackByField1DocCount: 7, + stackByField1Key: 'Host-3fbljiq8rj', + }, + { + doc_count: 34, + key: 'matches everything', + maxRiskSubAggregation: { value: 21 }, + stackByField1DocCount: 5, + stackByField1Key: 'Host-r4y6xi92ob', + }, + ]); + }); + + it(`returns an empty array when there's nothing to flatten, because stackByField1 is undefined`, () => { + expect( + flattenBucket({ bucket: bucketsWithoutStackByField1[0], maxRiskSubAggregations }) + ).toEqual([]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/flatten_bucket.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/flatten_bucket.ts new file mode 100644 index 0000000000000..aa7966e7f79cd --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/flatten_bucket.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 type { RawBucket, FlattenedBucket } from '../../types'; + +export const flattenBucket = ({ + bucket, + maxRiskSubAggregations, +}: { + bucket: RawBucket; + maxRiskSubAggregations: Record; +}): FlattenedBucket[] => + bucket.stackByField1?.buckets?.map((x) => ({ + doc_count: bucket.doc_count, + key: bucket.key, + maxRiskSubAggregation: bucket.maxRiskSubAggregation, + stackByField1Key: x.key, + stackByField1DocCount: x.doc_count, + })) ?? []; diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/get_flattened_buckets.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/get_flattened_buckets.test.ts new file mode 100644 index 0000000000000..f3292c0c8b2d3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/get_flattened_buckets.test.ts @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getFlattenedBuckets } from './get_flattened_buckets'; +import { + bucketsWithStackByField1, + bucketsWithoutStackByField1, + maxRiskSubAggregations, +} from './mocks/mock_buckets'; + +describe('getFlattenedBuckets', () => { + it(`returns the expected flattened buckets when stackByField1 has buckets`, () => { + expect( + getFlattenedBuckets({ + buckets: bucketsWithStackByField1, + maxRiskSubAggregations, + stackByField0: 'kibana.alert.rule.name', + }) + ).toEqual([ + { + doc_count: 34, + key: 'matches everything', + maxRiskSubAggregation: { value: 21 }, + stackByField1Key: 'Host-k8iyfzraq9', + stackByField1DocCount: 12, + }, + { + doc_count: 34, + key: 'matches everything', + maxRiskSubAggregation: { value: 21 }, + stackByField1Key: 'Host-ao1a4wu7vn', + stackByField1DocCount: 10, + }, + { + doc_count: 34, + key: 'matches everything', + maxRiskSubAggregation: { value: 21 }, + stackByField1Key: 'Host-3fbljiq8rj', + stackByField1DocCount: 7, + }, + { + doc_count: 34, + key: 'matches everything', + maxRiskSubAggregation: { value: 21 }, + stackByField1Key: 'Host-r4y6xi92ob', + stackByField1DocCount: 5, + }, + { + doc_count: 28, + key: 'EQL process sequence', + maxRiskSubAggregation: { value: 73 }, + stackByField1Key: 'Host-k8iyfzraq9', + stackByField1DocCount: 10, + }, + { + doc_count: 28, + key: 'EQL process sequence', + maxRiskSubAggregation: { value: 73 }, + stackByField1Key: 'Host-ao1a4wu7vn', + stackByField1DocCount: 7, + }, + { + doc_count: 28, + key: 'EQL process sequence', + maxRiskSubAggregation: { value: 73 }, + stackByField1Key: 'Host-3fbljiq8rj', + stackByField1DocCount: 5, + }, + { + doc_count: 28, + key: 'EQL process sequence', + maxRiskSubAggregation: { value: 73 }, + stackByField1Key: 'Host-r4y6xi92ob', + stackByField1DocCount: 3, + }, + { + doc_count: 19, + key: 'Endpoint Security', + maxRiskSubAggregation: { value: 47 }, + stackByField1Key: 'Host-ao1a4wu7vn', + stackByField1DocCount: 11, + }, + { + doc_count: 19, + key: 'Endpoint Security', + maxRiskSubAggregation: { value: 47 }, + stackByField1Key: 'Host-3fbljiq8rj', + stackByField1DocCount: 6, + }, + { + doc_count: 19, + key: 'Endpoint Security', + maxRiskSubAggregation: { value: 47 }, + stackByField1Key: 'Host-k8iyfzraq9', + stackByField1DocCount: 1, + }, + { + doc_count: 19, + key: 'Endpoint Security', + maxRiskSubAggregation: { value: 47 }, + stackByField1Key: 'Host-r4y6xi92ob', + stackByField1DocCount: 1, + }, + { + doc_count: 5, + key: 'mimikatz process started', + maxRiskSubAggregation: { value: 99 }, + stackByField1Key: 'Host-k8iyfzraq9', + stackByField1DocCount: 3, + }, + { + doc_count: 5, + key: 'mimikatz process started', + maxRiskSubAggregation: { value: 99 }, + stackByField1Key: 'Host-3fbljiq8rj', + stackByField1DocCount: 1, + }, + { + doc_count: 5, + key: 'mimikatz process started', + maxRiskSubAggregation: { value: 99 }, + stackByField1Key: 'Host-r4y6xi92ob', + stackByField1DocCount: 1, + }, + { + doc_count: 1, + key: 'Threshold rule', + maxRiskSubAggregation: { value: 99 }, + stackByField1Key: 'Host-r4y6xi92ob', + stackByField1DocCount: 1, + }, + ]); + }); + + it(`returns an empty array when there's nothing to flatten, because stackByField1 is undefined`, () => { + expect( + getFlattenedBuckets({ + buckets: bucketsWithoutStackByField1, + maxRiskSubAggregations, + stackByField0: 'kibana.alert.rule.name', + }) + ).toEqual([]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/get_flattened_buckets.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/get_flattened_buckets.ts new file mode 100644 index 0000000000000..a09a9c505a5ec --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/get_flattened_buckets.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { flattenBucket } from './flatten_bucket'; +import type { RawBucket, FlattenedBucket } from '../../types'; + +export const getFlattenedBuckets = ({ + buckets, + maxRiskSubAggregations, + stackByField0, +}: { + buckets: RawBucket[]; + maxRiskSubAggregations: Record; + stackByField0: string; +}): FlattenedBucket[] => + buckets.flatMap((bucket) => flattenBucket({ bucket, maxRiskSubAggregations })); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/mocks/mock_buckets.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/mocks/mock_buckets.ts new file mode 100644 index 0000000000000..59f1cb6de0997 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/mocks/mock_buckets.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RawBucket } from '../../../types'; + +export const bucketsWithStackByField1: RawBucket[] = [ + { + key: 'matches everything', + doc_count: 34, + maxRiskSubAggregation: { value: 21 }, + stackByField1: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'Host-k8iyfzraq9', doc_count: 12 }, + { key: 'Host-ao1a4wu7vn', doc_count: 10 }, + { key: 'Host-3fbljiq8rj', doc_count: 7 }, + { key: 'Host-r4y6xi92ob', doc_count: 5 }, + ], + }, + }, + { + key: 'EQL process sequence', + doc_count: 28, + maxRiskSubAggregation: { value: 73 }, + stackByField1: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'Host-k8iyfzraq9', doc_count: 10 }, + { key: 'Host-ao1a4wu7vn', doc_count: 7 }, + { key: 'Host-3fbljiq8rj', doc_count: 5 }, + { key: 'Host-r4y6xi92ob', doc_count: 3 }, + ], + }, + }, + { + key: 'Endpoint Security', + doc_count: 19, + maxRiskSubAggregation: { value: 47 }, + stackByField1: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'Host-ao1a4wu7vn', doc_count: 11 }, + { key: 'Host-3fbljiq8rj', doc_count: 6 }, + { key: 'Host-k8iyfzraq9', doc_count: 1 }, + { key: 'Host-r4y6xi92ob', doc_count: 1 }, + ], + }, + }, + { + key: 'mimikatz process started', + doc_count: 5, + maxRiskSubAggregation: { value: 99 }, + stackByField1: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'Host-k8iyfzraq9', doc_count: 3 }, + { key: 'Host-3fbljiq8rj', doc_count: 1 }, + { key: 'Host-r4y6xi92ob', 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-r4y6xi92ob', doc_count: 1 }], + }, + }, +]; + +export const bucketsWithoutStackByField1 = [ + { key: 'matches everything', doc_count: 34, maxRiskSubAggregation: { value: 21 } }, + { key: 'EQL process sequence', doc_count: 28, maxRiskSubAggregation: { value: 73 } }, + { key: 'Endpoint Security', doc_count: 19, maxRiskSubAggregation: { value: 47 } }, + { key: 'mimikatz process started', doc_count: 5, maxRiskSubAggregation: { value: 99 } }, + { key: 'Threshold rule', doc_count: 1, maxRiskSubAggregation: { value: 99 } }, +]; + +export const maxRiskSubAggregations = { + 'matches everything': 21, + 'EQL process sequence': 73, + 'Endpoint Security': 47, + 'mimikatz process started': 99, + 'Threshold rule': 99, +}; diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/mocks/mock_flattened_buckets.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/mocks/mock_flattened_buckets.ts new file mode 100644 index 0000000000000..0c465d6af116f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/flatten/mocks/mock_flattened_buckets.ts @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { FlattenedBucket } from '../../../types'; + +export const flattenedBuckets: FlattenedBucket[] = [ + { + doc_count: 34, + key: 'matches everything', + maxRiskSubAggregation: { value: 21 }, + stackByField1Key: 'Host-k8iyfzraq9', + stackByField1DocCount: 12, + }, + { + doc_count: 34, + key: 'matches everything', + maxRiskSubAggregation: { value: 21 }, + stackByField1Key: 'Host-ao1a4wu7vn', + stackByField1DocCount: 10, + }, + { + doc_count: 34, + key: 'matches everything', + maxRiskSubAggregation: { value: 21 }, + stackByField1Key: 'Host-3fbljiq8rj', + stackByField1DocCount: 7, + }, + { + doc_count: 34, + key: 'matches everything', + maxRiskSubAggregation: { value: 21 }, + stackByField1Key: 'Host-r4y6xi92ob', + stackByField1DocCount: 5, + }, + { + doc_count: 28, + key: 'EQL process sequence', + maxRiskSubAggregation: { value: 73 }, + stackByField1Key: 'Host-k8iyfzraq9', + stackByField1DocCount: 10, + }, + { + doc_count: 28, + key: 'EQL process sequence', + maxRiskSubAggregation: { value: 73 }, + stackByField1Key: 'Host-ao1a4wu7vn', + stackByField1DocCount: 7, + }, + { + doc_count: 28, + key: 'EQL process sequence', + maxRiskSubAggregation: { value: 73 }, + stackByField1Key: 'Host-3fbljiq8rj', + stackByField1DocCount: 5, + }, + { + doc_count: 28, + key: 'EQL process sequence', + maxRiskSubAggregation: { value: 73 }, + stackByField1Key: 'Host-r4y6xi92ob', + stackByField1DocCount: 3, + }, + { + doc_count: 19, + key: 'Endpoint Security', + maxRiskSubAggregation: { value: 47 }, + stackByField1Key: 'Host-ao1a4wu7vn', + stackByField1DocCount: 11, + }, + { + doc_count: 19, + key: 'Endpoint Security', + maxRiskSubAggregation: { value: 47 }, + stackByField1Key: 'Host-3fbljiq8rj', + stackByField1DocCount: 6, + }, + { + doc_count: 19, + key: 'Endpoint Security', + maxRiskSubAggregation: { value: 47 }, + stackByField1Key: 'Host-k8iyfzraq9', + stackByField1DocCount: 1, + }, + { + doc_count: 19, + key: 'Endpoint Security', + maxRiskSubAggregation: { value: 47 }, + stackByField1Key: 'Host-r4y6xi92ob', + stackByField1DocCount: 1, + }, + { + doc_count: 5, + key: 'mimikatz process started', + maxRiskSubAggregation: { value: 99 }, + stackByField1Key: 'Host-k8iyfzraq9', + stackByField1DocCount: 3, + }, + { + doc_count: 5, + key: 'mimikatz process started', + maxRiskSubAggregation: { value: 99 }, + stackByField1Key: 'Host-3fbljiq8rj', + stackByField1DocCount: 1, + }, + { + doc_count: 5, + key: 'mimikatz process started', + maxRiskSubAggregation: { value: 99 }, + stackByField1Key: 'Host-r4y6xi92ob', + stackByField1DocCount: 1, + }, + { + doc_count: 2, + key: 'Has Slack Interval Action', + maxRiskSubAggregation: { value: 21 }, + stackByField1Key: 'Host-k8iyfzraq9', + stackByField1DocCount: 1, + }, + { + doc_count: 2, + key: 'Has Slack Interval Action', + maxRiskSubAggregation: { value: 21 }, + stackByField1Key: 'Host-ryqxt6jjy2', + stackByField1DocCount: 1, + }, + { + doc_count: 1, + key: 'Threshold rule', + maxRiskSubAggregation: { value: 99 }, + stackByField1Key: 'Host-r4y6xi92ob', + stackByField1DocCount: 1, + }, +]; diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/helpers.test.ts new file mode 100644 index 0000000000000..9155c396390ca --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/helpers.test.ts @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { PartitionElementEvent } from '@elastic/charts'; +import { omit } from 'lodash/fp'; + +import { bucketsWithStackByField1, maxRiskSubAggregations } from './flatten/mocks/mock_buckets'; +import { + getGroupByFieldsOnClick, + getMaxRiskSubAggregations, + getUpToMaxBuckets, + hasOptionalStackByField, +} from './helpers'; + +describe('helpers', () => { + describe('getUpToMaxBuckets', () => { + it('returns the expected buckets when maxItems is smaller than the collection of buckets', () => { + expect(getUpToMaxBuckets({ buckets: bucketsWithStackByField1, maxItems: 2 })).toEqual([ + { + key: 'matches everything', + doc_count: 34, + maxRiskSubAggregation: { value: 21 }, + stackByField1: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'Host-k8iyfzraq9', doc_count: 12 }, + { key: 'Host-ao1a4wu7vn', doc_count: 10 }, + { key: 'Host-3fbljiq8rj', doc_count: 7 }, + { key: 'Host-r4y6xi92ob', doc_count: 5 }, + ], + }, + }, + { + key: 'EQL process sequence', + doc_count: 28, + maxRiskSubAggregation: { value: 73 }, + stackByField1: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { key: 'Host-k8iyfzraq9', doc_count: 10 }, + { key: 'Host-ao1a4wu7vn', doc_count: 7 }, + { key: 'Host-3fbljiq8rj', doc_count: 5 }, + { key: 'Host-r4y6xi92ob', doc_count: 3 }, + ], + }, + }, + ]); + }); + + it('returns the expected buckets when maxItems is the same length as the collection of buckets', () => { + expect( + getUpToMaxBuckets({ + buckets: bucketsWithStackByField1, + maxItems: bucketsWithStackByField1.length, + }) + ).toEqual(bucketsWithStackByField1); + }); + + it('returns the expected buckets when maxItems is greater than length as the collection of buckets', () => { + expect( + getUpToMaxBuckets({ + buckets: bucketsWithStackByField1, + maxItems: bucketsWithStackByField1.length + 50, + }) + ).toEqual(bucketsWithStackByField1); + }); + + it('returns an empty array when maxItems is zero', () => { + expect( + getUpToMaxBuckets({ + buckets: bucketsWithStackByField1, + maxItems: 0, + }) + ).toEqual([]); + }); + + it('returns an empty array when buckets is undefined', () => { + expect( + getUpToMaxBuckets({ + buckets: undefined, + maxItems: 50, + }) + ).toEqual([]); + }); + }); + + describe('getMaxRiskSubAggregations', () => { + it('returns the expected sub aggregations when all the buckets have a `maxRiskSubAggregation`', () => { + expect(getMaxRiskSubAggregations(bucketsWithStackByField1)).toEqual(maxRiskSubAggregations); + }); + + it('returns the expected sub aggregations when only some the buckets have a `maxRiskSubAggregation`', () => { + const hasMissingMaxRiskSubAggregation = bucketsWithStackByField1.map((x) => + x.key === 'EQL process sequence' ? omit('maxRiskSubAggregation', x) : x + ); + + expect(getMaxRiskSubAggregations(hasMissingMaxRiskSubAggregation)).toEqual({ + 'matches everything': 21, + 'EQL process sequence': undefined, + 'Endpoint Security': 47, + 'mimikatz process started': 99, + 'Threshold rule': 99, + }); + }); + }); + + describe('getGroupByFieldsOnClick', () => { + it('returns the expected group by fields when the event has two fields', () => { + const event: PartitionElementEvent[] = [ + [ + [ + { + smAccessorValue: '', + groupByRollup: 'mimikatz process started', + value: 5, + depth: 1, + sortIndex: 3, + path: [ + { index: 0, value: '__null_small_multiples_key__' }, + { index: 0, value: '__root_key__' }, + { index: 3, value: 'mimikatz process started' }, + ], + }, + { + smAccessorValue: '', + groupByRollup: 'Host-k8iyfzraq9', + value: 3, + depth: 2, + sortIndex: 0, + path: [ + { index: 0, value: '__null_small_multiples_key__' }, + { index: 0, value: '__root_key__' }, + { index: 3, value: 'mimikatz process started' }, + { index: 0, value: 'Host-k8iyfzraq9' }, + ], + }, + ], + { specId: 'spec_1', key: 'spec{spec_1}' }, + ], + ]; + + expect(getGroupByFieldsOnClick(event)).toEqual({ + groupByField0: 'mimikatz process started', + groupByField1: 'Host-k8iyfzraq9', + }); + }); + + it('returns the expected group by fields when the event has one field', () => { + const event: PartitionElementEvent[] = [ + [ + [ + { + smAccessorValue: '', + groupByRollup: 'matches everything', + value: 34, + depth: 1, + sortIndex: 0, + path: [ + { index: 0, value: '__null_small_multiples_key__' }, + { index: 0, value: '__root_key__' }, + { index: 0, value: 'matches everything' }, + ], + }, + ], + { specId: 'spec_1', key: 'spec{spec_1}' }, + ], + ]; + + expect(getGroupByFieldsOnClick(event)).toEqual({ + groupByField0: 'matches everything', + groupByField1: '', + }); + }); + + it('returns the expected group by fields groupByRollup is null', () => { + const event: PartitionElementEvent[] = [ + [ + [ + { + smAccessorValue: '', + groupByRollup: null, + value: 5, + depth: 1, + sortIndex: 3, + path: [ + { index: 0, value: '__null_small_multiples_key__' }, + { index: 0, value: '__root_key__' }, + { index: 3, value: 'mimikatz process started' }, + ], + }, + { + smAccessorValue: '', + groupByRollup: 'Host-k8iyfzraq9', + value: 3, + depth: 2, + sortIndex: 0, + path: [ + { index: 0, value: '__null_small_multiples_key__' }, + { index: 0, value: '__root_key__' }, + { index: 3, value: 'mimikatz process started' }, + { index: 0, value: 'Host-k8iyfzraq9' }, + ], + }, + ], + { specId: 'spec_1', key: 'spec{spec_1}' }, + ], + ]; + + expect(getGroupByFieldsOnClick(event)).toEqual({ + groupByField0: '', + groupByField1: 'Host-k8iyfzraq9', + }); + }); + }); + + describe('hasOptionalStackByField', () => { + it('returns true for a valid field', () => { + expect(hasOptionalStackByField('host.name')).toBe(true); + }); + + it('returns false when stackByField1 is undefined', () => { + expect(hasOptionalStackByField(undefined)).toBe(false); + }); + + it('returns false when stackByField1 is just whitespace', () => { + expect(hasOptionalStackByField(' ')).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/helpers.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/helpers.ts new file mode 100644 index 0000000000000..f71c49f08b05f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/helpers.ts @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + FlameElementEvent, + HeatmapElementEvent, + MetricElementEvent, + PartitionElementEvent, + WordCloudElementEvent, + XYChartElementEvent, +} from '@elastic/charts'; + +import type { RawBucket } from '../types'; + +export const getUpToMaxBuckets = ({ + buckets, + maxItems, +}: { + buckets: RawBucket[] | undefined; + maxItems: number; +}): RawBucket[] => buckets?.slice(0, maxItems) ?? []; + +export const getMaxRiskSubAggregations = ( + buckets: RawBucket[] +): Record => + buckets.reduce>( + (acc, x) => ({ ...acc, [x.key]: x.maxRiskSubAggregation?.value ?? undefined }), + {} + ); + +interface GetGroupByFieldsResult { + groupByField0: string; + groupByField1: string; +} + +export const getGroupByFieldsOnClick = ( + elements: Array< + | FlameElementEvent + | HeatmapElementEvent + | MetricElementEvent + | PartitionElementEvent + | WordCloudElementEvent + | XYChartElementEvent + > +): GetGroupByFieldsResult => { + const flattened = elements.flat(2); + + const groupByField0 = + flattened.length > 0 && 'groupByRollup' in flattened[0] && flattened[0].groupByRollup != null + ? `${flattened[0].groupByRollup}` + : ''; + + const groupByField1 = + flattened.length > 1 && 'groupByRollup' in flattened[1] && flattened[1].groupByRollup != null + ? `${flattened[1].groupByRollup}` + : ''; + + return { + groupByField0, + groupByField1, + }; +}; + +export const hasOptionalStackByField = (stackByField1: string | undefined): boolean => + stackByField1 != null && stackByField1.trim() !== ''; 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/lib/labels/index.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/labels/index.ts new file mode 100644 index 0000000000000..dc1be5a766162 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/labels/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as i18n from '../../translations'; + +export const getLabel = ({ + baseLabel, + riskScore, +}: { + baseLabel: string; + riskScore: number | null | undefined; +}): string => (riskScore != null ? `${baseLabel} ${i18n.RISK_LABEL(riskScore)}` : baseLabel); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/layers/index.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/layers/index.test.ts new file mode 100644 index 0000000000000..216de08851399 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/layers/index.test.ts @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getRiskScorePalette, RISK_SCORE_STEPS } from '../chart_palette'; +import { maxRiskSubAggregations } from '../flatten/mocks/mock_buckets'; +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( + getGroupFromPath({ + path: [ + { index: 0, value: '__null_small_multiples_key__' }, + { index: 0, value: '__root_key__' }, + { index: 0, value: 'matches everything' }, + { index: 0, value: 'Host-k8iyfzraq9' }, + ], + }) + ).toEqual('matches everything'); + }); + + it('returns undefined when path is undefined', () => { + const datumWithUndefinedPath: FillColorDatum = {}; + + expect(getGroupFromPath(datumWithUndefinedPath)).toBeUndefined(); + }); + + it('returns undefined when path is an empty array', () => { + expect( + getGroupFromPath({ + path: [], + }) + ).toBeUndefined(); + }); + + it('returns undefined when path is an array with only one value', () => { + expect( + getGroupFromPath({ + path: [{ index: 0, value: '__null_small_multiples_key__' }], + }) + ).toBeUndefined(); + }); + }); + + describe('getLayersOneDimension', () => { + it('returns the expected number of layers', () => { + expect(getLayersOneDimension({ colorPalette, maxRiskSubAggregations }).length).toEqual(1); + }); + + it('returns the expected fillLabel valueFormatter function', () => { + expect( + getLayersOneDimension({ colorPalette, maxRiskSubAggregations })[0].fillLabel.valueFormatter( + 123 + ) + ).toEqual('123'); + }); + + it('returns the expected groupByRollup function', () => { + expect( + getLayersOneDimension({ colorPalette, maxRiskSubAggregations })[0].groupByRollup({ + key: 'keystone', + }) + ).toEqual('keystone'); + }); + + it('returns the expected nodeLabel function', () => { + expect( + 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({ 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({ colorPalette, maxRiskSubAggregations })[0].shape.fillColor(dataName) + ).toEqual('#54b399'); + }); + }); + + describe('getLayersMultiDimensional', () => { + const layer0FillColor = 'transparent'; + it('returns the expected number of layers', () => { + expect( + getLayersMultiDimensional({ colorPalette, layer0FillColor, maxRiskSubAggregations }).length + ).toEqual(2); + }); + + it('returns the expected fillLabel valueFormatter function', () => { + getLayersMultiDimensional({ colorPalette, layer0FillColor, maxRiskSubAggregations }).forEach( + (x) => expect(x.fillLabel.valueFormatter(123)).toEqual('123') + ); + }); + + it('returns the expected groupByRollup function for layer 0', () => { + expect( + 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({ + colorPalette, + layer0FillColor, + maxRiskSubAggregations, + })[1].groupByRollup({ + stackByField1Key: 'host.name', + }) + ).toEqual('host.name'); + }); + + it('returns the expected nodeLabel function for layer 0', () => { + expect( + 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({ + colorPalette, + layer0FillColor, + maxRiskSubAggregations, + })[1].nodeLabel('Host-k8iyfzraq9') + ).toEqual('Host-k8iyfzraq9'); + }); + + it('returns the expected shape fillColor for layer 0', () => { + expect( + getLayersMultiDimensional({ colorPalette, layer0FillColor, maxRiskSubAggregations })[0] + .shape.fillColor + ).toEqual(layer0FillColor); + }); + + 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; + + expect( + fillColorFn({ + dataName: 'Host-k8iyfzraq9', + path: [ + { index: 0, value: '__null_small_multiples_key__' }, + { index: 0, value: '__root_key__' }, + { index: 0, value: 'mimikatz process started' }, + { index: 0, value: 'Host-k8iyfzraq9' }, + ], + }) + ).toEqual('#e7664c'); + }); + + 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( + fillColorFn({ + dataName: 'nope', + path: [ + { index: 0, value: '__null_small_multiples_key__' }, + { index: 0, value: '__root_key__' }, + { index: 0, value: 'matches everything' }, + { index: 0, value: 'nope' }, + ], + }) + ).toEqual('#54b399'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/layers/index.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/layers/index.ts new file mode 100644 index 0000000000000..09a4d95bdcb0f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/layers/index.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { Datum } from '@elastic/charts'; + +import { getFillColor } from '../chart_palette'; +import { getLabel } from '../labels'; + +export interface DataName { + dataName: string; +} + +export interface Path { + index: number; + value: string; +} + +export interface FillColorDatum { + path?: Path[]; +} + +// common functions used by getLayersOneDimension and getLayersMultiDimensional: +const valueFormatter = (d: number) => `${d}`; +const groupByRollup = (d: Datum) => d.key; + +/** + * Extracts the first group name from the data representing the second group + */ +export const getGroupFromPath = (datum: FillColorDatum): string | undefined => { + const OFFSET_FROM_END = 2; // The offset from the end of the path array containing the group + + const pathLength = datum.path?.length ?? 0; + const groupIndex = pathLength - OFFSET_FROM_END; + + return Array.isArray(datum.path) && groupIndex > 0 ? datum.path[groupIndex].value : undefined; +}; + +export const getLayersOneDimension = ({ + colorPalette, + maxRiskSubAggregations, +}: { + colorPalette: string[]; + maxRiskSubAggregations: Record; +}) => [ + { + fillLabel: { + valueFormatter, + }, + groupByRollup, + nodeLabel: (d: Datum) => getLabel({ baseLabel: d, riskScore: maxRiskSubAggregations[d] }), + shape: { + fillColor: (d: DataName) => + getFillColor({ + riskScore: maxRiskSubAggregations[d.dataName] ?? 0, + colorPalette, + }), + }, + }, +]; + +export const getLayersMultiDimensional = ({ + colorPalette, + layer0FillColor, + maxRiskSubAggregations, +}: { + colorPalette: string[]; + layer0FillColor: string; + maxRiskSubAggregations: Record; +}) => [ + { + fillLabel: { + valueFormatter, + }, + groupByRollup, + nodeLabel: (d: Datum) => getLabel({ baseLabel: d, riskScore: maxRiskSubAggregations[d] }), + shape: { + fillColor: layer0FillColor, + }, + }, + { + fillLabel: { + valueFormatter, + }, + groupByRollup: (d: Datum) => d.stackByField1Key, // different implementation than layer 0 + nodeLabel: (d: Datum) => `${d}`, + shape: { + fillColor: (d: FillColorDatum) => { + const groupFromPath = getGroupFromPath(d) ?? ''; + + return getFillColor({ + riskScore: maxRiskSubAggregations[groupFromPath] ?? 0, + colorPalette, + }); + }, + }, + }, +]; diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/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 new file mode 100644 index 0000000000000..325e2bab84d6f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/get_flattened_legend_items.test.ts @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { omit } from 'lodash/fp'; + +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'; + +describe('getFlattenedLegendItems', () => { + it('returns the expected legend items', () => { + const expected: Array> = [ + { + count: 34, + field: 'kibana.alert.rule.name', + value: 'matches everything', + }, + { + color: '#54b399', + count: 12, + field: 'host.name', + value: 'Host-k8iyfzraq9', + }, + { + color: '#54b399', + count: 10, + field: 'host.name', + value: 'Host-ao1a4wu7vn', + }, + { + color: '#54b399', + count: 7, + field: 'host.name', + value: 'Host-3fbljiq8rj', + }, + { + color: '#54b399', + count: 5, + field: 'host.name', + value: 'Host-r4y6xi92ob', + }, + { + count: 28, + field: 'kibana.alert.rule.name', + value: 'EQL process sequence', + }, + { + color: '#da8b45', + count: 10, + field: 'host.name', + value: 'Host-k8iyfzraq9', + }, + { + color: '#da8b45', + count: 7, + field: 'host.name', + value: 'Host-ao1a4wu7vn', + }, + { + color: '#da8b45', + count: 5, + field: 'host.name', + value: 'Host-3fbljiq8rj', + }, + { + color: '#da8b45', + count: 3, + field: 'host.name', + value: 'Host-r4y6xi92ob', + }, + { + count: 19, + field: 'kibana.alert.rule.name', + value: 'Endpoint Security', + }, + { + color: '#d6bf57', + count: 11, + field: 'host.name', + value: 'Host-ao1a4wu7vn', + }, + { + color: '#d6bf57', + count: 6, + field: 'host.name', + value: 'Host-3fbljiq8rj', + }, + { + color: '#d6bf57', + count: 1, + field: 'host.name', + value: 'Host-k8iyfzraq9', + }, + { + color: '#d6bf57', + count: 1, + field: 'host.name', + value: 'Host-r4y6xi92ob', + }, + { + count: 5, + field: 'kibana.alert.rule.name', + value: 'mimikatz process started', + }, + { + color: '#e7664c', + count: 3, + field: 'host.name', + value: 'Host-k8iyfzraq9', + }, + { + color: '#e7664c', + count: 1, + field: 'host.name', + value: 'Host-3fbljiq8rj', + }, + { + color: '#e7664c', + count: 1, + field: 'host.name', + value: 'Host-r4y6xi92ob', + }, + { + count: 1, + field: 'kibana.alert.rule.name', + value: 'Threshold rule', + }, + { + color: '#e7664c', + count: 1, + field: 'host.name', + value: 'Host-r4y6xi92ob', + }, + ]; + + const legendItems = getFlattenedLegendItems({ + buckets: bucketsWithStackByField1, + colorPalette: getRiskScorePalette(RISK_SCORE_STEPS), + flattenedBuckets, + maxRiskSubAggregations, + stackByField0: 'kibana.alert.rule.name', + stackByField1: 'host.name', + }); + + expect(legendItems.map((x) => omit(['render', 'dataProviderId'], x))).toEqual(expected); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/get_flattened_legend_items.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/get_flattened_legend_items.ts new file mode 100644 index 0000000000000..a904d6ef90bd0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/get_flattened_legend_items.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import 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; + stackByField1: string | undefined; +}): LegendItem[] => { + // create a map of bucket.key -> LegendItem[] from the raw buckets: + const legendMap: Record = getLegendMap({ + buckets, + colorPalette, + maxRiskSubAggregations, + stackByField0, + }); + + // append each flattened bucket to the appropriate parent in the legendMap: + const combinedLegendItems: Record = flattenedBuckets.reduce< + Record + >( + (acc, flattenedBucket) => ({ + ...acc, + [flattenedBucket.key]: [ + ...(acc[flattenedBucket.key] ?? []), + getLegendItemFromFlattenedBucket({ + colorPalette, + flattenedBucket, + maxRiskSubAggregations, + stackByField0, + stackByField1, + }), + ], + }), + legendMap + ); + + // reduce all the legend items to a single array in the same order as the raw buckets: + return buckets.reduce( + (acc, bucket) => [...acc, ...combinedLegendItems[bucket.key]], + [] + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/index.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/index.test.ts new file mode 100644 index 0000000000000..514b2743504d4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/index.test.ts @@ -0,0 +1,275 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 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 '.'; +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', + }).color + ).toBeUndefined(); + }); + + it('returns the expected color when showColor is true', () => { + expect( + getLegendItemFromRawBucket({ + bucket: bucketsWithStackByField1[0], + colorPalette, + maxRiskSubAggregations, + showColor: true, + stackByField0: 'kibana.alert.rule.name', + }).color + ).toEqual('#54b399'); + }); + + it('returns the expected count', () => { + expect( + getLegendItemFromRawBucket({ + bucket: bucketsWithStackByField1[0], + colorPalette, + maxRiskSubAggregations, + showColor: true, + stackByField0: 'kibana.alert.rule.name', + }).count + ).toEqual(34); + }); + + it('returns the expected dataProviderId', () => { + expect( + getLegendItemFromRawBucket({ + bucket: bucketsWithStackByField1[0], + colorPalette, + maxRiskSubAggregations, + showColor: true, + stackByField0: 'kibana.alert.rule.name', + }).dataProviderId + ).toContain('draggable-legend-item-treemap-kibana_alert_rule_name-matches everything-'); + }); + + it('returns the expected field', () => { + expect( + getLegendItemFromRawBucket({ + bucket: bucketsWithStackByField1[0], + colorPalette, + maxRiskSubAggregations, + showColor: true, + stackByField0: 'kibana.alert.rule.name', + }).field + ).toEqual('kibana.alert.rule.name'); + }); + + it('returns the expected value', () => { + expect( + getLegendItemFromRawBucket({ + bucket: bucketsWithStackByField1[0], + colorPalette, + maxRiskSubAggregations, + showColor: true, + stackByField0: 'kibana.alert.rule.name', + }).value + ).toEqual('matches everything'); + }); + }); + + describe('getLegendItemFromFlattenedBucket', () => { + const flattenedBucket: FlattenedBucket = { + doc_count: 34, + key: 'matches everything', + maxRiskSubAggregation: { value: 21 }, + stackByField1DocCount: 12, + stackByField1Key: 'Host-k8iyfzraq9', + }; + + it('returns the expected legend item', () => { + expect( + omit( + ['render', 'dataProviderId'], + getLegendItemFromFlattenedBucket({ + colorPalette, + flattenedBucket, + maxRiskSubAggregations, + stackByField0: 'kibana.alert.rule.name', + stackByField1: 'host.name', + }) + ) + ).toEqual({ + color: '#54b399', + count: 12, + field: 'host.name', + value: 'Host-k8iyfzraq9', + }); + }); + + it('returns the expected render function', () => { + const legendItem = getLegendItemFromFlattenedBucket({ + colorPalette, + flattenedBucket, + maxRiskSubAggregations, + stackByField0: 'kibana.alert.rule.name', + stackByField1: 'host.name', + }); + + expect(legendItem.render != null && legendItem.render()).toEqual('Host-k8iyfzraq9'); + }); + + it('returns the expected dataProviderId', () => { + const legendItem = getLegendItemFromFlattenedBucket({ + colorPalette, + flattenedBucket, + maxRiskSubAggregations, + stackByField0: 'kibana.alert.rule.name', + stackByField1: 'host.name', + }); + + expect(legendItem.dataProviderId).toContain( + 'draggable-legend-item-treemap-matches everything-Host-k8iyfzraq9-' + ); + }); + }); + + describe('getFirstGroupLegendItems', () => { + it('returns the expected legend item', () => { + expect( + getFirstGroupLegendItems({ + buckets: bucketsWithStackByField1, + colorPalette, + maxRiskSubAggregations, + stackByField0: 'kibana.alert.rule.name', + }).map((x) => omit(['render', 'dataProviderId'], x)) + ).toEqual([ + { + color: '#54b399', + count: 34, + field: 'kibana.alert.rule.name', + value: 'matches everything', + }, + { + color: '#da8b45', + count: 28, + field: 'kibana.alert.rule.name', + value: 'EQL process sequence', + }, + { + color: '#d6bf57', + count: 19, + field: 'kibana.alert.rule.name', + value: 'Endpoint Security', + }, + { + color: '#e7664c', + count: 5, + field: 'kibana.alert.rule.name', + value: 'mimikatz process started', + }, + { + color: '#e7664c', + count: 1, + field: 'kibana.alert.rule.name', + value: 'Threshold rule', + }, + ]); + }); + + it('returns the expected render function', () => { + expect( + getFirstGroupLegendItems({ + buckets: bucketsWithStackByField1, + colorPalette, + maxRiskSubAggregations, + stackByField0: 'kibana.alert.rule.name', + }).map((x) => (x.render != null ? x.render() : null)) + ).toEqual([ + 'matches everything (Risk 21)', + 'EQL process sequence (Risk 73)', + 'Endpoint Security (Risk 47)', + 'mimikatz process started (Risk 99)', + 'Threshold rule (Risk 99)', + ]); + }); + }); + + describe('getLegendMap', () => { + it('returns the expected legend item', () => { + const expected: Record< + string, + Array> + > = { + 'matches everything': [ + { + color: undefined, + count: 34, + field: 'kibana.alert.rule.name', + value: 'matches everything', + }, + ], + 'EQL process sequence': [ + { + color: undefined, + count: 28, + field: 'kibana.alert.rule.name', + value: 'EQL process sequence', + }, + ], + 'Endpoint Security': [ + { + color: undefined, + count: 19, + field: 'kibana.alert.rule.name', + value: 'Endpoint Security', + }, + ], + 'mimikatz process started': [ + { + color: undefined, + count: 5, + field: 'kibana.alert.rule.name', + value: 'mimikatz process started', + }, + ], + 'Threshold rule': [ + { + color: undefined, + count: 1, + field: 'kibana.alert.rule.name', + value: 'Threshold rule', + }, + ], + }; + + const legendMap = getLegendMap({ + buckets: bucketsWithStackByField1, + colorPalette, + maxRiskSubAggregations, + stackByField0: 'kibana.alert.rule.name', + }); + + Object.keys(expected).forEach((key) => { + expect(omit(['render', 'dataProviderId'], legendMap[key][0])).toEqual(expected[key][0]); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/index.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/index.ts new file mode 100644 index 0000000000000..77865b7d55013 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/lib/legend/index.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import uuid from 'uuid'; + +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, + stackByField0, +}: { + bucket: RawBucket; + colorPalette: string[]; + maxRiskSubAggregations: Record; + showColor: boolean; + stackByField0: string; +}): LegendItem => ({ + color: showColor + ? getFillColor({ + riskScore: maxRiskSubAggregations[bucket.key] ?? 0, + colorPalette, + }) + : undefined, + count: bucket.doc_count, + dataProviderId: escapeDataProviderId( + `draggable-legend-item-treemap-${stackByField0}-${bucket.key}-${uuid.v4()}` + ), + render: () => + getLabel({ + baseLabel: bucket.key, + riskScore: bucket.maxRiskSubAggregation?.value, + }), + field: stackByField0, + value: bucket.key, +}); + +export const getLegendItemFromFlattenedBucket = ({ + colorPalette, + flattenedBucket: { key, stackByField1Key, stackByField1DocCount }, + maxRiskSubAggregations, + stackByField0, + stackByField1, +}: { + colorPalette: string[]; + flattenedBucket: FlattenedBucket; + maxRiskSubAggregations: Record; + stackByField0: string; + stackByField1: string | undefined; +}): LegendItem => ({ + color: getFillColor({ + riskScore: maxRiskSubAggregations[key] ?? 0, + colorPalette, + }), + count: stackByField1DocCount, + dataProviderId: escapeDataProviderId( + `draggable-legend-item-treemap-${key}-${stackByField1Key}-${uuid.v4()}` + ), + render: () => `${stackByField1Key}`, + field: `${stackByField1}`, + value: `${stackByField1Key}`, +}); + +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 => + buckets.reduce>( + (acc, bucket) => ({ + ...acc, + [bucket.key]: [ + getLegendItemFromRawBucket({ + bucket, + colorPalette, + maxRiskSubAggregations, + 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..0cf39beae7b2d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/no_data/index.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import * as i18n from '../translations'; + +import { NoData } from '.'; + +describe('NoData', () => { + test('renders the expected "no data" message', () => { + render(); + + expect(screen.getByText(i18n.NO_DATA_LABEL)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/no_data/index.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/no_data/index.tsx new file mode 100644 index 0000000000000..2dba94d3c12fa --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/no_data/index.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +import * as i18n from '../translations'; + +const NoDataLabel = styled(EuiText)` + text-align: center; +`; + +const NoDataComponent: React.FC = () => ( + + + + {i18n.NO_DATA_LABEL} + + + +); + +NoDataComponent.displayName = 'NoDataComponent'; + +export const NoData = React.memo(NoDataComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_treemap/query/index.test.ts b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/query/index.test.ts new file mode 100644 index 0000000000000..9ef576cf931d6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/query/index.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { DEFAULT_STACK_BY_FIELD1_SIZE, getAlertsRiskQuery, getOptionalSubAggregation } from '.'; + +describe('query', () => { + describe('getOptionalSubAggregation', () => { + it('returns the expected sub aggregation', () => { + expect( + getOptionalSubAggregation({ + stackByField1: 'host.name', + stackByField1Size: DEFAULT_STACK_BY_FIELD1_SIZE, + }) + ).toEqual({ + stackByField1: { + terms: { + field: 'host.name', + order: { + _count: 'desc', + }, + size: 1000, + }, + }, + }); + }); + }); + + describe('getAlertsRiskQuery', () => { + it('returns the expected query', () => { + expect( + getAlertsRiskQuery({ + additionalFilters: [ + { + bool: { + must: [], + filter: [{ term: { 'kibana.alert.workflow_status': 'open' } }], + should: [], + must_not: [{ exists: { field: 'kibana.alert.building_block_type' } }], + }, + }, + ], + from: '2021-03-10T07:00:00.000Z', + runtimeMappings: {}, + stackByField0: 'kibana.alert.rule.name', + stackByField0Size: 1000, + stackByField1: 'host.name', + stackByField1Size: 1000, + riskSubAggregationField: 'signal.rule.risk_score', + to: '2022-03-11T06:13:10.002Z', + }) + ).toEqual({ + aggs: { + stackByField0: { + aggs: { + maxRiskSubAggregation: { + max: { + field: 'signal.rule.risk_score', + }, + }, + stackByField1: { + terms: { + field: 'host.name', + order: { + _count: 'desc', + }, + size: 1000, + }, + }, + }, + terms: { + field: 'kibana.alert.rule.name', + order: { + _count: 'desc', + }, + size: 1000, + }, + }, + }, + query: { + bool: { + filter: [ + { + bool: { + filter: [ + { + term: { + 'kibana.alert.workflow_status': 'open', + }, + }, + ], + must: [], + must_not: [ + { + exists: { + field: 'kibana.alert.building_block_type', + }, + }, + ], + should: [], + }, + }, + { + range: { + '@timestamp': { + gte: '2021-03-10T07:00:00.000Z', + lte: '2022-03-11T06:13:10.002Z', + }, + }, + }, + ], + }, + }, + runtime_mappings: {}, + size: 0, + }); + }); + }); +}); 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 new file mode 100644 index 0000000000000..cd73b9a5af434 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/query/index.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash/fp'; +import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +/** The maximum number of items to render */ +export const DEFAULT_STACK_BY_FIELD0_SIZE = 1000; +export const DEFAULT_STACK_BY_FIELD1_SIZE = 1000; + +interface OptionalSubAggregation { + stackByField1: { + terms: { + field: string; + order: { + _count: 'desc'; + }; + size: number; + }; + }; +} + +export const getOptionalSubAggregation = ({ + stackByField1, + stackByField1Size, +}: { + stackByField1: string | undefined; + stackByField1Size: number; +}): OptionalSubAggregation | {} => + stackByField1 != null && !isEmpty(stackByField1.trim()) + ? { + stackByField1: { + terms: { + field: stackByField1, + order: { + _count: 'desc', + }, + size: stackByField1Size, + }, + }, + } + : {}; + +export const getAlertsRiskQuery = ({ + additionalFilters = [], + from, + runtimeMappings, + stackByField0, + stackByField0Size = DEFAULT_STACK_BY_FIELD0_SIZE, + stackByField1, + stackByField1Size = DEFAULT_STACK_BY_FIELD1_SIZE, + riskSubAggregationField, + to, +}: { + additionalFilters: Array<{ + bool: { filter: unknown[]; should: unknown[]; must_not: unknown[]; must: unknown[] }; + }>; + from: string; + runtimeMappings?: MappingRuntimeFields; + stackByField0: string; + stackByField0Size?: number; + stackByField1: string | undefined; + stackByField1Size?: number; + riskSubAggregationField: string; + to: string; +}) => ({ + size: 0, + aggs: { + stackByField0: { + terms: { + field: stackByField0, + order: { + _count: 'desc', + }, + size: stackByField0Size, + }, + aggs: { + ...getOptionalSubAggregation({ + stackByField1, + stackByField1Size, + }), + maxRiskSubAggregation: { + max: { + field: riskSubAggregationField, + }, + }, + }, + }, + }, + query: { + bool: { + filter: [ + ...additionalFilters, + { + range: { + '@timestamp': { + gte: from, + lte: to, + }, + }, + }, + ], + }, + }, + runtime_mappings: runtimeMappings, +}); 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 new file mode 100644 index 0000000000000..c5566e62506a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/translations.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const NO_DATA_LABEL = i18n.translate( + 'xpack.securitySolution.components.alertsTreemap.noDataLabel', + { + defaultMessage: 'No data to display', + } +); + +export const RISK_LABEL = (riskScore: number) => + i18n.translate('xpack.securitySolution.components.alertsTreemap.riskLabel', { + values: { + riskScore, + }, + defaultMessage: '(Risk {riskScore})', + }); + +export const SUBTITLE = (maxItems: number) => + i18n.translate('xpack.securitySolution.components.alertsTreemap.subtitle', { + values: { + maxItems, + }, + defaultMessage: 'Showing the top {maxItems} most frequently occurring alerts', + }); + +export const SHOW_ALL = i18n.translate( + 'xpack.securitySolution.components.alertsTreemap.showAllButton', + { + defaultMessage: 'Show all alerts', + } +); 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 new file mode 100644 index 0000000000000..b0316952487d3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap/types.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { GenericBuckets } from '../../../../common/search_strategy/common'; + +export type RawBucket = GenericBuckets & { + maxRiskSubAggregation?: { + value?: number | null; // Elasticsearch returns `null` when a sub-aggregation cannot be computed + }; + stackByField1?: { + buckets?: GenericBuckets[]; + doc_count_error_upper_bound?: number; + sum_other_doc_count?: number; + }; +}; + +/** Defines the shape of the aggregation returned by Elasticsearch to visualize the treemap */ +export interface AlertsTreeMapAggregation { + stackByField0?: { + buckets?: RawBucket[]; + }; +} + +export type FlattenedBucket = Pick & { + stackByField1Key?: string; + stackByField1DocCount?: number; +}; 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..95f992d46aec7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/alerts_treemap_panel/index.test.tsx @@ -0,0 +1,245 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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')).not.toBeInTheDocument()); + }); + + 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')).not.toBeInTheDocument()); + }); + + 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')).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..93df193537486 --- /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 + 134; // 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 d934c117625d7..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 @@ -66,12 +66,14 @@ exports[`Authentication Host Table Component rendering it renders the host authe >
({ + 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 new file mode 100644 index 0000000000000..58b9ab25e008a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/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 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 { + onResetStackByFields: () => void; + queryId: string; +} + +export const useChartSettingsPopoverConfiguration = ({ + onResetStackByFields, + queryId, +}: Props): { + defaultInitialPanelId: string; + defaultMenuItems: EuiContextMenuPanelDescriptor[]; + isPopoverOpen: boolean; + setIsPopoverOpen: Dispatch>; +} => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { handleClick } = useInspect({ + queryId, + }); + + const defaultMenuItems: EuiContextMenuPanelDescriptor[] = useMemo( + () => [ + { + id: defaultInitialPanelId, + items: [ + { + icon: 'inspect', + name: i18n.INSPECT, + onClick: () => { + setIsPopoverOpen(false); + handleClick(); + }, + }, + { + name: i18n.RESET_GROUP_BY_FIELDS, + onClick: () => { + setIsPopoverOpen(false); + onResetStackByFields(); + }, + }, + ], + }, + ], + [handleClick, onResetStackByFields] + ); + + return { + defaultInitialPanelId, + defaultMenuItems, + isPopoverOpen, + 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 new file mode 100644 index 0000000000000..61a0e6d0904b4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/configurations/default/translations.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const INSPECT = i18n.translate( + 'xpack.securitySolution.components.chartSettingsPopover.contextMenuItems.inspectTitle', + { + defaultMessage: 'Inspect', + } +); + +export const RESET_GROUP_BY_FIELDS = i18n.translate( + 'xpack.securitySolution.components.chartSettingsPopover.contextMenuItems.resetGroupByFieldsMenuItem', + { + defaultMessage: 'Reset group by fields', + } +); 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..c6517a789859a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/index.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { CHART_SETTINGS_POPOVER_ARIA_LABEL } from './translations'; +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.getByRole('button', { name: CHART_SETTINGS_POPOVER_ARIA_LABEL }) + ).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 new file mode 100644 index 0000000000000..bfa6d0dff4a7c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/index.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import 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; + panels: EuiContextMenuPanelDescriptor[]; + setIsPopoverOpen: React.Dispatch>; +} + +const ChartSettingsPopoverComponent: React.FC = ({ + initialPanelId, + isPopoverOpen, + panels, + setIsPopoverOpen, +}: Props) => { + const onButtonClick = useCallback( + () => setIsPopoverOpen((isOpen) => !isOpen), + [setIsPopoverOpen] + ); + + const closePopover = useCallback(() => setIsPopoverOpen(false), [setIsPopoverOpen]); + + const button = useMemo( + () => ( + + ), + [onButtonClick] + ); + + return ( + + + + ); +}; + +ChartSettingsPopoverComponent.displayName = 'ChartSettingsPopoverComponent'; + +export const ChartSettingsPopover = React.memo(ChartSettingsPopoverComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/translations.ts b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/translations.ts new file mode 100644 index 0000000000000..4c9d7751e43fa --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/chart_settings_popover/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const CHART_SETTINGS_POPOVER_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.components.chartSettingsPopover.ariaLabel', + { + defaultMessage: 'Chart settings', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx index 6b7fa6fb5354b..dbfe911c3d6cf 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx @@ -13,7 +13,7 @@ import '../../mock/match_media'; import '../../mock/react_beautiful_dnd'; import { TestProviders } from '../../mock'; -import { MIN_LEGEND_HEIGHT, DraggableLegend } from './draggable_legend'; +import { DEFAULT_WIDTH, MIN_LEGEND_HEIGHT, DraggableLegend } from './draggable_legend'; import type { LegendItem } from './draggable_legend_item'; jest.mock('../../lib/kibana'); @@ -76,6 +76,28 @@ describe('DraggableLegend', () => { ); }); + 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 6c511ba874128..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 @@ -14,8 +14,9 @@ import type { LegendItem } from './draggable_legend_item'; import { DraggableLegendItem } from './draggable_legend_item'; export const MIN_LEGEND_HEIGHT = 175; +export const DEFAULT_WIDTH = 165; // px -const DraggableLegendContainer = styled.div<{ height: number }>` +const DraggableLegendContainer = styled.div<{ height: number; $minWidth: number }>` height: ${({ height }) => `${height}px`}; overflow: auto; scrollbar-width: thin; @@ -23,6 +24,7 @@ const DraggableLegendContainer = styled.div<{ height: number }>` @media only screen and (min-width: ${({ theme }) => theme.eui.euiBreakpoints.m}) { width: 165px; } + min-width: ${({ $minWidth }) => `${$minWidth}px`}; &::-webkit-scrollbar { height: ${({ theme }) => theme.eui.euiScrollBar}; @@ -44,7 +46,8 @@ const DraggableLegendContainer = styled.div<{ height: number }>` const DraggableLegendComponent: React.FC<{ height: number; legendItems: LegendItem[]; -}> = ({ height, legendItems }) => { + minWidth?: number; +}> = ({ height, legendItems, minWidth = DEFAULT_WIDTH }) => { if (legendItems.length === 0) { return null; } @@ -53,6 +56,7 @@ const DraggableLegendComponent: React.FC<{ 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 5072df82c96c6..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,6 +56,36 @@ describe('DraggableLegendItem', () => { ).toEqual(legendItem.value); }); + it('renders a custom legend item via the `render` prop when provided', () => { + const render = (fieldValuePair?: { field: string; value: string | number }) => ( +
{`${fieldValuePair?.field} - ${fieldValuePair?.value}`}
+ ); + + const customLegendItem = { ...legendItem, render }; + + wrapper = mount( + + + + ); + + expect(wrapper.find(`[data-test-subj="custom"]`).first().text()).toEqual( + `${legendItem.field} - ${legendItem.value}` + ); + }); + + 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 b61dc4331d5c1..16f1cc8c926b5 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 @@ -6,18 +6,28 @@ */ 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'; +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; + render?: (fieldValuePair?: { field: string; value: string | number }) => React.ReactNode; field: string; timelineId?: string; value: string | number; + count?: number; } /** @@ -36,7 +46,8 @@ ValueWrapper.displayName = 'ValueWrapper'; const DraggableLegendItemComponent: React.FC<{ legendItem: LegendItem; }> = ({ legendItem }) => { - const { color, dataProviderId, field, timelineId, value } = legendItem; + const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); + const { color, count, dataProviderId, field, timelineId, value } = legendItem; return ( @@ -47,18 +58,37 @@ const DraggableLegendItemComponent: React.FC<{ )} - - + - - + + + {legendItem.render == null ? ( + + ) : ( + legendItem.render({ field, value }) + )} + + + + {count != null && ( + + {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..91cb4f7b581be --- /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 '.'; +import { + GROUP_BY_LABEL, + GROUP_BY_TOP_LABEL, +} from '../../../detections/components/alerts_kpis/common/translations'; + +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.getByRole('combobox', { name: GROUP_BY_LABEL })).toBeInTheDocument(); + }); + + test('it renders the (second) "Group by top" selection', () => { + render( + + + + ); + + expect(screen.getByRole('combobox', { name: GROUP_BY_TOP_LABEL })).toBeInTheDocument(); + }); + + test('it renders the chart options context menu using the provided `uniqueQueryId`', () => { + const propsWithContextMenu = { + ...defaultProps, + chartOptionsContextMenu: (queryId: string) =>
{queryId}
, + }; + + render( + + + + ); + + expect(screen.getByText(defaultProps.uniqueQueryId)).toBeInTheDocument(); + }); + + test('it does NOT render the chart options context menu when `chartOptionsContextMenu` is undefined', () => { + render( + + + + ); + + expect(screen.queryByText(defaultProps.uniqueQueryId)).not.toBeInTheDocument(); + }); +}); 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..275e02bf21ce8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/field_selection/index.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 058f38c944347..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,13 +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 cadd0ec41b429..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,7 +51,9 @@ const Header = styled.header` Header.displayName = 'Header'; export interface HeaderSectionProps extends HeaderProps { + alignHeader?: 'center' | 'baseline' | 'stretch' | 'flexStart' | 'flexEnd'; children?: React.ReactNode; + outerDirection?: 'row' | 'rowReverse' | 'column' | 'columnReverse' | undefined; growLeftSplit?: boolean; headerFilters?: string | React.ReactNode; height?: number; @@ -70,9 +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, + outerDirection = 'column', growLeftSplit = true, headerFilters, height, @@ -108,10 +128,16 @@ const HeaderSectionComponent: React.FC = ({ className={classNames} $hideSubtitle={hideSubtitle} > - - + + @@ -158,13 +184,14 @@ const HeaderSectionComponent: React.FC = ({ - {id && showInspectButton && toggleStatus && ( + {id && toggleStatus && ( )} 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: TREEMAP_CATEGORY, + page: ALERTS_PAGE, + setting: STACK_BY_SETTING_NAME, + }) + ).toEqual(`${ALERTS_PAGE}.${TREEMAP_CATEGORY}.${STACK_BY_SETTING_NAME}`); + }); + }); + + describe('isDefaultWhenEmptyString', () => { + it('returns true when value is empty', () => { + expect(isDefaultWhenEmptyString('')).toBe(true); + }); + + it('returns false when value is non-empty', () => { + 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 new file mode 100644 index 0000000000000..2297fe2652286 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/local_storage/helpers.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { isEmpty } from 'lodash/fp'; + +/** Returns a settings key, typically used with local storage */ +export const getSettingKey = ({ + category, + page, + setting, +}: { + category: string; + page: string; + setting: string; +}): string => `${page}.${category}.${setting}`; + +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 new file mode 100644 index 0000000000000..d7dbfdeb5d026 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/local_storage/index.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 { 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 { + ALERTS_PAGE, + EXPAND_SETTING_NAME, + STACK_BY_SETTING_NAME, + 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 '.'; + +const mockedUseKibana = { + ...mockUseKibana(), + services: { + ...mockUseKibana().services, + storage: { + ...mockUseKibana().services.storage, + get: jest.fn(), + set: jest.fn(), + }, + }, +}; + +jest.mock('../../lib/kibana', () => { + return { + useKibana: () => mockedUseKibana, + }; +}); + +describe('useLocalStorage', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('it returns the expected value from local storage', async () => { + const mockLocalStorageValue = 'baz'; + mockedUseKibana.services.storage.get.mockReturnValue(mockLocalStorageValue); + + const { result } = renderHook(() => + useLocalStorage({ + defaultValue: DEFAULT_STACK_BY_FIELD, + key: getSettingKey({ + category: TREEMAP_CATEGORY, + page: ALERTS_PAGE, + setting: STACK_BY_SETTING_NAME, + }), + plugin: APP_ID, + }) + ); + + const [riskVisualizationStackBy] = result.current; + + expect(riskVisualizationStackBy).toEqual(mockLocalStorageValue); + }); + + test('it returns the expected default when the value in local storage is undefined', async () => { + const { result } = renderHook(() => + useLocalStorage({ + defaultValue: true, + key: getSettingKey({ + category: TREEMAP_CATEGORY, + page: ALERTS_PAGE, + setting: EXPAND_SETTING_NAME, + }), + plugin: APP_ID, + }) + ); + + const [riskVisualizationStackBy] = result.current; + + expect(riskVisualizationStackBy).toEqual(true); + }); + + test('it returns the expected default when the type of the value in local storage does not match the default', async () => { + const mockLocalStorageValue = 'abcd'; // <-- this type does not match the type of the expected default value + mockedUseKibana.services.storage.get.mockReturnValue(mockLocalStorageValue); + + const { result } = renderHook(() => + useLocalStorage({ + defaultValue: 1234, + key: getSettingKey({ + category: TREEMAP_CATEGORY, + page: ALERTS_PAGE, + setting: EXPAND_SETTING_NAME, + }), + plugin: APP_ID, + }) + ); + + const [riskVisualizationStackBy] = result.current; + + expect(riskVisualizationStackBy).toEqual(1234); + }); + + describe('setValue', () => { + test('it updates local storage', async () => { + const newValue = 'bazfact'; + + const { result } = renderHook(() => + useLocalStorage({ + defaultValue: DEFAULT_STACK_BY_FIELD, + key: getSettingKey({ + category: TREEMAP_CATEGORY, + page: ALERTS_PAGE, + setting: STACK_BY_SETTING_NAME, + }), + plugin: APP_ID, + isInvalidDefault: isDefaultWhenEmptyString, + }) + ); + + const [_, setValue] = result.current; + + act(() => setValue(newValue)); + + expect(mockedUseKibana.services.storage.set).toBeCalledWith( + `${APP_ID}.${getSettingKey({ + category: TREEMAP_CATEGORY, + page: ALERTS_PAGE, + setting: STACK_BY_SETTING_NAME, + })}`, + newValue + ); + }); + }); + + describe('isInvalidDefault', () => { + test('it returns the expected default value when the value in local storage matches the value returned by isInvalidDefault', async () => { + const mockLocalStorageValue = ''; // <-- this matches the value specified by isInvalidDefault + mockedUseKibana.services.storage.get.mockReturnValue(mockLocalStorageValue); + + const { result } = renderHook(() => + useLocalStorage({ + defaultValue: DEFAULT_STACK_BY_FIELD, + key: getSettingKey({ + category: TREEMAP_CATEGORY, + page: ALERTS_PAGE, + setting: STACK_BY_SETTING_NAME, + }), + plugin: APP_ID, + isInvalidDefault: isDefaultWhenEmptyString, + }) + ); + + const [riskVisualizationStackBy] = result.current; + + expect(riskVisualizationStackBy).toEqual(DEFAULT_STACK_BY_FIELD); + }); + + test('it returns the value from local storage when it does NOT match the value returned by isInvalidDefault', async () => { + const mockLocalStorageValue = 'totally valid'; // <-- this matches the value specified by isInvalidDefault + mockedUseKibana.services.storage.get.mockReturnValue(mockLocalStorageValue); + + const { result } = renderHook(() => + useLocalStorage({ + defaultValue: DEFAULT_STACK_BY_FIELD, + key: getSettingKey({ + category: TREEMAP_CATEGORY, + page: ALERTS_PAGE, + setting: STACK_BY_SETTING_NAME, + }), + plugin: APP_ID, + isInvalidDefault: isDefaultWhenEmptyString, + }) + ); + + const [riskVisualizationStackBy] = result.current; + + expect(riskVisualizationStackBy).toEqual(mockLocalStorageValue); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/local_storage/index.tsx b/x-pack/plugins/security_solution/public/common/components/local_storage/index.tsx new file mode 100644 index 0000000000000..8422b4dc96f28 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/local_storage/index.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useState } from 'react'; + +import { APP_ID } from '../../../../common/constants'; + +import { useKibana } from '../../lib/kibana'; +interface Props { + defaultValue: T; + isInvalidDefault?: (value: T) => boolean; + key: string; + plugin?: string; +} + +/** Reads and writes settings from local storage */ +export const useLocalStorage = ({ + defaultValue, + key, + plugin = APP_ID, + isInvalidDefault, +}: Props): [T, (value: T) => void] => { + const { storage } = useKibana().services; + const [initialized, setInitialized] = useState(false); + const [_value, _setValue] = useState(defaultValue); + + const readValueFromLocalStorage = useCallback(() => { + const value = storage.get(`${plugin}.${key}`); + + const valueAndDefaultTypesAreDifferent = typeof value !== typeof defaultValue; + const valueIsInvalid = isInvalidDefault != null && isInvalidDefault(value); + + _setValue(valueAndDefaultTypesAreDifferent || valueIsInvalid ? defaultValue : value); + }, [defaultValue, isInvalidDefault, key, plugin, storage]); + + const setValue = useCallback( + (value: T) => { + storage.set(`${plugin}.${key}`, value); + + _setValue(value); + }, + [key, plugin, storage] + ); + + useEffect(() => { + if (!initialized) { + readValueFromLocalStorage(); + setInitialized(true); + } + }, [initialized, readValueFromLocalStorage]); + + return [_value, setValue]; +}; 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 3fae45e1e0086..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 @@ -14,6 +14,15 @@ import { TestProviders } from '../../../../common/mock'; import { DragDropContextWrapper } from '../../../../common/components/drag_and_drop/drag_drop_context_wrapper'; import { mockBrowserFields } from '../../../../common/containers/source/mock'; import type { AlertsCountAggregation } from './types'; +import { emptyStackByField0Response } from './mocks/mock_response_empty_field0'; +import { + buckets as oneGroupByResponseBuckets, + mockMultiGroupResponse, +} from './mocks/mock_response_multi_group'; +import { + buckets as twoGroupByResponseBuckets, + singleGroupResponse, +} from './mocks/mock_response_single_group'; jest.mock('../../../../common/lib/kibana'); const mockDispatch = jest.fn(); @@ -37,60 +46,173 @@ describe('AlertsCount', () => { } loading={false} - selectedStackByOption={'test_selected_field'} + stackByField0={'test_selected_field'} + stackByField1={undefined} /> ); expect(wrapper.find('[data-test-subj="alertsCountTable"]').exists()).toBeTruthy(); }); - it('renders the given alert item', () => { - const alertFiedlKey = 'test_stack_by_test_key'; - const alertFiedlCount = 999; - const alertData = { - took: 0, - timeout: false, - hits: { - hits: [], - sequences: [], - events: [], - total: { - relation: 'eq', - value: 0, - }, - }, - _shards: { - failed: 0, - skipped: 0, - successful: 1, - total: 1, - }, - aggregations: { - alertsByGroupingCount: { - buckets: [ - { - key: alertFiedlKey, - doc_count: alertFiedlCount, - }, - ], - }, - alertsByGrouping: { buckets: [] }, - }, - } as AlertSearchResponse; - + it('renders the expected table body message when stackByField0 is an empty string', () => { const wrapper = mount( - - - + ); - expect(wrapper.text()).toContain(alertFiedlKey); - expect(wrapper.text()).toContain(alertFiedlCount); + expect(wrapper.find('[data-test-subj="alertsCountTable"] tbody').text()).toEqual( + 'No items found' + ); + }); + + describe('one group by field', () => { + oneGroupByResponseBuckets.forEach((bucket, i) => { + it(`renders the expected stackByField0 column text for bucket '${bucket.key}'`, () => { + const wrapper = mount( + + + + + + ); + + expect( + wrapper + .find(`[data-test-subj="stackByField0Key"] > div.euiTableCellContent`) + .hostNodes() + .at(i) + .text() + ).toEqual(bucket.key); + }); + }); + + oneGroupByResponseBuckets.forEach((bucket, i) => { + it(`renders the expected doc_count column value for bucket '${bucket.key}'`, () => { + const wrapper = mount( + + + + + + ); + + expect( + wrapper + .find(`[data-test-subj="doc_count"] > div.euiTableCellContent`) + .hostNodes() + .at(i) + .text() + ).toEqual(`${bucket.doc_count}`); + }); + }); + }); + + describe('two group by fields: stackByField0 column', () => { + let resultRow = 0; + + twoGroupByResponseBuckets.forEach((bucket) => { + bucket.stackByField1.buckets.forEach((b) => { + it(`renders the expected stackByField0 column text for stackByField0: '${bucket.key}', stackByField1 '${b.key}'`, () => { + const wrapper = mount( + + + + + + ); + + expect( + wrapper + .find(`[data-test-subj="stackByField0Key"] > div.euiTableCellContent`) + .hostNodes() + .at(resultRow++) + .text() + ).toEqual(bucket.key); + }); + }); + }); + }); + + describe('two group by fields: stackByField1 column', () => { + let resultRow = 0; + + twoGroupByResponseBuckets.forEach((bucket) => { + bucket.stackByField1.buckets.forEach((b, i) => { + it(`renders the expected stackByField1 column text for stackByField0: '${bucket.key}', stackByField1 '${b.key}'`, () => { + const wrapper = mount( + + + + + + ); + + expect( + wrapper + .find(`[data-test-subj="stackByField1Key"] > div.euiTableCellContent`) + .hostNodes() + .at(resultRow++) + .text() + ).toEqual(b.key); + }); + }); + }); + }); + + describe('two group by fields: stackByField1DocCount column', () => { + let resultRow = 0; + + twoGroupByResponseBuckets.forEach((bucket) => { + bucket.stackByField1.buckets.forEach((b, i) => { + it(`renders the expected doc_count column value for stackByField0: '${bucket.key}', stackByField1 '${b.key}'`, () => { + const wrapper = mount( + + + + + + ); + + expect( + wrapper + .find(`[data-test-subj="stackByField1DocCount"] > div.euiTableCellContent`) + .hostNodes() + .at(resultRow++) + .text() + ).toEqual(`${b.doc_count}`); + }); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.tsx index 730ae0405c722..4f538b64b31ee 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/alerts_count.tsx @@ -5,85 +5,100 @@ * 2.0. */ -import type { EuiBasicTableColumn } from '@elastic/eui'; -import { EuiProgress, EuiInMemoryTable } from '@elastic/eui'; -import React, { memo, useMemo } from 'react'; +import { EuiInMemoryTable } from '@elastic/eui'; +import { isEmpty } from 'lodash/fp'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; -import numeral from '@elastic/numeral'; + import { useUiSetting$ } from '../../../../common/lib/kibana'; import { DEFAULT_NUMBER_FORMAT } from '../../../../../common/constants'; -import * as i18n from './translations'; -import { DefaultDraggable } from '../../../../common/components/draggables'; -import type { GenericBuckets } from '../../../../../common/search_strategy'; import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; import type { AlertsCountAggregation } from './types'; +import { + getMaxRiskSubAggregations, + getUpToMaxBuckets, +} from '../../../../common/components/alerts_treemap/lib/helpers'; +import { getFlattenedBuckets } from '../../../../common/components/alerts_treemap/lib/flatten/get_flattened_buckets'; +import type { + FlattenedBucket, + RawBucket, +} from '../../../../common/components/alerts_treemap/types'; +import { + getMultiGroupAlertsCountTableColumns, + getSingleGroupByAlertsCountTableColumns, +} from './columns'; +import { DEFAULT_STACK_BY_FIELD0_SIZE } from './helpers'; interface AlertsCountProps { loading: boolean; - data: AlertSearchResponse | null; - selectedStackByOption: string; + data: AlertSearchResponse; + stackByField0: string; + stackByField1: string | undefined; } const Wrapper = styled.div` margin-top: -${({ theme }) => theme.eui.euiSizeS}; `; -const getAlertsCountTableColumns = ( - selectedStackByOption: string, - defaultNumberFormat: string -): Array> => { - return [ - { - field: 'key', - name: selectedStackByOption, - truncateText: false, - render: function DraggableStackOptionField(value: string) { - return ( - - ); - }, - }, - { - field: 'doc_count', - name: i18n.COUNT_TABLE_COLUMN_TITLE, - sortable: true, - textOnly: true, - dataType: 'number', - render: (item: string) => numeral(item).format(defaultNumberFormat), - }, - ]; -}; - -export const AlertsCount = memo(({ loading, selectedStackByOption, data }) => { +export const AlertsCountComponent: React.FC = ({ + data, + loading, + stackByField0, + stackByField1, +}) => { const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); - const listItems: GenericBuckets[] = data?.aggregations?.alertsByGroupingCount?.buckets ?? []; + const tableColumns = useMemo( - () => getAlertsCountTableColumns(selectedStackByOption, defaultNumberFormat), - [selectedStackByOption, defaultNumberFormat] + () => + isEmpty(stackByField1?.trim()) + ? getSingleGroupByAlertsCountTableColumns({ + defaultNumberFormat, + stackByField0, + }) + : getMultiGroupAlertsCountTableColumns({ + defaultNumberFormat, + stackByField0, + stackByField1, + }), + [defaultNumberFormat, stackByField0, stackByField1] ); - return ( - <> - {loading && } + 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 items: FlattenedBucket[] = useMemo( + () => + isEmpty(stackByField1?.trim()) + ? buckets + : getFlattenedBuckets({ + buckets, + maxRiskSubAggregations, + stackByField0, + }), + [buckets, maxRiskSubAggregations, stackByField0, stackByField1] + ); + + return ( + + + ); -}); +}; + +AlertsCountComponent.displayName = 'AlertsCountComponent'; -AlertsCount.displayName = 'AlertsCount'; +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 new file mode 100644 index 0000000000000..7dfb3170e43e0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/columns.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { 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 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, + stackByField0, +}: { + defaultNumberFormat: string; + stackByField0: string; +}): Array> => [ + { + 'data-test-subj': 'stackByField0Key', + field: 'key', + name: stackByField0, + render: function DraggableStackOptionField(value: string) { + return ( + + ); + }, + truncateText: false, + }, + { + 'data-test-subj': 'doc_count', + dataType: 'number', + field: 'doc_count', + name: i18n.COUNT_TABLE_COLUMN_TITLE, + render: (item: string) => numeral(item).format(defaultNumberFormat), + sortable: true, + textOnly: true, + }, +]; + +export const getMultiGroupAlertsCountTableColumns = ({ + defaultNumberFormat, + stackByField0, + stackByField1, +}: { + defaultNumberFormat: string; + stackByField0: string; + stackByField1: string | undefined; +}): Array> => [ + { + 'data-test-subj': 'stackByField0Key', + field: 'key', + name: i18n.COLUMN_LABEL({ fieldName: stackByField0, topN: DEFAULT_STACK_BY_FIELD0_SIZE }), + render: function DraggableStackOptionField(value: string) { + return ( + + ); + }, + truncateText: false, + }, + { + 'data-test-subj': 'stackByField1Key', + field: 'stackByField1Key', + name: i18n.COLUMN_LABEL({ fieldName: stackByField1 ?? '', topN: DEFAULT_STACK_BY_FIELD1_SIZE }), + render: function DraggableStackOptionField(value: string) { + return ( + + ); + }, + truncateText: false, + }, + { + 'data-test-subj': 'stackByField1DocCount', + dataType: 'number', + field: 'stackByField1DocCount', + name: i18n.COUNT_TABLE_COLUMN_TITLE, + render: (item: string) => numeral(item).format(defaultNumberFormat), + sortable: true, + textOnly: true, + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.test.tsx new file mode 100644 index 0000000000000..e651f17d59157 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.test.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getAlertsCountQuery } from './helpers'; + +const stackByField0 = 'kibana.alert.rule.name'; +const stackByField1 = 'host.name'; +const from = '2022-07-08T06:00:00.000Z'; +const to = '2022-07-09T05:59:59.999Z'; +const additionalFilters = [ + { + bool: { + must: [], + filter: [ + { + term: { + 'kibana.alert.workflow_status': 'open', + }, + }, + ], + should: [], + must_not: [ + { + exists: { + field: 'kibana.alert.building_block_type', + }, + }, + ], + }, + }, +]; +const runtimeMappings = {}; + +describe('helpers', () => { + 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/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.tsx index 509053e8244fe..d052af0ae0b9e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/helpers.tsx @@ -6,27 +6,44 @@ */ import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../common/constants'; +import { getOptionalSubAggregation } from '../../../../common/components/alerts_treemap/query'; -export const getAlertsCountQuery = ( - stackByField: string, - from: string, - to: string, +export const DEFAULT_STACK_BY_FIELD0_SIZE = 1000; +export const DEFAULT_STACK_BY_FIELD1_SIZE = 1000; + +export const getAlertsCountQuery = ({ + additionalFilters = [], + from, + runtimeMappings, + stackByField0, + stackByField1, + to, +}: { additionalFilters: Array<{ bool: { filter: unknown[]; should: unknown[]; must_not: unknown[]; must: unknown[] }; - }> = [], - runtimeMappings?: MappingRuntimeFields -) => { + }>; + from: string; + runtimeMappings?: MappingRuntimeFields; + stackByField0: string; + stackByField1: string | undefined; + to: string; +}) => { return { size: 0, aggs: { - alertsByGroupingCount: { + stackByField0: { terms: { - field: stackByField, + field: stackByField0, order: { _count: 'desc', }, - size: DEFAULT_MAX_TABLE_QUERY_SIZE, + size: DEFAULT_STACK_BY_FIELD0_SIZE, + }, + aggs: { + ...getOptionalSubAggregation({ + stackByField1, + stackByField1Size: DEFAULT_STACK_BY_FIELD1_SIZE, + }), }, }, }, 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 94e009a067fb5..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 @@ -7,12 +7,13 @@ import React from 'react'; import { waitFor, act } from '@testing-library/react'; - import { mount } from 'enzyme'; -import { TestProviders } from '../../../../common/mock'; 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', () => { @@ -20,12 +21,32 @@ jest.mock('react-router-dom', () => { return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; }); +const defaultUseQueryAlertsReturn = { + loading: false, + data: {}, + setQuery: () => {}, + response: '', + request: '', + refetch: () => {}, +}; +const mockUseQueryAlerts = jest.fn().mockReturnValue(defaultUseQueryAlertsReturn); +jest.mock('../../../containers/detection_engine/alerts/use_query', () => { + return { + useQueryAlerts: (...props: unknown[]) => mockUseQueryAlerts(...props), + }; +}); + describe('AlertsCountPanel', () => { const defaultProps = { signalIndexName: 'signalIndexName', + stackByField0: DEFAULT_STACK_BY_FIELD, + stackByField1: DEFAULT_STACK_BY_FIELD1, + setStackByField0: jest.fn(), + setStackByField1: jest.fn(), }; const mockSetToggle = jest.fn(); const mockUseQueryToggle = useQueryToggle as jest.Mock; + beforeEach(() => { jest.clearAllMocks(); mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); @@ -43,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 d70b77a16521a..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 @@ -21,27 +21,49 @@ import { getAlertsCountQuery } from './helpers'; import * as i18n from './translations'; import { AlertsCount } from './alerts_count'; import type { AlertsCountAggregation } from './types'; -import { DEFAULT_STACK_BY_FIELD } from '../common/config'; -import { KpiPanel, StackByComboBox } from '../common/components'; +import { KpiPanel } from '../common/components'; import { useInspectButton } from '../common/hooks'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { FieldSelection } from '../../../../common/components/field_selection'; export const DETECTIONS_ALERTS_COUNT_ID = 'detections-alerts-count'; interface AlertsCountPanelProps { + 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; signalIndexName: string | null; + stackByField0: string; + stackByField1: string | undefined; + stackByWidth?: number; + title?: React.ReactNode; runtimeMappings?: MappingRuntimeFields; } export const AlertsCountPanel = memo( - ({ filters, query, signalIndexName, runtimeMappings }) => { + ({ + alignHeader, + chartOptionsContextMenu, + filters, + panelHeight, + query, + runtimeMappings, + setStackByField0, + setStackByField1, + signalIndexName, + stackByField0, + stackByField1, + stackByWidth, + title = i18n.COUNT_TABLE_TITLE, + }) => { const { to, from, deleteQuery, setQuery } = useGlobalTime(); // create a unique, but stable (across re-renders) query id const uniqueQueryId = useMemo(() => `${DETECTIONS_ALERTS_COUNT_ID}-${uuid.v4()}`, []); - const [selectedStackByOption, setSelectedStackByOption] = useState(DEFAULT_STACK_BY_FIELD); // Disabling the fecth method in useQueryAlerts since it is defaulted to the old one // const fetchMethod = fetchQueryRuleRegistryAlerts; @@ -82,22 +104,38 @@ export const AlertsCountPanel = memo( request, refetch, } = useQueryAlerts<{}, AlertsCountAggregation>({ - query: getAlertsCountQuery( - selectedStackByOption, + query: getAlertsCountQuery({ + stackByField0, + stackByField1, from, to, additionalFilters, - runtimeMappings - ), + runtimeMappings, + }), indexName: signalIndexName, skip: querySkip, }); useEffect(() => { setAlertsQuery( - getAlertsCountQuery(selectedStackByOption, from, to, additionalFilters, runtimeMappings) + getAlertsCountQuery({ + additionalFilters, + from, + runtimeMappings, + stackByField0, + stackByField1, + to, + }) ); - }, [setAlertsQuery, selectedStackByOption, from, to, additionalFilters, runtimeMappings]); + }, [ + additionalFilters, + from, + runtimeMappings, + setAlertsQuery, + stackByField0, + stackByField1, + to, + ]); useInspectButton({ setQuery, @@ -111,22 +149,39 @@ export const AlertsCountPanel = memo( return ( - + - + - {toggleStatus && ( + {toggleStatus && alertsData != null && ( )} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_empty_field0.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_empty_field0.ts new file mode 100644 index 0000000000000..a2d9d92cd75a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_empty_field0.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AlertSearchResponse } from '../../../../containers/detection_engine/alerts/types'; +import type { AlertsCountAggregation } from '../types'; + +export const emptyStackByField0Response: AlertSearchResponse = { + took: 0, + timeout: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 87, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + stackByField0: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, +}; 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 new file mode 100644 index 0000000000000..730fded03f88b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_multi_group.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 '../../../../containers/detection_engine/alerts/types'; +import type { AlertsCountAggregation } from '../types'; + +export const buckets = [ + { + key: 'matches everything', + doc_count: 34, + }, + { + key: 'EQL process sequence', + doc_count: 28, + }, + { + key: 'Endpoint Security', + doc_count: 19, + }, + { + key: 'mimikatz process started', + doc_count: 5, + }, + { + key: 'Threshold rule', + doc_count: 1, + }, +]; + +/** + * A mock response to a request containing multiple group by fields + */ +export const mockMultiGroupResponse: AlertSearchResponse = { + took: 0, + timeout: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 87, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + stackByField0: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets, + }, + }, +}; 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 new file mode 100644 index 0000000000000..e7c0f982be03b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/mocks/mock_response_single_group.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 '../../../../containers/detection_engine/alerts/types'; +import type { AlertsCountAggregation } from '../types'; + +export const buckets = [ + { + key: 'matches everything', + doc_count: 34, + stackByField1: { + buckets: [ + { + key: 'Host-k8iyfzraq9', + doc_count: 12, + }, + { + key: 'Host-ao1a4wu7vn', + doc_count: 10, + }, + { + key: 'Host-3fbljiq8rj', + doc_count: 7, + }, + { + key: 'Host-r4y6xi92ob', + doc_count: 5, + }, + ], + }, + }, + { + key: 'EQL process sequence', + doc_count: 28, + stackByField1: { + sum_other_doc_count: 0, + buckets: [ + { + key: 'Host-k8iyfzraq9', + doc_count: 10, + }, + { + key: 'Host-ao1a4wu7vn', + doc_count: 7, + }, + { + key: 'Host-3fbljiq8rj', + doc_count: 5, + }, + { + key: 'Host-r4y6xi92ob', + doc_count: 3, + }, + ], + }, + }, + { + key: 'Endpoint Security', + doc_count: 19, + stackByField1: { + sum_other_doc_count: 0, + buckets: [ + { + key: 'Host-ao1a4wu7vn', + doc_count: 11, + }, + { + key: 'Host-3fbljiq8rj', + doc_count: 6, + }, + { + key: 'Host-k8iyfzraq9', + doc_count: 1, + }, + { + key: 'Host-r4y6xi92ob', + doc_count: 1, + }, + ], + }, + }, + { + key: 'mimikatz process started', + doc_count: 5, + stackByField1: { + sum_other_doc_count: 0, + buckets: [ + { + key: 'Host-k8iyfzraq9', + doc_count: 3, + }, + { + key: 'Host-3fbljiq8rj', + doc_count: 1, + }, + { + key: 'Host-r4y6xi92ob', + doc_count: 1, + }, + ], + }, + }, + { + key: 'Threshold rule', + doc_count: 1, + stackByField1: { + sum_other_doc_count: 0, + buckets: [ + { + key: 'Host-r4y6xi92ob', + doc_count: 1, + }, + ], + }, + }, +]; + +export const singleGroupResponse: AlertSearchResponse = { + took: 0, + timeout: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 87, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + stackByField0: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + 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_count_panel/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/types.ts index b541c7234f08e..a26024b80dba7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/types.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_count_panel/types.ts @@ -5,10 +5,12 @@ * 2.0. */ -import type { GenericBuckets } from '../../../../../common/search_strategy'; +import type { RawBucket } from '../../../../common/components/alerts_treemap/types'; export interface AlertsCountAggregation { - alertsByGroupingCount: { - buckets: GenericBuckets[]; + stackByField0: { + buckets: RawBucket[]; + doc_count_error_upper_bound?: number; + sum_other_doc_count?: number; }; } 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 c2c712c718762..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 @@ -27,6 +27,7 @@ interface AlertsHistogramProps { from: string; legendItems: LegendItem[]; legendPosition?: Position; + legendMinWidth?: number; loading: boolean; showLegend?: boolean; to: string; @@ -40,6 +41,7 @@ export const AlertsHistogram = React.memo( from, legendItems, legendPosition = Position.Right, + legendMinWidth, loading, showLegend, to, @@ -98,7 +100,11 @@ export const AlertsHistogram = React.memo( {legendItems.length > 0 && ( - + )} 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..2288132ffd020 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,22 @@ * 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 { CHART_SETTINGS_POPOVER_ARIA_LABEL } from '../../../../common/components/chart_settings_popover/translations'; +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 +78,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 +124,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) => ( + + ); + + render( + + + + ); + + expect( + screen.getByRole('button', { name: CHART_SETTINGS_POPOVER_ARIA_LABEL }) + ).toBeInTheDocument(); + }); + + 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 c8ab450bb6f04..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,6 +48,7 @@ import { KpiPanel, StackByComboBox } from '../common/components'; import { useInspectButton } from '../common/hooks'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { GROUP_BY_TOP_LABEL } from '../common/translations'; const defaultTotalAlertsObj: AlertsTotal = { value: 0, @@ -60,25 +61,39 @@ 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?: (queryId: string) => React.ReactNode; combinedQueries?: string; - defaultStackByOption?: AlertsStackByField; + defaultStackByOption?: string; filters?: Filter[]; headerChildren?: React.ReactNode; + onFieldSelected?: (field: string) => void; /** Override all defaults, and only display this field */ onlyField?: AlertsStackByField; paddingSize?: 's' | 'm' | 'l' | 'none'; + panelHeight?: number; titleSize?: EuiTitleSize; query?: Query; 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; } @@ -87,20 +102,28 @@ const NO_LEGEND_DATA: LegendItem[] = []; export const AlertsHistogramPanel = memo( ({ + alignHeader, chartHeight, + chartOptionsContextMenu, combinedQueries, defaultStackByOption = DEFAULT_STACK_BY_FIELD, filters, headerChildren, + onFieldSelected, onlyField, paddingSize = 'm', + panelHeight = PANEL_HEIGHT, query, legendPosition = 'right', signalIndexName, + showCountsInLegend = false, + showGroupByPlaceholder = false, showLegend = true, showLinkToAlerts = false, showTotalAlertsCount = false, showStackBy = true, + stackByLabel, + stackByWidth, timelineId, title = i18n.HISTOGRAM_HEADER, updateDateRange, @@ -118,6 +141,19 @@ export const AlertsHistogramPanel = memo( const [selectedStackByOption, setSelectedStackByOption] = useState( onlyField == null ? defaultStackByOption : onlyField ); + const onSelect = useCallback( + (field: string) => { + setSelectedStackByOption(field); + if (onFieldSelected != null) { + onFieldSelected(field); + } + }, + [onFieldSelected] + ); + + useEffect(() => { + setSelectedStackByOption(onlyField == null ? defaultStackByOption : onlyField); + }, [defaultStackByOption, onlyField]); const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_HISTOGRAM_ID); const [querySkip, setQuerySkip] = useState(!toggleStatus); @@ -183,6 +219,7 @@ export const AlertsHistogramPanel = memo( showLegend && alertsData?.aggregations?.alertsByGrouping?.buckets != null ? alertsData.aggregations.alertsByGrouping.buckets.map((bucket, i) => ({ color: i < defaultLegendColors.length ? defaultLegendColors[i] : undefined, + count: showCountsInLegend ? bucket.doc_count : undefined, dataProviderId: escapeDataProviderId( `draggable-legend-item-${uuid.v4()}-${selectedStackByOption}-${bucket.key}` ), @@ -194,6 +231,7 @@ export const AlertsHistogramPanel = memo( [ alertsData?.aggregations?.alertsByGrouping.buckets, selectedStackByOption, + showCountsInLegend, showLegend, timelineId, ] @@ -289,34 +327,64 @@ export const AlertsHistogramPanel = memo( return ( - + {showStackBy && ( <> + {showGroupByPlaceholder && ( + <> + + + + + + )} )} {headerChildren != null && headerChildren} + {chartOptionsContextMenu != null && ( + + {chartOptionsContextMenu(uniqueQueryId)} + + )} + {linkButton} @@ -331,6 +399,7 @@ export const AlertsHistogramPanel = memo( from={from} legendItems={legendItems} legendPosition={legendPosition} + legendMinWidth={showCountsInLegend ? LEGEND_WITH_COUNTS_WIDTH : undefined} loading={isLoadingAlerts} to={to} showLegend={showLegend} 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/alerts_histogram_panel/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/types.ts index 433fee1716a47..7b1136d5b11c6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/types.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/types.ts @@ -31,6 +31,7 @@ export interface AlertsGroupBucket { alerts: { buckets: AlertsBucket[]; }; + doc_count: number; } export interface AlertsTotal { 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..3967655d6f089 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/common/components.test.tsx @@ -0,0 +1,205 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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'; +import * as i18n from './translations'; + +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.getByRole('combobox', { name: i18n.STACK_BY_ARIA_LABEL }); + 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.getByRole('combobox', { name: i18n.STACK_BY_ARIA_LABEL })).not.toHaveAttribute( + 'disabled' + ); + }); + + test('it disables the combo box when `isDisabled` is true', () => { + render( + + + + ); + + expect(screen.getByRole('combobox', { name: i18n.STACK_BY_ARIA_LABEL })).toHaveAttribute( + 'disabled' + ); + }); + + test('overrides the default accessible name via the `aria-label` prop when provided', () => { + const customAccessibleName = 'custom'; + + render( + + + + ); + + expect(screen.getByRole('combobox', { name: customAccessibleName })).toBeInTheDocument(); + }); + + 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 fdc07cb9c91d0..06c6368b2725a 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 @@ -12,16 +12,34 @@ import { PANEL_HEIGHT, MOBILE_PANEL_HEIGHT } from './config'; import { useStackByFields } from './hooks'; import * as i18n from './translations'; -export const KpiPanel = styled(EuiPanel)<{ height?: number; $toggleStatus: boolean }>` +const DEFAULT_WIDTH = 400; + +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}) { - ${({ $toggleStatus }) => + ${({ height, $toggleStatus }) => $toggleStatus && ` - height: ${PANEL_HEIGHT}px; + height: ${height != null ? height : PANEL_HEIGHT}px; `} } ${({ $toggleStatus }) => @@ -31,15 +49,29 @@ export const KpiPanel = styled(EuiPanel)<{ height?: number; $toggleStatus: boole `} `; interface StackedBySelectProps { + 'aria-label'?: string; + 'data-test-subj'?: string; + isDisabled?: boolean; + prepend?: string; selected: string; onSelect: (selected: string) => void; + width?: number; } -export const StackByComboBoxWrapper = styled.div` +export const StackByComboBoxWrapper = styled.div<{ width: number }>` max-width: 400px; + width: ${({ width }) => width}px; `; -export const StackByComboBox: React.FC = ({ selected, onSelect }) => { +export const StackByComboBox: React.FC = ({ + 'aria-label': ariaLabel = i18n.STACK_BY_ARIA_LABEL, + 'data-test-subj': dataTestSubj, + isDisabled = false, + onSelect, + prepend = i18n.STACK_BY_LABEL, + selected, + width = DEFAULT_WIDTH, +}) => { const onChange = useCallback( (options) => { if (options && options.length > 0) { @@ -58,11 +90,13 @@ export const StackByComboBox: React.FC = ({ selected, onSe return { asPlainText: true }; }, []); return ( - + {I18n.LOW}, + inputDisplay: {I18n.LOW}, }, { value: 'medium', - inputDisplay: ( - {I18n.MEDIUM} - ), + inputDisplay: {I18n.MEDIUM}, }, { value: 'high', - inputDisplay: {I18n.HIGH}, + inputDisplay: {I18n.HIGH}, }, { value: 'critical', - inputDisplay: ( - {I18n.CRITICAL} - ), + inputDisplay: {I18n.CRITICAL}, }, ]; export const defaultRiskScoreBySeverity: Record = { - low: 21, - medium: 47, - high: 73, - critical: 99, + low: RISK_SCORE_LOW, + medium: RISK_SCORE_MEDIUM, + high: RISK_SCORE_HIGH, + critical: RISK_SCORE_CRITICAL, }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts index ffc46610ae06d..db68e700de4ff 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/types.ts @@ -33,6 +33,7 @@ export interface AlertSearchResponse value: number; relation: string; }; + max_score?: number | null; hits: Hit[]; }; } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/constants.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/constants.ts new file mode 100644 index 0000000000000..6df717c6b541e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/constants.ts @@ -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. + */ + +/** settings for the `Alerts` page are grouped under this logical key */ +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` 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 `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'; + +/** This setting persists the value of the first `Stack by` field selector when there are multiple */ +export const STACK_BY_0_SETTING_NAME = 'stack-by-0'; + +/** This setting persists the value of the second `Stack by` field selector when there are multiple */ +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'; + +/** 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..5ac129b6368e3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/index.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { + 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, + }), + isInvalidDefault: isDefaultWhenEmptyString, + }); + + const [isTreemapPanelExpanded, setIsTreemapPanelExpanded] = useLocalStorage({ + defaultValue: true, + key: getSettingKey({ + category: TREEMAP_CATEGORY, + page: ALERTS_PAGE, + setting: EXPAND_SETTING_NAME, + }), + }); + + const [riskChartStackBy0, setRiskChartStackBy0] = useLocalStorage({ + defaultValue: DEFAULT_STACK_BY_FIELD, + key: getSettingKey({ + category: TREEMAP_CATEGORY, + page: ALERTS_PAGE, + setting: STACK_BY_0_SETTING_NAME, + }), + 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, + }), + }); + + const [countTableStackBy0, setCountTableStackBy0] = useLocalStorage({ + defaultValue: DEFAULT_STACK_BY_FIELD, + key: getSettingKey({ + category: TABLE_CATEGORY, + page: ALERTS_PAGE, + setting: STACK_BY_0_SETTING_NAME, + }), + 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, + }), + }); + + const [trendChartStackBy, setTrendChartStackBy] = useLocalStorage({ + defaultValue: DEFAULT_STACK_BY_FIELD, + key: getSettingKey({ + category: TREND_CHART_CATEGORY, + page: ALERTS_PAGE, + setting: STACK_BY_SETTING_NAME, + }), + 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/chart_panels/alerts_local_storage/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/types.ts new file mode 100644 index 0000000000000..e909fc66f11db --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/alerts_local_storage/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 '../chart_select/helpers'; + +export interface AlertsSettings { + alertViewSelection: AlertViewSelection; + countTableStackBy0: string; + countTableStackBy1: string | undefined; + isTreemapPanelExpanded: boolean; + riskChartStackBy0: string; + riskChartStackBy1: string | undefined; + setAlertViewSelection: (alertViewSelection: AlertViewSelection) => void; + setCountTableStackBy0: (value: string) => void; + setCountTableStackBy1: (value: string | undefined) => void; + setIsTreemapPanelExpanded: (value: boolean) => void; + setRiskChartStackBy0: (value: string) => void; + setRiskChartStackBy1: (value: string | undefined) => void; + setTrendChartStackBy: (value: string) => void; + 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..fa9ab51d19161 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.test.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { RESET_GROUP_BY_FIELDS } from '../../../../../common/components/chart_settings_popover/configurations/default/translations'; +import { CHART_SETTINGS_POPOVER_ARIA_LABEL } from '../../../../../common/components/chart_settings_popover/translations'; +import { INSPECT } from '../../../../../common/components/inspect/translations'; +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.getByRole('button', { name: CHART_SETTINGS_POPOVER_ARIA_LABEL }) + ).toBeInTheDocument(); + }); + + test('it renders the Inspect menu item', () => { + render( + + + + ); + + const menuButton = screen.getByRole('button', { name: CHART_SETTINGS_POPOVER_ARIA_LABEL }); + menuButton.click(); + + expect(screen.getByRole('button', { name: INSPECT })).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.getByRole('button', { name: CHART_SETTINGS_POPOVER_ARIA_LABEL }); + menuButton.click(); + + const resetMenuItem = screen.getByRole('button', { name: RESET_GROUP_BY_FIELDS }); + 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_panels/chart_context_menu/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.tsx new file mode 100644 index 0000000000000..36ac97de09610 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_context_menu/index.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; + +import { ChartSettingsPopover } from '../../../../../common/components/chart_settings_popover'; +import { useChartSettingsPopoverConfiguration } from '../../../../../common/components/chart_settings_popover/configurations/default'; + +interface Props { + defaultStackByField: string; + defaultStackByField1?: string; + queryId: string; + setStackBy: (value: string) => void; + setStackByField1?: (stackBy: string | undefined) => void; +} + +const ChartContextMenuComponent: React.FC = ({ + defaultStackByField, + defaultStackByField1, + queryId, + setStackBy, + setStackByField1, +}: Props) => { + const onResetStackByFields = useCallback(() => { + setStackBy(defaultStackByField); + + if (setStackByField1 != null) { + setStackByField1(defaultStackByField1); + } + }, [defaultStackByField, defaultStackByField1, setStackBy, setStackByField1]); + + const { defaultInitialPanelId, defaultMenuItems, isPopoverOpen, setIsPopoverOpen } = + useChartSettingsPopoverConfiguration({ + onResetStackByFields, + queryId, + }); + + return ( + + ); +}; + +ChartContextMenuComponent.displayName = 'ChartContextMenuComponent'; + +export const ChartContextMenu = React.memo(ChartContextMenuComponent); 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..7d24cee9b6533 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import 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 closePopover = jest.fn(); + const setAlertViewSelection = jest.fn(); + + alertViewSelections.forEach((alertViewSelection) => { + test(`it returns the expected panel id when alertViewSelection is '${alertViewSelection}'`, () => { + const panels = getContextMenuPanels({ + alertViewSelection, + closePopover, + setAlertViewSelection, + }); + + expect(panels[0].id).toEqual(0); + }); + + test(`onClick invokes setAlertViewSelection with '${alertViewSelection}' item when alertViewSelection is '${alertViewSelection}'`, () => { + const panels = getContextMenuPanels({ + alertViewSelection, + closePopover, + setAlertViewSelection, + }); + + const item = panels[0].items?.find((x) => x['data-test-subj'] === alertViewSelection); + (item?.onClick as () => void)(); + + expect(setAlertViewSelection).toBeCalledWith(alertViewSelection); + }); + + test(`onClick invokes closePopover when alertViewSelection is '${alertViewSelection}'`, () => { + const panels = getContextMenuPanels({ + alertViewSelection, + closePopover, + setAlertViewSelection, + }); + + const item = panels[0].items?.find((x) => x['data-test-subj'] === alertViewSelection); + (item?.onClick as () => void)(); + + expect(closePopover).toBeCalled(); + }); + }); + }); +}); 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..08507759b375e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import 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, + closePopover, + setAlertViewSelection, +}: { + alertViewSelection: AlertViewSelection; + closePopover: () => void; + setAlertViewSelection: (alertViewSelection: AlertViewSelection) => void; +}): EuiContextMenuPanelDescriptor[] => [ + { + id: 0, + items: [ + { + ...getButtonProperties('table'), + onClick: () => { + closePopover(); + setAlertViewSelection('table'); + }, + }, + { + ...getButtonProperties('trend'), + onClick: () => { + closePopover(); + setAlertViewSelection('trend'); + }, + }, + { + ...getButtonProperties('treemap'), + onClick: () => { + closePopover(); + 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..82861cf2b9d6e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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 { SELECT_A_CHART_ARIA_LABEL, TREEMAP } from './translations'; +import { ChartSelect } from '.'; + +describe('ChartSelect', () => { + test('it renders the chart select button', () => { + render( + + + + ); + + expect(screen.getByRole('button', { name: SELECT_A_CHART_ARIA_LABEL })).toBeInTheDocument(); + }); + + test('it invokes `setAlertViewSelection` with the expected value when a chart is selected', () => { + const setAlertViewSelection = jest.fn(); + + render( + + + + ); + + const selectButton = screen.getByRole('button', { name: SELECT_A_CHART_ARIA_LABEL }); + selectButton.click(); + + const treemapMenuItem = screen.getByRole('button', { name: 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..5203eaf77edab --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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'; +import * as i18n from './translations'; + +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, closePopover, setAlertViewSelection }), + [alertViewSelection, closePopover, 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..e776b94f3f957 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/translations.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const SELECT_A_CHART_ARIA_LABEL = i18n.translate( + 'xpack.securitySolution.components.chartSelect.selectAChartAriaLabel', + { + defaultMessage: 'Select a chart', + } +); + +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 new file mode 100644 index 0000000000000..9d57590c72bff --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/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/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 { 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 { + 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}; +`; + +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[]; + isLoadingIndexPattern: boolean; + query: Query; + runtimeMappings: MappingRuntimeFields; + signalIndexName: string | null; + updateDateRangeCallback: UpdateDateRange; +} + +const ChartPanelsComponent: React.FC = ({ + addFilter, + alertsHistogramDefaultFilters, + isLoadingIndexPattern, + query, + runtimeMappings, + signalIndexName, + 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, setTrendChartStackBy] + ); + + const updateCommonStackBy1 = useCallback( + (value: string | undefined) => { + setCountTableStackBy1(value); + setRiskChartStackBy1(value); + }, + [setCountTableStackBy1, setRiskChartStackBy1] + ); + + const chartOptionsContextMenu = useCallback( + (queryId: string) => ( + + ), + [updateCommonStackBy0, updateCommonStackBy1] + ); + + const title = useMemo( + () => ( + + + + ), + [alertViewSelection, setAlertViewSelection] + ); + + return ( +
+ {alertViewSelection === 'trend' && ( + + {isLoadingIndexPattern ? ( + + ) : ( + + )} + + )} + + {alertViewSelection === 'table' && ( + + {isLoadingIndexPattern ? ( + + ) : ( + + )} + + )} + + {alertViewSelection === 'treemap' && ( + + {isLoadingIndexPattern ? ( + + ) : ( + + )} + + )} +
+ ); +}; + +export const ChartPanels = React.memo(ChartPanelsComponent); 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 9680692b9da53..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 @@ -93,6 +93,10 @@ jest.mock('../../../common/lib/kibana', () => { }, }, }, + storage: { + get: jest.fn(), + set: jest.fn(), + }, }, }), useToasts: jest.fn().mockReturnValue({ @@ -139,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 58984e2703521..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 @@ -39,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'; @@ -62,6 +61,7 @@ import { buildShowBuildingBlockFilter, buildThreatMatchFilter, } from '../../components/alerts_table/default_config'; +import { ChartPanels } from './chart_panels'; import { useSourcererDataView } from '../../../common/containers/sourcerer'; import { useSignalHelpers } from '../../../common/containers/sourcerer/use_signal_helpers'; @@ -69,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, @@ -78,6 +76,7 @@ import { import { EmptyPage } from '../../../common/components/empty_page'; import { HeaderPage } from '../../../common/components/header_page'; import { LandingPageComponent } from '../../../common/components/landing_page'; + /** * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. */ @@ -144,10 +143,29 @@ const DetectionEnginePageComponent: React.FC = ({ const { application: { navigateToUrl }, timelines: timelinesUi, + data, docLinks, } = useKibana().services; const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); + const { filterManager } = data.query; + + const addFilter = useCallback( + ({ field, value }: { field: string; value: string | number }) => { + filterManager.addFilters([ + { + meta: { + alias: null, + disabled: false, + negate: false, + }, + query: { match_phrase: { [field]: value } }, + }, + ]); + }, + [filterManager] + ); + const showUpdating = useMemo(() => isAlertsLoading || loading, [isAlertsLoading, loading]); const updateDateRangeCallback = useCallback( @@ -330,45 +348,30 @@ const DetectionEnginePageComponent: React.FC = ({ onFilterGroupChanged={onFilterGroupChangedCallback} /> + - {updatedAt && - timelinesUi.getLastUpdated({ - updatedAt: updatedAt || Date.now(), - showUpdating, - })} + + + {updatedAt && + timelinesUi.getLastUpdated({ + updatedAt: updatedAt || Date.now(), + showUpdating, + })} + + - - - {isLoadingIndexPattern ? ( - - ) : ( - - )} - - - {isLoadingIndexPattern ? ( - - ) : ( - - )} - - + +