[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`


![image](https://user-images.githubusercontent.com/18648970/213945018-57a15c60-ed53-4e86-90f5-c1909e88420d.png)

### 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:
christineweng 2023-01-30 17:27:26 -06:00 committed by GitHub
parent 4a4138dc3a
commit dda650f91b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 2303 additions and 560 deletions

View file

@ -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}
/>
);
}

View file

@ -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()
);
});
});
});
});

View file

@ -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';

View file

@ -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%',
},
];

View file

@ -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([]);
});
});

View file

@ -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'));
};

View file

@ -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();
});
});
});

View file

@ -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';

View file

@ -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' },
];

View file

@ -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',
}
);

View file

@ -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;
}

View file

@ -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());
});
});
});
});

View file

@ -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';

View file

@ -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([]);
});
});

View file

@ -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'));
};

View file

@ -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);
});
});
});
});

View file

@ -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';

View file

@ -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 },
];

View file

@ -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',
}
);

View file

@ -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;
}

View file

@ -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,
},
},
};
};

View file

@ -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);
});
});

View file

@ -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 [];
};

View file

@ -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} />

View file

@ -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>
);

View file

@ -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();
});
});

View file

@ -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';

View file

@ -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,
});
});
});

View file

@ -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',
}
);

View file

@ -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');
};

View file

@ -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,
});
});
});
});

View file

@ -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]);

View file

@ -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}

View file

@ -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} />

View file

@ -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([]);
});
});

View file

@ -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'));
};

View file

@ -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();
});
});
});

View file

@ -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';

View file

@ -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' },
];

View file

@ -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()
);
});
});
});
});

View file

@ -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';

View file

@ -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' }
);

View file

@ -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[];
};
}

View file

@ -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%;

View file

@ -20,7 +20,7 @@ interface StatusBucket {
statusBySeverity?: StatusBySeverity;
}
interface SeverityBucket {
export interface SeverityBucket {
key: Severity;
doc_count: number;
}