Skip to content

Commit

Permalink
[Security Solution] KPI visualizations on Alerts Page (#149173)
Browse files Browse the repository at this point in the history
## Summary

This PR is a part 2 of #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>
  • Loading branch information
christineweng and kibanamachine committed Jan 30, 2023
1 parent 4a4138d commit dda650f
Show file tree
Hide file tree
Showing 45 changed files with 2,303 additions and 560 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface DefaultDraggableType {
scopeId?: string;
tooltipContent?: React.ReactNode;
tooltipPosition?: ToolTipPositions;
truncate?: boolean;
}

/**
Expand Down Expand Up @@ -111,6 +112,7 @@ export const DefaultDraggable = React.memo<DefaultDraggableType>(
tooltipContent,
tooltipPosition,
queryValue,
truncate,
}) => {
const dataProviderProp: DataProvider = useMemo(
() => ({
Expand Down Expand Up @@ -159,6 +161,7 @@ export const DefaultDraggable = React.memo<DefaultDraggableType>(
isDraggable={isDraggable}
render={renderCallback}
scopeId={scopeId}
truncate={truncate}
/>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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%',
},
];
Original file line number Diff line number Diff line change
@@ -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 comments on commit dda650f

Please sign in to comment.