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

{'Test children'}

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

{'Test children'}

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

{'Test children'}

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

{'Test children'}

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

{'Test children'}

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

{'Test children'}

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