diff --git a/x-pack/plugins/security_solution/public/common/components/authentication/__snapshots__/authentications_host_table.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/authentication/__snapshots__/authentications_host_table.test.tsx.snap index b0a55bb72acb8..ff496768397c1 100644 --- a/x-pack/plugins/security_solution/public/common/components/authentication/__snapshots__/authentications_host_table.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/authentication/__snapshots__/authentications_host_table.test.tsx.snap @@ -19,6 +19,14 @@ exports[`Authentication Host Table Component rendering it renders the host authe user-select: text; } +.c1.toggle-expand .header-section-content { + height: 48px; +} + +.c1.toggle-expand .header-section-titles { + margin-top: 16px; +} + .c0 { position: relative; } @@ -52,7 +60,7 @@ exports[`Authentication Host Table Component rendering it renders the host authe data-test-subj="authentications-host-table-loading-false" >
{ + const actual = jest.requireActual('@elastic/charts'); + return { + ...actual, + Chart: jest.fn(({ children, ...props }) => ( +
+ {children} +
+ )), + Partition: jest.fn((props) =>
), + Settings: jest.fn((props) =>
), + }; +}); + +jest.mock('uuid', () => { + const actual = jest.requireActual('uuid'); + + return { + ...actual, + v4: jest.fn().mockReturnValue('test-uuid'), + }; +}); + +jest.mock('../../../overview/components/detection_response/alerts_by_status/chart_label', () => { + return { + ChartLabel: jest.fn((props) => ), + }; +}); + +jest.mock('./draggable_legend', () => { + return { + DraggableLegend: jest.fn((props) => ), + }; +}); + +jest.mock('./common', () => { + return { + useTheme: jest.fn(() => ({ + eui: { + euiScrollBar: 0, + euiColorDarkShade: '#fff', + euiScrollBarCorner: '#ccc', + }, + })), + }; +}); + +const testColors = { + critical: '#EF6550', + high: '#EE9266', + medium: '#F3B689', + low: '#F8D9B2', +}; + +describe('DonutChart', () => { + const props: DonutChartProps = { + data: parsedMockAlertsData?.open?.severities, + label: 'Open', + link: null, + title: , + fillColor: jest.fn(() => '#ccc'), + totalCount: parsedMockAlertsData?.open?.total, + legendItems: (['critical', 'high', 'medium', 'low'] as Severity[]).map((d) => ({ + color: testColors[d], + dataProviderId: escapeDataProviderId(`draggable-legend-item-${uuid.v4()}-${d}`), + timelineId: undefined, + field: 'kibana.alert.severity', + value: d, + })), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + test('should render Chart', () => { + const { container } = render(); + expect(container.querySelector(`[data-test-subj="es-chart"]`)).toBeInTheDocument(); + }); + + test('should render chart Settings', () => { + const { container } = render(); + expect(container.querySelector(`[data-test-subj="es-chart-settings"]`)).toBeInTheDocument(); + + expect((Settings as jest.Mock).mock.calls[0][0]).toEqual({ + baseTheme: { + eui: { + euiColorDarkShade: '#fff', + euiScrollBar: 0, + euiScrollBarCorner: '#ccc', + }, + }, + theme: { + chartMargins: { bottom: 0, left: 0, right: 0, top: 0 }, + partition: { + circlePadding: 4, + emptySizeRatio: 0.8, + idealFontSizeJump: 1.1, + outerSizeRatio: 1, + }, + }, + }); + }); + + test('should render an empty chart', () => { + const testProps = { + ...props, + data: parsedMockAlertsData?.acknowledged?.severities, + label: 'Acknowledged', + title: , + totalCount: parsedMockAlertsData?.acknowledged?.total, + }; + const { container } = render(); + expect(container.querySelector(`[data-test-subj="empty-donut"]`)).toBeInTheDocument(); + }); + + test('should render chart Partition', () => { + const { container } = render(); + expect(container.querySelector(`[data-test-subj="es-chart-partition"]`)).toBeInTheDocument(); + expect((Partition as jest.Mock).mock.calls[0][0].data).toEqual( + parsedMockAlertsData?.open?.severities + ); + expect((Partition as jest.Mock).mock.calls[0][0].layout).toEqual('sunburst'); + }); + + test('should render chart legend', () => { + const { container } = render(); + expect(container.querySelector(`[data-test-subj="draggable-legend"]`)).toBeInTheDocument(); + expect((DraggableLegend as unknown as jest.Mock).mock.calls[0][0].legendItems).toEqual([ + { + color: '#EF6550', + dataProviderId: 'draggable-legend-item-test-uuid-critical', + field: 'kibana.alert.severity', + timelineId: undefined, + value: 'critical', + }, + { + color: '#EE9266', + dataProviderId: 'draggable-legend-item-test-uuid-high', + field: 'kibana.alert.severity', + timelineId: undefined, + value: 'high', + }, + { + color: '#F3B689', + dataProviderId: 'draggable-legend-item-test-uuid-medium', + field: 'kibana.alert.severity', + timelineId: undefined, + value: 'medium', + }, + { + color: '#F8D9B2', + dataProviderId: 'draggable-legend-item-test-uuid-low', + field: 'kibana.alert.severity', + timelineId: undefined, + value: 'low', + }, + ]); + }); + + test('should NOT render chart legend if showLegend is false', () => { + const testProps = { + ...props, + legendItems: null, + }; + const { container } = render(); + expect(container.querySelector(`[data-test-subj="legend"]`)).not.toBeInTheDocument(); + }); +}); 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 new file mode 100644 index 0000000000000..2708fefc76522 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/charts/donutchart.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiText, useEuiTheme } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { + Chart, + Datum, + Partition, + Settings, + PartitionLayout, + defaultPartitionValueFormatter, + NodeColorAccessor, + PartialTheme, +} from '@elastic/charts'; +import styled from 'styled-components'; +import { useTheme } from './common'; +import { DraggableLegend } from './draggable_legend'; +import { LegendItem } from './draggable_legend_item'; +import { DonutChartEmpty } from './donutchart_empty'; + +export const NO_LEGEND_DATA: LegendItem[] = []; + +const donutTheme: PartialTheme = { + chartMargins: { top: 0, bottom: 0, left: 0, right: 0 }, + partition: { + idealFontSizeJump: 1.1, + outerSizeRatio: 1, + emptySizeRatio: 0.8, + circlePadding: 4, + }, +}; + +interface DonutChartData { + key: string; + value: number; + group?: string; + label?: string; +} + +export type FillColor = string | NodeColorAccessor; +export interface DonutChartProps { + data: DonutChartData[] | null | undefined; + fillColor: FillColor; + height?: number; + label: string; + legendItems?: LegendItem[] | null | undefined; + link?: string | null; + title: React.ReactElement | string | number | null; + totalCount: number | null | undefined; +} + +/* Make this position absolute in order to overlap the text onto the donut */ +const DonutTextWrapper = styled(EuiFlexGroup)` + top: 34%; + width: 100%; + max-width: 77px; + position: absolute; + z-index: 1; +`; + +const StyledEuiFlexItem = styled(EuiFlexItem)` + position: relative; + align-items: center; +`; + +export const DonutChart = ({ + data, + fillColor, + height = 90, + label, + legendItems, + link, + title, + totalCount, +}: DonutChartProps) => { + const theme = useTheme(); + const { euiTheme } = useEuiTheme(); + const emptyLabelStyle = useMemo( + () => ({ + color: euiTheme.colors.disabled, + }), + [euiTheme.colors.disabled] + ); + return ( + + + + {title} + + {data ? ( + + {label} + + ) : ( + + {label} + + )} + + + {data == null || totalCount == null || totalCount === 0 ? ( + + ) : ( + + + d.value as number} + valueFormatter={(d: number) => `${defaultPartitionValueFormatter(d)}`} + layers={[ + { + groupByRollup: (d: Datum) => d.label ?? d.key, + nodeLabel: (d: Datum) => d, + shape: { + fillColor, + }, + }, + ]} + /> + + )} + + {legendItems && legendItems?.length > 0 && ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/charts/donutchart_empty.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/donutchart_empty.test.tsx new file mode 100644 index 0000000000000..858347226272f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/charts/donutchart_empty.test.tsx @@ -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 { render } from '@testing-library/react'; +import React from 'react'; +import { DonutChartEmpty } from './donutchart_empty'; + +describe('DonutChartEmpty', () => { + test('render', () => { + const { container } = render(); + expect(container.querySelector(`[data-test-subj="empty-donut"]`)).toBeInTheDocument(); + expect(container.querySelector(`[data-test-subj="empty-donut-small"]`)).toBeInTheDocument(); + }); + + test('does Not render', () => { + const props = { + size: 90, + donutWidth: 90, + }; + const { container } = render(); + expect(container.querySelector(`[data-test-subj="empty-donut"]`)).not.toBeInTheDocument(); + expect(container.querySelector(`[data-test-subj="empty-donut-small"]`)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/charts/donutchart_empty.tsx b/x-pack/plugins/security_solution/public/common/components/charts/donutchart_empty.tsx new file mode 100644 index 0000000000000..a378f94bfbe1e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/charts/donutchart_empty.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import styled from 'styled-components'; + +interface DonutChartEmptyProps { + size?: number; + donutWidth?: number; +} + +export const emptyDonutColor = '#FAFBFD'; + +const BigRing = styled.div` + border-radius: 50%; + ${({ size }) => + `height: ${size}px; + width: ${size}px; + background-color: ${emptyDonutColor}; + text-align: center; + line-height: ${size}px;`} +`; + +const SmallRing = styled.div` + border-radius: 50%; + ${({ size }) => ` + height: ${size}px; + width: ${size}px; + background-color: white; + display: inline-block; + vertical-align: middle;`} +`; + +const EmptyDonutChartComponent: React.FC = ({ size = 90, donutWidth = 20 }) => + size - donutWidth > 0 ? ( + + + + ) : null; + +export const DonutChartEmpty = React.memo(EmptyDonutChartComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/charts/legend.tsx b/x-pack/plugins/security_solution/public/common/components/charts/legend.tsx new file mode 100644 index 0000000000000..d01d9057c6979 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/charts/legend.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; +import React from 'react'; + +import { LegendItem } from './legend_item'; + +const LegendComponent: React.FC<{ + legendItems: LegendItem[]; +}> = ({ legendItems }) => { + if (legendItems.length === 0) { + return null; + } + + return ( + + + {legendItems.map((item, i) => ( + + + + + ))} + + + ); +}; + +LegendComponent.displayName = 'LegendComponent'; + +export const Legend = React.memo(LegendComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/charts/legend_item.tsx b/x-pack/plugins/security_solution/public/common/components/charts/legend_item.tsx new file mode 100644 index 0000000000000..867a48294c09b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/charts/legend_item.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiHealth, EuiText } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +import { EMPTY_VALUE_LABEL } from './translation'; +import { hasValueToDisplay } from '../../utils/validators'; + +export interface LegendItem { + color?: string; + field: string; + value: string | number; +} + +const LegendText = styled.span` + font-size: 10.5px; +`; + +/** + * Renders the value or a placeholder in case the value is empty + */ +const ValueWrapper = React.memo<{ value: LegendItem['value'] }>(({ value }) => + hasValueToDisplay(value) ? ( + {value} + ) : ( + {EMPTY_VALUE_LABEL} + ) +); + +ValueWrapper.displayName = 'ValueWrapper'; + +const LegendItemComponent: React.FC<{ + legendItem: LegendItem; +}> = ({ legendItem }) => { + const { color, value } = legendItem; + + return ( + + + {color != null && ( + + + + )} + + + + + + ); +}; + +LegendItemComponent.displayName = 'LegendItemComponent'; + +export const LegendItem = React.memo(LegendItemComponent); diff --git a/x-pack/plugins/security_solution/public/resolver/view/submenu.test.tsx b/x-pack/plugins/security_solution/public/common/components/formatted_number/index.test.tsx similarity index 95% rename from x-pack/plugins/security_solution/public/resolver/view/submenu.test.tsx rename to x-pack/plugins/security_solution/public/common/components/formatted_number/index.test.tsx index 8cfaf3fe71670..bbf53c5a38aaa 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/submenu.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/formatted_number/index.test.tsx @@ -5,9 +5,9 @@ * 2.0. */ -import { compactNotationParts } from './submenu'; +import { compactNotationParts } from '.'; -describe('The Resolver node pills number presentation', () => { +describe('compactNotationParts', () => { describe('When given a small number under 1000', () => { it('does not change the presentation of small numbers', () => { expect(compactNotationParts(1)).toEqual([1, '', '']); diff --git a/x-pack/plugins/security_solution/public/common/components/formatted_number/index.tsx b/x-pack/plugins/security_solution/public/common/components/formatted_number/index.tsx new file mode 100644 index 0000000000000..ebab730d5a96f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/formatted_number/index.tsx @@ -0,0 +1,81 @@ +/* + * 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 { EuiI18nNumber } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useMemo } from 'react'; + +/** + * Until browser support accomodates the `notation="compact"` feature of Intl.NumberFormat... + * exported for testing + * @param num The number to format + * @returns [mantissa ("12" in "12k+"), Scalar of compact notation (k,M,B,T), remainder indicator ("+" in "12k+")] + */ +export function compactNotationParts( + num: number +): [mantissa: number, compactNotation: string, remainderIndicator: string] { + if (!Number.isFinite(num)) { + return [num, '', '']; + } + + // "scale" here will be a term indicating how many thousands there are in the number + // e.g. 1001 will be 1000, 1000002 will be 1000000, etc. + const scale = Math.pow(10, 3 * Math.min(Math.floor(Math.floor(Math.log10(num)) / 3), 4)); + + const compactPrefixTranslations = { + compactThousands: i18n.translate('xpack.securitySolution.formattedNumber.compactThousands', { + defaultMessage: 'k', + }), + compactMillions: i18n.translate('xpack.securitySolution.formattedNumber.compactMillions', { + defaultMessage: 'M', + }), + + compactBillions: i18n.translate('xpack.securitySolution.formattedNumber.compactBillions', { + defaultMessage: 'B', + }), + + compactTrillions: i18n.translate('xpack.securitySolution.formattedNumber.compactTrillions', { + defaultMessage: 'T', + }), + }; + const prefixMap: Map = new Map([ + [1, ''], + [1000, compactPrefixTranslations.compactThousands], + [1000000, compactPrefixTranslations.compactMillions], + [1000000000, compactPrefixTranslations.compactBillions], + [1000000000000, compactPrefixTranslations.compactTrillions], + ]); + const hasRemainder = i18n.translate('xpack.securitySolution.formattedNumber.compactOverflow', { + defaultMessage: '+', + }); + const prefix = prefixMap.get(scale) ?? ''; + return [Math.floor(num / scale), prefix, (num / scale) % 1 > Number.EPSILON ? hasRemainder : '']; +} + +const FormattedCountComponent: React.FC<{ count: number | null }> = ({ count }) => { + const [mantissa, scale, hasRemainder] = useMemo(() => compactNotationParts(count || 0), [count]); + + if (count == null) { + return null; + } + + if (count === 0) { + return <>{0}; + } + + return ( + , scale, hasRemainder }} + /> + ); +}; + +export const FormattedCount = React.memo(FormattedCountComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap index 45a6e20cf087d..4447f631c1572 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/header_section/__snapshots__/index.test.tsx.snap @@ -2,6 +2,8 @@ exports[`HeaderSection it renders 1`] = `
diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx index a03999479e398..d835f4b2b236a 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx @@ -231,6 +231,28 @@ describe('HeaderSection', () => { expect(wrapper.find('[data-test-subj="header-section-filters"]').first().exists()).toBe(true); expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(true); }); + + test('it appends `toggle-expand` class to Header when toggleStatus = true', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="header-section"]').first().prop('className')).toBe( + 'toggle-expand siemHeaderSection' + ); + }); + test('it does not render anything but title when toggleStatus = false', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx index 7997dfa83e27b..86cdc45312cf2 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.tsx @@ -16,6 +16,7 @@ import { import React, { useCallback } from 'react'; import styled, { css } from 'styled-components'; +import classnames from 'classnames'; import { InspectButton } from '../inspect'; import { Subtitle } from '../subtitle'; @@ -24,11 +25,23 @@ import * as i18n from '../../containers/query_toggle/translations'; interface HeaderProps { border?: boolean; height?: number; + className?: string; + $hideSubtitle?: boolean; } -const Header = styled.header.attrs(() => ({ - className: 'siemHeaderSection', -}))` +const Header = styled.header` + &.toggle-expand { + .header-section-content { + height: 48px; + } + + ${({ $hideSubtitle, theme }) => + !$hideSubtitle && + `.header-section-titles { + margin-top: ${theme.eui.paddingSizes.m}; + }`} + } + ${({ height }) => height && css` @@ -91,17 +104,33 @@ const HeaderSectionComponent: React.FC = ({ toggleQuery(!toggleStatus); } }, [toggleQuery, toggleStatus]); + + const classNames = classnames({ + 'toggle-expand': toggleStatus, + siemHeaderSection: true, + }); return ( -
+
- + - + {toggleQuery && ( { +describe('useAddToExistingCase', () => { const mockCases = mockCasesContract(); const mockOnAddToCaseClicked = jest.fn(); const timeRange = { diff --git a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.test.tsx b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.test.tsx index 35a05281b9325..7b5a8b09e5f5e 100644 --- a/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/visualization_actions/use_add_to_new_case.test.tsx @@ -12,7 +12,7 @@ import { useAddToNewCase } from './use_add_to_new_case'; jest.mock('../../lib/kibana/kibana_react'); -describe('', () => { +describe('useAddToNewCase', () => { const mockCases = mockCasesContract(); const timeRange = { from: '2022-03-06T16:00:00.000Z', diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.test.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.test.tsx new file mode 100644 index 0000000000000..4c8280f90304d --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.test.tsx @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import { render } from '@testing-library/react'; +import { AlertsByStatus } from './alerts_by_status'; +import { parsedMockAlertsData } from './mock_data'; +import { useKibana } from '../../../../common/lib/kibana/kibana_react'; +import { mockCasesContract } from '@kbn/cases-plugin/public/mocks'; +import { CASES_FEATURE_ID } from '../../../../../common/constants'; +import { TestProviders } from '../../../../common/mock/test_providers'; +import { useAlertsByStatus } from './use_alerts_by_status'; + +jest.mock('../../../../common/lib/kibana/kibana_react'); + +jest.mock('./chart_label', () => { + return { + ChartLabel: jest.fn((props) => ), + }; +}); +jest.mock('./use_alerts_by_status', () => ({ + useAlertsByStatus: jest.fn().mockReturnValue({ + items: [], + isLoading: true, + }), +})); +describe('AlertsByStatus', () => { + const mockCases = mockCasesContract(); + + const props = { + showInspectButton: true, + signalIndexName: 'mock-signal-index', + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useKibana as jest.Mock).mockReturnValue({ + services: { + cases: mockCases, + application: { + capabilities: { [CASES_FEATURE_ID]: { crud_cases: true, read_cases: true } }, + getUrlForApp: jest.fn(), + }, + theme: {}, + }, + }); + (useAlertsByStatus as jest.Mock).mockReturnValue({ + items: [], + isLoading: true, + }); + }); + + test('render HoverVisibilityContainer', () => { + const { container } = render( + + + + ); + expect( + container.querySelector(`[data-test-subj="hoverVisibilityContainer"]`) + ).toBeInTheDocument(); + }); + test('render HistogramPanel', () => { + const { container } = render( + + + + ); + expect( + container.querySelector(`[data-test-subj="detection-response-alerts-by-status-panel"]`) + ).toBeInTheDocument(); + }); + + test('render HeaderSection', () => { + const { container } = render( + + + + ); + expect(container.querySelector(`[data-test-subj="header-section"]`)).toBeInTheDocument(); + }); + + test('render Legend', () => { + const testProps = { + ...props, + isInitialLoading: false, + }; + (useAlertsByStatus as jest.Mock).mockReturnValue({ + items: parsedMockAlertsData, + isLoading: false, + }); + + const { container } = render( + + + + ); + expect(container.querySelector(`[data-test-subj="legend"]`)).toBeInTheDocument(); + }); + + test('render toggle query button', () => { + const testProps = { + ...props, + isInitialLoading: false, + }; + + (useAlertsByStatus as jest.Mock).mockReturnValue({ + items: parsedMockAlertsData, + isLoading: false, + }); + + const { container } = render( + + + + ); + expect(container.querySelector(`[data-test-subj="query-toggle-header"]`)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.tsx new file mode 100644 index 0000000000000..963b12a35dbf0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.tsx @@ -0,0 +1,214 @@ +/* + * 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, EuiProgress, EuiSpacer, EuiText } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { ShapeTreeNode } from '@elastic/charts'; +import { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; +import styled from 'styled-components'; +import { DonutChart, FillColor } from '../../../../common/components/charts/donutchart'; +import { SecurityPageName } from '../../../../../common/constants'; +import { useNavigation } from '../../../../common/lib/kibana'; +import { HeaderSection } from '../../../../common/components/header_section'; +import { HoverVisibilityContainer } from '../../../../common/components/hover_visibility_container'; +import { BUTTON_CLASS as INPECT_BUTTON_CLASS } from '../../../../common/components/inspect'; +import { LegendItem } from '../../../../common/components/charts/legend_item'; +import { useAlertsByStatus } from './use_alerts_by_status'; +import { + ALERTS, + ALERTS_TITLE, + STATUS_ACKNOWLEDGED, + STATUS_CLOSED, + STATUS_CRITICAL_LABEL, + STATUS_HIGH_LABEL, + STATUS_LOW_LABEL, + STATUS_MEDIUM_LABEL, + STATUS_OPEN, +} from '../translations'; +import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { getDetectionEngineUrl, useFormatUrl } from '../../../../common/components/link_to'; +import { VIEW_ALERTS } from '../../../pages/translations'; +import { LastUpdatedAt, SEVERITY_COLOR } from '../utils'; +import { FormattedCount } from '../../../../common/components/formatted_number'; +import { ChartLabel } from './chart_label'; +import { Legend } from '../../../../common/components/charts/legend'; +import { emptyDonutColor } from '../../../../common/components/charts/donutchart_empty'; +import { LinkButton } from '../../../../common/components/links'; + +const donutHeight = 120; +const StyledFlexItem = styled(EuiFlexItem)` + padding: 0 4px; +`; + +const StyledLegendFlexItem = styled(EuiFlexItem)` + padding-left: 32px; + padding-top: 45px; +`; + +interface AlertsByStatusProps { + signalIndexName: string | null; +} + +const legendField = 'kibana.alert.severity'; +const chartConfigs: Array<{ key: Severity; label: string; color: string }> = [ + { key: 'critical', label: STATUS_CRITICAL_LABEL, color: SEVERITY_COLOR.critical }, + { key: 'high', label: STATUS_HIGH_LABEL, color: SEVERITY_COLOR.high }, + { key: 'medium', label: STATUS_MEDIUM_LABEL, color: SEVERITY_COLOR.medium }, + { key: 'low', label: STATUS_LOW_LABEL, color: SEVERITY_COLOR.low }, +]; +const DETECTION_RESPONSE_ALERTS_BY_STATUS_ID = 'detection-response-alerts-by-status'; + +export const AlertsByStatus = ({ signalIndexName }: AlertsByStatusProps) => { + const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTION_RESPONSE_ALERTS_BY_STATUS_ID); + const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.alerts); + const { navigateTo } = useNavigation(); + const goToAlerts = useCallback( + (ev) => { + ev.preventDefault(); + navigateTo({ + deepLinkId: SecurityPageName.alerts, + path: getDetectionEngineUrl(urlSearch), + }); + }, + [navigateTo, urlSearch] + ); + + const detailsButtonOptions = useMemo( + () => ({ + name: VIEW_ALERTS, + href: formatUrl(getDetectionEngineUrl()), + onClick: goToAlerts, + }), + [formatUrl, goToAlerts] + ); + + const { + items: donutData, + isLoading: loading, + updatedAt, + } = useAlertsByStatus({ + signalIndexName, + queryId: DETECTION_RESPONSE_ALERTS_BY_STATUS_ID, + skip: !toggleStatus, + }); + const legendItems: LegendItem[] = useMemo( + () => + chartConfigs.map((d) => ({ + color: d.color, + field: legendField, + value: d.label, + })), + [] + ); + + const totalAlerts = + loading || donutData == null + ? 0 + : (donutData?.open?.total ?? 0) + + (donutData?.acknowledged?.total ?? 0) + + (donutData?.closed?.total ?? 0); + + const fillColor: FillColor = useCallback((d: ShapeTreeNode) => { + return chartConfigs.find((cfg) => cfg.label === d.dataName)?.color ?? emptyDonutColor; + }, []); + + return ( + <> + + + {loading && ( + + )} + } + inspectMultiple + toggleStatus={toggleStatus} + toggleQuery={setToggleStatus} + > + + + + {detailsButtonOptions.name} + + + + + {toggleStatus && ( + <> + + + + {loading ? ( + + ) : ( + <> + + + + <> + {ALERTS(totalAlerts)} + + )} + + + + + + } + totalCount={donutData?.open?.total ?? 0} + /> + + + } + totalCount={donutData?.acknowledged?.total ?? 0} + /> + + + } + totalCount={donutData?.closed?.total ?? 0} + /> + + + + + {legendItems.length > 0 && } + + + + + )} + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/chart_label.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/chart_label.tsx new file mode 100644 index 0000000000000..ad609102409d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/chart_label.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React from 'react'; +import styled from 'styled-components'; +import { FormattedCount } from '../../../../common/components/formatted_number'; + +interface ChartLabelProps { + count: number | null | undefined; +} + +const PlaceHolder = styled.div` + padding: ${(props) => props.theme.eui.paddingSizes.s}; +`; + +const ChartLabelComponent: React.FC = ({ count }) => { + return count != null ? ( + + + + ) : ( + + ); +}; + +ChartLabelComponent.displayName = 'ChartLabelComponent'; +export const ChartLabel = React.memo(ChartLabelComponent); diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/index.ts b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/index.ts new file mode 100644 index 0000000000000..257e690ba72e5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { AlertsByStatus } from './alerts_by_status'; diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/mock_data.ts b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/mock_data.ts new file mode 100644 index 0000000000000..f8aff3bdc87ac --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/mock_data.ts @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AlertsByStatusResponse, AlertsByStatusAgg, ParsedAlertsData } from './types'; + +export const from = '2022-04-05T12:00:00.000Z'; +export const to = '2022-04-08T12:00:00.000Z'; + +export const mockAlertsData: AlertsByStatusResponse<[], AlertsByStatusAgg> = { + took: 4, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 10000, + relation: 'gte', + }, + hits: [], + }, + aggregations: { + alertsByStatus: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'open', + doc_count: 28149, + statusBySeverity: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'low', + doc_count: 22717, + }, + { + key: 'high', + doc_count: 5027, + }, + { + key: 'medium', + doc_count: 405, + }, + ], + }, + }, + { + key: 'closed', + doc_count: 4, + statusBySeverity: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'high', + doc_count: 4, + }, + { + key: 'low', + doc_count: 0, + }, + ], + }, + }, + ], + }, + }, +}; + +export const parsedMockAlertsData: ParsedAlertsData = { + open: { + total: 28149, + severities: [ + { + key: 'low', + label: 'Low', + value: 22717, + }, + { + key: 'high', + label: 'High', + value: 5027, + }, + { + key: 'medium', + label: 'Medium', + value: 405, + }, + ], + }, + closed: { + total: 4, + severities: [ + { + key: 'high', + label: 'High', + value: 4, + }, + { + key: 'low', + label: 'Low', + value: 0, + }, + ], + }, +}; + +export const alertsByStatusQuery = { + size: 0, + query: { + bool: { + filter: [{ range: { '@timestamp': { gte: from, lte: to } } }], + }, + }, + aggs: { + alertsByStatus: { + terms: { + field: 'kibana.alert.workflow_status', + }, + aggs: { + statusBySeverity: { + terms: { + field: 'kibana.alert.severity', + }, + }, + }, + }, + }, +}; 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 new file mode 100644 index 0000000000000..523edf91775fe --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/types.ts @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; +import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; + +interface StatusBySeverity { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: SeverityBucket[]; +} + +interface StatusBucket { + key: Status; + doc_count: number; + statusBySeverity?: StatusBySeverity; +} + +interface SeverityBucket { + key: Severity; + doc_count: number; +} + +export interface AlertsByStatusAgg { + alertsByStatus: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: StatusBucket[]; + }; +} + +export interface AlertsByStatusResponse { + took: number; + _shards: { + total: number; + successful: number; + skipped: number; + failed: number; + }; + aggregations?: Aggregations; + hits: { + total: { + value: number; + relation: string; + }; + hits: Hit[]; + }; +} + +export interface SeverityBuckets { + key: Severity; + value: number; + label?: string; +} +export type ParsedAlertsData = Partial< + Record +> | null; diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status.test.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status.test.tsx new file mode 100644 index 0000000000000..68ee64370b26d --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status.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 { from, mockAlertsData, alertsByStatusQuery, parsedMockAlertsData, to } from './mock_data'; +import { + useAlertsByStatus, + UseAlertsByStatus, + UseAlertsByStatusProps, +} from './use_alerts_by_status'; + +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('../../../../detections/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 renderUseAlertsByStatus = (props: Partial = {}) => + renderHook>( + () => + useAlertsByStatus({ + queryId: 'test', + signalIndexName: 'signal-alerts', + ...props, + }), + { + wrapper: TestProviders, + } + ); + +describe('useAlertsByStatus', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockDateNow.mockReturnValue(dateNow); + mockUseQueryAlerts.mockReturnValue(defaultUseQueryAlertsReturn); + }); + + it('should return default values', () => { + const { result } = renderUseAlertsByStatus(); + + expect(result.current).toEqual({ + items: null, + isLoading: false, + updatedAt: dateNow, + }); + expect(mockUseQueryAlerts).toBeCalledWith({ + query: alertsByStatusQuery, + indexName: 'signal-alerts', + skip: false, + }); + }); + + it('should return parsed items', () => { + mockUseQueryAlerts.mockReturnValue({ + ...defaultUseQueryAlertsReturn, + data: mockAlertsData, + }); + + const { result } = renderUseAlertsByStatus(); + + expect(result.current).toEqual({ + items: parsedMockAlertsData, + 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 } = renderUseAlertsByStatus(); + + expect(mockDateNow).toHaveBeenCalled(); + expect(result.current).toEqual({ + items: parsedMockAlertsData, + isLoading: false, + updatedAt: newDateNow, + }); + }); + + it('should skip the query', () => { + const { result } = renderUseAlertsByStatus({ skip: true }); + + expect(mockUseQueryAlerts).toBeCalledWith({ + query: alertsByStatusQuery, + indexName: 'signal-alerts', + skip: true, + }); + + expect(result.current).toEqual({ + items: null, + isLoading: false, + updatedAt: dateNow, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status.ts b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status.ts new file mode 100644 index 0000000000000..979fd6292243f --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status.ts @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { Severity } from '@kbn/securitysolution-io-ts-alerting-types'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import { useQueryAlerts } from '../../../../detections/containers/detection_engine/alerts/use_query'; +import { useQueryInspector } from '../../../../common/components/page/manage_query'; +import { AlertsByStatusAgg, AlertsByStatusResponse, ParsedAlertsData } from './types'; +import { + STATUS_CRITICAL_LABEL, + STATUS_HIGH_LABEL, + STATUS_LOW_LABEL, + STATUS_MEDIUM_LABEL, +} from '../translations'; + +export const severityLabels: Record = { + critical: STATUS_CRITICAL_LABEL, + high: STATUS_HIGH_LABEL, + medium: STATUS_MEDIUM_LABEL, + low: STATUS_LOW_LABEL, +}; + +export const getAlertsByStatusQuery = ({ from, to }: { from: string; to: string }) => ({ + size: 0, + query: { + bool: { + filter: [{ range: { '@timestamp': { gte: from, lte: to } } }], + }, + }, + aggs: { + alertsByStatus: { + terms: { + field: 'kibana.alert.workflow_status', + }, + aggs: { + statusBySeverity: { + terms: { + field: 'kibana.alert.severity', + }, + }, + }, + }, + }, +}); + +export const parseAlertsData = ( + response: AlertsByStatusResponse<{}, AlertsByStatusAgg> +): ParsedAlertsData => { + const statusBuckets = response?.aggregations?.alertsByStatus?.buckets ?? []; + + if (statusBuckets.length === 0) { + return null; + } + + return statusBuckets.reduce((parsedAlertsData, statusBucket) => { + const severityBuckets = statusBucket.statusBySeverity?.buckets ?? []; + + return { + ...parsedAlertsData, + [statusBucket.key]: { + total: statusBucket.doc_count, + severities: severityBuckets.map((severityBucket) => ({ + key: severityBucket.key, + value: severityBucket.doc_count, + label: severityLabels[severityBucket.key], + })), + }, + }; + }, {}); +}; + +export interface UseAlertsByStatusProps { + queryId: string; + signalIndexName: string | null; + skip?: boolean; +} + +export type UseAlertsByStatus = (props: UseAlertsByStatusProps) => { + items: ParsedAlertsData; + isLoading: boolean; + updatedAt: number; +}; + +export const useAlertsByStatus: UseAlertsByStatus = ({ + queryId, + signalIndexName, + skip = false, +}) => { + const { to, from, deleteQuery, setQuery } = useGlobalTime(); + const [updatedAt, setUpdatedAt] = useState(Date.now()); + const [items, setItems] = useState(null); + + const { + data, + loading: isLoading, + refetch: refetchQuery, + request, + response, + setQuery: setAlertsQuery, + } = useQueryAlerts<{}, AlertsByStatusAgg>({ + query: getAlertsByStatusQuery({ + from, + to, + }), + indexName: signalIndexName, + skip, + }); + + useEffect(() => { + setAlertsQuery( + getAlertsByStatusQuery({ + from, + to, + }) + ); + }, [setAlertsQuery, from, to]); + + useEffect(() => { + if (data == null) { + setItems(null); + } else { + setItems(parseAlertsData(data)); + } + setUpdatedAt(Date.now()); + }, [data]); + + const refetch = useCallback(() => { + if (!skip && refetchQuery) { + refetchQuery(); + } + }, [skip, refetchQuery]); + + useQueryInspector({ + deleteQuery, + inspect: { + dsl: [request], + response: [response], + }, + refetch, + setQuery, + queryId, + loading: isLoading, + }); + + return { items, isLoading, updatedAt }; +}; diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx index 7a053fd0366dd..a5ddfe25dd985 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/rule_alerts_table/rule_alerts_table.tsx @@ -28,6 +28,8 @@ import { useRuleAlertsItems, RuleAlertsItem } from './use_rule_alerts_items'; import { useNavigation, NavigateTo, GetAppUrl } from '../../../../common/lib/kibana'; import { SecurityPageName } from '../../../../../common/constants'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { HoverVisibilityContainer } from '../../../../common/components/hover_visibility_container'; +import { BUTTON_CLASS as INPECT_BUTTON_CLASS } from '../../../../common/components/inspect'; export interface RuleAlertsTableProps { signalIndexName: string | null; @@ -106,33 +108,35 @@ export const RuleAlertsTable = React.memo(({ signalIndexNa ); return ( - - } - /> - {toggleStatus && ( - <> - {i18n.NO_ALERTS_FOUND}} titleSize="xs" /> - } - /> - - - {i18n.OPEN_ALL_ALERTS_BUTTON} - - - )} - + + + } + /> + {toggleStatus && ( + <> + {i18n.NO_ALERTS_FOUND}} titleSize="xs" /> + } + /> + + + {i18n.OPEN_ALL_ALERTS_BUTTON} + + + )} + + ); }); RuleAlertsTable.displayName = 'RuleAlertsTable'; diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/translations.ts b/x-pack/plugins/security_solution/public/overview/components/detection_response/translations.ts index 81e3bff33545d..c2c5b412f1a9d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/detection_response/translations.ts +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/translations.ts @@ -7,6 +7,65 @@ import { i18n } from '@kbn/i18n'; +export const STATUS_CRITICAL_LABEL = i18n.translate( + 'xpack.securitySolution.detectionResponse.alertsByStatus.donut.criticalLabel', + { + defaultMessage: 'Critical', + } +); +export const STATUS_HIGH_LABEL = i18n.translate( + 'xpack.securitySolution.detectionResponse.alertsByStatus.donut.highLabel', + { + defaultMessage: 'High', + } +); +export const STATUS_MEDIUM_LABEL = i18n.translate( + 'xpack.securitySolution.detectionResponse.alertsByStatus.donut.mediumLabel', + { + defaultMessage: 'Medium', + } +); +export const STATUS_LOW_LABEL = i18n.translate( + 'xpack.securitySolution.detectionResponse.alertsByStatus.donut.lowLabel', + { + defaultMessage: 'Low', + } +); +export const STATUS_OPEN = i18n.translate( + 'xpack.securitySolution.detectionResponse.alertsByStatus.donut.title.open', + { + defaultMessage: 'Open', + } +); +export const STATUS_ACKNOWLEDGED = i18n.translate( + 'xpack.securitySolution.detectionResponse.alertsByStatus.donut.title.acknowledged', + { + defaultMessage: 'Acknowledged', + } +); +export const STATUS_CLOSED = i18n.translate( + 'xpack.securitySolution.detectionResponse.alertsByStatus.donut.title.closed', + { + defaultMessage: 'Closed', + } +); +export const STATUS_IN_PROGRESS = i18n.translate( + 'xpack.securitySolution.detectionResponse.alertsByStatus.donut.title.inProgress', + { + defaultMessage: 'In progress', + } +); +export const ALERTS = (totalAlerts: number) => + i18n.translate('xpack.securitySolution.detectionResponse.alertsByStatus.totalAlerts', { + values: { totalAlerts }, + defaultMessage: 'total {totalAlerts, plural, =1 {alert} other {alerts}}', + }); +export const ALERTS_TITLE = i18n.translate( + 'xpack.securitySolution.detectionResponse.alertsByStatus.title', + { + defaultMessage: 'Alerts', + } +); export const UPDATING = i18n.translate('xpack.securitySolution.detectionResponse.updating', { defaultMessage: 'Updating...', }); diff --git a/x-pack/plugins/security_solution/public/overview/components/detection_response/utils.tsx b/x-pack/plugins/security_solution/public/overview/components/detection_response/utils.tsx new file mode 100644 index 0000000000000..4ceba66773397 --- /dev/null +++ b/x-pack/plugins/security_solution/public/overview/components/detection_response/utils.tsx @@ -0,0 +1,39 @@ +/* + * 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 { FormattedRelative } from '@kbn/i18n-react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import * as i18n from './translations'; + +export const SEVERITY_COLOR = { + critical: '#EF6550', + high: '#EE9266', + medium: '#F3B689', + low: '#F8D9B2', +} as const; + +export interface LastUpdatedAtProps { + updatedAt: number; + isUpdating: boolean; +} +export const LastUpdatedAt: React.FC = ({ isUpdating, updatedAt }) => ( + + {isUpdating ? ( + {i18n.UPDATING} + ) : ( + + <>{i18n.UPDATED} + + + )} + +); diff --git a/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx b/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx index 719cb88b62043..2e0030bafa00e 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx @@ -18,6 +18,7 @@ import { RuleAlertsTable } from '../components/detection_response/rule_alerts_ta import { LandingPageComponent } from '../../common/components/landing_page'; import * as i18n from './translations'; import { EmptyPage } from '../../common/components/empty_page'; +import { AlertsByStatus } from '../components/detection_response/alerts_by_status'; const NoPrivilegePage: React.FC = () => { const { docLinks } = useKibana().services; @@ -67,7 +68,11 @@ const DetectionResponseComponent = () => { - {canReadAlerts && {'[alerts chart]'}} + {canReadAlerts && ( + + + + )} {canReadCases && {'[cases chart]'}} diff --git a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx index fe25547ef99dc..878dbe30a6971 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx @@ -5,66 +5,17 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; import React, { useMemo, useContext, useCallback } from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; import { useDispatch } from 'react-redux'; -import { EuiI18nNumber } from '@elastic/eui'; import { EventStats } from '../../../common/endpoint/types'; import { useColors } from './use_colors'; import { useLinkProps } from './use_link_props'; import { ResolverAction } from '../store/actions'; import { SideEffectContext } from './side_effect_context'; +import { FormattedCount } from '../../common/components/formatted_number'; /* eslint-disable react/display-name */ -/** - * Until browser support accomodates the `notation="compact"` feature of Intl.NumberFormat... - * exported for testing - * @param num The number to format - * @returns [mantissa ("12" in "12k+"), Scalar of compact notation (k,M,B,T), remainder indicator ("+" in "12k+")] - */ -export function compactNotationParts( - num: number -): [mantissa: number, compactNotation: string, remainderIndicator: string] { - if (!Number.isFinite(num)) { - return [num, '', '']; - } - - // "scale" here will be a term indicating how many thousands there are in the number - // e.g. 1001 will be 1000, 1000002 will be 1000000, etc. - const scale = Math.pow(10, 3 * Math.min(Math.floor(Math.floor(Math.log10(num)) / 3), 4)); - - const compactPrefixTranslations = { - compactThousands: i18n.translate('xpack.securitySolution.endpoint.resolver.compactThousands', { - defaultMessage: 'k', - }), - compactMillions: i18n.translate('xpack.securitySolution.endpoint.resolver.compactMillions', { - defaultMessage: 'M', - }), - - compactBillions: i18n.translate('xpack.securitySolution.endpoint.resolver.compactBillions', { - defaultMessage: 'B', - }), - - compactTrillions: i18n.translate('xpack.securitySolution.endpoint.resolver.compactTrillions', { - defaultMessage: 'T', - }), - }; - const prefixMap: Map = new Map([ - [1, ''], - [1000, compactPrefixTranslations.compactThousands], - [1000000, compactPrefixTranslations.compactMillions], - [1000000000, compactPrefixTranslations.compactBillions], - [1000000000000, compactPrefixTranslations.compactTrillions], - ]); - const hasRemainder = i18n.translate('xpack.securitySolution.endpoint.resolver.compactOverflow', { - defaultMessage: '+', - }); - const prefix = prefixMap.get(scale) ?? ''; - return [Math.floor(num / scale), prefix, (num / scale) % 1 > Number.EPSILON ? hasRemainder : '']; -} - /** * A Submenu that displays a collection of "pills" for each related event * category it has events for. @@ -89,15 +40,7 @@ export const NodeSubMenuComponents = React.memo( return []; } else { return Object.entries(nodeStats.byCategory).map(([category, total]) => { - const [mantissa, scale, hasRemainder] = compactNotationParts(total || 0); - const prefix = ( - , scale, hasRemainder }} - /> - ); + const prefix = ; return { prefix, category, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx index e2184aaaec773..ddf35eec86e04 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx @@ -29,6 +29,7 @@ describe('OpenTimelineModal', () => { euiBreakpoints: { s: '500px', }, + paddingSizes: { m: '16px' }, }, }); const title = 'All Timelines / Open Timelines'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/title_row/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/title_row/index.test.tsx index d46cad3c43a98..9e54d708db496 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/title_row/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/title_row/index.test.tsx @@ -14,7 +14,13 @@ import { TitleRow } from '.'; import { getMockTheme } from '../../../../common/lib/kibana/kibana_react.mock'; const mockTheme = getMockTheme({ - eui: { euiSizeS: '10px', euiLineHeight: 10, euiBreakpoints: { s: '10px' }, euiSize: '10px' }, + eui: { + euiSizeS: '10px', + euiLineHeight: 10, + euiBreakpoints: { s: '10px' }, + euiSize: '10px', + paddingSizes: { m: '16px' }, + }, }); describe('TitleRow', () => { diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 123ce3c2a15be..104e7d57c58e0 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -25481,17 +25481,11 @@ "xpack.securitySolution.endpoint.policyResponse.appliedOn": "Révision {rev} appliquée le {date}", "xpack.securitySolution.endpoint.policyResponse.backLinkTitle": "Détails de point de terminaison", "xpack.securitySolution.endpoint.policyResponse.title": "Réponse de politique", - "xpack.securitySolution.endpoint.resolver.compactBillions": "B", - "xpack.securitySolution.endpoint.resolver.compactMillions": "M", - "xpack.securitySolution.endpoint.resolver.compactOverflow": "+", - "xpack.securitySolution.endpoint.resolver.compactThousands": "k", - "xpack.securitySolution.endpoint.resolver.compactTrillions": "T", "xpack.securitySolution.endpoint.resolver.eitherLineageLimitExceeded": "Certains événements de processus dans la visualisation et la liste d'événements ci-dessous n'ont pas pu être affichés, car la limite de données a été atteinte.", "xpack.securitySolution.endpoint.resolver.elapsedTime": "{duration} {durationType}", "xpack.securitySolution.endpoint.resolver.errorProcess": "Processus d'erreur", "xpack.securitySolution.endpoint.resolver.loadingError": "Erreur lors du chargement des données.", "xpack.securitySolution.endpoint.resolver.loadingProcess": "Processus de chargement", - "xpack.securitySolution.endpoint.resolver.node.pillNumber": "{mantissa}{scale}{hasRemainder}", "xpack.securitySolution.endpoint.resolver.panel.error.error": "Erreur", "xpack.securitySolution.endpoint.resolver.panel.error.events": "Événements", "xpack.securitySolution.endpoint.resolver.panel.error.goBack": "Afficher tous les processus", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index cd6ac0cdfc14d..32034df551712 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -25648,17 +25648,11 @@ "xpack.securitySolution.endpoint.policyResponse.appliedOn": "{date}に改訂{rev}が適用されました", "xpack.securitySolution.endpoint.policyResponse.backLinkTitle": "エンドポイント詳細", "xpack.securitySolution.endpoint.policyResponse.title": "ポリシー応答", - "xpack.securitySolution.endpoint.resolver.compactBillions": "B", - "xpack.securitySolution.endpoint.resolver.compactMillions": "M", - "xpack.securitySolution.endpoint.resolver.compactOverflow": "+", - "xpack.securitySolution.endpoint.resolver.compactThousands": "k", - "xpack.securitySolution.endpoint.resolver.compactTrillions": "T", "xpack.securitySolution.endpoint.resolver.eitherLineageLimitExceeded": "以下のビジュアライゼーションとイベントリストの一部のプロセスイベントを表示できませんでした。データの上限に達しました。", "xpack.securitySolution.endpoint.resolver.elapsedTime": "{duration} {durationType}", "xpack.securitySolution.endpoint.resolver.errorProcess": "エラープロセス", "xpack.securitySolution.endpoint.resolver.loadingError": "データの読み込み中にエラーが発生しました。", "xpack.securitySolution.endpoint.resolver.loadingProcess": "プロセスの読み込み中", - "xpack.securitySolution.endpoint.resolver.node.pillNumber": "{mantissa}{scale}{hasRemainder}", "xpack.securitySolution.endpoint.resolver.panel.error.error": "エラー", "xpack.securitySolution.endpoint.resolver.panel.error.events": "イベント", "xpack.securitySolution.endpoint.resolver.panel.error.goBack": "すべてのプロセスを表示", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 5fe8340d34682..ad5b6e158e7dc 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25682,17 +25682,11 @@ "xpack.securitySolution.endpoint.policyResponse.appliedOn": "修订 {rev} 应用于 {date}", "xpack.securitySolution.endpoint.policyResponse.backLinkTitle": "终端详情", "xpack.securitySolution.endpoint.policyResponse.title": "策略响应", - "xpack.securitySolution.endpoint.resolver.compactBillions": "B", - "xpack.securitySolution.endpoint.resolver.compactMillions": "M", - "xpack.securitySolution.endpoint.resolver.compactOverflow": "+", - "xpack.securitySolution.endpoint.resolver.compactThousands": "k", - "xpack.securitySolution.endpoint.resolver.compactTrillions": "T", "xpack.securitySolution.endpoint.resolver.eitherLineageLimitExceeded": "下面可视化和事件列表中的一些进程事件无法显示,因为已达到数据限制。", "xpack.securitySolution.endpoint.resolver.elapsedTime": "{duration} {durationType}", "xpack.securitySolution.endpoint.resolver.errorProcess": "进程错误", "xpack.securitySolution.endpoint.resolver.loadingError": "加载数据时出错。", "xpack.securitySolution.endpoint.resolver.loadingProcess": "正在加载进程", - "xpack.securitySolution.endpoint.resolver.node.pillNumber": "{mantissa}{scale}{hasRemainder}", "xpack.securitySolution.endpoint.resolver.panel.error.error": "错误", "xpack.securitySolution.endpoint.resolver.panel.error.events": "事件", "xpack.securitySolution.endpoint.resolver.panel.error.goBack": "查看所有进程",