mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Security Solution] KPI visualizations on Alerts Page (#149173)
## Summary This PR is a part 2 of https://github.com/elastic/kibana/pull/146938 that populates the remaining 2 charts for the summary section on Alerts Page. Capabilities added - Alerts by type: alert count by rule and by type (prevention vs. detection) - Top alerts: top 10 alert grouping based on user selected drop down Changes from previous PR - Refactor `useSeverityChartData` to `useSummaryChartData` so that it can be used by all 3 charts to fetch data - Move `SeverityLevel` chart up one level to `alerts_kpi` folder to better isolate components for testing. Feature flag: `alertsPageChartsEnabled`  ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
4a4138dc3a
commit
dda650f91b
45 changed files with 2303 additions and 560 deletions
|
@ -31,6 +31,7 @@ export interface DefaultDraggableType {
|
|||
scopeId?: string;
|
||||
tooltipContent?: React.ReactNode;
|
||||
tooltipPosition?: ToolTipPositions;
|
||||
truncate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -111,6 +112,7 @@ export const DefaultDraggable = React.memo<DefaultDraggableType>(
|
|||
tooltipContent,
|
||||
tooltipPosition,
|
||||
queryValue,
|
||||
truncate,
|
||||
}) => {
|
||||
const dataProviderProp: DataProvider = useMemo(
|
||||
() => ({
|
||||
|
@ -159,6 +161,7 @@ export const DefaultDraggable = React.memo<DefaultDraggableType>(
|
|||
isDraggable={isDraggable}
|
||||
render={renderCallback}
|
||||
scopeId={scopeId}
|
||||
truncate={truncate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { act, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { AlertsByType } from './alerts_by_type';
|
||||
import { parsedAlerts } from './mock_data';
|
||||
|
||||
const display = 'alerts-by-type-palette-display';
|
||||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) };
|
||||
});
|
||||
|
||||
describe('Alert by type chart', () => {
|
||||
const defaultProps = {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders health and pallette display correctly without data', () => {
|
||||
act(() => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsByType {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector(`[data-test-subj="${display}"]`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`[data-test-subj="${display}"]`)?.textContent).toContain(
|
||||
'Detection:0'
|
||||
);
|
||||
expect(container.querySelector(`[data-test-subj="${display}"]`)?.textContent).toContain(
|
||||
'Prevention:0'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders table correctly without data', () => {
|
||||
act(() => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsByType {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[data-test-subj="alerts-by-type-table"]')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector('[data-test-subj="alerts-by-type-table"] tbody')?.textContent
|
||||
).toEqual('No items found');
|
||||
});
|
||||
});
|
||||
|
||||
test('renders health and pallette display correctly with data', () => {
|
||||
act(() => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsByType data={parsedAlerts} isLoading={false} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector(`[data-test-subj="${display}"]`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`[data-test-subj="${display}"]`)?.textContent).toContain(
|
||||
'Detection:583'
|
||||
);
|
||||
expect(container.querySelector(`[data-test-subj="${display}"]`)?.textContent).toContain(
|
||||
'Prevention:6'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders table correctly with data', () => {
|
||||
act(() => {
|
||||
const { queryAllByRole } = render(
|
||||
<TestProviders>
|
||||
<AlertsByType data={parsedAlerts} isLoading={false} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
parsedAlerts.forEach((_, i) => {
|
||||
expect(queryAllByRole('row')[i + 1].textContent).toContain(parsedAlerts[i].rule);
|
||||
expect(queryAllByRole('row')[i + 1].textContent).toContain(parsedAlerts[i].type);
|
||||
expect(queryAllByRole('row')[i + 1].textContent).toContain(
|
||||
parsedAlerts[i].value.toString()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
EuiFlexItem,
|
||||
EuiInMemoryTable,
|
||||
EuiColorPaletteDisplay,
|
||||
EuiSpacer,
|
||||
EuiFlexGroup,
|
||||
EuiHealth,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import React, { useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import type { AlertsTypeData, AlertType } from './types';
|
||||
import { FormattedCount } from '../../../../common/components/formatted_number';
|
||||
import { getAlertsTypeTableColumns } from './columns';
|
||||
import { ALERT_TYPE_COLOR } from './helpers';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin-top: -${({ theme }) => theme.eui.euiSizeM};
|
||||
`;
|
||||
const TableWrapper = styled.div`
|
||||
height: 178px;
|
||||
`;
|
||||
const StyledEuiColorPaletteDisplay = styled(EuiColorPaletteDisplay)`
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
`;
|
||||
interface PalletteObject {
|
||||
stop: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface AlertsByTypeProps {
|
||||
data: AlertsTypeData[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const AlertsByType: React.FC<AlertsByTypeProps> = ({ data, isLoading }) => {
|
||||
const columns = useMemo(() => getAlertsTypeTableColumns(), []);
|
||||
|
||||
const subtotals = useMemo(
|
||||
() =>
|
||||
data.reduce(
|
||||
(acc: { Detection: number; Prevention: number }, item: AlertsTypeData) => {
|
||||
if (item.type === 'Detection') {
|
||||
acc.Detection += item.value;
|
||||
}
|
||||
if (item.type === 'Prevention') {
|
||||
acc.Prevention += item.value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ Detection: 0, Prevention: 0 }
|
||||
),
|
||||
[data]
|
||||
);
|
||||
|
||||
const palette: PalletteObject[] = useMemo(
|
||||
() =>
|
||||
(Object.keys(subtotals) as AlertType[]).reduce((acc: PalletteObject[], type: AlertType) => {
|
||||
const previousStop = acc.length > 0 ? acc[acc.length - 1].stop : 0;
|
||||
if (subtotals[type]) {
|
||||
const newEntry: PalletteObject = {
|
||||
stop: previousStop + (subtotals[type] || 0),
|
||||
color: ALERT_TYPE_COLOR[type],
|
||||
};
|
||||
acc.push(newEntry);
|
||||
}
|
||||
return acc;
|
||||
}, [] as PalletteObject[]),
|
||||
[subtotals]
|
||||
);
|
||||
|
||||
const sorting: { sort: { field: keyof AlertsTypeData; direction: SortOrder } } = {
|
||||
sort: {
|
||||
field: 'value',
|
||||
direction: 'desc',
|
||||
},
|
||||
};
|
||||
|
||||
const pagination: {} = {
|
||||
pageSize: 25,
|
||||
showPerPageOptions: false,
|
||||
};
|
||||
|
||||
return (
|
||||
<Wrapper data-test-subj="alerts-by-type">
|
||||
<EuiFlexGroup gutterSize="xs" data-test-subj="alerts-by-type-palette-display">
|
||||
{(Object.keys(subtotals) as AlertType[]).map((type) => (
|
||||
<EuiFlexItem key={type} grow={false}>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiHealth className="eui-alignMiddle" color={ALERT_TYPE_COLOR[type]}>
|
||||
<EuiText size="xs">
|
||||
<h4>{`${type}:`}</h4>
|
||||
</EuiText>
|
||||
</EuiHealth>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="xs">
|
||||
<FormattedCount count={subtotals[type] || 0} />
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
<EuiSpacer size="xs" />
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="xs" />
|
||||
<StyledEuiColorPaletteDisplay size="xs" palette={palette} />
|
||||
|
||||
<EuiSpacer size="xs" />
|
||||
<TableWrapper className="eui-yScroll">
|
||||
<EuiInMemoryTable
|
||||
data-test-subj="alerts-by-type-table"
|
||||
columns={columns}
|
||||
items={data}
|
||||
loading={isLoading}
|
||||
sorting={sorting}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</TableWrapper>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
AlertsByType.displayName = 'AlertsByType';
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { EuiHealth, EuiText } from '@elastic/eui';
|
||||
import { ALERT_RULE_NAME } from '@kbn/rule-data-utils';
|
||||
import type { EuiBasicTableColumn } from '@elastic/eui';
|
||||
import type { AlertsTypeData, AlertType } from './types';
|
||||
import { DefaultDraggable } from '../../../../common/components/draggables';
|
||||
import { FormattedCount } from '../../../../common/components/formatted_number';
|
||||
import { ALERTS_HEADERS_RULE_NAME } from '../../alerts_table/translations';
|
||||
import { ALERT_TYPE_COLOR } from './helpers';
|
||||
import { COUNT_TABLE_TITLE } from '../alerts_count_panel/translations';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const getAlertsTypeTableColumns = (): Array<EuiBasicTableColumn<AlertsTypeData>> => [
|
||||
{
|
||||
field: 'rule',
|
||||
name: ALERTS_HEADERS_RULE_NAME,
|
||||
'data-test-subj': 'detectionsTable-rule',
|
||||
truncateText: true,
|
||||
render: (rule: string) => (
|
||||
<EuiText size="xs" className="eui-textTruncate">
|
||||
<DefaultDraggable
|
||||
isDraggable={false}
|
||||
field={ALERT_RULE_NAME}
|
||||
hideTopN={true}
|
||||
id={`alert-detection-draggable-${rule}`}
|
||||
value={rule}
|
||||
queryValue={rule}
|
||||
tooltipContent={null}
|
||||
truncate={true}
|
||||
/>
|
||||
</EuiText>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
name: i18n.ALERTS_TYPE_COLUMN_TITLE,
|
||||
'data-test-subj': 'detectionsTable-type',
|
||||
truncateText: true,
|
||||
render: (type: string) => {
|
||||
return (
|
||||
<EuiHealth color={ALERT_TYPE_COLOR[type as AlertType]}>
|
||||
<EuiText grow={false} size="xs">
|
||||
{type}
|
||||
</EuiText>
|
||||
</EuiHealth>
|
||||
);
|
||||
},
|
||||
width: '30%',
|
||||
},
|
||||
{
|
||||
field: 'value',
|
||||
name: COUNT_TABLE_TITLE,
|
||||
dataType: 'number',
|
||||
sortable: true,
|
||||
'data-test-subj': 'detectionsTable-count',
|
||||
render: (count: number) => (
|
||||
<EuiText grow={false} size="xs">
|
||||
<FormattedCount count={count} />
|
||||
</EuiText>
|
||||
),
|
||||
width: '22%',
|
||||
},
|
||||
];
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { parseAlertsTypeData } from './helpers';
|
||||
import * as mock from './mock_data';
|
||||
import type { AlertsByTypeAgg } from './types';
|
||||
import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types';
|
||||
|
||||
describe('parse alerts by type data', () => {
|
||||
test('parse alerts with data', () => {
|
||||
const res = parseAlertsTypeData(
|
||||
mock.mockAlertsData as AlertSearchResponse<{}, AlertsByTypeAgg>
|
||||
);
|
||||
expect(res).toEqual(mock.parsedAlerts);
|
||||
});
|
||||
|
||||
test('parse alerts without data', () => {
|
||||
const res = parseAlertsTypeData(
|
||||
mock.mockAlertsEmptyData as AlertSearchResponse<{}, AlertsByTypeAgg>
|
||||
);
|
||||
expect(res).toEqual([]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { has } from 'lodash';
|
||||
import type { AlertType, AlertsByTypeAgg, AlertsTypeData } from './types';
|
||||
import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types';
|
||||
import type { SummaryChartsData } from '../alerts_summary_charts_panel/types';
|
||||
|
||||
export const ALERT_TYPE_COLOR = {
|
||||
Detection: '#D36086',
|
||||
Prevention: '#54B399',
|
||||
};
|
||||
|
||||
export const parseAlertsTypeData = (
|
||||
response: AlertSearchResponse<{}, AlertsByTypeAgg>
|
||||
): AlertsTypeData[] => {
|
||||
const rulesBuckets = response?.aggregations?.alertsByRule?.buckets ?? [];
|
||||
return rulesBuckets.length === 0
|
||||
? []
|
||||
: rulesBuckets.flatMap((rule) => {
|
||||
const events = rule.ruleByEventType?.buckets ?? [];
|
||||
return getAggregateAlerts(rule.key, events);
|
||||
});
|
||||
};
|
||||
|
||||
const getAggregateAlerts = (
|
||||
ruleName: string,
|
||||
ruleEvents: Array<{ key: string; doc_count: number }>
|
||||
): AlertsTypeData[] => {
|
||||
let preventions = 0;
|
||||
let detections = 0;
|
||||
|
||||
ruleEvents.map((eventBucket) => {
|
||||
return eventBucket.key === 'denied'
|
||||
? (preventions += eventBucket.doc_count)
|
||||
: (detections += eventBucket.doc_count);
|
||||
});
|
||||
|
||||
const ret = [];
|
||||
if (detections > 0) {
|
||||
ret.push({
|
||||
rule: ruleName,
|
||||
type: 'Detection' as AlertType,
|
||||
value: detections,
|
||||
color: ALERT_TYPE_COLOR.Detection,
|
||||
});
|
||||
}
|
||||
if (preventions > 0) {
|
||||
ret.push({
|
||||
rule: ruleName,
|
||||
type: 'Prevention' as AlertType,
|
||||
value: preventions,
|
||||
color: ALERT_TYPE_COLOR.Prevention,
|
||||
});
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
|
||||
export const isAlertsTypeData = (data: SummaryChartsData[]): data is AlertsTypeData[] => {
|
||||
return data?.every((x) => has(x, 'type'));
|
||||
};
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { act, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { AlertsByTypePanel } from '.';
|
||||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) };
|
||||
});
|
||||
|
||||
describe('Alert by type panel', () => {
|
||||
const defaultProps = {
|
||||
signalIndexName: 'signalIndexName',
|
||||
skip: false,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders correctly', async () => {
|
||||
await act(async () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsByTypePanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[data-test-subj="alerts-by-type-panel"]')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('renders HeaderSection', async () => {
|
||||
await act(async () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsByTypePanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector(`[data-test-subj="header-section"]`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('renders inspect button', async () => {
|
||||
await act(async () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsByTypePanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector('[data-test-subj="inspect-icon-button"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('renders alert by type chart', async () => {
|
||||
await act(async () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsByTypePanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector('[data-test-subj="alerts-by-type"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiPanel } from '@elastic/eui';
|
||||
import React, { useMemo } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { ChartsPanelProps } from '../alerts_summary_charts_panel/types';
|
||||
import { AlertsByType } from './alerts_by_type';
|
||||
import { HeaderSection } from '../../../../common/components/header_section';
|
||||
import { InspectButtonContainer } from '../../../../common/components/inspect';
|
||||
import { useSummaryChartData } from '../alerts_summary_charts_panel/use_summary_chart_data';
|
||||
import { alertTypeAggregations } from '../alerts_summary_charts_panel/aggregations';
|
||||
import { isAlertsTypeData } from './helpers';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const ALERTS_BY_TYPE_CHART_ID = 'alerts-summary-alert_by_type';
|
||||
|
||||
export const AlertsByTypePanel: React.FC<ChartsPanelProps> = ({
|
||||
filters,
|
||||
query,
|
||||
signalIndexName,
|
||||
runtimeMappings,
|
||||
skip,
|
||||
}) => {
|
||||
const uniqueQueryId = useMemo(() => `${ALERTS_BY_TYPE_CHART_ID}-${uuid()}`, []);
|
||||
|
||||
const { items, isLoading } = useSummaryChartData({
|
||||
aggregations: alertTypeAggregations,
|
||||
filters,
|
||||
query,
|
||||
signalIndexName,
|
||||
runtimeMappings,
|
||||
skip,
|
||||
uniqueQueryId,
|
||||
});
|
||||
const data = useMemo(() => (isAlertsTypeData(items) ? items : []), [items]);
|
||||
|
||||
return (
|
||||
<InspectButtonContainer>
|
||||
<EuiPanel hasBorder hasShadow={false} data-test-subj="alerts-by-type-panel">
|
||||
<HeaderSection
|
||||
id={uniqueQueryId}
|
||||
inspectTitle={i18n.ALERTS_TYPE_TITLE}
|
||||
outerDirection="row"
|
||||
title={i18n.ALERTS_TYPE_TITLE}
|
||||
titleSize="xs"
|
||||
hideSubtitle
|
||||
/>
|
||||
<AlertsByType data={data} isLoading={isLoading} />
|
||||
</EuiPanel>
|
||||
</InspectButtonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
AlertsByTypePanel.displayName = 'AlertsByTypePanel';
|
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { AlertsTypeData } from './types';
|
||||
|
||||
const from = '2022-04-05T12:00:00.000Z';
|
||||
const to = '2022-04-08T12:00:00.000Z';
|
||||
|
||||
export const mockAlertsData = {
|
||||
took: 0,
|
||||
timeout: false,
|
||||
_shards: {
|
||||
total: 1,
|
||||
successful: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: {
|
||||
value: 589,
|
||||
relation: 'eq',
|
||||
},
|
||||
max_score: null,
|
||||
hits: [],
|
||||
},
|
||||
aggregations: {
|
||||
alertsByRule: {
|
||||
doc_count_error_upper_bound: 0,
|
||||
sum_other_doc_count: 0,
|
||||
buckets: [
|
||||
{
|
||||
key: 'Test rule 1',
|
||||
doc_count: 537,
|
||||
ruleByEventType: {
|
||||
doc_count_error_upper_bound: 0,
|
||||
sum_other_doc_count: 0,
|
||||
buckets: [
|
||||
{
|
||||
key: 'info',
|
||||
doc_count: 406,
|
||||
},
|
||||
{
|
||||
key: 'creation',
|
||||
doc_count: 131,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Test rule 2',
|
||||
doc_count: 27,
|
||||
ruleByEventType: {
|
||||
doc_count_error_upper_bound: 0,
|
||||
sum_other_doc_count: 0,
|
||||
buckets: [
|
||||
{
|
||||
key: 'info',
|
||||
doc_count: 19,
|
||||
},
|
||||
{
|
||||
key: 'creation',
|
||||
doc_count: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Test rule 3',
|
||||
doc_count: 25,
|
||||
ruleByEventType: {
|
||||
doc_count_error_upper_bound: 0,
|
||||
sum_other_doc_count: 0,
|
||||
buckets: [
|
||||
{
|
||||
key: 'info',
|
||||
doc_count: 19,
|
||||
},
|
||||
{
|
||||
key: 'denied',
|
||||
doc_count: 6,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockAlertsEmptyData = {
|
||||
took: 0,
|
||||
timeout: false,
|
||||
_shards: {
|
||||
total: 1,
|
||||
successful: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: {
|
||||
value: 0,
|
||||
relation: 'eq',
|
||||
},
|
||||
max_score: null,
|
||||
hits: [],
|
||||
},
|
||||
aggregations: {
|
||||
alertsByRule: {
|
||||
doc_count_error_upper_bound: 0,
|
||||
sum_other_doc_count: 0,
|
||||
buckets: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const query = {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{
|
||||
bool: {
|
||||
filter: [],
|
||||
must: [],
|
||||
must_not: [],
|
||||
should: [],
|
||||
},
|
||||
},
|
||||
{ range: { '@timestamp': { gte: from, lte: to } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
alertsByRule: {
|
||||
terms: {
|
||||
field: 'kibana.alert.rule.name',
|
||||
size: 1000,
|
||||
},
|
||||
aggs: {
|
||||
ruleByEventType: {
|
||||
terms: {
|
||||
field: 'event.type',
|
||||
size: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime_mappings: undefined,
|
||||
};
|
||||
|
||||
export const parsedAlerts: AlertsTypeData[] = [
|
||||
{ rule: 'Test rule 1', type: 'Detection', value: 537, color: '#D36086' },
|
||||
{ rule: 'Test rule 2', type: 'Detection', value: 27, color: '#D36086' },
|
||||
{ rule: 'Test rule 3', type: 'Detection', value: 19, color: '#D36086' },
|
||||
{ rule: 'Test rule 3', type: 'Prevention', value: 6, color: '#54B399' },
|
||||
];
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const ALERTS_TYPE_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.alerts.alertsByType.alertTypeChartTitle',
|
||||
{
|
||||
defaultMessage: 'Alerts by type',
|
||||
}
|
||||
);
|
||||
|
||||
export const ALERTS_TYPE_COLUMN_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.alerts.alertsByType.typeColumn',
|
||||
{
|
||||
defaultMessage: 'Type',
|
||||
}
|
||||
);
|
||||
|
||||
export const PREVENTIONS = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.alerts.alertsByType.preventions',
|
||||
{
|
||||
defaultMessage: 'Preventions',
|
||||
}
|
||||
);
|
||||
|
||||
export const DETECTIONS = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.alerts.alertsByType.detections',
|
||||
{
|
||||
defaultMessage: 'Detections',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { BucketItem } from '../../../../../common/search_strategy/security_solution/cti';
|
||||
|
||||
export type AlertType = 'Detection' | 'Prevention';
|
||||
|
||||
export interface AlertsByTypeAgg {
|
||||
alertsByRule: {
|
||||
doc_count_error_upper_bound: number;
|
||||
sum_other_doc_count: number;
|
||||
buckets: RuleBucket[];
|
||||
};
|
||||
}
|
||||
|
||||
interface RuleBucket {
|
||||
key: string;
|
||||
doc_count: number;
|
||||
ruleByEventType?: RuleByEventType;
|
||||
}
|
||||
|
||||
interface RuleByEventType {
|
||||
doc_count_error_upper_bound: number;
|
||||
sum_other_doc_count: number;
|
||||
buckets: BucketItem[];
|
||||
}
|
||||
|
||||
export interface AlertsTypeData {
|
||||
rule: string;
|
||||
type: AlertType;
|
||||
value: number;
|
||||
color: string;
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { act, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { AlertsProgressBar } from './alerts_progress_bar';
|
||||
import { parsedAlerts } from './mock_data';
|
||||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) };
|
||||
});
|
||||
|
||||
describe('Alert by grouping', () => {
|
||||
const defaultProps = {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
stackByField: 'host.name',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('progress bars renders correctly', () => {
|
||||
act(() => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsProgressBar {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(
|
||||
container.querySelector(`[data-test-subj="alerts-progress-bar-title"]`)?.textContent
|
||||
).toEqual(defaultProps.stackByField);
|
||||
expect(container.querySelector(`[data-test-subj="empty-proress-bar"]`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`[data-test-subj="empty-proress-bar"]`)?.textContent).toEqual(
|
||||
'No items found'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('progress bars renders correctly with data', () => {
|
||||
act(() => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsProgressBar data={parsedAlerts} isLoading={false} stackByField={'host.name'} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(
|
||||
container.querySelector(`[data-test-subj="alerts-progress-bar-title"]`)?.textContent
|
||||
).toEqual('host.name');
|
||||
expect(container.querySelector(`[data-test-subj="progress-bar"]`)).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
container.querySelector(`[data-test-subj="empty-proress-bar"]`)
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
parsedAlerts.forEach((alert, i) => {
|
||||
expect(
|
||||
container.querySelector(`[data-test-subj="progress-bar-${alert.key}"]`)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector(`[data-test-subj="progress-bar-${alert.key}"]`)?.textContent
|
||||
).toContain(parsedAlerts[i].label);
|
||||
expect(
|
||||
container.querySelector(`[data-test-subj="progress-bar-${alert.key}"]`)?.textContent
|
||||
).toContain(parsedAlerts[i].percentage.toString());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { EuiProgress, EuiSpacer, EuiText, EuiHorizontalRule } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import type { AlertsProgressBarData } from './types';
|
||||
import { DefaultDraggable } from '../../../../common/components/draggables';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const ProgressWrapper = styled.div`
|
||||
height: 160px;
|
||||
`;
|
||||
|
||||
const StyledEuiText = styled(EuiText)`
|
||||
margin-top: -${({ theme }) => theme.eui.euiSizeM};
|
||||
`;
|
||||
|
||||
export interface AlertsProcessBarProps {
|
||||
data: AlertsProgressBarData[];
|
||||
isLoading: boolean;
|
||||
stackByField: string;
|
||||
addFilter?: ({ field, value }: { field: string; value: string | number }) => void;
|
||||
}
|
||||
|
||||
export const AlertsProgressBar: React.FC<AlertsProcessBarProps> = ({
|
||||
data,
|
||||
isLoading,
|
||||
stackByField,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<StyledEuiText size="s" data-test-subj="alerts-progress-bar-title">
|
||||
<h5>{stackByField}</h5>
|
||||
</StyledEuiText>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
{!isLoading && data.length === 0 ? (
|
||||
<>
|
||||
<EuiText size="s" textAlign="center" data-test-subj="empty-proress-bar">
|
||||
{i18n.EMPTY_DATA_MESSAGE}
|
||||
</EuiText>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
</>
|
||||
) : (
|
||||
<ProgressWrapper data-test-subj="progress-bar" className="eui-yScroll">
|
||||
{data.map((item) => (
|
||||
<div key={`${item.key}`} data-test-subj={`progress-bar-${item.key}`}>
|
||||
<EuiProgress
|
||||
valueText={
|
||||
<EuiText size="xs" color="default">
|
||||
<strong>{`${item.percentage}%`}</strong>
|
||||
</EuiText>
|
||||
}
|
||||
max={100}
|
||||
color={`vis9`}
|
||||
size="s"
|
||||
value={item.percentage}
|
||||
label={
|
||||
item.key === 'Other' ? (
|
||||
item.label
|
||||
) : (
|
||||
<DefaultDraggable
|
||||
isDraggable={false}
|
||||
field={stackByField}
|
||||
hideTopN={true}
|
||||
id={`top-alerts-${item.key}`}
|
||||
value={item.key}
|
||||
queryValue={item.key}
|
||||
tooltipContent={null}
|
||||
>
|
||||
<EuiText size="xs" className="eui-textTruncate">
|
||||
{item.key}
|
||||
</EuiText>
|
||||
</DefaultDraggable>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
</div>
|
||||
))}
|
||||
</ProgressWrapper>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
AlertsProgressBar.displayName = 'AlertsProgressBar';
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { parseAlertsGroupingData } from './helpers';
|
||||
import * as mock from './mock_data';
|
||||
import type { AlertsByGroupingAgg } from './types';
|
||||
import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types';
|
||||
|
||||
describe('parse progress bar data', () => {
|
||||
test('parse alerts with data', () => {
|
||||
const res = parseAlertsGroupingData(
|
||||
mock.mockAlertsData as AlertSearchResponse<{}, AlertsByGroupingAgg>
|
||||
);
|
||||
expect(res).toEqual(mock.parsedAlerts);
|
||||
});
|
||||
|
||||
test('parse severity without data', () => {
|
||||
const res = parseAlertsGroupingData(
|
||||
mock.mockAlertsEmptyData as AlertSearchResponse<{}, AlertsByGroupingAgg>
|
||||
);
|
||||
expect(res).toEqual([]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { has } from 'lodash';
|
||||
import type { AlertsByGroupingAgg, AlertsProgressBarData } from './types';
|
||||
import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types';
|
||||
import type { BucketItem } from '../../../../../common/search_strategy/security_solution/cti';
|
||||
import type { SummaryChartsData } from '../alerts_summary_charts_panel/types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const parseAlertsGroupingData = (
|
||||
response: AlertSearchResponse<{}, AlertsByGroupingAgg>
|
||||
): AlertsProgressBarData[] => {
|
||||
const buckets = response?.aggregations?.alertsByGrouping?.buckets ?? [];
|
||||
if (buckets.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const other = response?.aggregations?.alertsByGrouping?.sum_other_doc_count ?? 0;
|
||||
const total =
|
||||
buckets.reduce((acc: number, group: BucketItem) => acc + group.doc_count, 0) + other;
|
||||
|
||||
const topAlerts = buckets.map((group) => {
|
||||
return {
|
||||
key: group.key,
|
||||
value: group.doc_count,
|
||||
percentage: Math.round((group.doc_count / total) * 1000) / 10,
|
||||
label: group.key,
|
||||
};
|
||||
});
|
||||
|
||||
topAlerts.push({
|
||||
key: 'Other',
|
||||
value: other,
|
||||
percentage: Math.round((other / total) * 1000) / 10,
|
||||
label: i18n.OTHER,
|
||||
});
|
||||
|
||||
return topAlerts;
|
||||
};
|
||||
|
||||
export const isAlertsProgressBarData = (
|
||||
data: SummaryChartsData[]
|
||||
): data is AlertsProgressBarData[] => {
|
||||
return data?.every((x) => has(x, 'percentage'));
|
||||
};
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { AlertsProgressBarPanel } from '.';
|
||||
import { useSummaryChartData } from '../alerts_summary_charts_panel/use_summary_chart_data';
|
||||
import { STACK_BY_ARIA_LABEL } from '../common/translations';
|
||||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) };
|
||||
});
|
||||
|
||||
jest.mock('../alerts_summary_charts_panel/use_summary_chart_data');
|
||||
const mockUseSummaryChartData = useSummaryChartData as jest.Mock;
|
||||
|
||||
const options = ['host.name', 'user.name', 'source.ip', 'destination.ip'];
|
||||
|
||||
describe('Alert by grouping', () => {
|
||||
const defaultProps = {
|
||||
signalIndexName: 'signalIndexName',
|
||||
skip: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseSummaryChartData.mockReturnValue({ items: [], isLoading: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders correctly', async () => {
|
||||
await act(async () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsProgressBarPanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[data-test-subj="alerts-progress-bar-panel"]')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('render HeaderSection', async () => {
|
||||
await act(async () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsProgressBarPanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector(`[data-test-subj="header-section"]`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('renders inspect button', async () => {
|
||||
await act(async () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsProgressBarPanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector('[data-test-subj="inspect-icon-button"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('combo box', () => {
|
||||
test('renders combo box', async () => {
|
||||
await act(async () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsProgressBarPanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector('[data-test-subj="stackByComboBox"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('combo box renders corrected options', async () => {
|
||||
await act(async () => {
|
||||
render(
|
||||
<TestProviders>
|
||||
<AlertsProgressBarPanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
const comboBox = screen.getByRole('combobox', { name: STACK_BY_ARIA_LABEL });
|
||||
if (comboBox) {
|
||||
comboBox.focus(); // display the combo box options
|
||||
}
|
||||
});
|
||||
const optionsFound = screen.getAllByRole('option').map((option) => option.textContent);
|
||||
options.forEach((option, i) => {
|
||||
expect(optionsFound[i]).toEqual(option);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { EuiPanel, EuiLoadingSpinner } from '@elastic/eui';
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { ChartsPanelProps } from '../alerts_summary_charts_panel/types';
|
||||
import { HeaderSection } from '../../../../common/components/header_section';
|
||||
import { InspectButtonContainer } from '../../../../common/components/inspect';
|
||||
import { StackByComboBox } from '../common/components';
|
||||
import { AlertsProgressBar } from './alerts_progress_bar';
|
||||
import { useSummaryChartData } from '../alerts_summary_charts_panel/use_summary_chart_data';
|
||||
import { alertsGroupingAggregations } from '../alerts_summary_charts_panel/aggregations';
|
||||
import { showInitialLoadingSpinner } from '../alerts_histogram_panel/helpers';
|
||||
import { isAlertsProgressBarData } from './helpers';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const TOP_ALERTS_CHART_ID = 'alerts-summary-top-alerts';
|
||||
const DEFAULT_COMBOBOX_WIDTH = 150;
|
||||
const DEFAULT_OPTIONS = ['host.name', 'user.name', 'source.ip', 'destination.ip'];
|
||||
|
||||
export const AlertsProgressBarPanel: React.FC<ChartsPanelProps> = ({
|
||||
filters,
|
||||
query,
|
||||
signalIndexName,
|
||||
runtimeMappings,
|
||||
skip,
|
||||
}) => {
|
||||
const [stackByField, setStackByField] = useState('host.name');
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const uniqueQueryId = useMemo(() => `${TOP_ALERTS_CHART_ID}-${uuid()}`, []);
|
||||
const dropDownOptions = DEFAULT_OPTIONS.map((field) => {
|
||||
return { value: field, label: field };
|
||||
});
|
||||
const aggregations = useMemo(() => alertsGroupingAggregations(stackByField), [stackByField]);
|
||||
const onSelect = useCallback((field: string) => {
|
||||
setStackByField(field);
|
||||
}, []);
|
||||
|
||||
const { items, isLoading } = useSummaryChartData({
|
||||
aggregations,
|
||||
filters,
|
||||
query,
|
||||
signalIndexName,
|
||||
runtimeMappings,
|
||||
skip,
|
||||
uniqueQueryId,
|
||||
});
|
||||
const data = useMemo(() => (isAlertsProgressBarData(items) ? items : []), [items]);
|
||||
useEffect(() => {
|
||||
if (!showInitialLoadingSpinner({ isInitialLoading, isLoadingAlerts: isLoading })) {
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}, [isInitialLoading, isLoading, setIsInitialLoading]);
|
||||
|
||||
return (
|
||||
<InspectButtonContainer>
|
||||
<EuiPanel hasBorder hasShadow={false} data-test-subj="alerts-progress-bar-panel">
|
||||
<HeaderSection
|
||||
id={uniqueQueryId}
|
||||
inspectTitle={`${i18n.ALERT_BY_TITLE} ${stackByField}`}
|
||||
outerDirection="row"
|
||||
title={i18n.ALERT_BY_TITLE}
|
||||
titleSize="xs"
|
||||
hideSubtitle
|
||||
>
|
||||
<StackByComboBox
|
||||
data-test-subj="stackByComboBox"
|
||||
selected={stackByField}
|
||||
onSelect={onSelect}
|
||||
prepend={''}
|
||||
width={DEFAULT_COMBOBOX_WIDTH}
|
||||
dropDownoptions={dropDownOptions}
|
||||
/>
|
||||
</HeaderSection>
|
||||
{isInitialLoading ? (
|
||||
<EuiLoadingSpinner size="l" />
|
||||
) : (
|
||||
<AlertsProgressBar data={data} isLoading={isLoading} stackByField={stackByField} />
|
||||
)}
|
||||
</EuiPanel>
|
||||
</InspectButtonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
AlertsProgressBarPanel.displayName = 'AlertsProgressBarPanel';
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
const from = '2022-04-05T12:00:00.000Z';
|
||||
const to = '2022-04-08T12:00:00.000Z';
|
||||
|
||||
export const mockAlertsData = {
|
||||
took: 0,
|
||||
timeout: false,
|
||||
_shards: {
|
||||
total: 1,
|
||||
successful: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: {
|
||||
value: 570,
|
||||
relation: 'eq',
|
||||
},
|
||||
max_score: null,
|
||||
hits: [],
|
||||
},
|
||||
aggregations: {
|
||||
alertsByGrouping: {
|
||||
doc_count_error_upper_bound: 0,
|
||||
sum_other_doc_count: 0,
|
||||
buckets: [
|
||||
{
|
||||
key: 'Host-v5biklvcy8',
|
||||
doc_count: 234,
|
||||
},
|
||||
{
|
||||
key: 'Host-5y1uprxfv2',
|
||||
doc_count: 186,
|
||||
},
|
||||
{
|
||||
key: 'Host-ssf1mhgy5c',
|
||||
doc_count: 150,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockAlertsEmptyData = {
|
||||
took: 0,
|
||||
timeout: false,
|
||||
_shards: {
|
||||
total: 1,
|
||||
successful: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: {
|
||||
value: 0,
|
||||
relation: 'eq',
|
||||
},
|
||||
max_score: null,
|
||||
hits: [],
|
||||
},
|
||||
aggregations: {
|
||||
alertsByGrouping: {
|
||||
doc_count_error_upper_bound: 0,
|
||||
sum_other_doc_count: 0,
|
||||
buckets: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const query = {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{ bool: { filter: [], must: [], must_not: [], should: [] } },
|
||||
{ range: { '@timestamp': { gte: from, lte: to } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
alertsByGrouping: {
|
||||
terms: {
|
||||
field: 'host.name',
|
||||
size: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime_mappings: undefined,
|
||||
};
|
||||
|
||||
export const parsedAlerts = [
|
||||
{ key: 'Host-v5biklvcy8', value: 234, label: 'Host-v5biklvcy8', percentage: 41.1 },
|
||||
{ key: 'Host-5y1uprxfv2', value: 186, label: 'Host-5y1uprxfv2', percentage: 32.6 },
|
||||
{ key: 'Host-ssf1mhgy5c', value: 150, label: 'Host-ssf1mhgy5c', percentage: 26.3 },
|
||||
{ key: 'Other', value: 0, label: 'Other', percentage: 0 },
|
||||
];
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const ALERT_BY_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.alerts.alertsByGrouping.chartTitle',
|
||||
{
|
||||
defaultMessage: 'Top alerts by',
|
||||
}
|
||||
);
|
||||
|
||||
export const EMPTY_DATA_MESSAGE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.alerts.alertsByGrouping.noItemsFoundMessage',
|
||||
{
|
||||
defaultMessage: 'No items found',
|
||||
}
|
||||
);
|
||||
|
||||
export const OTHER = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.alerts.alertsByGrouping.otherGroup',
|
||||
{
|
||||
defaultMessage: 'Other',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { BucketItem } from '../../../../../common/search_strategy/security_solution/cti';
|
||||
|
||||
export interface AlertsByGroupingAgg {
|
||||
alertsByGrouping: {
|
||||
doc_count_error_upper_bound: number;
|
||||
sum_other_doc_count: number;
|
||||
buckets: BucketItem[];
|
||||
};
|
||||
}
|
||||
export interface AlertsProgressBarData {
|
||||
key: string;
|
||||
value: number;
|
||||
percentage: number;
|
||||
label: string;
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { ALERT_SEVERITY, ALERT_RULE_NAME } from '@kbn/rule-data-utils';
|
||||
|
||||
const DEFAULT_QUERY_SIZE = 1000;
|
||||
|
||||
export const severityAggregations = {
|
||||
statusBySeverity: {
|
||||
terms: {
|
||||
field: ALERT_SEVERITY,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const alertTypeAggregations = {
|
||||
alertsByRule: {
|
||||
terms: {
|
||||
field: ALERT_RULE_NAME,
|
||||
size: DEFAULT_QUERY_SIZE,
|
||||
},
|
||||
aggs: {
|
||||
ruleByEventType: {
|
||||
terms: {
|
||||
field: 'event.type',
|
||||
size: DEFAULT_QUERY_SIZE,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const alertsGroupingAggregations = (stackByField: string) => {
|
||||
return {
|
||||
alertsByGrouping: {
|
||||
terms: {
|
||||
field: stackByField,
|
||||
size: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
|
@ -4,18 +4,30 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { parseSeverityAlerts } from './helpers';
|
||||
import { parsedAlerts, mockAlertsData, mockAlertsEmptyData } from './severity_donut/mock_data';
|
||||
import type { AlertsResponse, AlertsBySeverityAgg } from './types';
|
||||
import { parseData } from './helpers';
|
||||
import * as severityMock from '../severity_level_panel/mock_data';
|
||||
import * as alertsTypeMock from '../alerts_by_type_panel/mock_data';
|
||||
import * as alertsGroupingMock from '../alerts_progress_bar_panel/mock_data';
|
||||
import type { SummaryChartsAgg } from './types';
|
||||
import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types';
|
||||
|
||||
describe('parse alerts by severity data', () => {
|
||||
test('parse alerts with data', () => {
|
||||
const res = parseSeverityAlerts(mockAlertsData as AlertsResponse<{}, AlertsBySeverityAgg>);
|
||||
expect(res).toEqual(parsedAlerts);
|
||||
describe('parse data by aggregation type', () => {
|
||||
test('parse severity data', () => {
|
||||
const res = parseData(severityMock.mockAlertsData as AlertSearchResponse<{}, SummaryChartsAgg>);
|
||||
expect(res).toEqual(severityMock.parsedAlerts);
|
||||
});
|
||||
|
||||
test('parse alerts without data', () => {
|
||||
const res = parseSeverityAlerts(mockAlertsEmptyData);
|
||||
expect(res).toEqual(null);
|
||||
test('parse detections data', () => {
|
||||
const res = parseData(
|
||||
alertsTypeMock.mockAlertsData as AlertSearchResponse<{}, SummaryChartsAgg>
|
||||
);
|
||||
expect(res).toEqual(alertsTypeMock.parsedAlerts);
|
||||
});
|
||||
|
||||
test('parse host data', () => {
|
||||
const res = parseData(
|
||||
alertsGroupingMock.mockAlertsData as AlertSearchResponse<{}, SummaryChartsAgg>
|
||||
);
|
||||
expect(res).toEqual(alertsGroupingMock.parsedAlerts);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,30 +4,22 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
|
||||
import type { AlertsResponse, AlertsBySeverityAgg, ParsedSeverityData } from './types';
|
||||
import * as i18n from './translations';
|
||||
import { severityLabels } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status';
|
||||
import { emptyDonutColor } from '../../../../common/components/charts/donutchart_empty';
|
||||
import { SEVERITY_COLOR } from '../../../../overview/components/detection_response/utils';
|
||||
import { isAlertsBySeverityAgg, isAlertsByTypeAgg, isAlertsByGroupingAgg } from './types';
|
||||
import type { SummaryChartsAgg } from './types';
|
||||
import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types';
|
||||
import { parseSeverityData } from '../severity_level_panel/helpers';
|
||||
import { parseAlertsTypeData } from '../alerts_by_type_panel/helpers';
|
||||
import { parseAlertsGroupingData } from '../alerts_progress_bar_panel/helpers';
|
||||
|
||||
export const parseSeverityAlerts = (
|
||||
response: AlertsResponse<{}, AlertsBySeverityAgg>
|
||||
): ParsedSeverityData => {
|
||||
const severityBuckets = response?.aggregations?.statusBySeverity?.buckets ?? [];
|
||||
if (severityBuckets.length === 0) {
|
||||
return null;
|
||||
export const parseData = (data: AlertSearchResponse<{}, SummaryChartsAgg>) => {
|
||||
if (isAlertsBySeverityAgg(data)) {
|
||||
return parseSeverityData(data);
|
||||
}
|
||||
const data = severityBuckets.map((severity) => {
|
||||
return {
|
||||
key: severity.key,
|
||||
value: severity.doc_count,
|
||||
label: severityLabels[severity.key] ?? i18n.UNKNOWN_SEVERITY,
|
||||
};
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getSeverityColor = (severity: string) => {
|
||||
return SEVERITY_COLOR[severity.toLocaleLowerCase() as Severity] ?? emptyDonutColor;
|
||||
if (isAlertsByTypeAgg(data)) {
|
||||
return parseAlertsTypeData(data);
|
||||
}
|
||||
if (isAlertsByGroupingAgg(data)) {
|
||||
return parseAlertsGroupingData(data);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { act, render, fireEvent } from '@testing-library/react';
|
||||
import { act, render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { useQueryToggle } from '../../../../common/containers/query_toggle';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
|
@ -18,22 +18,16 @@ jest.mock('react-router-dom', () => {
|
|||
return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) };
|
||||
});
|
||||
|
||||
describe('AlertsChartsPanel', () => {
|
||||
describe('AlertsSummaryChartsPanel', () => {
|
||||
const defaultProps = {
|
||||
signalIndexName: 'signalIndexName',
|
||||
};
|
||||
|
||||
const mockSetToggle = jest.fn();
|
||||
const mockUseQueryToggle = useQueryToggle as jest.Mock;
|
||||
beforeEach(() => {
|
||||
mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders correctly', async () => {
|
||||
await act(async () => {
|
||||
const { container } = render(
|
||||
|
@ -60,19 +54,21 @@ describe('AlertsChartsPanel', () => {
|
|||
|
||||
describe('Query', () => {
|
||||
test('it render with a illegal KQL', async () => {
|
||||
await act(async () => {
|
||||
jest.mock('@kbn/es-query', () => ({
|
||||
buildEsQuery: jest.fn().mockImplementation(() => {
|
||||
throw new Error('Something went wrong');
|
||||
}),
|
||||
}));
|
||||
const props = { ...defaultProps, query: { query: 'host.name: "', language: 'kql' } };
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsSummaryChartsPanel {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector('[data-test-subj="severty-chart"]')).toBeInTheDocument();
|
||||
jest.mock('@kbn/es-query', () => ({
|
||||
buildEsQuery: jest.fn().mockImplementation(() => {
|
||||
throw new Error('Something went wrong');
|
||||
}),
|
||||
}));
|
||||
const props = { ...defaultProps, query: { query: 'host.name: "', language: 'kql' } };
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsSummaryChartsPanel {...props} />
|
||||
</TestProviders>
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
container.querySelector('[data-test-subj="alerts-charts-panel"]')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -107,8 +103,8 @@ describe('AlertsChartsPanel', () => {
|
|||
});
|
||||
|
||||
test('toggleStatus=false, hide', async () => {
|
||||
mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle });
|
||||
await act(async () => {
|
||||
mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle });
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<AlertsSummaryChartsPanel {...defaultProps} />
|
||||
|
|
|
@ -4,33 +4,29 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui';
|
||||
import React, { useMemo, useCallback, useState, useEffect } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import React, { useCallback, useState, useEffect } from 'react';
|
||||
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
|
||||
import type { Filter, Query } from '@kbn/es-query';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import styled from 'styled-components';
|
||||
import * as i18n from './translations';
|
||||
import { KpiPanel } from '../common/components';
|
||||
import { HeaderSection } from '../../../../common/components/header_section';
|
||||
import { SeverityLevelPanel } from '../severity_level_panel';
|
||||
import { AlertsByTypePanel } from '../alerts_by_type_panel';
|
||||
import { AlertsProgressBarPanel } from '../alerts_progress_bar_panel';
|
||||
import { useQueryToggle } from '../../../../common/containers/query_toggle';
|
||||
import { useSeverityChartData } from './severity_donut/use_severity_chart_data';
|
||||
import { SeverityLevelChart } from './severity_donut/severity_level_chart';
|
||||
|
||||
const StyledFlexGroup = styled(EuiFlexGroup)`
|
||||
@media only screen and (min-width: ${({ theme }) => theme.eui.euiBreakpoints.l});
|
||||
`;
|
||||
|
||||
const StyledFlexItem = styled(EuiFlexItem)`
|
||||
min-width: 355px;
|
||||
`;
|
||||
|
||||
const DETECTIONS_ALERTS_CHARTS_ID = 'detections-alerts-charts';
|
||||
|
||||
const PlaceHolder = ({ title }: { title: string }) => {
|
||||
return (
|
||||
<EuiFlexItem>
|
||||
<EuiPanel>
|
||||
<EuiTitle size="xs">
|
||||
<h4>{title}</h4>
|
||||
</EuiTitle>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
alignHeader?: 'center' | 'baseline' | 'stretch' | 'flexStart' | 'flexEnd';
|
||||
filters?: Filter[];
|
||||
|
@ -52,11 +48,9 @@ export const AlertsSummaryChartsPanel: React.FC<Props> = ({
|
|||
signalIndexName,
|
||||
title = i18n.CHARTS_TITLE,
|
||||
}: Props) => {
|
||||
// create a unique, but stable (across re-renders) query id
|
||||
const uniqueQueryId = useMemo(() => `${DETECTIONS_ALERTS_CHARTS_ID}-${uuidv4()}`, []);
|
||||
|
||||
const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_ALERTS_CHARTS_ID);
|
||||
const [querySkip, setQuerySkip] = useState(!toggleStatus);
|
||||
|
||||
useEffect(() => {
|
||||
setQuerySkip(!toggleStatus);
|
||||
}, [toggleStatus]);
|
||||
|
@ -69,15 +63,6 @@ export const AlertsSummaryChartsPanel: React.FC<Props> = ({
|
|||
[setQuerySkip, setToggleStatus]
|
||||
);
|
||||
|
||||
const { items: severityData, isLoading: isSeverityLoading } = useSeverityChartData({
|
||||
filters,
|
||||
query,
|
||||
signalIndexName,
|
||||
runtimeMappings,
|
||||
skip: querySkip,
|
||||
uniqueQueryId,
|
||||
});
|
||||
|
||||
return (
|
||||
<KpiPanel
|
||||
$toggleStatus={toggleStatus}
|
||||
|
@ -96,16 +81,42 @@ export const AlertsSummaryChartsPanel: React.FC<Props> = ({
|
|||
toggleQuery={toggleQuery}
|
||||
/>
|
||||
{toggleStatus && (
|
||||
<EuiFlexGroup data-test-subj="alerts-charts-container">
|
||||
<PlaceHolder title={i18n.DETECTIONS_TITLE} />
|
||||
<SeverityLevelChart
|
||||
data={severityData}
|
||||
isLoading={isSeverityLoading}
|
||||
uniqueQueryId={uniqueQueryId}
|
||||
addFilter={addFilter}
|
||||
/>
|
||||
<PlaceHolder title={i18n.ALERT_BY_HOST_TITLE} />
|
||||
</EuiFlexGroup>
|
||||
<StyledFlexGroup
|
||||
data-test-subj="alerts-charts-container"
|
||||
className="eui-yScroll"
|
||||
wrap
|
||||
gutterSize="m"
|
||||
>
|
||||
<StyledFlexItem>
|
||||
<SeverityLevelPanel
|
||||
filters={filters}
|
||||
query={query}
|
||||
signalIndexName={signalIndexName}
|
||||
runtimeMappings={runtimeMappings}
|
||||
skip={querySkip}
|
||||
addFilter={addFilter}
|
||||
/>
|
||||
</StyledFlexItem>
|
||||
<StyledFlexItem>
|
||||
<AlertsByTypePanel
|
||||
filters={filters}
|
||||
query={query}
|
||||
signalIndexName={signalIndexName}
|
||||
runtimeMappings={runtimeMappings}
|
||||
skip={querySkip}
|
||||
/>
|
||||
</StyledFlexItem>
|
||||
<StyledFlexItem>
|
||||
<AlertsProgressBarPanel
|
||||
addFilter={addFilter}
|
||||
filters={filters}
|
||||
query={query}
|
||||
signalIndexName={signalIndexName}
|
||||
runtimeMappings={runtimeMappings}
|
||||
skip={querySkip}
|
||||
/>
|
||||
</StyledFlexItem>
|
||||
</StyledFlexGroup>
|
||||
)}
|
||||
</KpiPanel>
|
||||
);
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProviders } from '../../../../../common/mock';
|
||||
import { SeverityLevelChart } from './severity_level_chart';
|
||||
|
||||
jest.mock('../../../../../common/lib/kibana');
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) };
|
||||
});
|
||||
|
||||
describe('Severity level chart', () => {
|
||||
const defaultProps = {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
uniqueQueryId: 'test-query-id',
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders correctly', () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<SeverityLevelChart {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector('[data-test-subj="severty-chart"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('render HeaderSection', () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<SeverityLevelChart {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector(`[data-test-subj="header-section"]`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('inspect button renders correctly', () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<SeverityLevelChart {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector('[data-test-subj="inspect-icon-button"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -1,119 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiInMemoryTable } from '@elastic/eui';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import { ALERT_SEVERITY } from '@kbn/rule-data-utils';
|
||||
import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import type { ShapeTreeNode, ElementClickListener } from '@elastic/charts';
|
||||
import * as i18n from '../translations';
|
||||
import type { ParsedSeverityData, SeverityData } from '../types';
|
||||
import type { FillColor } from '../../../../../common/components/charts/donutchart';
|
||||
import { DonutChart } from '../../../../../common/components/charts/donutchart';
|
||||
import { ChartLabel } from '../../../../../overview/components/detection_response/alerts_by_status/chart_label';
|
||||
import { HeaderSection } from '../../../../../common/components/header_section';
|
||||
import { InspectButtonContainer } from '../../../../../common/components/inspect';
|
||||
import { getSeverityTableColumns } from '../columns';
|
||||
import { getSeverityColor } from '../helpers';
|
||||
|
||||
const DONUT_HEIGHT = 150;
|
||||
|
||||
interface AlertsChartsPanelProps {
|
||||
data: ParsedSeverityData;
|
||||
isLoading: boolean;
|
||||
uniqueQueryId: string;
|
||||
addFilter?: ({ field, value }: { field: string; value: string | number }) => void;
|
||||
}
|
||||
|
||||
export const SeverityLevelChart: React.FC<AlertsChartsPanelProps> = ({
|
||||
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 (
|
||||
<EuiFlexItem>
|
||||
<InspectButtonContainer>
|
||||
<EuiPanel>
|
||||
<HeaderSection
|
||||
id={uniqueQueryId}
|
||||
inspectTitle={i18n.SEVERITY_LEVELS_TITLE}
|
||||
outerDirection="row"
|
||||
title={i18n.SEVERITY_LEVELS_TITLE}
|
||||
titleSize="xs"
|
||||
hideSubtitle
|
||||
/>
|
||||
<EuiFlexGroup data-test-subj="severty-chart" gutterSize="l">
|
||||
<EuiFlexItem>
|
||||
<EuiInMemoryTable
|
||||
data-test-subj="severity-level-alerts-table"
|
||||
columns={columns}
|
||||
items={items}
|
||||
loading={isLoading}
|
||||
sorting={sorting}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<DonutChart
|
||||
data-test-subj="severity-level-donut"
|
||||
data={data}
|
||||
fillColor={fillColor}
|
||||
height={DONUT_HEIGHT}
|
||||
label={i18n.SEVERITY_TOTAL_ALERTS}
|
||||
title={<ChartLabel count={count} />}
|
||||
totalCount={count}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
</InspectButtonContainer>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
};
|
||||
|
||||
SeverityLevelChart.displayName = 'SeverityLevelChart';
|
|
@ -1,131 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { TestProviders } from '../../../../../common/mock';
|
||||
import { ALERTS_QUERY_NAMES } from '../../../../containers/detection_engine/alerts/constants';
|
||||
import { mockAlertsData, alertsBySeverityQuery, parsedAlerts, from, to } from './mock_data';
|
||||
import type { UseAlertsBySeverity, UseSeverityChartProps } from './use_severity_chart_data';
|
||||
import { useSeverityChartData } from './use_severity_chart_data';
|
||||
|
||||
const dateNow = new Date('2022-04-08T12:00:00.000Z').valueOf();
|
||||
const mockDateNow = jest.fn().mockReturnValue(dateNow);
|
||||
Date.now = jest.fn(() => mockDateNow()) as unknown as DateConstructor['now'];
|
||||
|
||||
const defaultUseQueryAlertsReturn = {
|
||||
loading: false,
|
||||
data: null,
|
||||
setQuery: () => {},
|
||||
response: '',
|
||||
request: '',
|
||||
refetch: () => {},
|
||||
};
|
||||
const mockUseQueryAlerts = jest.fn().mockReturnValue(defaultUseQueryAlertsReturn);
|
||||
jest.mock('../../../../containers/detection_engine/alerts/use_query', () => {
|
||||
return {
|
||||
useQueryAlerts: (...props: unknown[]) => mockUseQueryAlerts(...props),
|
||||
};
|
||||
});
|
||||
|
||||
const mockUseGlobalTime = jest
|
||||
.fn()
|
||||
.mockReturnValue({ from, to, setQuery: jest.fn(), deleteQuery: jest.fn() });
|
||||
jest.mock('../../../../../common/containers/use_global_time', () => {
|
||||
return {
|
||||
useGlobalTime: (...props: unknown[]) => mockUseGlobalTime(...props),
|
||||
};
|
||||
});
|
||||
|
||||
// helper function to render the hook
|
||||
const renderUseSeverityChartData = (props: Partial<UseSeverityChartProps> = {}) =>
|
||||
renderHook<UseSeverityChartProps, ReturnType<UseAlertsBySeverity>>(
|
||||
() =>
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -7,57 +7,8 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const CHARTS_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.alerts.charts.chartsTitle',
|
||||
'xpack.securitySolution.detectionEngine.alerts.chartsTitle',
|
||||
{
|
||||
defaultMessage: 'Charts',
|
||||
}
|
||||
);
|
||||
|
||||
export const SEVERITY_LEVELS_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionResponse.alertsBySeverity.severityTitle',
|
||||
{
|
||||
defaultMessage: 'Severity levels',
|
||||
}
|
||||
);
|
||||
|
||||
export const SEVERITY_TOTAL_ALERTS = i18n.translate(
|
||||
'xpack.securitySolution.detectionResponse.alertsBySeverity.donut.totalAlerts',
|
||||
{
|
||||
defaultMessage: 'alerts',
|
||||
}
|
||||
);
|
||||
|
||||
export const UNKNOWN_SEVERITY = i18n.translate(
|
||||
'xpack.securitySolution.detectionResponse.alertsBySeverity.unknown',
|
||||
{
|
||||
defaultMessage: 'Unknown',
|
||||
}
|
||||
);
|
||||
|
||||
export const SEVERITY_LEVEL_COLUMN_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionResponse.alertsBySeverity.tableColumnLevelTitle',
|
||||
{
|
||||
defaultMessage: 'Levels',
|
||||
}
|
||||
);
|
||||
|
||||
export const SEVERITY_COUNT_COULMN_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionResponse.alertsBySeverity.tableColumnCountTitle',
|
||||
{
|
||||
defaultMessage: 'Counts',
|
||||
}
|
||||
);
|
||||
|
||||
export const DETECTIONS_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionResponse.alertsBySeverity.chartDetectionTitle',
|
||||
{
|
||||
defaultMessage: 'Detections',
|
||||
}
|
||||
);
|
||||
|
||||
export const ALERT_BY_HOST_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionResponse.alertsBySeverity.chartAlertHostTitle',
|
||||
{
|
||||
defaultMessage: 'Alert by host type',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -4,45 +4,45 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
|
||||
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
|
||||
import type { Filter, Query } from '@kbn/es-query';
|
||||
import { has } from 'lodash';
|
||||
import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types';
|
||||
import type { SeverityBuckets as SeverityData } from '../../../../overview/components/detection_response/alerts_by_status/types';
|
||||
import type { AlertsBySeverityAgg } from '../severity_level_panel/types';
|
||||
import type { AlertsByTypeAgg, AlertsTypeData } from '../alerts_by_type_panel/types';
|
||||
import type {
|
||||
AlertsByGroupingAgg,
|
||||
AlertsProgressBarData,
|
||||
} from '../alerts_progress_bar_panel/types';
|
||||
|
||||
export interface EntityFilter {
|
||||
field: string;
|
||||
value: string;
|
||||
export type SummaryChartsAgg = Partial<AlertsBySeverityAgg | AlertsByTypeAgg | AlertsByGroupingAgg>;
|
||||
|
||||
export type SummaryChartsData = SeverityData | AlertsTypeData | AlertsProgressBarData;
|
||||
|
||||
export interface ChartsPanelProps {
|
||||
filters?: Filter[];
|
||||
query?: Query;
|
||||
signalIndexName: string | null;
|
||||
runtimeMappings?: MappingRuntimeFields;
|
||||
skip?: boolean;
|
||||
addFilter?: ({ field, value }: { field: string; value: string | number }) => void;
|
||||
}
|
||||
|
||||
export type ParsedSeverityData = SeverityData[] | undefined | null;
|
||||
export interface SeverityData {
|
||||
key: Severity;
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
export const isAlertsBySeverityAgg = (
|
||||
data: AlertSearchResponse<{}, SummaryChartsAgg>
|
||||
): data is AlertSearchResponse<{}, AlertsBySeverityAgg> => {
|
||||
return has(data, 'aggregations.statusBySeverity');
|
||||
};
|
||||
|
||||
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<Hit = {}, Aggregations = {} | undefined> {
|
||||
took: number;
|
||||
_shards: {
|
||||
total: number;
|
||||
successful: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
};
|
||||
aggregations?: Aggregations;
|
||||
hits: {
|
||||
total: {
|
||||
value: number;
|
||||
relation: string;
|
||||
};
|
||||
hits: Hit[];
|
||||
};
|
||||
}
|
||||
export const isAlertsByTypeAgg = (
|
||||
data: AlertSearchResponse<{}, SummaryChartsAgg>
|
||||
): data is AlertSearchResponse<{}, AlertsByTypeAgg> => {
|
||||
return has(data, 'aggregations.alertsByRule');
|
||||
};
|
||||
|
||||
export const isAlertsByGroupingAgg = (
|
||||
data: AlertSearchResponse<{}, SummaryChartsAgg>
|
||||
): data is AlertSearchResponse<{}, AlertsByGroupingAgg> => {
|
||||
return has(data, 'aggregations.alertsByGrouping');
|
||||
};
|
||||
|
|
|
@ -0,0 +1,258 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { ALERTS_QUERY_NAMES } from '../../../containers/detection_engine/alerts/constants';
|
||||
import type { UseAlerts, UseAlertsQueryProps } from './use_summary_chart_data';
|
||||
import { useSummaryChartData, getAlertsQuery } from './use_summary_chart_data';
|
||||
import * as aggregations from './aggregations';
|
||||
import * as severityMock from '../severity_level_panel/mock_data';
|
||||
import * as alertTypeMock from '../alerts_by_type_panel/mock_data';
|
||||
import * as alertsGroupingMock from '../alerts_progress_bar_panel/mock_data';
|
||||
|
||||
const from = '2022-04-05T12:00:00.000Z';
|
||||
const to = '2022-04-08T12:00:00.000Z';
|
||||
const additionalFilters = [{ bool: { filter: [], must: [], must_not: [], should: [] } }];
|
||||
|
||||
const dateNow = new Date(to).valueOf();
|
||||
const mockDateNow = jest.fn().mockReturnValue(dateNow);
|
||||
Date.now = jest.fn(() => mockDateNow()) as unknown as DateConstructor['now'];
|
||||
|
||||
const defaultUseQueryAlertsReturn = {
|
||||
loading: false,
|
||||
data: null,
|
||||
setQuery: () => {},
|
||||
response: '',
|
||||
request: '',
|
||||
refetch: () => {},
|
||||
};
|
||||
const mockUseQueryAlerts = jest.fn().mockReturnValue(defaultUseQueryAlertsReturn);
|
||||
jest.mock('../../../containers/detection_engine/alerts/use_query', () => {
|
||||
return {
|
||||
useQueryAlerts: (...props: unknown[]) => mockUseQueryAlerts(...props),
|
||||
};
|
||||
});
|
||||
|
||||
const mockUseGlobalTime = jest
|
||||
.fn()
|
||||
.mockReturnValue({ from, to, setQuery: jest.fn(), deleteQuery: jest.fn() });
|
||||
jest.mock('../../../../common/containers/use_global_time', () => {
|
||||
return {
|
||||
useGlobalTime: (...props: unknown[]) => mockUseGlobalTime(...props),
|
||||
};
|
||||
});
|
||||
|
||||
describe('getAlertsQuery', () => {
|
||||
test('it returns the expected severity query', () => {
|
||||
expect(
|
||||
getAlertsQuery({
|
||||
from,
|
||||
to,
|
||||
additionalFilters,
|
||||
aggregations: aggregations.severityAggregations,
|
||||
})
|
||||
).toEqual(severityMock.query);
|
||||
});
|
||||
|
||||
test('it returns the expected alerts by type query', () => {
|
||||
expect(
|
||||
getAlertsQuery({
|
||||
from,
|
||||
to,
|
||||
additionalFilters,
|
||||
aggregations: aggregations.alertTypeAggregations,
|
||||
})
|
||||
).toEqual(alertTypeMock.query);
|
||||
});
|
||||
|
||||
test('it returns the expected alerts by grouping query', () => {
|
||||
expect(
|
||||
getAlertsQuery({
|
||||
from,
|
||||
to,
|
||||
additionalFilters,
|
||||
aggregations: aggregations.alertsGroupingAggregations('host.name'),
|
||||
})
|
||||
).toEqual(alertsGroupingMock.query);
|
||||
});
|
||||
});
|
||||
|
||||
// helper function to render the hook
|
||||
const renderUseSummaryChartData = (props: Partial<UseAlertsQueryProps> = {}) =>
|
||||
renderHook<UseAlertsQueryProps, ReturnType<UseAlerts>>(
|
||||
() =>
|
||||
useSummaryChartData({
|
||||
aggregations: aggregations.severityAggregations,
|
||||
uniqueQueryId: 'test',
|
||||
signalIndexName: 'signal-alerts',
|
||||
...props,
|
||||
}),
|
||||
{
|
||||
wrapper: TestProviders,
|
||||
}
|
||||
);
|
||||
|
||||
describe('get severity chart data', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDateNow.mockReturnValue(dateNow);
|
||||
mockUseQueryAlerts.mockReturnValue(defaultUseQueryAlertsReturn);
|
||||
});
|
||||
|
||||
it('should return default values', () => {
|
||||
const { result } = renderUseSummaryChartData();
|
||||
|
||||
expect(result.current).toEqual({
|
||||
items: [],
|
||||
isLoading: false,
|
||||
updatedAt: dateNow,
|
||||
});
|
||||
|
||||
expect(mockUseQueryAlerts).toBeCalledWith({
|
||||
query: severityMock.query,
|
||||
indexName: 'signal-alerts',
|
||||
skip: false,
|
||||
queryName: ALERTS_QUERY_NAMES.COUNT,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return parsed items', () => {
|
||||
mockUseQueryAlerts.mockReturnValue({
|
||||
...defaultUseQueryAlertsReturn,
|
||||
data: severityMock.mockAlertsData,
|
||||
});
|
||||
|
||||
const { result } = renderUseSummaryChartData();
|
||||
expect(result.current).toEqual({
|
||||
items: severityMock.parsedAlerts,
|
||||
isLoading: false,
|
||||
updatedAt: dateNow,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return new updatedAt', () => {
|
||||
const newDateNow = new Date('2022-04-08T14:00:00.000Z').valueOf();
|
||||
mockDateNow.mockReturnValue(newDateNow); // setUpdatedAt call
|
||||
mockDateNow.mockReturnValueOnce(dateNow); // initialization call
|
||||
|
||||
mockUseQueryAlerts.mockReturnValue({
|
||||
...defaultUseQueryAlertsReturn,
|
||||
data: severityMock.mockAlertsData,
|
||||
});
|
||||
|
||||
const { result } = renderUseSummaryChartData();
|
||||
|
||||
expect(mockDateNow).toHaveBeenCalled();
|
||||
expect(result.current).toEqual({
|
||||
items: severityMock.parsedAlerts,
|
||||
isLoading: false,
|
||||
updatedAt: newDateNow,
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip the query', () => {
|
||||
const { result } = renderUseSummaryChartData({ skip: true });
|
||||
|
||||
expect(mockUseQueryAlerts).toBeCalledWith({
|
||||
query: severityMock.query,
|
||||
indexName: 'signal-alerts',
|
||||
skip: true,
|
||||
queryName: ALERTS_QUERY_NAMES.COUNT,
|
||||
});
|
||||
|
||||
expect(result.current).toEqual({
|
||||
items: [],
|
||||
isLoading: false,
|
||||
updatedAt: dateNow,
|
||||
});
|
||||
});
|
||||
|
||||
describe('get alerts by type data', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDateNow.mockReturnValue(dateNow);
|
||||
mockUseQueryAlerts.mockReturnValue(defaultUseQueryAlertsReturn);
|
||||
});
|
||||
it('should return default values', () => {
|
||||
const { result } = renderUseSummaryChartData({
|
||||
aggregations: aggregations.alertTypeAggregations,
|
||||
});
|
||||
|
||||
expect(result.current).toEqual({
|
||||
items: [],
|
||||
isLoading: false,
|
||||
updatedAt: dateNow,
|
||||
});
|
||||
|
||||
expect(mockUseQueryAlerts).toBeCalledWith({
|
||||
query: alertTypeMock.query,
|
||||
indexName: 'signal-alerts',
|
||||
skip: false,
|
||||
queryName: ALERTS_QUERY_NAMES.COUNT,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return parsed alerts by type items', () => {
|
||||
mockUseQueryAlerts.mockReturnValue({
|
||||
...defaultUseQueryAlertsReturn,
|
||||
data: alertTypeMock.mockAlertsData,
|
||||
});
|
||||
|
||||
const { result } = renderUseSummaryChartData({
|
||||
aggregations: aggregations.alertTypeAggregations,
|
||||
});
|
||||
expect(result.current).toEqual({
|
||||
items: alertTypeMock.parsedAlerts,
|
||||
isLoading: false,
|
||||
updatedAt: dateNow,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('get top alerts data', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockDateNow.mockReturnValue(dateNow);
|
||||
mockUseQueryAlerts.mockReturnValue(defaultUseQueryAlertsReturn);
|
||||
});
|
||||
it('should return default values', () => {
|
||||
const { result } = renderUseSummaryChartData({
|
||||
aggregations: aggregations.alertsGroupingAggregations('host.name'),
|
||||
});
|
||||
|
||||
expect(result.current).toEqual({
|
||||
items: [],
|
||||
isLoading: false,
|
||||
updatedAt: dateNow,
|
||||
});
|
||||
|
||||
expect(mockUseQueryAlerts).toBeCalledWith({
|
||||
query: alertsGroupingMock.query,
|
||||
indexName: 'signal-alerts',
|
||||
skip: false,
|
||||
queryName: ALERTS_QUERY_NAMES.COUNT,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return parsed top alert items', () => {
|
||||
mockUseQueryAlerts.mockReturnValue({
|
||||
...defaultUseQueryAlertsReturn,
|
||||
data: alertsGroupingMock.mockAlertsData,
|
||||
});
|
||||
|
||||
const { result } = renderUseSummaryChartData({
|
||||
aggregations: aggregations.alertsGroupingAggregations('host.name'),
|
||||
});
|
||||
expect(result.current).toEqual({
|
||||
items: alertsGroupingMock.parsedAlerts,
|
||||
isLoading: false,
|
||||
updatedAt: dateNow,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -5,31 +5,50 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { buildEsQuery } from '@kbn/es-query';
|
||||
import { ALERT_SEVERITY } from '@kbn/rule-data-utils';
|
||||
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
|
||||
import type { Filter, Query } from '@kbn/es-query';
|
||||
import type { AlertsBySeverityAgg, EntityFilter, ParsedSeverityData } from '../types';
|
||||
import type { ESBoolQuery } from '../../../../../../common/typed_json';
|
||||
import { useGlobalTime } from '../../../../../common/containers/use_global_time';
|
||||
import { useQueryAlerts } from '../../../../containers/detection_engine/alerts/use_query';
|
||||
import { ALERTS_QUERY_NAMES } from '../../../../containers/detection_engine/alerts/constants';
|
||||
import { useInspectButton } from '../../common/hooks';
|
||||
import { parseSeverityAlerts } from '../helpers';
|
||||
import type { SummaryChartsAgg, SummaryChartsData } from './types';
|
||||
import type { EntityFilter } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status';
|
||||
import type { ESBoolQuery } from '../../../../../common/typed_json';
|
||||
import { useGlobalTime } from '../../../../common/containers/use_global_time';
|
||||
import { useQueryAlerts } from '../../../containers/detection_engine/alerts/use_query';
|
||||
import { ALERTS_QUERY_NAMES } from '../../../containers/detection_engine/alerts/constants';
|
||||
import { useInspectButton } from '../common/hooks';
|
||||
import { parseData } from './helpers';
|
||||
|
||||
export const getAlertsBySeverityQuery = ({
|
||||
export type UseAlerts = (props: UseAlertsQueryProps) => {
|
||||
items: SummaryChartsData[];
|
||||
isLoading: boolean;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export interface UseAlertsQueryProps {
|
||||
aggregations: {};
|
||||
uniqueQueryId: string;
|
||||
signalIndexName: string | null;
|
||||
skip?: boolean;
|
||||
entityFilter?: EntityFilter;
|
||||
query?: Query;
|
||||
filters?: Filter[];
|
||||
runtimeMappings?: MappingRuntimeFields;
|
||||
}
|
||||
|
||||
export const getAlertsQuery = ({
|
||||
additionalFilters = [],
|
||||
from,
|
||||
to,
|
||||
entityFilter,
|
||||
runtimeMappings,
|
||||
aggregations,
|
||||
}: {
|
||||
from: string;
|
||||
to: string;
|
||||
entityFilter?: EntityFilter;
|
||||
additionalFilters?: ESBoolQuery[];
|
||||
runtimeMappings?: MappingRuntimeFields;
|
||||
aggregations: {};
|
||||
}) => ({
|
||||
size: 0,
|
||||
query: {
|
||||
|
@ -49,33 +68,12 @@ export const getAlertsBySeverityQuery = ({
|
|||
],
|
||||
},
|
||||
},
|
||||
aggs: {
|
||||
statusBySeverity: {
|
||||
terms: {
|
||||
field: ALERT_SEVERITY,
|
||||
},
|
||||
},
|
||||
},
|
||||
aggs: aggregations,
|
||||
runtime_mappings: runtimeMappings,
|
||||
});
|
||||
|
||||
export interface UseSeverityChartProps {
|
||||
uniqueQueryId: string;
|
||||
signalIndexName: string | null;
|
||||
skip?: boolean;
|
||||
entityFilter?: EntityFilter;
|
||||
query?: Query;
|
||||
filters?: Filter[];
|
||||
runtimeMappings?: MappingRuntimeFields;
|
||||
}
|
||||
|
||||
export type UseAlertsBySeverity = (props: UseSeverityChartProps) => {
|
||||
items: ParsedSeverityData;
|
||||
isLoading: boolean;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export const useSeverityChartData: UseAlertsBySeverity = ({
|
||||
export const useSummaryChartData: UseAlerts = ({
|
||||
aggregations,
|
||||
uniqueQueryId,
|
||||
entityFilter,
|
||||
query,
|
||||
|
@ -84,9 +82,9 @@ export const useSeverityChartData: UseAlertsBySeverity = ({
|
|||
signalIndexName,
|
||||
skip = false,
|
||||
}) => {
|
||||
const { to, from, deleteQuery, setQuery } = useGlobalTime();
|
||||
const { to, from, deleteQuery, setQuery } = useGlobalTime(false);
|
||||
const [updatedAt, setUpdatedAt] = useState(Date.now());
|
||||
const [items, setItems] = useState<null | ParsedSeverityData>(null);
|
||||
const [items, setItems] = useState<SummaryChartsData[]>([]);
|
||||
|
||||
const additionalFilters = useMemo(() => {
|
||||
try {
|
||||
|
@ -109,13 +107,14 @@ export const useSeverityChartData: UseAlertsBySeverity = ({
|
|||
request,
|
||||
response,
|
||||
setQuery: setAlertsQuery,
|
||||
} = useQueryAlerts<{}, AlertsBySeverityAgg>({
|
||||
query: getAlertsBySeverityQuery({
|
||||
} = useQueryAlerts<{}, SummaryChartsAgg>({
|
||||
query: getAlertsQuery({
|
||||
from,
|
||||
to,
|
||||
entityFilter,
|
||||
additionalFilters,
|
||||
runtimeMappings,
|
||||
aggregations,
|
||||
}),
|
||||
indexName: signalIndexName,
|
||||
skip,
|
||||
|
@ -124,21 +123,22 @@ export const useSeverityChartData: UseAlertsBySeverity = ({
|
|||
|
||||
useEffect(() => {
|
||||
setAlertsQuery(
|
||||
getAlertsBySeverityQuery({
|
||||
getAlertsQuery({
|
||||
from,
|
||||
to,
|
||||
entityFilter,
|
||||
additionalFilters,
|
||||
runtimeMappings,
|
||||
aggregations,
|
||||
})
|
||||
);
|
||||
}, [setAlertsQuery, from, to, entityFilter, additionalFilters, runtimeMappings]);
|
||||
}, [setAlertsQuery, from, to, entityFilter, additionalFilters, runtimeMappings, aggregations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data == null) {
|
||||
setItems(null);
|
||||
setItems([]);
|
||||
} else {
|
||||
setItems(parseSeverityAlerts(data));
|
||||
setItems(parseData(data));
|
||||
}
|
||||
setUpdatedAt(Date.now());
|
||||
}, [data]);
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { EuiComboBoxOptionOption } from '@elastic/eui';
|
||||
import { EuiPanel, EuiComboBox } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import type { LegacyRef } from 'react';
|
||||
|
@ -58,6 +59,7 @@ interface StackedBySelectProps {
|
|||
inputRef?: (inputRef: HTMLInputElement | null) => void;
|
||||
onSelect: (selected: string) => void;
|
||||
width?: number;
|
||||
dropDownoptions?: Array<EuiComboBoxOptionOption<string | number | string[] | undefined>>;
|
||||
}
|
||||
|
||||
export const StackByComboBoxWrapper = styled.div<{ width: number }>`
|
||||
|
@ -76,6 +78,7 @@ export const StackByComboBox = React.forwardRef(
|
|||
selected,
|
||||
inputRef,
|
||||
width = DEFAULT_WIDTH,
|
||||
dropDownoptions,
|
||||
}: StackedBySelectProps,
|
||||
ref
|
||||
) => {
|
||||
|
@ -92,6 +95,7 @@ export const StackByComboBox = React.forwardRef(
|
|||
const selectedOptions = useMemo(() => {
|
||||
return [{ label: selected, value: selected }];
|
||||
}, [selected]);
|
||||
|
||||
const stackOptions = useStackByFields();
|
||||
const singleSelection = useMemo(() => {
|
||||
return { asPlainText: true };
|
||||
|
@ -109,7 +113,7 @@ export const StackByComboBox = React.forwardRef(
|
|||
singleSelection={singleSelection}
|
||||
isClearable={false}
|
||||
sortMatchesBy="startsWith"
|
||||
options={stackOptions}
|
||||
options={dropDownoptions ?? stackOptions}
|
||||
selectedOptions={selectedOptions}
|
||||
compressed
|
||||
onChange={onChange}
|
||||
|
|
|
@ -6,22 +6,18 @@
|
|||
*/
|
||||
import React from 'react';
|
||||
import { EuiHealth, EuiText } from '@elastic/eui';
|
||||
import type { EuiBasicTableColumn } from '@elastic/eui';
|
||||
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
|
||||
import { capitalize } from 'lodash';
|
||||
import { ALERT_SEVERITY } from '@kbn/rule-data-utils';
|
||||
import type { EuiBasicTableColumn } from '@elastic/eui';
|
||||
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
|
||||
import type { SeverityBuckets as SeverityData } from '../../../../overview/components/detection_response/alerts_by_status/types';
|
||||
import { DefaultDraggable } from '../../../../common/components/draggables';
|
||||
import { SEVERITY_COLOR } from '../../../../overview/components/detection_response/utils';
|
||||
import { FormattedCount } from '../../../../common/components/formatted_number';
|
||||
import { COUNT_TABLE_TITLE } from '../alerts_count_panel/translations';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface SeverityTableItem {
|
||||
key: Severity;
|
||||
value: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const getSeverityTableColumns = (): Array<EuiBasicTableColumn<SeverityTableItem>> => [
|
||||
export const getSeverityTableColumns = (): Array<EuiBasicTableColumn<SeverityData>> => [
|
||||
{
|
||||
field: 'key',
|
||||
name: i18n.SEVERITY_LEVEL_COLUMN_TITLE,
|
||||
|
@ -42,10 +38,11 @@ export const getSeverityTableColumns = (): Array<EuiBasicTableColumn<SeverityTab
|
|||
},
|
||||
{
|
||||
field: 'value',
|
||||
name: i18n.SEVERITY_COUNT_COULMN_TITLE,
|
||||
name: COUNT_TABLE_TITLE,
|
||||
sortable: true,
|
||||
dataType: 'number',
|
||||
'data-test-subj': 'severityTable-alertCount',
|
||||
width: '45%',
|
||||
render: (alertCount: number) => (
|
||||
<EuiText grow={false} size="xs">
|
||||
<FormattedCount count={alertCount} />
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { parseSeverityData } from './helpers';
|
||||
import * as mock from './mock_data';
|
||||
import type { AlertsBySeverityAgg } from './types';
|
||||
import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types';
|
||||
|
||||
describe('parse severity data', () => {
|
||||
test('parse alerts with data', () => {
|
||||
const res = parseSeverityData(
|
||||
mock.mockAlertsData as AlertSearchResponse<{}, AlertsBySeverityAgg>
|
||||
);
|
||||
expect(res).toEqual(mock.parsedAlerts);
|
||||
});
|
||||
|
||||
test('parse alerts without data', () => {
|
||||
const res = parseSeverityData(
|
||||
mock.mockAlertsEmptyData as AlertSearchResponse<{}, AlertsBySeverityAgg>
|
||||
);
|
||||
expect(res).toEqual([]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
|
||||
import { has } from 'lodash';
|
||||
import type { AlertsBySeverityAgg } from './types';
|
||||
import type { AlertSearchResponse } from '../../../containers/detection_engine/alerts/types';
|
||||
import type { SeverityBuckets as SeverityData } from '../../../../overview/components/detection_response/alerts_by_status/types';
|
||||
import type { SummaryChartsData } from '../alerts_summary_charts_panel/types';
|
||||
import { severityLabels } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status';
|
||||
import { emptyDonutColor } from '../../../../common/components/charts/donutchart_empty';
|
||||
import { SEVERITY_COLOR } from '../../../../overview/components/detection_response/utils';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const getSeverityColor = (severity: string) => {
|
||||
return SEVERITY_COLOR[severity.toLocaleLowerCase() as Severity] ?? emptyDonutColor;
|
||||
};
|
||||
|
||||
export const parseSeverityData = (
|
||||
response: AlertSearchResponse<{}, AlertsBySeverityAgg>
|
||||
): SeverityData[] => {
|
||||
const severityBuckets = response?.aggregations?.statusBySeverity?.buckets ?? [];
|
||||
|
||||
return severityBuckets.length === 0
|
||||
? []
|
||||
: severityBuckets.map((severity) => {
|
||||
return {
|
||||
key: severity.key,
|
||||
value: severity.doc_count,
|
||||
label: severityLabels[severity.key] ?? i18n.UNKNOWN_SEVERITY,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const isAlertsBySeverityData = (data: SummaryChartsData[]): data is SeverityData[] => {
|
||||
return data?.every((x) => has(x, 'key'));
|
||||
};
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { act, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { SeverityLevelPanel } from '.';
|
||||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) };
|
||||
});
|
||||
|
||||
describe('Severity level panel', () => {
|
||||
const defaultProps = {
|
||||
signalIndexName: 'signalIndexName',
|
||||
skip: false,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders correctly', async () => {
|
||||
await act(async () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<SeverityLevelPanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector('[data-test-subj="severty-level-panel"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('render HeaderSection', async () => {
|
||||
await act(async () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<SeverityLevelPanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector(`[data-test-subj="header-section"]`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('inspect button renders correctly', async () => {
|
||||
await act(async () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<SeverityLevelPanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector('[data-test-subj="inspect-icon-button"]')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('renders severity chart correctly', async () => {
|
||||
await act(async () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<SeverityLevelPanel {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(
|
||||
container.querySelector(`[data-test-subj="severity-level-chart"]`)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiPanel } from '@elastic/eui';
|
||||
import React, { useMemo } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { ChartsPanelProps } from '../alerts_summary_charts_panel/types';
|
||||
import { HeaderSection } from '../../../../common/components/header_section';
|
||||
import { InspectButtonContainer } from '../../../../common/components/inspect';
|
||||
import { useSummaryChartData } from '../alerts_summary_charts_panel/use_summary_chart_data';
|
||||
import { severityAggregations } from '../alerts_summary_charts_panel/aggregations';
|
||||
import { isAlertsBySeverityData } from './helpers';
|
||||
import { SeverityLevelChart } from './severity_level_chart';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const SEVERITY_DONUT_CHART_ID = 'alerts-summary-severity-donut';
|
||||
|
||||
export const SeverityLevelPanel: React.FC<ChartsPanelProps> = ({
|
||||
filters,
|
||||
query,
|
||||
signalIndexName,
|
||||
runtimeMappings,
|
||||
addFilter,
|
||||
skip,
|
||||
}) => {
|
||||
const uniqueQueryId = useMemo(() => `${SEVERITY_DONUT_CHART_ID}-${uuid()}`, []);
|
||||
|
||||
const { items, isLoading } = useSummaryChartData({
|
||||
aggregations: severityAggregations,
|
||||
filters,
|
||||
query,
|
||||
signalIndexName,
|
||||
runtimeMappings,
|
||||
skip,
|
||||
uniqueQueryId,
|
||||
});
|
||||
const data = useMemo(() => (isAlertsBySeverityData(items) ? items : []), [items]);
|
||||
return (
|
||||
<InspectButtonContainer>
|
||||
<EuiPanel hasBorder hasShadow={false} data-test-subj="severty-level-panel">
|
||||
<HeaderSection
|
||||
id={uniqueQueryId}
|
||||
inspectTitle={i18n.SEVERITY_LEVELS_TITLE}
|
||||
outerDirection="row"
|
||||
title={i18n.SEVERITY_LEVELS_TITLE}
|
||||
titleSize="xs"
|
||||
hideSubtitle
|
||||
/>
|
||||
<SeverityLevelChart data={data} isLoading={isLoading} addFilter={addFilter} />
|
||||
</EuiPanel>
|
||||
</InspectButtonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
SeverityLevelPanel.displayName = 'SeverityLevelPanel';
|
|
@ -4,12 +4,13 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
export const from = '2022-04-05T12:00:00.000Z';
|
||||
export const to = '2022-04-08T12:00:00.000Z';
|
||||
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
|
||||
const from = '2022-04-05T12:00:00.000Z';
|
||||
const to = '2022-04-08T12:00:00.000Z';
|
||||
|
||||
export const mockAlertsData = {
|
||||
took: 0,
|
||||
timed_out: false,
|
||||
timeout: false,
|
||||
_shards: {
|
||||
total: 1,
|
||||
successful: 1,
|
||||
|
@ -50,16 +51,9 @@ export const mockAlertsData = {
|
|||
},
|
||||
};
|
||||
|
||||
export const parsedAlerts = [
|
||||
{ key: 'high', value: 78, label: 'High' },
|
||||
{ key: 'low', value: 46, label: 'Low' },
|
||||
{ key: 'medium', value: 32, label: 'Medium' },
|
||||
{ key: 'critical', value: 21, label: 'Critical' },
|
||||
];
|
||||
|
||||
export const mockAlertsEmptyData = {
|
||||
took: 0,
|
||||
timed_out: false,
|
||||
timeout: false,
|
||||
_shards: {
|
||||
total: 1,
|
||||
successful: 1,
|
||||
|
@ -83,12 +77,19 @@ export const mockAlertsEmptyData = {
|
|||
},
|
||||
};
|
||||
|
||||
export const alertsBySeverityQuery = {
|
||||
export const query = {
|
||||
size: 0,
|
||||
query: {
|
||||
bool: {
|
||||
filter: [
|
||||
{ bool: { filter: [], must: [], must_not: [], should: [] } },
|
||||
{
|
||||
bool: {
|
||||
filter: [],
|
||||
must: [],
|
||||
must_not: [],
|
||||
should: [],
|
||||
},
|
||||
},
|
||||
{ range: { '@timestamp': { gte: from, lte: to } } },
|
||||
],
|
||||
},
|
||||
|
@ -102,3 +103,10 @@ export const alertsBySeverityQuery = {
|
|||
},
|
||||
runtime_mappings: undefined,
|
||||
};
|
||||
|
||||
export const parsedAlerts: Array<{ key: Severity; value: number; label: string }> = [
|
||||
{ key: 'high', value: 78, label: 'High' },
|
||||
{ key: 'low', value: 46, label: 'Low' },
|
||||
{ key: 'medium', value: 32, label: 'Medium' },
|
||||
{ key: 'critical', value: 21, label: 'Critical' },
|
||||
];
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { act, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { SeverityLevelChart } from './severity_level_chart';
|
||||
import { parsedAlerts } from './mock_data';
|
||||
|
||||
jest.mock('../../../../common/lib/kibana');
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) };
|
||||
});
|
||||
|
||||
describe('Severity level chart', () => {
|
||||
const defaultProps = {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders severity table correctly', () => {
|
||||
act(() => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<SeverityLevelChart {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector('[data-test-subj="severity-level-table"')).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector('[data-test-subj="severity-level-table"] tbody')?.textContent
|
||||
).toEqual('No items found');
|
||||
});
|
||||
});
|
||||
|
||||
test('renders severity donut correctly', () => {
|
||||
act(() => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<SeverityLevelChart {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[data-test-subj="severity-level-donut"]')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('renders table correctly with data', () => {
|
||||
act(() => {
|
||||
const { queryAllByRole, container } = render(
|
||||
<TestProviders>
|
||||
<SeverityLevelChart data={parsedAlerts} isLoading={false} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(container.querySelector('[data-test-subj="severity-level-table"')).toBeInTheDocument();
|
||||
parsedAlerts.forEach((_, i) => {
|
||||
expect(queryAllByRole('row')[i + 1].textContent).toContain(parsedAlerts[i].label);
|
||||
expect(queryAllByRole('row')[i + 1].textContent).toContain(
|
||||
parsedAlerts[i].value.toString()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React, { useCallback, useMemo, useEffect, useState } from 'react';
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import { ALERT_SEVERITY } from '@kbn/rule-data-utils';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiInMemoryTable, EuiLoadingSpinner } from '@elastic/eui';
|
||||
import type { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import type { ShapeTreeNode, ElementClickListener } from '@elastic/charts';
|
||||
import type { SeverityBuckets as SeverityData } from '../../../../overview/components/detection_response/alerts_by_status/types';
|
||||
import type { FillColor } from '../../../../common/components/charts/donutchart';
|
||||
import { DonutChart } from '../../../../common/components/charts/donutchart';
|
||||
import { ChartLabel } from '../../../../overview/components/detection_response/alerts_by_status/chart_label';
|
||||
import { getSeverityTableColumns } from './columns';
|
||||
import { getSeverityColor } from './helpers';
|
||||
import { TOTAL_COUNT_OF_ALERTS } from '../../alerts_table/translations';
|
||||
import { showInitialLoadingSpinner } from '../alerts_histogram_panel/helpers';
|
||||
|
||||
const DONUT_HEIGHT = 150;
|
||||
|
||||
export interface SeverityLevelProps {
|
||||
data: SeverityData[];
|
||||
isLoading: boolean;
|
||||
addFilter?: ({ field, value }: { field: string; value: string | number }) => void;
|
||||
}
|
||||
|
||||
export const SeverityLevelChart: React.FC<SeverityLevelProps> = ({
|
||||
data,
|
||||
isLoading,
|
||||
addFilter,
|
||||
}) => {
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
const columns = useMemo(() => getSeverityTableColumns(), []);
|
||||
|
||||
const count = useMemo(() => {
|
||||
return data
|
||||
? data.reduce(function (prev, cur) {
|
||||
return prev + cur.value;
|
||||
}, 0)
|
||||
: 0;
|
||||
}, [data]);
|
||||
|
||||
const fillColor: FillColor = useCallback((d: ShapeTreeNode) => {
|
||||
return getSeverityColor(d.dataName);
|
||||
}, []);
|
||||
|
||||
const sorting: { sort: { field: keyof SeverityData; direction: SortOrder } } = {
|
||||
sort: {
|
||||
field: 'value',
|
||||
direction: 'desc',
|
||||
},
|
||||
};
|
||||
|
||||
const onElementClick: ElementClickListener = useCallback(
|
||||
(event) => {
|
||||
const flattened = event.flat(2);
|
||||
const level =
|
||||
flattened.length > 0 &&
|
||||
'groupByRollup' in flattened[0] &&
|
||||
flattened[0].groupByRollup != null
|
||||
? `${flattened[0].groupByRollup}`
|
||||
: '';
|
||||
|
||||
if (addFilter != null && !isEmpty(level.trim())) {
|
||||
addFilter({ field: ALERT_SEVERITY, value: level.toLowerCase() });
|
||||
}
|
||||
},
|
||||
[addFilter]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showInitialLoadingSpinner({ isInitialLoading, isLoadingAlerts: isLoading })) {
|
||||
setIsInitialLoading(false);
|
||||
}
|
||||
}, [isInitialLoading, isLoading, setIsInitialLoading]);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup gutterSize="s" data-test-subj="severity-level-chart">
|
||||
<EuiFlexItem>
|
||||
<EuiInMemoryTable
|
||||
data-test-subj="severity-level-table"
|
||||
columns={columns}
|
||||
items={data}
|
||||
loading={isLoading}
|
||||
sorting={sorting}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem data-test-subj="severity-level-donut">
|
||||
{isInitialLoading ? (
|
||||
<EuiLoadingSpinner size="l" />
|
||||
) : (
|
||||
<DonutChart
|
||||
data={data}
|
||||
fillColor={fillColor}
|
||||
height={DONUT_HEIGHT}
|
||||
label={TOTAL_COUNT_OF_ALERTS}
|
||||
title={<ChartLabel count={count} />}
|
||||
totalCount={count}
|
||||
onElementClick={onElementClick}
|
||||
/>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
SeverityLevelChart.displayName = 'SeverityLevelChart';
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const SEVERITY_LEVELS_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.alerts.severity.severityDonutTitle',
|
||||
{
|
||||
defaultMessage: 'Severity levels',
|
||||
}
|
||||
);
|
||||
|
||||
export const UNKNOWN_SEVERITY = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.alerts.severity.unknown',
|
||||
{
|
||||
defaultMessage: 'Unknown',
|
||||
}
|
||||
);
|
||||
|
||||
export const SEVERITY_LEVEL_COLUMN_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.detectionEngine.alerts.severity.severityTableLevelColumn',
|
||||
{ defaultMessage: 'Levels' }
|
||||
);
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { SeverityBucket } from '../../../../overview/components/detection_response/alerts_by_status/types';
|
||||
|
||||
export interface AlertsBySeverityAgg {
|
||||
statusBySeverity: {
|
||||
doc_count_error_upper_bound: number;
|
||||
sum_other_doc_count: number;
|
||||
buckets: SeverityBucket[];
|
||||
};
|
||||
}
|
|
@ -32,7 +32,7 @@ import { GROUP_BY_LABEL } from '../../../components/alerts_kpis/common/translati
|
|||
const TABLE_PANEL_HEIGHT = 330; // px
|
||||
const TRENT_CHART_HEIGHT = 127; // px
|
||||
const TREND_CHART_PANEL_HEIGHT = 256; // px
|
||||
const ALERTS_CHARTS_PANEL_HEIGHT = 330; // px
|
||||
const ALERTS_CHARTS_PANEL_HEIGHT = 375; // px
|
||||
|
||||
const FullHeightFlexItem = styled(EuiFlexItem)`
|
||||
height: 100%;
|
||||
|
|
|
@ -20,7 +20,7 @@ interface StatusBucket {
|
|||
statusBySeverity?: StatusBySeverity;
|
||||
}
|
||||
|
||||
interface SeverityBucket {
|
||||
export interface SeverityBucket {
|
||||
key: Severity;
|
||||
doc_count: number;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue