diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index ac3a0e2f1bfc6..91415ada0fe1a 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -81,6 +81,11 @@ export const allowedExperimentalValues = Object.freeze({ */ responseActionGetFileEnabled: false, + /** + * Enables top charts on Alerts Page + */ + alertsPageChartsEnabled: false, + /** * Keep DEPRECATED experimental flags that are documented to prevent failed upgrades. * https://www.elastic.co/guide/en/security/current/user-risk-score.html diff --git a/x-pack/plugins/security_solution/public/common/components/charts/donutchart.tsx b/x-pack/plugins/security_solution/public/common/components/charts/donutchart.tsx index 4a0a8db70c58c..499ac862c6375 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/donutchart.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/donutchart.tsx @@ -8,7 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip, useEuiTheme } from '@elastic/eui'; import React, { useMemo } from 'react'; -import type { Datum, NodeColorAccessor, PartialTheme } from '@elastic/charts'; +import type { Datum, NodeColorAccessor, PartialTheme, ElementClickListener } from '@elastic/charts'; import { Chart, Partition, @@ -48,6 +48,7 @@ export interface DonutChartProps { legendItems?: LegendItem[] | null | undefined; title: React.ReactElement | string | number | null; totalCount: number | null | undefined; + onElementClick?: ElementClickListener; } /* Make this position absolute in order to overlap the text onto the donut */ @@ -72,6 +73,7 @@ export const DonutChart = ({ legendItems, title, totalCount, + onElementClick, }: DonutChartProps) => { const theme = useTheme(); const { euiTheme } = useEuiTheme(); @@ -114,7 +116,7 @@ export const DonutChart = ({ ) : ( - + > => [ + { + field: 'key', + name: i18n.SEVERITY_LEVEL_COLUMN_TITLE, + 'data-test-subj': 'severityTable-severity', + render: (severity: Severity) => ( + + + + ), + }, + { + field: 'value', + name: i18n.SEVERITY_COUNT_COULMN_TITLE, + sortable: true, + dataType: 'number', + 'data-test-subj': 'severityTable-alertCount', + render: (alertCount: number) => ( + + + + ), + }, +]; 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 new file mode 100644 index 0000000000000..f78ac40a0766d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/helpers.test.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { parseSeverityAlerts } from './helpers'; +import { parsedAlerts, mockAlertsData, mockAlertsEmptyData } from './severity_donut/mock_data'; +import type { AlertsResponse, AlertsBySeverityAgg } from './types'; + +describe('parse alerts by severity data', () => { + test('parse alerts with data', () => { + const res = parseSeverityAlerts(mockAlertsData as AlertsResponse<{}, AlertsBySeverityAgg>); + expect(res).toEqual(parsedAlerts); + }); + + test('parse alerts without data', () => { + const res = parseSeverityAlerts(mockAlertsEmptyData); + expect(res).toEqual(null); + }); +}); 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 new file mode 100644 index 0000000000000..2737d6d33e961 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/helpers.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import 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'; + +export const parseSeverityAlerts = ( + response: AlertsResponse<{}, AlertsBySeverityAgg> +): ParsedSeverityData => { + const severityBuckets = response?.aggregations?.statusBySeverity?.buckets ?? []; + if (severityBuckets.length === 0) { + return null; + } + 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; +}; 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 new file mode 100644 index 0000000000000..a885eca625c4d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.test.tsx @@ -0,0 +1,123 @@ +/* + * 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, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { TestProviders } from '../../../../common/mock'; +import { AlertsSummaryChartsPanel } from '.'; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/containers/query_toggle'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +describe('AlertsChartsPanel', () => { + 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( + + + + ); + expect(container.querySelector('[data-test-subj="alerts-charts-panel"]')).toBeInTheDocument(); + }); + }); + + test('it renders the header with the specified `alignHeader` alignment', async () => { + await act(async () => { + const { container } = render( + + + + ); + expect( + container.querySelector('[data-test-subj="headerSectionInnerFlexGroup"]')?.classList[1] + ).toContain('flexEnd'); + }); + }); + + 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(); + }); + }); + }); + + describe('toggleQuery', () => { + test('toggles', async () => { + await act(async () => { + const { container } = render( + + + + ); + const element = container.querySelector('[data-test-subj="query-toggle-header"]'); + if (element) { + fireEvent.click(element); + } + expect(mockSetToggle).toBeCalledWith(false); + }); + }); + + test('toggleStatus=true, render', async () => { + await act(async () => { + const { container } = render( + + + + ); + expect( + container.querySelector('[data-test-subj="alerts-charts-container"]') + ).toBeInTheDocument(); + }); + }); + + test('toggleStatus=false, hide', async () => { + await act(async () => { + mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle }); + const { container } = render( + + + + ); + expect( + container.querySelector('[data-test-subj="alerts-charts-container"]') + ).not.toBeInTheDocument(); + }); + }); + }); +}); 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 new file mode 100644 index 0000000000000..fa2cd5519103a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/index.tsx @@ -0,0 +1,114 @@ +/* + * 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, EuiTitle } from '@elastic/eui'; +import React, { useMemo, useCallback, useState, useEffect } from 'react'; +import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; +import type { Filter, Query } from '@kbn/es-query'; +import uuid from 'uuid'; +import * as i18n from './translations'; +import { KpiPanel } from '../common/components'; +import { HeaderSection } from '../../../../common/components/header_section'; +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 PlaceHolder = ({ title }: { title: string }) => { + return ( + + + +

{title}

+
+
+
+ ); +}; + +interface Props { + alignHeader?: 'center' | 'baseline' | 'stretch' | 'flexStart' | 'flexEnd'; + filters?: Filter[]; + addFilter?: ({ field, value }: { field: string; value: string | number }) => void; + panelHeight?: number; + query?: Query; + signalIndexName: string | null; + title?: React.ReactNode; + runtimeMappings?: MappingRuntimeFields; +} + +export const AlertsSummaryChartsPanel: React.FC = ({ + alignHeader, + filters, + addFilter, + panelHeight, + query, + runtimeMappings, + signalIndexName, + title = i18n.CHARTS_TITLE, +}: Props) => { + // create a unique, but stable (across re-renders) query id + const uniqueQueryId = useMemo(() => `${DETECTIONS_ALERTS_CHARTS_ID}-${uuid.v4()}`, []); + + const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_ALERTS_CHARTS_ID); + const [querySkip, setQuerySkip] = useState(!toggleStatus); + useEffect(() => { + setQuerySkip(!toggleStatus); + }, [toggleStatus]); + const toggleQuery = useCallback( + (status: boolean) => { + setToggleStatus(status); + // toggle on = skipQuery false + setQuerySkip(!status); + }, + [setQuerySkip, setToggleStatus] + ); + + const { items: severityData, isLoading: isSeverityLoading } = useSeverityChartData({ + filters, + query, + signalIndexName, + runtimeMappings, + skip: querySkip, + uniqueQueryId, + }); + + return ( + + + {toggleStatus && ( + + + + + + )} + + ); +}; + +AlertsSummaryChartsPanel.displayName = 'AlertsSummaryChartsPanel'; 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/alerts_summary_charts_panel/severity_donut/mock_data.ts new file mode 100644 index 0000000000000..c6c2404ec9f18 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/mock_data.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +export const from = '2022-04-05T12:00:00.000Z'; +export const to = '2022-04-08T12:00:00.000Z'; + +export const mockAlertsData = { + took: 0, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 47, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + statusBySeverity: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'high', + doc_count: 78, + }, + { + key: 'low', + doc_count: 46, + }, + { + key: 'medium', + doc_count: 32, + }, + { + key: 'critical', + doc_count: 21, + }, + ], + }, + }, +}; + +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, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 0, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + statusBySeverity: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [], + }, + }, +}; + +export const alertsBySeverityQuery = { + size: 0, + query: { + bool: { + filter: [ + { bool: { filter: [], must: [], must_not: [], should: [] } }, + { range: { '@timestamp': { gte: from, lte: to } } }, + ], + }, + }, + aggs: { + statusBySeverity: { + terms: { + field: 'kibana.alert.severity', + }, + }, + }, + runtime_mappings: undefined, +}; 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 new file mode 100644 index 0000000000000..3b6b262efb545 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/severity_level_chart.test.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { 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 new file mode 100644 index 0000000000000..1183c66735f29 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/severity_level_chart.tsx @@ -0,0 +1,119 @@ +/* + * 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 new file mode 100644 index 0000000000000..0d48d023d06a3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/use_severity_chart_data.test.tsx @@ -0,0 +1,131 @@ +/* + * 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/severity_donut/use_severity_chart_data.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/use_severity_chart_data.ts new file mode 100644 index 0000000000000..ce51c78f3f64e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/severity_donut/use_severity_chart_data.ts @@ -0,0 +1,163 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useState, useMemo } from 'react'; +import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; +import { buildEsQuery } from '@kbn/es-query'; +import { ALERT_SEVERITY } from '@kbn/rule-data-utils'; +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'; + +export const getAlertsBySeverityQuery = ({ + additionalFilters = [], + from, + to, + entityFilter, + runtimeMappings, +}: { + from: string; + to: string; + entityFilter?: EntityFilter; + additionalFilters?: ESBoolQuery[]; + runtimeMappings?: MappingRuntimeFields; +}) => ({ + size: 0, + query: { + bool: { + filter: [ + ...additionalFilters, + { range: { '@timestamp': { gte: from, lte: to } } }, + ...(entityFilter + ? [ + { + term: { + [entityFilter.field]: entityFilter.value, + }, + }, + ] + : []), + ], + }, + }, + aggs: { + statusBySeverity: { + terms: { + field: ALERT_SEVERITY, + }, + }, + }, + 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 = ({ + uniqueQueryId, + entityFilter, + query, + filters, + runtimeMappings, + signalIndexName, + skip = false, +}) => { + const { to, from, deleteQuery, setQuery } = useGlobalTime(); + const [updatedAt, setUpdatedAt] = useState(Date.now()); + const [items, setItems] = useState(null); + + const additionalFilters = useMemo(() => { + try { + return [ + buildEsQuery( + undefined, + query != null ? [query] : [], + filters?.filter((f) => f.meta.disabled === false) ?? [] + ), + ]; + } catch (e) { + return []; + } + }, [query, filters]); + + const { + data, + loading: isLoading, + refetch: refetchQuery, + request, + response, + setQuery: setAlertsQuery, + } = useQueryAlerts<{}, AlertsBySeverityAgg>({ + query: getAlertsBySeverityQuery({ + from, + to, + entityFilter, + additionalFilters, + runtimeMappings, + }), + indexName: signalIndexName, + skip, + queryName: ALERTS_QUERY_NAMES.COUNT, + }); + + useEffect(() => { + setAlertsQuery( + getAlertsBySeverityQuery({ + from, + to, + entityFilter, + additionalFilters, + runtimeMappings, + }) + ); + }, [setAlertsQuery, from, to, entityFilter, additionalFilters, runtimeMappings]); + + useEffect(() => { + if (data == null) { + setItems(null); + } else { + setItems(parseSeverityAlerts(data)); + } + setUpdatedAt(Date.now()); + }, [data]); + + const refetch = useCallback(() => { + if (!skip && refetchQuery) { + refetchQuery(); + } + }, [skip, refetchQuery]); + + useInspectButton({ + deleteQuery, + loading: isLoading, + response, + setQuery, + refetch, + request, + uniqueQueryId, + }); + + return { items, isLoading, updatedAt }; +}; 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 new file mode 100644 index 0000000000000..77499e59a452e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/translations.ts @@ -0,0 +1,63 @@ +/* + * 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 CHARTS_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.charts.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 new file mode 100644 index 0000000000000..5230a4f06f6fa --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_summary_charts_panel/types.ts @@ -0,0 +1,48 @@ +/* + * 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'; + +export interface EntityFilter { + field: string; + value: string; +} + +export type ParsedSeverityData = SeverityData[] | undefined | null; +export interface SeverityData { + key: Severity; + value: number; + label: string; +} + +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[]; + }; +} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts index 7d24cee9b6533..fe4cf26fd2c7d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.test.ts @@ -12,6 +12,7 @@ import { TABLE_ID, TREEMAP_ID, TREND_ID, + CHARTS_ID, } from './helpers'; import * as i18n from './translations'; @@ -42,10 +43,18 @@ describe('helpers', () => { name: i18n.TREEMAP, }); }); + + test('it returns the expected properties when alertViewSelection is charts', () => { + expect(getButtonProperties(CHARTS_ID)).toEqual({ + 'data-test-subj': CHARTS_ID, + icon: 'visPie', + name: i18n.CHARTS, + }); + }); }); describe('getContextMenuPanels', () => { - const alertViewSelections: AlertViewSelection[] = ['trend', 'table', 'treemap']; + const alertViewSelections: AlertViewSelection[] = ['trend', 'table', 'treemap', 'charts']; const closePopover = jest.fn(); const setAlertViewSelection = jest.fn(); @@ -55,6 +64,7 @@ describe('helpers', () => { alertViewSelection, closePopover, setAlertViewSelection, + isAlertsPageChartsEnabled: true, // remove after charts is implemented }); expect(panels[0].id).toEqual(0); @@ -65,6 +75,7 @@ describe('helpers', () => { alertViewSelection, closePopover, setAlertViewSelection, + isAlertsPageChartsEnabled: true, // remove after charts is implemented }); const item = panels[0].items?.find((x) => x['data-test-subj'] === alertViewSelection); @@ -78,6 +89,7 @@ describe('helpers', () => { alertViewSelection, closePopover, setAlertViewSelection, + isAlertsPageChartsEnabled: true, // remove after charts is implemented }); const item = panels[0].items?.find((x) => x['data-test-subj'] === alertViewSelection); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.ts index 08507759b375e..9166e73331413 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/helpers.ts @@ -12,8 +12,9 @@ import * as i18n from './translations'; export const TABLE_ID = 'table'; export const TREND_ID = 'trend'; export const TREEMAP_ID = 'treemap'; +export const CHARTS_ID = 'charts'; -export type AlertViewSelection = 'trend' | 'table' | 'treemap'; +export type AlertViewSelection = 'trend' | 'table' | 'treemap' | 'charts'; export interface ButtonProperties { 'data-test-subj': string; @@ -35,6 +36,8 @@ export const getButtonProperties = (alertViewSelection: AlertViewSelection): But }; case TREEMAP_ID: return { 'data-test-subj': alertViewSelection, icon: 'grid', name: i18n.TREEMAP }; + case CHARTS_ID: + return { 'data-test-subj': alertViewSelection, icon: 'visPie', name: i18n.CHARTS }; default: return table; } @@ -44,10 +47,12 @@ export const getContextMenuPanels = ({ alertViewSelection, closePopover, setAlertViewSelection, + isAlertsPageChartsEnabled, }: { alertViewSelection: AlertViewSelection; closePopover: () => void; setAlertViewSelection: (alertViewSelection: AlertViewSelection) => void; + isAlertsPageChartsEnabled: boolean; }): EuiContextMenuPanelDescriptor[] => [ { id: 0, @@ -73,6 +78,17 @@ export const getContextMenuPanels = ({ setAlertViewSelection('treemap'); }, }, + ...(isAlertsPageChartsEnabled + ? [ + { + ...getButtonProperties('charts'), + onClick: () => { + closePopover(); + setAlertViewSelection('charts'); + }, + }, + ] + : []), ], }, ]; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.tsx index 5203eaf77edab..307dfb24f0600 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/index.tsx @@ -13,7 +13,7 @@ import styled from 'styled-components'; import type { AlertViewSelection } from './helpers'; import { getButtonProperties, getContextMenuPanels } from './helpers'; import * as i18n from './translations'; - +import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; interface Props { alertViewSelection: AlertViewSelection; setAlertViewSelection: (alertViewSelection: AlertViewSelection) => void; @@ -49,10 +49,16 @@ const ChartSelectComponent: React.FC = ({ ); }, [alertViewSelection, onButtonClick]); - + const isAlertsPageChartsEnabled = useIsExperimentalFeatureEnabled('alertsPageChartsEnabled'); const panels: EuiContextMenuPanelDescriptor[] = useMemo( - () => getContextMenuPanels({ alertViewSelection, closePopover, setAlertViewSelection }), - [alertViewSelection, closePopover, setAlertViewSelection] + () => + getContextMenuPanels({ + alertViewSelection, + closePopover, + setAlertViewSelection, + isAlertsPageChartsEnabled, + }), + [alertViewSelection, closePopover, setAlertViewSelection, isAlertsPageChartsEnabled] ); return ( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/translations.ts index e776b94f3f957..a170be5b29720 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/chart_panels/chart_select/translations.ts @@ -28,3 +28,7 @@ export const TREEMAP = i18n.translate( defaultMessage: 'Treemap', } ); + +export const CHARTS = i18n.translate('xpack.securitySolution.components.chartSelect.chartsOption', { + defaultMessage: 'Charts', +}); 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 14fc97a251309..83df1b0018b1f 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 @@ -11,15 +11,17 @@ import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { useAlertsLocalStorage } from './alerts_local_storage'; import type { AlertsSettings } from './alerts_local_storage/types'; import { ChartContextMenu } from './chart_context_menu'; import { ChartSelect } from './chart_select'; -import { TABLE, TREEMAP, TREND } from './chart_select/translations'; +import * as i18n from './chart_select/translations'; import { AlertsTreemapPanel } from '../../../../common/components/alerts_treemap_panel'; import type { UpdateDateRange } from '../../../../common/components/charts/common'; import { useEuiComboBoxReset } from '../../../../common/components/use_combo_box_reset'; import { AlertsHistogramPanel } from '../../../components/alerts_kpis/alerts_histogram_panel'; +import { AlertsSummaryChartsPanel } from '../../../components/alerts_kpis/alerts_summary_charts_panel'; import { DEFAULT_STACK_BY_FIELD, DEFAULT_STACK_BY_FIELD1, @@ -30,6 +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 FullHeightFlexItem = styled(EuiFlexItem)` height: 100%; @@ -134,7 +137,7 @@ const ChartPanelsComponent: React.FC = ({ ), [alertViewSelection, setAlertViewSelection] ); - + const isAlertsPageChartsEnabled = useIsExperimentalFeatureEnabled('alertsPageChartsEnabled'); return (
{alertViewSelection === 'trend' && ( @@ -149,7 +152,7 @@ const ChartPanelsComponent: React.FC = ({ comboboxRef={stackByField0ComboboxRef} defaultStackByOption={trendChartStackBy} filters={alertsHistogramDefaultFilters} - inspectTitle={TREND} + inspectTitle={i18n.TREND} setComboboxInputRef={setStackByField0ComboboxInputRef} onFieldSelected={updateCommonStackBy0} panelHeight={TREND_CHART_PANEL_HEIGHT} @@ -177,7 +180,7 @@ const ChartPanelsComponent: React.FC = ({ alignHeader="flexStart" chartOptionsContextMenu={chartOptionsContextMenu} filters={alertsHistogramDefaultFilters} - inspectTitle={TABLE} + inspectTitle={i18n.TABLE} panelHeight={TABLE_PANEL_HEIGHT} query={query} runtimeMappings={runtimeMappings} @@ -205,7 +208,7 @@ const ChartPanelsComponent: React.FC = ({ addFilter={addFilter} alignHeader="flexStart" chartOptionsContextMenu={chartOptionsContextMenu} - inspectTitle={TREEMAP} + inspectTitle={i18n.TREEMAP} isPanelExpanded={isTreemapPanelExpanded} filters={alertsHistogramDefaultFilters} query={query} @@ -226,6 +229,25 @@ const ChartPanelsComponent: React.FC = ({ )} )} + + {isAlertsPageChartsEnabled && alertViewSelection === 'charts' && ( + + {isLoadingIndexPattern ? ( + + ) : ( + + )} + + )}
); };