diff --git a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx index 28946d6c2936e..c927768094e32 100644 --- a/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/draggables/index.tsx @@ -31,6 +31,7 @@ export interface DefaultDraggableType { scopeId?: string; tooltipContent?: React.ReactNode; tooltipPosition?: ToolTipPositions; + truncate?: boolean; } /** @@ -111,6 +112,7 @@ export const DefaultDraggable = React.memo( tooltipContent, tooltipPosition, queryValue, + truncate, }) => { const dataProviderProp: DataProvider = useMemo( () => ({ @@ -159,6 +161,7 @@ export const DefaultDraggable = React.memo( isDraggable={isDraggable} render={renderCallback} scopeId={scopeId} + truncate={truncate} /> ); } diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/alerts_by_type.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/alerts_by_type.test.tsx new file mode 100644 index 0000000000000..a3e2638b61db1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/alerts_by_type.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { act, render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../../../common/mock'; +import { AlertsByType } from './alerts_by_type'; +import { parsedAlerts } from './mock_data'; + +const display = 'alerts-by-type-palette-display'; + +jest.mock('../../../../common/lib/kibana'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +describe('Alert by type chart', () => { + const defaultProps = { + data: [], + isLoading: false, + }; + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('renders health and pallette display correctly without data', () => { + act(() => { + const { container } = render( + + + + ); + expect(container.querySelector(`[data-test-subj="${display}"]`)).toBeInTheDocument(); + expect(container.querySelector(`[data-test-subj="${display}"]`)?.textContent).toContain( + 'Detection:0' + ); + expect(container.querySelector(`[data-test-subj="${display}"]`)?.textContent).toContain( + 'Prevention:0' + ); + }); + }); + + test('renders table correctly without data', () => { + act(() => { + const { container } = render( + + + + ); + expect( + container.querySelector('[data-test-subj="alerts-by-type-table"]') + ).toBeInTheDocument(); + expect( + container.querySelector('[data-test-subj="alerts-by-type-table"] tbody')?.textContent + ).toEqual('No items found'); + }); + }); + + test('renders health and pallette display correctly with data', () => { + act(() => { + const { container } = render( + + + + ); + expect(container.querySelector(`[data-test-subj="${display}"]`)).toBeInTheDocument(); + expect(container.querySelector(`[data-test-subj="${display}"]`)?.textContent).toContain( + 'Detection:583' + ); + expect(container.querySelector(`[data-test-subj="${display}"]`)?.textContent).toContain( + 'Prevention:6' + ); + }); + }); + + test('renders table correctly with data', () => { + act(() => { + const { queryAllByRole } = render( + + + + ); + + parsedAlerts.forEach((_, i) => { + expect(queryAllByRole('row')[i + 1].textContent).toContain(parsedAlerts[i].rule); + expect(queryAllByRole('row')[i + 1].textContent).toContain(parsedAlerts[i].type); + expect(queryAllByRole('row')[i + 1].textContent).toContain( + parsedAlerts[i].value.toString() + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/alerts_by_type.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/alerts_by_type.tsx new file mode 100644 index 0000000000000..cbed898e75dc6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/alerts_by_type.tsx @@ -0,0 +1,134 @@ +/* + * 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, + EuiInMemoryTable, + EuiColorPaletteDisplay, + EuiSpacer, + EuiFlexGroup, + EuiHealth, + EuiText, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import styled from 'styled-components'; +import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { AlertsTypeData, AlertType } from './types'; +import { FormattedCount } from '../../../../common/components/formatted_number'; +import { getAlertsTypeTableColumns } from './columns'; +import { ALERT_TYPE_COLOR } from './helpers'; + +const Wrapper = styled.div` + margin-top: -${({ theme }) => theme.eui.euiSizeM}; +`; +const TableWrapper = styled.div` + height: 178px; +`; +const StyledEuiColorPaletteDisplay = styled(EuiColorPaletteDisplay)` + border: none; + border-radius: 0; +`; +interface PalletteObject { + stop: number; + color: string; +} + +export interface AlertsByTypeProps { + data: AlertsTypeData[]; + isLoading: boolean; +} + +export const AlertsByType: React.FC = ({ data, isLoading }) => { + const columns = useMemo(() => getAlertsTypeTableColumns(), []); + + const subtotals = useMemo( + () => + data.reduce( + (acc: { Detection: number; Prevention: number }, item: AlertsTypeData) => { + if (item.type === 'Detection') { + acc.Detection += item.value; + } + if (item.type === 'Prevention') { + acc.Prevention += item.value; + } + return acc; + }, + { Detection: 0, Prevention: 0 } + ), + [data] + ); + + const palette: PalletteObject[] = useMemo( + () => + (Object.keys(subtotals) as AlertType[]).reduce((acc: PalletteObject[], type: AlertType) => { + const previousStop = acc.length > 0 ? acc[acc.length - 1].stop : 0; + if (subtotals[type]) { + const newEntry: PalletteObject = { + stop: previousStop + (subtotals[type] || 0), + color: ALERT_TYPE_COLOR[type], + }; + acc.push(newEntry); + } + return acc; + }, [] as PalletteObject[]), + [subtotals] + ); + + const sorting: { sort: { field: keyof AlertsTypeData; direction: SortOrder } } = { + sort: { + field: 'value', + direction: 'desc', + }, + }; + + const pagination: {} = { + pageSize: 25, + showPerPageOptions: false, + }; + + return ( + + + {(Object.keys(subtotals) as AlertType[]).map((type) => ( + + + + + +

{`${type}:`}

+
+
+
+ + + + + +
+
+ ))} + +
+ + + + + + + +
+ ); +}; + +AlertsByType.displayName = 'AlertsByType'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/columns.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/columns.tsx new file mode 100644 index 0000000000000..f1479ae58f945 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/columns.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 React from 'react'; +import { EuiHealth, EuiText } from '@elastic/eui'; +import { ALERT_RULE_NAME } from '@kbn/rule-data-utils'; +import type { EuiBasicTableColumn } from '@elastic/eui'; +import type { AlertsTypeData, AlertType } from './types'; +import { DefaultDraggable } from '../../../../common/components/draggables'; +import { FormattedCount } from '../../../../common/components/formatted_number'; +import { ALERTS_HEADERS_RULE_NAME } from '../../alerts_table/translations'; +import { ALERT_TYPE_COLOR } from './helpers'; +import { COUNT_TABLE_TITLE } from '../alerts_count_panel/translations'; +import * as i18n from './translations'; + +export const getAlertsTypeTableColumns = (): Array> => [ + { + field: 'rule', + name: ALERTS_HEADERS_RULE_NAME, + 'data-test-subj': 'detectionsTable-rule', + truncateText: true, + render: (rule: string) => ( + + + + ), + }, + { + field: 'type', + name: i18n.ALERTS_TYPE_COLUMN_TITLE, + 'data-test-subj': 'detectionsTable-type', + truncateText: true, + render: (type: string) => { + return ( + + + {type} + + + ); + }, + width: '30%', + }, + { + field: 'value', + name: COUNT_TABLE_TITLE, + dataType: 'number', + sortable: true, + 'data-test-subj': 'detectionsTable-count', + render: (count: number) => ( + + + + ), + width: '22%', + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/helpers.test.tsx new file mode 100644 index 0000000000000..3ec44344ea44a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/helpers.test.tsx @@ -0,0 +1,26 @@ +/* + * 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 { parseAlertsTypeData } from './helpers'; +import * as mock from './mock_data'; +import type { AlertsByTypeAgg } from './types'; +import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; + +describe('parse alerts by type data', () => { + test('parse alerts with data', () => { + const res = parseAlertsTypeData( + mock.mockAlertsData as AlertSearchResponse<{}, AlertsByTypeAgg> + ); + expect(res).toEqual(mock.parsedAlerts); + }); + + test('parse alerts without data', () => { + const res = parseAlertsTypeData( + mock.mockAlertsEmptyData as AlertSearchResponse<{}, AlertsByTypeAgg> + ); + expect(res).toEqual([]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/helpers.tsx new file mode 100644 index 0000000000000..ad357b81b5e22 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/helpers.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { has } from 'lodash'; +import type { AlertType, AlertsByTypeAgg, AlertsTypeData } from './types'; +import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; +import type { SummaryChartsData } from '../alerts_summary_charts_panel/types'; + +export const ALERT_TYPE_COLOR = { + Detection: '#D36086', + Prevention: '#54B399', +}; + +export const parseAlertsTypeData = ( + response: AlertSearchResponse<{}, AlertsByTypeAgg> +): AlertsTypeData[] => { + const rulesBuckets = response?.aggregations?.alertsByRule?.buckets ?? []; + return rulesBuckets.length === 0 + ? [] + : rulesBuckets.flatMap((rule) => { + const events = rule.ruleByEventType?.buckets ?? []; + return getAggregateAlerts(rule.key, events); + }); +}; + +const getAggregateAlerts = ( + ruleName: string, + ruleEvents: Array<{ key: string; doc_count: number }> +): AlertsTypeData[] => { + let preventions = 0; + let detections = 0; + + ruleEvents.map((eventBucket) => { + return eventBucket.key === 'denied' + ? (preventions += eventBucket.doc_count) + : (detections += eventBucket.doc_count); + }); + + const ret = []; + if (detections > 0) { + ret.push({ + rule: ruleName, + type: 'Detection' as AlertType, + value: detections, + color: ALERT_TYPE_COLOR.Detection, + }); + } + if (preventions > 0) { + ret.push({ + rule: ruleName, + type: 'Prevention' as AlertType, + value: preventions, + color: ALERT_TYPE_COLOR.Prevention, + }); + } + return ret; +}; + +export const isAlertsTypeData = (data: SummaryChartsData[]): data is AlertsTypeData[] => { + return data?.every((x) => has(x, 'type')); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/index.test.tsx new file mode 100644 index 0000000000000..7e29495237ef2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/index.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { act, render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../../../common/mock'; +import { AlertsByTypePanel } from '.'; + +jest.mock('../../../../common/lib/kibana'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +describe('Alert by type panel', () => { + const defaultProps = { + signalIndexName: 'signalIndexName', + skip: false, + }; + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('renders correctly', async () => { + await act(async () => { + const { container } = render( + + + + ); + expect( + container.querySelector('[data-test-subj="alerts-by-type-panel"]') + ).toBeInTheDocument(); + }); + }); + + test('renders HeaderSection', async () => { + await act(async () => { + const { container } = render( + + + + ); + expect(container.querySelector(`[data-test-subj="header-section"]`)).toBeInTheDocument(); + }); + }); + + test('renders inspect button', async () => { + await act(async () => { + const { container } = render( + + + + ); + expect(container.querySelector('[data-test-subj="inspect-icon-button"]')).toBeInTheDocument(); + }); + }); + + test('renders alert by type chart', async () => { + await act(async () => { + const { container } = render( + + + + ); + expect(container.querySelector('[data-test-subj="alerts-by-type"]')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/index.tsx new file mode 100644 index 0000000000000..666be899d2a17 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/index.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiPanel } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { v4 as uuid } from 'uuid'; +import type { ChartsPanelProps } from '../alerts_summary_charts_panel/types'; +import { AlertsByType } from './alerts_by_type'; +import { HeaderSection } from '../../../../common/components/header_section'; +import { InspectButtonContainer } from '../../../../common/components/inspect'; +import { useSummaryChartData } from '../alerts_summary_charts_panel/use_summary_chart_data'; +import { alertTypeAggregations } from '../alerts_summary_charts_panel/aggregations'; +import { isAlertsTypeData } from './helpers'; +import * as i18n from './translations'; + +const ALERTS_BY_TYPE_CHART_ID = 'alerts-summary-alert_by_type'; + +export const AlertsByTypePanel: React.FC = ({ + filters, + query, + signalIndexName, + runtimeMappings, + skip, +}) => { + const uniqueQueryId = useMemo(() => `${ALERTS_BY_TYPE_CHART_ID}-${uuid()}`, []); + + const { items, isLoading } = useSummaryChartData({ + aggregations: alertTypeAggregations, + filters, + query, + signalIndexName, + runtimeMappings, + skip, + uniqueQueryId, + }); + const data = useMemo(() => (isAlertsTypeData(items) ? items : []), [items]); + + return ( + + + + + + + ); +}; + +AlertsByTypePanel.displayName = 'AlertsByTypePanel'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/mock_data.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/mock_data.ts new file mode 100644 index 0000000000000..e1949dd8893d5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/mock_data.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { AlertsTypeData } from './types'; + +const from = '2022-04-05T12:00:00.000Z'; +const to = '2022-04-08T12:00:00.000Z'; + +export const mockAlertsData = { + took: 0, + timeout: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 589, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + alertsByRule: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Test rule 1', + doc_count: 537, + ruleByEventType: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'info', + doc_count: 406, + }, + { + key: 'creation', + doc_count: 131, + }, + ], + }, + }, + { + key: 'Test rule 2', + doc_count: 27, + ruleByEventType: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'info', + doc_count: 19, + }, + { + key: 'creation', + doc_count: 8, + }, + ], + }, + }, + { + key: 'Test rule 3', + doc_count: 25, + ruleByEventType: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'info', + doc_count: 19, + }, + { + key: 'denied', + doc_count: 6, + }, + ], + }, + }, + ], + }, + }, +}; + +export const mockAlertsEmptyData = { + took: 0, + timeout: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 0, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + alertsByRule: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, +}; + +export const query = { + size: 0, + query: { + bool: { + filter: [ + { + bool: { + filter: [], + must: [], + must_not: [], + should: [], + }, + }, + { range: { '@timestamp': { gte: from, lte: to } } }, + ], + }, + }, + aggs: { + alertsByRule: { + terms: { + field: 'kibana.alert.rule.name', + size: 1000, + }, + aggs: { + ruleByEventType: { + terms: { + field: 'event.type', + size: 1000, + }, + }, + }, + }, + }, + runtime_mappings: undefined, +}; + +export const parsedAlerts: AlertsTypeData[] = [ + { rule: 'Test rule 1', type: 'Detection', value: 537, color: '#D36086' }, + { rule: 'Test rule 2', type: 'Detection', value: 27, color: '#D36086' }, + { rule: 'Test rule 3', type: 'Detection', value: 19, color: '#D36086' }, + { rule: 'Test rule 3', type: 'Prevention', value: 6, color: '#54B399' }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/translations.ts new file mode 100644 index 0000000000000..462ff3ea2774b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/translations.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { i18n } from '@kbn/i18n'; + +export const ALERTS_TYPE_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.alertsByType.alertTypeChartTitle', + { + defaultMessage: 'Alerts by type', + } +); + +export const ALERTS_TYPE_COLUMN_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.alertsByType.typeColumn', + { + defaultMessage: 'Type', + } +); + +export const PREVENTIONS = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.alertsByType.preventions', + { + defaultMessage: 'Preventions', + } +); + +export const DETECTIONS = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.alertsByType.detections', + { + defaultMessage: 'Detections', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/types.ts new file mode 100644 index 0000000000000..8f87d1ed81ed4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_by_type_panel/types.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { BucketItem } from '../../../../../common/search_strategy/security_solution/cti'; + +export type AlertType = 'Detection' | 'Prevention'; + +export interface AlertsByTypeAgg { + alertsByRule: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: RuleBucket[]; + }; +} + +interface RuleBucket { + key: string; + doc_count: number; + ruleByEventType?: RuleByEventType; +} + +interface RuleByEventType { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: BucketItem[]; +} + +export interface AlertsTypeData { + rule: string; + type: AlertType; + value: number; + color: string; +} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.test.tsx new file mode 100644 index 0000000000000..5f8141ad57e8e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.test.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { act, render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../../../common/mock'; +import { AlertsProgressBar } from './alerts_progress_bar'; +import { parsedAlerts } from './mock_data'; + +jest.mock('../../../../common/lib/kibana'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +describe('Alert by grouping', () => { + const defaultProps = { + data: [], + isLoading: false, + stackByField: 'host.name', + }; + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('progress bars renders correctly', () => { + act(() => { + const { container } = render( + + + + ); + expect( + container.querySelector(`[data-test-subj="alerts-progress-bar-title"]`)?.textContent + ).toEqual(defaultProps.stackByField); + expect(container.querySelector(`[data-test-subj="empty-proress-bar"]`)).toBeInTheDocument(); + expect(container.querySelector(`[data-test-subj="empty-proress-bar"]`)?.textContent).toEqual( + 'No items found' + ); + }); + }); + + test('progress bars renders correctly with data', () => { + act(() => { + const { container } = render( + + + + ); + expect( + container.querySelector(`[data-test-subj="alerts-progress-bar-title"]`)?.textContent + ).toEqual('host.name'); + expect(container.querySelector(`[data-test-subj="progress-bar"]`)).toBeInTheDocument(); + + expect( + container.querySelector(`[data-test-subj="empty-proress-bar"]`) + ).not.toBeInTheDocument(); + + parsedAlerts.forEach((alert, i) => { + expect( + container.querySelector(`[data-test-subj="progress-bar-${alert.key}"]`) + ).toBeInTheDocument(); + expect( + container.querySelector(`[data-test-subj="progress-bar-${alert.key}"]`)?.textContent + ).toContain(parsedAlerts[i].label); + expect( + container.querySelector(`[data-test-subj="progress-bar-${alert.key}"]`)?.textContent + ).toContain(parsedAlerts[i].percentage.toString()); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.tsx new file mode 100644 index 0000000000000..e20f10543845e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/alerts_progress_bar.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiProgress, EuiSpacer, EuiText, EuiHorizontalRule } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; +import type { AlertsProgressBarData } from './types'; +import { DefaultDraggable } from '../../../../common/components/draggables'; +import * as i18n from './translations'; + +const ProgressWrapper = styled.div` + height: 160px; +`; + +const StyledEuiText = styled(EuiText)` + margin-top: -${({ theme }) => theme.eui.euiSizeM}; +`; + +export interface AlertsProcessBarProps { + data: AlertsProgressBarData[]; + isLoading: boolean; + stackByField: string; + addFilter?: ({ field, value }: { field: string; value: string | number }) => void; +} + +export const AlertsProgressBar: React.FC = ({ + data, + isLoading, + stackByField, +}) => { + return ( + <> + +
{stackByField}
+
+ + {!isLoading && data.length === 0 ? ( + <> + + {i18n.EMPTY_DATA_MESSAGE} + + + + ) : ( + + {data.map((item) => ( +
+ + {`${item.percentage}%`} + + } + max={100} + color={`vis9`} + size="s" + value={item.percentage} + label={ + item.key === 'Other' ? ( + item.label + ) : ( + + + {item.key} + + + ) + } + /> + +
+ ))} +
+ )} + + ); +}; + +AlertsProgressBar.displayName = 'AlertsProgressBar'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/helpers.test.tsx new file mode 100644 index 0000000000000..06c7cc0ce7e16 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/helpers.test.tsx @@ -0,0 +1,26 @@ +/* + * 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 { parseAlertsGroupingData } from './helpers'; +import * as mock from './mock_data'; +import type { AlertsByGroupingAgg } from './types'; +import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; + +describe('parse progress bar data', () => { + test('parse alerts with data', () => { + const res = parseAlertsGroupingData( + mock.mockAlertsData as AlertSearchResponse<{}, AlertsByGroupingAgg> + ); + expect(res).toEqual(mock.parsedAlerts); + }); + + test('parse severity without data', () => { + const res = parseAlertsGroupingData( + mock.mockAlertsEmptyData as AlertSearchResponse<{}, AlertsByGroupingAgg> + ); + expect(res).toEqual([]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/helpers.tsx new file mode 100644 index 0000000000000..2540f0f4dda7e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/helpers.tsx @@ -0,0 +1,50 @@ +/* + * 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 { has } from 'lodash'; +import type { AlertsByGroupingAgg, AlertsProgressBarData } from './types'; +import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; +import type { BucketItem } from '../../../../../common/search_strategy/security_solution/cti'; +import type { SummaryChartsData } from '../alerts_summary_charts_panel/types'; +import * as i18n from './translations'; + +export const parseAlertsGroupingData = ( + response: AlertSearchResponse<{}, AlertsByGroupingAgg> +): AlertsProgressBarData[] => { + const buckets = response?.aggregations?.alertsByGrouping?.buckets ?? []; + if (buckets.length === 0) { + return []; + } + + const other = response?.aggregations?.alertsByGrouping?.sum_other_doc_count ?? 0; + const total = + buckets.reduce((acc: number, group: BucketItem) => acc + group.doc_count, 0) + other; + + const topAlerts = buckets.map((group) => { + return { + key: group.key, + value: group.doc_count, + percentage: Math.round((group.doc_count / total) * 1000) / 10, + label: group.key, + }; + }); + + topAlerts.push({ + key: 'Other', + value: other, + percentage: Math.round((other / total) * 1000) / 10, + label: i18n.OTHER, + }); + + return topAlerts; +}; + +export const isAlertsProgressBarData = ( + data: SummaryChartsData[] +): data is AlertsProgressBarData[] => { + return data?.every((x) => has(x, 'percentage')); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.test.tsx new file mode 100644 index 0000000000000..5016e98140d32 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../../../common/mock'; +import { AlertsProgressBarPanel } from '.'; +import { useSummaryChartData } from '../alerts_summary_charts_panel/use_summary_chart_data'; +import { STACK_BY_ARIA_LABEL } from '../common/translations'; + +jest.mock('../../../../common/lib/kibana'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +jest.mock('../alerts_summary_charts_panel/use_summary_chart_data'); +const mockUseSummaryChartData = useSummaryChartData as jest.Mock; + +const options = ['host.name', 'user.name', 'source.ip', 'destination.ip']; + +describe('Alert by grouping', () => { + const defaultProps = { + signalIndexName: 'signalIndexName', + skip: false, + }; + + beforeEach(() => { + mockUseSummaryChartData.mockReturnValue({ items: [], isLoading: false }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('renders correctly', async () => { + await act(async () => { + const { container } = render( + + + + ); + expect( + container.querySelector('[data-test-subj="alerts-progress-bar-panel"]') + ).toBeInTheDocument(); + }); + }); + + test('render HeaderSection', async () => { + await act(async () => { + const { container } = render( + + + + ); + expect(container.querySelector(`[data-test-subj="header-section"]`)).toBeInTheDocument(); + }); + }); + + test('renders inspect button', async () => { + await act(async () => { + const { container } = render( + + + + ); + expect(container.querySelector('[data-test-subj="inspect-icon-button"]')).toBeInTheDocument(); + }); + }); + + describe('combo box', () => { + test('renders combo box', async () => { + await act(async () => { + const { container } = render( + + + + ); + expect(container.querySelector('[data-test-subj="stackByComboBox"]')).toBeInTheDocument(); + }); + }); + + test('combo box renders corrected options', async () => { + await act(async () => { + render( + + + + ); + const comboBox = screen.getByRole('combobox', { name: STACK_BY_ARIA_LABEL }); + if (comboBox) { + comboBox.focus(); // display the combo box options + } + }); + const optionsFound = screen.getAllByRole('option').map((option) => option.textContent); + options.forEach((option, i) => { + expect(optionsFound[i]).toEqual(option); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.tsx new file mode 100644 index 0000000000000..0baeeb8e36d08 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/index.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiPanel, EuiLoadingSpinner } from '@elastic/eui'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import { v4 as uuid } from 'uuid'; +import type { ChartsPanelProps } from '../alerts_summary_charts_panel/types'; +import { HeaderSection } from '../../../../common/components/header_section'; +import { InspectButtonContainer } from '../../../../common/components/inspect'; +import { StackByComboBox } from '../common/components'; +import { AlertsProgressBar } from './alerts_progress_bar'; +import { useSummaryChartData } from '../alerts_summary_charts_panel/use_summary_chart_data'; +import { alertsGroupingAggregations } from '../alerts_summary_charts_panel/aggregations'; +import { showInitialLoadingSpinner } from '../alerts_histogram_panel/helpers'; +import { isAlertsProgressBarData } from './helpers'; +import * as i18n from './translations'; + +const TOP_ALERTS_CHART_ID = 'alerts-summary-top-alerts'; +const DEFAULT_COMBOBOX_WIDTH = 150; +const DEFAULT_OPTIONS = ['host.name', 'user.name', 'source.ip', 'destination.ip']; + +export const AlertsProgressBarPanel: React.FC = ({ + filters, + query, + signalIndexName, + runtimeMappings, + skip, +}) => { + const [stackByField, setStackByField] = useState('host.name'); + const [isInitialLoading, setIsInitialLoading] = useState(true); + const uniqueQueryId = useMemo(() => `${TOP_ALERTS_CHART_ID}-${uuid()}`, []); + const dropDownOptions = DEFAULT_OPTIONS.map((field) => { + return { value: field, label: field }; + }); + const aggregations = useMemo(() => alertsGroupingAggregations(stackByField), [stackByField]); + const onSelect = useCallback((field: string) => { + setStackByField(field); + }, []); + + const { items, isLoading } = useSummaryChartData({ + aggregations, + filters, + query, + signalIndexName, + runtimeMappings, + skip, + uniqueQueryId, + }); + const data = useMemo(() => (isAlertsProgressBarData(items) ? items : []), [items]); + useEffect(() => { + if (!showInitialLoadingSpinner({ isInitialLoading, isLoadingAlerts: isLoading })) { + setIsInitialLoading(false); + } + }, [isInitialLoading, isLoading, setIsInitialLoading]); + + return ( + + + + + + {isInitialLoading ? ( + + ) : ( + + )} + + + ); +}; + +AlertsProgressBarPanel.displayName = 'AlertsProgressBarPanel'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/mock_data.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/mock_data.ts new file mode 100644 index 0000000000000..6eed4866333bc --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/mock_data.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +const from = '2022-04-05T12:00:00.000Z'; +const to = '2022-04-08T12:00:00.000Z'; + +export const mockAlertsData = { + took: 0, + timeout: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 570, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + alertsByGrouping: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Host-v5biklvcy8', + doc_count: 234, + }, + { + key: 'Host-5y1uprxfv2', + doc_count: 186, + }, + { + key: 'Host-ssf1mhgy5c', + doc_count: 150, + }, + ], + }, + }, +}; + +export const mockAlertsEmptyData = { + took: 0, + timeout: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 0, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + alertsByGrouping: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, +}; + +export const query = { + size: 0, + query: { + bool: { + filter: [ + { bool: { filter: [], must: [], must_not: [], should: [] } }, + { range: { '@timestamp': { gte: from, lte: to } } }, + ], + }, + }, + aggs: { + alertsByGrouping: { + terms: { + field: 'host.name', + size: 10, + }, + }, + }, + runtime_mappings: undefined, +}; + +export const parsedAlerts = [ + { key: 'Host-v5biklvcy8', value: 234, label: 'Host-v5biklvcy8', percentage: 41.1 }, + { key: 'Host-5y1uprxfv2', value: 186, label: 'Host-5y1uprxfv2', percentage: 32.6 }, + { key: 'Host-ssf1mhgy5c', value: 150, label: 'Host-ssf1mhgy5c', percentage: 26.3 }, + { key: 'Other', value: 0, label: 'Other', percentage: 0 }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/translations.ts new file mode 100644 index 0000000000000..514aff9fef4e1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/translations.ts @@ -0,0 +1,28 @@ +/* + * 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 ALERT_BY_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.alertsByGrouping.chartTitle', + { + defaultMessage: 'Top alerts by', + } +); + +export const EMPTY_DATA_MESSAGE = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.alertsByGrouping.noItemsFoundMessage', + { + defaultMessage: 'No items found', + } +); + +export const OTHER = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.alertsByGrouping.otherGroup', + { + defaultMessage: 'Other', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/types.ts new file mode 100644 index 0000000000000..efc278f9b4c47 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_progress_bar_panel/types.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { BucketItem } from '../../../../../common/search_strategy/security_solution/cti'; + +export interface AlertsByGroupingAgg { + alertsByGrouping: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: BucketItem[]; + }; +} +export interface AlertsProgressBarData { + key: string; + value: number; + percentage: number; + label: string; +} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/aggregations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/aggregations.ts new file mode 100644 index 0000000000000..80151de9252bc --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/aggregations.ts @@ -0,0 +1,45 @@ +/* + * 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 { ALERT_SEVERITY, ALERT_RULE_NAME } from '@kbn/rule-data-utils'; + +const DEFAULT_QUERY_SIZE = 1000; + +export const severityAggregations = { + statusBySeverity: { + terms: { + field: ALERT_SEVERITY, + }, + }, +}; + +export const alertTypeAggregations = { + alertsByRule: { + terms: { + field: ALERT_RULE_NAME, + size: DEFAULT_QUERY_SIZE, + }, + aggs: { + ruleByEventType: { + terms: { + field: 'event.type', + size: DEFAULT_QUERY_SIZE, + }, + }, + }, + }, +}; + +export const alertsGroupingAggregations = (stackByField: string) => { + return { + alertsByGrouping: { + terms: { + field: stackByField, + size: 10, + }, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/helpers.test.tsx index f78ac40a0766d..37bd7e7c4b1a8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/helpers.test.tsx @@ -4,18 +4,30 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { parseSeverityAlerts } from './helpers'; -import { parsedAlerts, mockAlertsData, mockAlertsEmptyData } from './severity_donut/mock_data'; -import type { AlertsResponse, AlertsBySeverityAgg } from './types'; +import { parseData } from './helpers'; +import * as severityMock from '../severity_level_panel/mock_data'; +import * as alertsTypeMock from '../alerts_by_type_panel/mock_data'; +import * as alertsGroupingMock from '../alerts_progress_bar_panel/mock_data'; +import type { SummaryChartsAgg } from './types'; +import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; -describe('parse alerts by severity data', () => { - test('parse alerts with data', () => { - const res = parseSeverityAlerts(mockAlertsData as AlertsResponse<{}, AlertsBySeverityAgg>); - expect(res).toEqual(parsedAlerts); +describe('parse data by aggregation type', () => { + test('parse severity data', () => { + const res = parseData(severityMock.mockAlertsData as AlertSearchResponse<{}, SummaryChartsAgg>); + expect(res).toEqual(severityMock.parsedAlerts); }); - test('parse alerts without data', () => { - const res = parseSeverityAlerts(mockAlertsEmptyData); - expect(res).toEqual(null); + test('parse detections data', () => { + const res = parseData( + alertsTypeMock.mockAlertsData as AlertSearchResponse<{}, SummaryChartsAgg> + ); + expect(res).toEqual(alertsTypeMock.parsedAlerts); + }); + + test('parse host data', () => { + const res = parseData( + alertsGroupingMock.mockAlertsData as AlertSearchResponse<{}, SummaryChartsAgg> + ); + expect(res).toEqual(alertsGroupingMock.parsedAlerts); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/helpers.tsx index 2737d6d33e961..6ea064bf7b7be 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/helpers.tsx @@ -4,30 +4,22 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; -import type { AlertsResponse, AlertsBySeverityAgg, ParsedSeverityData } from './types'; -import * as i18n from './translations'; -import { severityLabels } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; -import { emptyDonutColor } from '../../../../common/components/charts/donutchart_empty'; -import { SEVERITY_COLOR } from '../../../../overview/components/detection_response/utils'; +import { isAlertsBySeverityAgg, isAlertsByTypeAgg, isAlertsByGroupingAgg } from './types'; +import type { SummaryChartsAgg } from './types'; +import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; +import { parseSeverityData } from '../severity_level_panel/helpers'; +import { parseAlertsTypeData } from '../alerts_by_type_panel/helpers'; +import { parseAlertsGroupingData } from '../alerts_progress_bar_panel/helpers'; -export const parseSeverityAlerts = ( - response: AlertsResponse<{}, AlertsBySeverityAgg> -): ParsedSeverityData => { - const severityBuckets = response?.aggregations?.statusBySeverity?.buckets ?? []; - if (severityBuckets.length === 0) { - return null; +export const parseData = (data: AlertSearchResponse<{}, SummaryChartsAgg>) => { + if (isAlertsBySeverityAgg(data)) { + return parseSeverityData(data); } - const data = severityBuckets.map((severity) => { - return { - key: severity.key, - value: severity.doc_count, - label: severityLabels[severity.key] ?? i18n.UNKNOWN_SEVERITY, - }; - }); - return data; -}; - -export const getSeverityColor = (severity: string) => { - return SEVERITY_COLOR[severity.toLocaleLowerCase() as Severity] ?? emptyDonutColor; + if (isAlertsByTypeAgg(data)) { + return parseAlertsTypeData(data); + } + if (isAlertsByGroupingAgg(data)) { + return parseAlertsGroupingData(data); + } + return []; }; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.test.tsx index a885eca625c4d..ac5d20fcdcfa9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.test.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { act, render, fireEvent } from '@testing-library/react'; +import { act, render, fireEvent, waitFor } from '@testing-library/react'; import React from 'react'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; import { TestProviders } from '../../../../common/mock'; @@ -18,22 +18,16 @@ jest.mock('react-router-dom', () => { return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; }); -describe('AlertsChartsPanel', () => { +describe('AlertsSummaryChartsPanel', () => { const defaultProps = { signalIndexName: 'signalIndexName', }; - const mockSetToggle = jest.fn(); const mockUseQueryToggle = useQueryToggle as jest.Mock; beforeEach(() => { mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle }); }); - afterEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - test('renders correctly', async () => { await act(async () => { const { container } = render( @@ -60,19 +54,21 @@ describe('AlertsChartsPanel', () => { describe('Query', () => { test('it render with a illegal KQL', async () => { - await act(async () => { - jest.mock('@kbn/es-query', () => ({ - buildEsQuery: jest.fn().mockImplementation(() => { - throw new Error('Something went wrong'); - }), - })); - const props = { ...defaultProps, query: { query: 'host.name: "', language: 'kql' } }; - const { container } = render( - - - - ); - expect(container.querySelector('[data-test-subj="severty-chart"]')).toBeInTheDocument(); + jest.mock('@kbn/es-query', () => ({ + buildEsQuery: jest.fn().mockImplementation(() => { + throw new Error('Something went wrong'); + }), + })); + const props = { ...defaultProps, query: { query: 'host.name: "', language: 'kql' } }; + const { container } = render( + + + + ); + await waitFor(() => { + expect( + container.querySelector('[data-test-subj="alerts-charts-panel"]') + ).toBeInTheDocument(); }); }); }); @@ -107,8 +103,8 @@ describe('AlertsChartsPanel', () => { }); test('toggleStatus=false, hide', async () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); await act(async () => { - mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); const { container } = render( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.tsx index b49a91069fce2..951aeefa1f82f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.tsx @@ -4,32 +4,28 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui'; -import React, { useMemo, useCallback, useState, useEffect } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useCallback, useState, useEffect } from 'react'; import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; import type { Filter, Query } from '@kbn/es-query'; -import { v4 as uuidv4 } from 'uuid'; +import styled from 'styled-components'; import * as i18n from './translations'; import { KpiPanel } from '../common/components'; import { HeaderSection } from '../../../../common/components/header_section'; +import { SeverityLevelPanel } from '../severity_level_panel'; +import { AlertsByTypePanel } from '../alerts_by_type_panel'; +import { AlertsProgressBarPanel } from '../alerts_progress_bar_panel'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; -import { useSeverityChartData } from './severity_donut/use_severity_chart_data'; -import { SeverityLevelChart } from './severity_donut/severity_level_chart'; -const DETECTIONS_ALERTS_CHARTS_ID = 'detections-alerts-charts'; +const StyledFlexGroup = styled(EuiFlexGroup)` + @media only screen and (min-width: ${({ theme }) => theme.eui.euiBreakpoints.l}); +`; -const PlaceHolder = ({ title }: { title: string }) => { - return ( - - - -

{title}

-
-
-
- ); -}; +const StyledFlexItem = styled(EuiFlexItem)` + min-width: 355px; +`; + +const DETECTIONS_ALERTS_CHARTS_ID = 'detections-alerts-charts'; interface Props { alignHeader?: 'center' | 'baseline' | 'stretch' | 'flexStart' | 'flexEnd'; @@ -52,11 +48,9 @@ export const AlertsSummaryChartsPanel: React.FC = ({ signalIndexName, title = i18n.CHARTS_TITLE, }: Props) => { - // create a unique, but stable (across re-renders) query id - const uniqueQueryId = useMemo(() => `${DETECTIONS_ALERTS_CHARTS_ID}-${uuidv4()}`, []); - const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_ALERTS_CHARTS_ID); const [querySkip, setQuerySkip] = useState(!toggleStatus); + useEffect(() => { setQuerySkip(!toggleStatus); }, [toggleStatus]); @@ -69,15 +63,6 @@ export const AlertsSummaryChartsPanel: React.FC = ({ [setQuerySkip, setToggleStatus] ); - const { items: severityData, isLoading: isSeverityLoading } = useSeverityChartData({ - filters, - query, - signalIndexName, - runtimeMappings, - skip: querySkip, - uniqueQueryId, - }); - return ( = ({ toggleQuery={toggleQuery} /> {toggleStatus && ( - - - - - + + + + + + + + + + + )} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/severity_level_chart.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/severity_level_chart.test.tsx deleted file mode 100644 index 3b6b262efb545..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/severity_level_chart.test.tsx +++ /dev/null @@ -1,57 +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 { render } from '@testing-library/react'; -import React from 'react'; -import { TestProviders } from '../../../../../common/mock'; -import { SeverityLevelChart } from './severity_level_chart'; - -jest.mock('../../../../../common/lib/kibana'); - -jest.mock('react-router-dom', () => { - const actual = jest.requireActual('react-router-dom'); - return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; -}); - -describe('Severity level chart', () => { - const defaultProps = { - data: [], - isLoading: false, - uniqueQueryId: 'test-query-id', - }; - - afterEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - - test('renders correctly', () => { - const { container } = render( - - - - ); - expect(container.querySelector('[data-test-subj="severty-chart"]')).toBeInTheDocument(); - }); - - test('render HeaderSection', () => { - const { container } = render( - - - - ); - expect(container.querySelector(`[data-test-subj="header-section"]`)).toBeInTheDocument(); - }); - - test('inspect button renders correctly', () => { - const { container } = render( - - - - ); - expect(container.querySelector('[data-test-subj="inspect-icon-button"]')).toBeInTheDocument(); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/severity_level_chart.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/severity_level_chart.tsx deleted file mode 100644 index 1183c66735f29..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/severity_level_chart.tsx +++ /dev/null @@ -1,119 +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 { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiInMemoryTable } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; -import { isEmpty } from 'lodash/fp'; -import { ALERT_SEVERITY } from '@kbn/rule-data-utils'; -import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import type { ShapeTreeNode, ElementClickListener } from '@elastic/charts'; -import * as i18n from '../translations'; -import type { ParsedSeverityData, SeverityData } from '../types'; -import type { FillColor } from '../../../../../common/components/charts/donutchart'; -import { DonutChart } from '../../../../../common/components/charts/donutchart'; -import { ChartLabel } from '../../../../../overview/components/detection_response/alerts_by_status/chart_label'; -import { HeaderSection } from '../../../../../common/components/header_section'; -import { InspectButtonContainer } from '../../../../../common/components/inspect'; -import { getSeverityTableColumns } from '../columns'; -import { getSeverityColor } from '../helpers'; - -const DONUT_HEIGHT = 150; - -interface AlertsChartsPanelProps { - data: ParsedSeverityData; - isLoading: boolean; - uniqueQueryId: string; - addFilter?: ({ field, value }: { field: string; value: string | number }) => void; -} - -export const SeverityLevelChart: React.FC = ({ - data, - isLoading, - uniqueQueryId, - addFilter, -}) => { - const fillColor: FillColor = useCallback((d: ShapeTreeNode) => { - return getSeverityColor(d.dataName); - }, []); - - const columns = useMemo(() => getSeverityTableColumns(), []); - const items = data ?? []; - - const count = useMemo(() => { - return data - ? data.reduce(function (prev, cur) { - return prev + cur.value; - }, 0) - : 0; - }, [data]); - - const sorting: { sort: { field: keyof SeverityData; direction: SortOrder } } = { - sort: { - field: 'value', - direction: 'desc', - }, - }; - - const onElementClick: ElementClickListener = useCallback( - (event) => { - const flattened = event.flat(2); - const level = - flattened.length > 0 && - 'groupByRollup' in flattened[0] && - flattened[0].groupByRollup != null - ? `${flattened[0].groupByRollup}` - : ''; - - if (addFilter != null && !isEmpty(level.trim())) { - addFilter({ field: ALERT_SEVERITY, value: level.toLowerCase() }); - } - }, - [addFilter] - ); - - return ( - - - - - - - - - - } - totalCount={count} - onElementClick={onElementClick} - /> - - - - - - ); -}; - -SeverityLevelChart.displayName = 'SeverityLevelChart'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/use_severity_chart_data.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/use_severity_chart_data.test.tsx deleted file mode 100644 index 0d48d023d06a3..0000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/use_severity_chart_data.test.tsx +++ /dev/null @@ -1,131 +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 { renderHook } from '@testing-library/react-hooks'; -import { TestProviders } from '../../../../../common/mock'; -import { ALERTS_QUERY_NAMES } from '../../../../containers/detection_engine/alerts/constants'; -import { mockAlertsData, alertsBySeverityQuery, parsedAlerts, from, to } from './mock_data'; -import type { UseAlertsBySeverity, UseSeverityChartProps } from './use_severity_chart_data'; -import { useSeverityChartData } from './use_severity_chart_data'; - -const dateNow = new Date('2022-04-08T12:00:00.000Z').valueOf(); -const mockDateNow = jest.fn().mockReturnValue(dateNow); -Date.now = jest.fn(() => mockDateNow()) as unknown as DateConstructor['now']; - -const defaultUseQueryAlertsReturn = { - loading: false, - data: null, - setQuery: () => {}, - response: '', - request: '', - refetch: () => {}, -}; -const mockUseQueryAlerts = jest.fn().mockReturnValue(defaultUseQueryAlertsReturn); -jest.mock('../../../../containers/detection_engine/alerts/use_query', () => { - return { - useQueryAlerts: (...props: unknown[]) => mockUseQueryAlerts(...props), - }; -}); - -const mockUseGlobalTime = jest - .fn() - .mockReturnValue({ from, to, setQuery: jest.fn(), deleteQuery: jest.fn() }); -jest.mock('../../../../../common/containers/use_global_time', () => { - return { - useGlobalTime: (...props: unknown[]) => mockUseGlobalTime(...props), - }; -}); - -// helper function to render the hook -const renderUseSeverityChartData = (props: Partial = {}) => - renderHook>( - () => - useSeverityChartData({ - uniqueQueryId: 'test', - signalIndexName: 'signal-alerts', - ...props, - }), - { - wrapper: TestProviders, - } - ); - -describe('useSeverityChartData', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockDateNow.mockReturnValue(dateNow); - mockUseQueryAlerts.mockReturnValue(defaultUseQueryAlertsReturn); - }); - - it('should return default values', () => { - const { result } = renderUseSeverityChartData(); - - expect(result.current).toEqual({ - items: null, - isLoading: false, - updatedAt: dateNow, - }); - - expect(mockUseQueryAlerts).toBeCalledWith({ - query: alertsBySeverityQuery, - indexName: 'signal-alerts', - skip: false, - queryName: ALERTS_QUERY_NAMES.COUNT, - }); - }); - - it('should return parsed items', () => { - mockUseQueryAlerts.mockReturnValue({ - ...defaultUseQueryAlertsReturn, - data: mockAlertsData, - }); - - const { result } = renderUseSeverityChartData(); - expect(result.current).toEqual({ - items: parsedAlerts, - isLoading: false, - updatedAt: dateNow, - }); - }); - - it('should return new updatedAt', () => { - const newDateNow = new Date('2022-04-08T14:00:00.000Z').valueOf(); - mockDateNow.mockReturnValue(newDateNow); // setUpdatedAt call - mockDateNow.mockReturnValueOnce(dateNow); // initialization call - - mockUseQueryAlerts.mockReturnValue({ - ...defaultUseQueryAlertsReturn, - data: mockAlertsData, - }); - - const { result } = renderUseSeverityChartData(); - - expect(mockDateNow).toHaveBeenCalled(); - expect(result.current).toEqual({ - items: parsedAlerts, - isLoading: false, - updatedAt: newDateNow, - }); - }); - - it('should skip the query', () => { - const { result } = renderUseSeverityChartData({ skip: true }); - - expect(mockUseQueryAlerts).toBeCalledWith({ - query: alertsBySeverityQuery, - indexName: 'signal-alerts', - skip: true, - queryName: ALERTS_QUERY_NAMES.COUNT, - }); - - expect(result.current).toEqual({ - items: null, - isLoading: false, - updatedAt: dateNow, - }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/translations.ts index 77499e59a452e..c55bca449e82d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/translations.ts @@ -7,57 +7,8 @@ import { i18n } from '@kbn/i18n'; export const CHARTS_TITLE = i18n.translate( - 'xpack.securitySolution.detectionEngine.alerts.charts.chartsTitle', + 'xpack.securitySolution.detectionEngine.alerts.chartsTitle', { defaultMessage: 'Charts', } ); - -export const SEVERITY_LEVELS_TITLE = i18n.translate( - 'xpack.securitySolution.detectionResponse.alertsBySeverity.severityTitle', - { - defaultMessage: 'Severity levels', - } -); - -export const SEVERITY_TOTAL_ALERTS = i18n.translate( - 'xpack.securitySolution.detectionResponse.alertsBySeverity.donut.totalAlerts', - { - defaultMessage: 'alerts', - } -); - -export const UNKNOWN_SEVERITY = i18n.translate( - 'xpack.securitySolution.detectionResponse.alertsBySeverity.unknown', - { - defaultMessage: 'Unknown', - } -); - -export const SEVERITY_LEVEL_COLUMN_TITLE = i18n.translate( - 'xpack.securitySolution.detectionResponse.alertsBySeverity.tableColumnLevelTitle', - { - defaultMessage: 'Levels', - } -); - -export const SEVERITY_COUNT_COULMN_TITLE = i18n.translate( - 'xpack.securitySolution.detectionResponse.alertsBySeverity.tableColumnCountTitle', - { - defaultMessage: 'Counts', - } -); - -export const DETECTIONS_TITLE = i18n.translate( - 'xpack.securitySolution.detectionResponse.alertsBySeverity.chartDetectionTitle', - { - defaultMessage: 'Detections', - } -); - -export const ALERT_BY_HOST_TITLE = i18n.translate( - 'xpack.securitySolution.detectionResponse.alertsBySeverity.chartAlertHostTitle', - { - defaultMessage: 'Alert by host type', - } -); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/types.ts index 5230a4f06f6fa..6e25ac136281a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/types.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/types.ts @@ -4,45 +4,45 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; +import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; +import type { Filter, Query } from '@kbn/es-query'; +import { has } from 'lodash'; +import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; +import type { SeverityBuckets as SeverityData } from '../../../../overview/components/detection_response/alerts_by_status/types'; +import type { AlertsBySeverityAgg } from '../severity_level_panel/types'; +import type { AlertsByTypeAgg, AlertsTypeData } from '../alerts_by_type_panel/types'; +import type { + AlertsByGroupingAgg, + AlertsProgressBarData, +} from '../alerts_progress_bar_panel/types'; -export interface EntityFilter { - field: string; - value: string; -} +export type SummaryChartsAgg = Partial; -export type ParsedSeverityData = SeverityData[] | undefined | null; -export interface SeverityData { - key: Severity; - value: number; - label: string; -} +export type SummaryChartsData = SeverityData | AlertsTypeData | AlertsProgressBarData; -export interface AlertsBySeverityAgg { - statusBySeverity: { - doc_count_error_upper_bound: number; - sum_other_doc_count: number; - buckets: SeverityBucket[]; - }; -} -interface SeverityBucket { - key: Severity; - doc_count: number; -} -export interface AlertsResponse { - took: number; - _shards: { - total: number; - successful: number; - skipped: number; - failed: number; - }; - aggregations?: Aggregations; - hits: { - total: { - value: number; - relation: string; - }; - hits: Hit[]; - }; +export interface ChartsPanelProps { + filters?: Filter[]; + query?: Query; + signalIndexName: string | null; + runtimeMappings?: MappingRuntimeFields; + skip?: boolean; + addFilter?: ({ field, value }: { field: string; value: string | number }) => void; } + +export const isAlertsBySeverityAgg = ( + data: AlertSearchResponse<{}, SummaryChartsAgg> +): data is AlertSearchResponse<{}, AlertsBySeverityAgg> => { + return has(data, 'aggregations.statusBySeverity'); +}; + +export const isAlertsByTypeAgg = ( + data: AlertSearchResponse<{}, SummaryChartsAgg> +): data is AlertSearchResponse<{}, AlertsByTypeAgg> => { + return has(data, 'aggregations.alertsByRule'); +}; + +export const isAlertsByGroupingAgg = ( + data: AlertSearchResponse<{}, SummaryChartsAgg> +): data is AlertSearchResponse<{}, AlertsByGroupingAgg> => { + return has(data, 'aggregations.alertsByGrouping'); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data.test.tsx new file mode 100644 index 0000000000000..d5a835d662af2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data.test.tsx @@ -0,0 +1,258 @@ +/* + * 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 { TestProviders } from '../../../../common/mock'; +import { ALERTS_QUERY_NAMES } from '../../../containers/detection_engine/alerts/constants'; +import type { UseAlerts, UseAlertsQueryProps } from './use_summary_chart_data'; +import { useSummaryChartData, getAlertsQuery } from './use_summary_chart_data'; +import * as aggregations from './aggregations'; +import * as severityMock from '../severity_level_panel/mock_data'; +import * as alertTypeMock from '../alerts_by_type_panel/mock_data'; +import * as alertsGroupingMock from '../alerts_progress_bar_panel/mock_data'; + +const from = '2022-04-05T12:00:00.000Z'; +const to = '2022-04-08T12:00:00.000Z'; +const additionalFilters = [{ bool: { filter: [], must: [], must_not: [], should: [] } }]; + +const dateNow = new Date(to).valueOf(); +const mockDateNow = jest.fn().mockReturnValue(dateNow); +Date.now = jest.fn(() => mockDateNow()) as unknown as DateConstructor['now']; + +const defaultUseQueryAlertsReturn = { + loading: false, + data: null, + setQuery: () => {}, + response: '', + request: '', + refetch: () => {}, +}; +const mockUseQueryAlerts = jest.fn().mockReturnValue(defaultUseQueryAlertsReturn); +jest.mock('../../../containers/detection_engine/alerts/use_query', () => { + return { + useQueryAlerts: (...props: unknown[]) => mockUseQueryAlerts(...props), + }; +}); + +const mockUseGlobalTime = jest + .fn() + .mockReturnValue({ from, to, setQuery: jest.fn(), deleteQuery: jest.fn() }); +jest.mock('../../../../common/containers/use_global_time', () => { + return { + useGlobalTime: (...props: unknown[]) => mockUseGlobalTime(...props), + }; +}); + +describe('getAlertsQuery', () => { + test('it returns the expected severity query', () => { + expect( + getAlertsQuery({ + from, + to, + additionalFilters, + aggregations: aggregations.severityAggregations, + }) + ).toEqual(severityMock.query); + }); + + test('it returns the expected alerts by type query', () => { + expect( + getAlertsQuery({ + from, + to, + additionalFilters, + aggregations: aggregations.alertTypeAggregations, + }) + ).toEqual(alertTypeMock.query); + }); + + test('it returns the expected alerts by grouping query', () => { + expect( + getAlertsQuery({ + from, + to, + additionalFilters, + aggregations: aggregations.alertsGroupingAggregations('host.name'), + }) + ).toEqual(alertsGroupingMock.query); + }); +}); + +// helper function to render the hook +const renderUseSummaryChartData = (props: Partial = {}) => + renderHook>( + () => + useSummaryChartData({ + aggregations: aggregations.severityAggregations, + uniqueQueryId: 'test', + signalIndexName: 'signal-alerts', + ...props, + }), + { + wrapper: TestProviders, + } + ); + +describe('get severity chart data', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDateNow.mockReturnValue(dateNow); + mockUseQueryAlerts.mockReturnValue(defaultUseQueryAlertsReturn); + }); + + it('should return default values', () => { + const { result } = renderUseSummaryChartData(); + + expect(result.current).toEqual({ + items: [], + isLoading: false, + updatedAt: dateNow, + }); + + expect(mockUseQueryAlerts).toBeCalledWith({ + query: severityMock.query, + indexName: 'signal-alerts', + skip: false, + queryName: ALERTS_QUERY_NAMES.COUNT, + }); + }); + + it('should return parsed items', () => { + mockUseQueryAlerts.mockReturnValue({ + ...defaultUseQueryAlertsReturn, + data: severityMock.mockAlertsData, + }); + + const { result } = renderUseSummaryChartData(); + expect(result.current).toEqual({ + items: severityMock.parsedAlerts, + isLoading: false, + updatedAt: dateNow, + }); + }); + + it('should return new updatedAt', () => { + const newDateNow = new Date('2022-04-08T14:00:00.000Z').valueOf(); + mockDateNow.mockReturnValue(newDateNow); // setUpdatedAt call + mockDateNow.mockReturnValueOnce(dateNow); // initialization call + + mockUseQueryAlerts.mockReturnValue({ + ...defaultUseQueryAlertsReturn, + data: severityMock.mockAlertsData, + }); + + const { result } = renderUseSummaryChartData(); + + expect(mockDateNow).toHaveBeenCalled(); + expect(result.current).toEqual({ + items: severityMock.parsedAlerts, + isLoading: false, + updatedAt: newDateNow, + }); + }); + + it('should skip the query', () => { + const { result } = renderUseSummaryChartData({ skip: true }); + + expect(mockUseQueryAlerts).toBeCalledWith({ + query: severityMock.query, + indexName: 'signal-alerts', + skip: true, + queryName: ALERTS_QUERY_NAMES.COUNT, + }); + + expect(result.current).toEqual({ + items: [], + isLoading: false, + updatedAt: dateNow, + }); + }); + + describe('get alerts by type data', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDateNow.mockReturnValue(dateNow); + mockUseQueryAlerts.mockReturnValue(defaultUseQueryAlertsReturn); + }); + it('should return default values', () => { + const { result } = renderUseSummaryChartData({ + aggregations: aggregations.alertTypeAggregations, + }); + + expect(result.current).toEqual({ + items: [], + isLoading: false, + updatedAt: dateNow, + }); + + expect(mockUseQueryAlerts).toBeCalledWith({ + query: alertTypeMock.query, + indexName: 'signal-alerts', + skip: false, + queryName: ALERTS_QUERY_NAMES.COUNT, + }); + }); + + it('should return parsed alerts by type items', () => { + mockUseQueryAlerts.mockReturnValue({ + ...defaultUseQueryAlertsReturn, + data: alertTypeMock.mockAlertsData, + }); + + const { result } = renderUseSummaryChartData({ + aggregations: aggregations.alertTypeAggregations, + }); + expect(result.current).toEqual({ + items: alertTypeMock.parsedAlerts, + isLoading: false, + updatedAt: dateNow, + }); + }); + }); + + describe('get top alerts data', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDateNow.mockReturnValue(dateNow); + mockUseQueryAlerts.mockReturnValue(defaultUseQueryAlertsReturn); + }); + it('should return default values', () => { + const { result } = renderUseSummaryChartData({ + aggregations: aggregations.alertsGroupingAggregations('host.name'), + }); + + expect(result.current).toEqual({ + items: [], + isLoading: false, + updatedAt: dateNow, + }); + + expect(mockUseQueryAlerts).toBeCalledWith({ + query: alertsGroupingMock.query, + indexName: 'signal-alerts', + skip: false, + queryName: ALERTS_QUERY_NAMES.COUNT, + }); + }); + + it('should return parsed top alert items', () => { + mockUseQueryAlerts.mockReturnValue({ + ...defaultUseQueryAlertsReturn, + data: alertsGroupingMock.mockAlertsData, + }); + + const { result } = renderUseSummaryChartData({ + aggregations: aggregations.alertsGroupingAggregations('host.name'), + }); + expect(result.current).toEqual({ + items: alertsGroupingMock.parsedAlerts, + isLoading: false, + updatedAt: dateNow, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/use_severity_chart_data.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data.tsx similarity index 67% rename from x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/use_severity_chart_data.ts rename to x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data.tsx index ce51c78f3f64e..fb5024d3c2e50 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/use_severity_chart_data.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data.tsx @@ -5,31 +5,50 @@ * 2.0. */ -import { useCallback, useEffect, useState, useMemo } from 'react'; -import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; +import { useEffect, useState, useMemo, useCallback } from 'react'; import { buildEsQuery } from '@kbn/es-query'; -import { ALERT_SEVERITY } from '@kbn/rule-data-utils'; +import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; import type { Filter, Query } from '@kbn/es-query'; -import type { AlertsBySeverityAgg, EntityFilter, ParsedSeverityData } from '../types'; -import type { ESBoolQuery } from '../../../../../../common/typed_json'; -import { useGlobalTime } from '../../../../../common/containers/use_global_time'; -import { useQueryAlerts } from '../../../../containers/detection_engine/alerts/use_query'; -import { ALERTS_QUERY_NAMES } from '../../../../containers/detection_engine/alerts/constants'; -import { useInspectButton } from '../../common/hooks'; -import { parseSeverityAlerts } from '../helpers'; +import type { SummaryChartsAgg, SummaryChartsData } from './types'; +import type { EntityFilter } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; +import type { ESBoolQuery } from '../../../../../common/typed_json'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import { useQueryAlerts } from '../../../containers/detection_engine/alerts/use_query'; +import { ALERTS_QUERY_NAMES } from '../../../containers/detection_engine/alerts/constants'; +import { useInspectButton } from '../common/hooks'; +import { parseData } from './helpers'; + +export type UseAlerts = (props: UseAlertsQueryProps) => { + items: SummaryChartsData[]; + isLoading: boolean; + updatedAt: number; +}; + +export interface UseAlertsQueryProps { + aggregations: {}; + uniqueQueryId: string; + signalIndexName: string | null; + skip?: boolean; + entityFilter?: EntityFilter; + query?: Query; + filters?: Filter[]; + runtimeMappings?: MappingRuntimeFields; +} -export const getAlertsBySeverityQuery = ({ +export const getAlertsQuery = ({ additionalFilters = [], from, to, entityFilter, runtimeMappings, + aggregations, }: { from: string; to: string; entityFilter?: EntityFilter; additionalFilters?: ESBoolQuery[]; runtimeMappings?: MappingRuntimeFields; + aggregations: {}; }) => ({ size: 0, query: { @@ -49,33 +68,12 @@ export const getAlertsBySeverityQuery = ({ ], }, }, - aggs: { - statusBySeverity: { - terms: { - field: ALERT_SEVERITY, - }, - }, - }, + aggs: aggregations, runtime_mappings: runtimeMappings, }); -export interface UseSeverityChartProps { - uniqueQueryId: string; - signalIndexName: string | null; - skip?: boolean; - entityFilter?: EntityFilter; - query?: Query; - filters?: Filter[]; - runtimeMappings?: MappingRuntimeFields; -} - -export type UseAlertsBySeverity = (props: UseSeverityChartProps) => { - items: ParsedSeverityData; - isLoading: boolean; - updatedAt: number; -}; - -export const useSeverityChartData: UseAlertsBySeverity = ({ +export const useSummaryChartData: UseAlerts = ({ + aggregations, uniqueQueryId, entityFilter, query, @@ -84,9 +82,9 @@ export const useSeverityChartData: UseAlertsBySeverity = ({ signalIndexName, skip = false, }) => { - const { to, from, deleteQuery, setQuery } = useGlobalTime(); + const { to, from, deleteQuery, setQuery } = useGlobalTime(false); const [updatedAt, setUpdatedAt] = useState(Date.now()); - const [items, setItems] = useState(null); + const [items, setItems] = useState([]); const additionalFilters = useMemo(() => { try { @@ -109,13 +107,14 @@ export const useSeverityChartData: UseAlertsBySeverity = ({ request, response, setQuery: setAlertsQuery, - } = useQueryAlerts<{}, AlertsBySeverityAgg>({ - query: getAlertsBySeverityQuery({ + } = useQueryAlerts<{}, SummaryChartsAgg>({ + query: getAlertsQuery({ from, to, entityFilter, additionalFilters, runtimeMappings, + aggregations, }), indexName: signalIndexName, skip, @@ -124,21 +123,22 @@ export const useSeverityChartData: UseAlertsBySeverity = ({ useEffect(() => { setAlertsQuery( - getAlertsBySeverityQuery({ + getAlertsQuery({ from, to, entityFilter, additionalFilters, runtimeMappings, + aggregations, }) ); - }, [setAlertsQuery, from, to, entityFilter, additionalFilters, runtimeMappings]); + }, [setAlertsQuery, from, to, entityFilter, additionalFilters, runtimeMappings, aggregations]); useEffect(() => { if (data == null) { - setItems(null); + setItems([]); } else { - setItems(parseSeverityAlerts(data)); + setItems(parseData(data)); } setUpdatedAt(Date.now()); }, [data]); 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 4395405580362..31b137e24cd15 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 @@ -5,6 +5,7 @@ * 2.0. */ +import type { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiPanel, EuiComboBox } from '@elastic/eui'; import styled from 'styled-components'; import type { LegacyRef } from 'react'; @@ -58,6 +59,7 @@ interface StackedBySelectProps { inputRef?: (inputRef: HTMLInputElement | null) => void; onSelect: (selected: string) => void; width?: number; + dropDownoptions?: Array>; } export const StackByComboBoxWrapper = styled.div<{ width: number }>` @@ -76,6 +78,7 @@ export const StackByComboBox = React.forwardRef( selected, inputRef, width = DEFAULT_WIDTH, + dropDownoptions, }: StackedBySelectProps, ref ) => { @@ -92,6 +95,7 @@ export const StackByComboBox = React.forwardRef( const selectedOptions = useMemo(() => { return [{ label: selected, value: selected }]; }, [selected]); + const stackOptions = useStackByFields(); const singleSelection = useMemo(() => { return { asPlainText: true }; @@ -109,7 +113,7 @@ export const StackByComboBox = React.forwardRef( singleSelection={singleSelection} isClearable={false} sortMatchesBy="startsWith" - options={stackOptions} + options={dropDownoptions ?? stackOptions} selectedOptions={selectedOptions} compressed onChange={onChange} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/columns.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/columns.tsx similarity index 86% rename from x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/columns.tsx rename to x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/columns.tsx index ee9de4667ec26..8593c12874206 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/columns.tsx @@ -6,22 +6,18 @@ */ import React from 'react'; import { EuiHealth, EuiText } from '@elastic/eui'; -import type { EuiBasicTableColumn } from '@elastic/eui'; -import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; import { capitalize } from 'lodash'; import { ALERT_SEVERITY } from '@kbn/rule-data-utils'; +import type { EuiBasicTableColumn } from '@elastic/eui'; +import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; +import type { SeverityBuckets as SeverityData } from '../../../../overview/components/detection_response/alerts_by_status/types'; import { DefaultDraggable } from '../../../../common/components/draggables'; import { SEVERITY_COLOR } from '../../../../overview/components/detection_response/utils'; import { FormattedCount } from '../../../../common/components/formatted_number'; +import { COUNT_TABLE_TITLE } from '../alerts_count_panel/translations'; import * as i18n from './translations'; -interface SeverityTableItem { - key: Severity; - value: number; - label: string; -} - -export const getSeverityTableColumns = (): Array> => [ +export const getSeverityTableColumns = (): Array> => [ { field: 'key', name: i18n.SEVERITY_LEVEL_COLUMN_TITLE, @@ -42,10 +38,11 @@ export const getSeverityTableColumns = (): Array ( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/helpers.test.tsx new file mode 100644 index 0000000000000..9e493ba8fe48f --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/helpers.test.tsx @@ -0,0 +1,26 @@ +/* + * 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 { parseSeverityData } from './helpers'; +import * as mock from './mock_data'; +import type { AlertsBySeverityAgg } from './types'; +import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; + +describe('parse severity data', () => { + test('parse alerts with data', () => { + const res = parseSeverityData( + mock.mockAlertsData as AlertSearchResponse<{}, AlertsBySeverityAgg> + ); + expect(res).toEqual(mock.parsedAlerts); + }); + + test('parse alerts without data', () => { + const res = parseSeverityData( + mock.mockAlertsEmptyData as AlertSearchResponse<{}, AlertsBySeverityAgg> + ); + expect(res).toEqual([]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/helpers.tsx new file mode 100644 index 0000000000000..165bbef35fcfc --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/helpers.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; +import { has } from 'lodash'; +import type { AlertsBySeverityAgg } from './types'; +import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types'; +import type { SeverityBuckets as SeverityData } from '../../../../overview/components/detection_response/alerts_by_status/types'; +import type { SummaryChartsData } from '../alerts_summary_charts_panel/types'; +import { severityLabels } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; +import { emptyDonutColor } from '../../../../common/components/charts/donutchart_empty'; +import { SEVERITY_COLOR } from '../../../../overview/components/detection_response/utils'; +import * as i18n from './translations'; + +export const getSeverityColor = (severity: string) => { + return SEVERITY_COLOR[severity.toLocaleLowerCase() as Severity] ?? emptyDonutColor; +}; + +export const parseSeverityData = ( + response: AlertSearchResponse<{}, AlertsBySeverityAgg> +): SeverityData[] => { + const severityBuckets = response?.aggregations?.statusBySeverity?.buckets ?? []; + + return severityBuckets.length === 0 + ? [] + : severityBuckets.map((severity) => { + return { + key: severity.key, + value: severity.doc_count, + label: severityLabels[severity.key] ?? i18n.UNKNOWN_SEVERITY, + }; + }); +}; + +export const isAlertsBySeverityData = (data: SummaryChartsData[]): data is SeverityData[] => { + return data?.every((x) => has(x, 'key')); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/index.test.tsx new file mode 100644 index 0000000000000..f7e8ebdd9bf51 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/index.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { act, render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../../../common/mock'; +import { SeverityLevelPanel } from '.'; + +jest.mock('../../../../common/lib/kibana'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +describe('Severity level panel', () => { + const defaultProps = { + signalIndexName: 'signalIndexName', + skip: false, + }; + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('renders correctly', async () => { + await act(async () => { + const { container } = render( + + + + ); + expect(container.querySelector('[data-test-subj="severty-level-panel"]')).toBeInTheDocument(); + }); + }); + + test('render HeaderSection', async () => { + await act(async () => { + const { container } = render( + + + + ); + expect(container.querySelector(`[data-test-subj="header-section"]`)).toBeInTheDocument(); + }); + }); + + test('inspect button renders correctly', async () => { + await act(async () => { + const { container } = render( + + + + ); + expect(container.querySelector('[data-test-subj="inspect-icon-button"]')).toBeInTheDocument(); + }); + }); + + test('renders severity chart correctly', async () => { + await act(async () => { + const { container } = render( + + + + ); + expect( + container.querySelector(`[data-test-subj="severity-level-chart"]`) + ).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/index.tsx new file mode 100644 index 0000000000000..04ad4cbed8572 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/index.tsx @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiPanel } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { v4 as uuid } from 'uuid'; +import type { ChartsPanelProps } from '../alerts_summary_charts_panel/types'; +import { HeaderSection } from '../../../../common/components/header_section'; +import { InspectButtonContainer } from '../../../../common/components/inspect'; +import { useSummaryChartData } from '../alerts_summary_charts_panel/use_summary_chart_data'; +import { severityAggregations } from '../alerts_summary_charts_panel/aggregations'; +import { isAlertsBySeverityData } from './helpers'; +import { SeverityLevelChart } from './severity_level_chart'; +import * as i18n from './translations'; + +const SEVERITY_DONUT_CHART_ID = 'alerts-summary-severity-donut'; + +export const SeverityLevelPanel: React.FC = ({ + filters, + query, + signalIndexName, + runtimeMappings, + addFilter, + skip, +}) => { + const uniqueQueryId = useMemo(() => `${SEVERITY_DONUT_CHART_ID}-${uuid()}`, []); + + const { items, isLoading } = useSummaryChartData({ + aggregations: severityAggregations, + filters, + query, + signalIndexName, + runtimeMappings, + skip, + uniqueQueryId, + }); + const data = useMemo(() => (isAlertsBySeverityData(items) ? items : []), [items]); + return ( + + + + + + + ); +}; + +SeverityLevelPanel.displayName = 'SeverityLevelPanel'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/mock_data.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/mock_data.ts similarity index 79% rename from x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/mock_data.ts rename to x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/mock_data.ts index c6c2404ec9f18..e14fe7cafd0ab 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/mock_data.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/mock_data.ts @@ -4,12 +4,13 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -export const from = '2022-04-05T12:00:00.000Z'; -export const to = '2022-04-08T12:00:00.000Z'; +import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; +const from = '2022-04-05T12:00:00.000Z'; +const to = '2022-04-08T12:00:00.000Z'; export const mockAlertsData = { took: 0, - timed_out: false, + timeout: false, _shards: { total: 1, successful: 1, @@ -50,16 +51,9 @@ export const mockAlertsData = { }, }; -export const parsedAlerts = [ - { key: 'high', value: 78, label: 'High' }, - { key: 'low', value: 46, label: 'Low' }, - { key: 'medium', value: 32, label: 'Medium' }, - { key: 'critical', value: 21, label: 'Critical' }, -]; - export const mockAlertsEmptyData = { took: 0, - timed_out: false, + timeout: false, _shards: { total: 1, successful: 1, @@ -83,12 +77,19 @@ export const mockAlertsEmptyData = { }, }; -export const alertsBySeverityQuery = { +export const query = { size: 0, query: { bool: { filter: [ - { bool: { filter: [], must: [], must_not: [], should: [] } }, + { + bool: { + filter: [], + must: [], + must_not: [], + should: [], + }, + }, { range: { '@timestamp': { gte: from, lte: to } } }, ], }, @@ -102,3 +103,10 @@ export const alertsBySeverityQuery = { }, runtime_mappings: undefined, }; + +export const parsedAlerts: Array<{ key: Severity; value: number; label: string }> = [ + { key: 'high', value: 78, label: 'High' }, + { key: 'low', value: 46, label: 'Low' }, + { key: 'medium', value: 32, label: 'Medium' }, + { key: 'critical', value: 21, label: 'Critical' }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.test.tsx new file mode 100644 index 0000000000000..ece1301dd3883 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.test.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { act, render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../../../common/mock'; +import { SeverityLevelChart } from './severity_level_chart'; +import { parsedAlerts } from './mock_data'; + +jest.mock('../../../../common/lib/kibana'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +describe('Severity level chart', () => { + const defaultProps = { + data: [], + isLoading: false, + }; + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('renders severity table correctly', () => { + act(() => { + const { container } = render( + + + + ); + expect(container.querySelector('[data-test-subj="severity-level-table"')).toBeInTheDocument(); + expect( + container.querySelector('[data-test-subj="severity-level-table"] tbody')?.textContent + ).toEqual('No items found'); + }); + }); + + test('renders severity donut correctly', () => { + act(() => { + const { container } = render( + + + + ); + expect( + container.querySelector('[data-test-subj="severity-level-donut"]') + ).toBeInTheDocument(); + }); + }); + + test('renders table correctly with data', () => { + act(() => { + const { queryAllByRole, container } = render( + + + + ); + expect(container.querySelector('[data-test-subj="severity-level-table"')).toBeInTheDocument(); + parsedAlerts.forEach((_, i) => { + expect(queryAllByRole('row')[i + 1].textContent).toContain(parsedAlerts[i].label); + expect(queryAllByRole('row')[i + 1].textContent).toContain( + parsedAlerts[i].value.toString() + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.tsx new file mode 100644 index 0000000000000..1ef5a9dd53f28 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/severity_level_chart.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useCallback, useMemo, useEffect, useState } from 'react'; +import { isEmpty } from 'lodash/fp'; +import { ALERT_SEVERITY } from '@kbn/rule-data-utils'; +import { EuiFlexGroup, EuiFlexItem, EuiInMemoryTable, EuiLoadingSpinner } from '@elastic/eui'; +import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { ShapeTreeNode, ElementClickListener } from '@elastic/charts'; +import type { SeverityBuckets as SeverityData } from '../../../../overview/components/detection_response/alerts_by_status/types'; +import type { FillColor } from '../../../../common/components/charts/donutchart'; +import { DonutChart } from '../../../../common/components/charts/donutchart'; +import { ChartLabel } from '../../../../overview/components/detection_response/alerts_by_status/chart_label'; +import { getSeverityTableColumns } from './columns'; +import { getSeverityColor } from './helpers'; +import { TOTAL_COUNT_OF_ALERTS } from '../../alerts_table/translations'; +import { showInitialLoadingSpinner } from '../alerts_histogram_panel/helpers'; + +const DONUT_HEIGHT = 150; + +export interface SeverityLevelProps { + data: SeverityData[]; + isLoading: boolean; + addFilter?: ({ field, value }: { field: string; value: string | number }) => void; +} + +export const SeverityLevelChart: React.FC = ({ + data, + isLoading, + addFilter, +}) => { + const [isInitialLoading, setIsInitialLoading] = useState(true); + const columns = useMemo(() => getSeverityTableColumns(), []); + + const count = useMemo(() => { + return data + ? data.reduce(function (prev, cur) { + return prev + cur.value; + }, 0) + : 0; + }, [data]); + + const fillColor: FillColor = useCallback((d: ShapeTreeNode) => { + return getSeverityColor(d.dataName); + }, []); + + const sorting: { sort: { field: keyof SeverityData; direction: SortOrder } } = { + sort: { + field: 'value', + direction: 'desc', + }, + }; + + const onElementClick: ElementClickListener = useCallback( + (event) => { + const flattened = event.flat(2); + const level = + flattened.length > 0 && + 'groupByRollup' in flattened[0] && + flattened[0].groupByRollup != null + ? `${flattened[0].groupByRollup}` + : ''; + + if (addFilter != null && !isEmpty(level.trim())) { + addFilter({ field: ALERT_SEVERITY, value: level.toLowerCase() }); + } + }, + [addFilter] + ); + + useEffect(() => { + if (!showInitialLoadingSpinner({ isInitialLoading, isLoadingAlerts: isLoading })) { + setIsInitialLoading(false); + } + }, [isInitialLoading, isLoading, setIsInitialLoading]); + + return ( + + + + + + {isInitialLoading ? ( + + ) : ( + } + totalCount={count} + onElementClick={onElementClick} + /> + )} + + + ); +}; + +SeverityLevelChart.displayName = 'SeverityLevelChart'; diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/translations.ts new file mode 100644 index 0000000000000..809f089721a64 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/translations.ts @@ -0,0 +1,26 @@ +/* + * 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 SEVERITY_LEVELS_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.severity.severityDonutTitle', + { + defaultMessage: 'Severity levels', + } +); + +export const UNKNOWN_SEVERITY = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.severity.unknown', + { + defaultMessage: 'Unknown', + } +); + +export const SEVERITY_LEVEL_COLUMN_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.severity.severityTableLevelColumn', + { defaultMessage: 'Levels' } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/types.ts new file mode 100644 index 0000000000000..e80d05f4218ec --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/severity_level_panel/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SeverityBucket } from '../../../../overview/components/detection_response/alerts_by_status/types'; + +export interface AlertsBySeverityAgg { + statusBySeverity: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: SeverityBucket[]; + }; +} 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 ddfe14d51201f..6ed2353165f1d 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 @@ -32,7 +32,7 @@ import { GROUP_BY_LABEL } from '../../../components/alerts_kpis/common/translati const TABLE_PANEL_HEIGHT = 330; // px const TRENT_CHART_HEIGHT = 127; // px const TREND_CHART_PANEL_HEIGHT = 256; // px -const ALERTS_CHARTS_PANEL_HEIGHT = 330; // px +const ALERTS_CHARTS_PANEL_HEIGHT = 375; // px const FullHeightFlexItem = styled(EuiFlexItem)` height: 100%; diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/types.ts b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/types.ts index d1da9558ca999..595ca6b84b9a3 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/types.ts +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/types.ts @@ -20,7 +20,7 @@ interface StatusBucket { statusBySeverity?: StatusBySeverity; } -interface SeverityBucket { +export interface SeverityBucket { key: Severity; doc_count: number; }