Skip to content

Commit

Permalink
[Security Solution] Severity level chart on Alerts page (elastic#146938)
Browse files Browse the repository at this point in the history
## Summary

#### Capabilities added
- Additional 'Charts' drop down on Alerts page
- A table and a donut chart that shows severity level composition
- Filter capabilities when donut is clicked or when hovering to the
`Levels` column

Feature flag: `alertsPageChartsEnabled`


![image](https://user-images.githubusercontent.com/18648970/205413975-98d63313-2e9d-4168-9f80-b762866b05fd.png)



https://user-images.githubusercontent.com/18648970/206264697-882abb6e-d5f1-49ab-b07e-d865b1907dbb.mov



### Checklist
- [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] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [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: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
2 people authored and saarikabhasi committed Dec 14, 2022
1 parent 0a6f15d commit b9291ea
Show file tree
Hide file tree
Showing 19 changed files with 1,111 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ export const allowedExperimentalValues = Object.freeze({
*/
responseActionGetFileEnabled: false,

/**
* Enables top charts on Alerts Page
*/
alertsPageChartsEnabled: false,

/**
* Keep DEPRECATED experimental flags that are documented to prevent failed upgrades.
* https://www.elastic.co/guide/en/security/current/user-risk-score.html
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip, useEuiTheme } from '@elastic/eui';
import React, { useMemo } from 'react';

import type { Datum, NodeColorAccessor, PartialTheme } from '@elastic/charts';
import type { Datum, NodeColorAccessor, PartialTheme, ElementClickListener } from '@elastic/charts';
import {
Chart,
Partition,
Expand Down Expand Up @@ -48,6 +48,7 @@ export interface DonutChartProps {
legendItems?: LegendItem[] | null | undefined;
title: React.ReactElement | string | number | null;
totalCount: number | null | undefined;
onElementClick?: ElementClickListener;
}

/* Make this position absolute in order to overlap the text onto the donut */
Expand All @@ -72,6 +73,7 @@ export const DonutChart = ({
legendItems,
title,
totalCount,
onElementClick,
}: DonutChartProps) => {
const theme = useTheme();
const { euiTheme } = useEuiTheme();
Expand Down Expand Up @@ -114,7 +116,7 @@ export const DonutChart = ({
<DonutChartEmpty size={height} />
) : (
<Chart size={height}>
<Settings theme={donutTheme} baseTheme={theme} />
<Settings theme={donutTheme} baseTheme={theme} onElementClick={onElementClick} />
<Partition
id="donut-chart"
data={data}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* 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 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 { DefaultDraggable } from '../../../../common/components/draggables';
import { SEVERITY_COLOR } from '../../../../overview/components/detection_response/utils';
import { FormattedCount } from '../../../../common/components/formatted_number';
import * as i18n from './translations';

interface SeverityTableItem {
key: Severity;
value: number;
label: string;
}

export const getSeverityTableColumns = (): Array<EuiBasicTableColumn<SeverityTableItem>> => [
{
field: 'key',
name: i18n.SEVERITY_LEVEL_COLUMN_TITLE,
'data-test-subj': 'severityTable-severity',
render: (severity: Severity) => (
<EuiHealth color={SEVERITY_COLOR[severity]} textSize="xs">
<DefaultDraggable
isDraggable={false}
field={ALERT_SEVERITY}
hideTopN
id={`alert-severity-draggable-${severity}`}
value={capitalize(severity)}
queryValue={severity}
tooltipContent={null}
/>
</EuiHealth>
),
},
{
field: 'value',
name: i18n.SEVERITY_COUNT_COULMN_TITLE,
sortable: true,
dataType: 'number',
'data-test-subj': 'severityTable-alertCount',
render: (alertCount: number) => (
<EuiText grow={false} size="xs">
<FormattedCount count={alertCount} />
</EuiText>
),
},
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { parseSeverityAlerts } from './helpers';
import { parsedAlerts, mockAlertsData, mockAlertsEmptyData } from './severity_donut/mock_data';
import type { AlertsResponse, AlertsBySeverityAgg } from './types';

describe('parse alerts by severity data', () => {
test('parse alerts with data', () => {
const res = parseSeverityAlerts(mockAlertsData as AlertsResponse<{}, AlertsBySeverityAgg>);
expect(res).toEqual(parsedAlerts);
});

test('parse alerts without data', () => {
const res = parseSeverityAlerts(mockAlertsEmptyData);
expect(res).toEqual(null);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
import type { AlertsResponse, AlertsBySeverityAgg, ParsedSeverityData } from './types';
import * as i18n from './translations';
import { severityLabels } from '../../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status';
import { emptyDonutColor } from '../../../../common/components/charts/donutchart_empty';
import { SEVERITY_COLOR } from '../../../../overview/components/detection_response/utils';

export const parseSeverityAlerts = (
response: AlertsResponse<{}, AlertsBySeverityAgg>
): ParsedSeverityData => {
const severityBuckets = response?.aggregations?.statusBySeverity?.buckets ?? [];
if (severityBuckets.length === 0) {
return null;
}
const data = severityBuckets.map((severity) => {
return {
key: severity.key,
value: severity.doc_count,
label: severityLabels[severity.key] ?? i18n.UNKNOWN_SEVERITY,
};
});
return data;
};

export const getSeverityColor = (severity: string) => {
return SEVERITY_COLOR[severity.toLocaleLowerCase() as Severity] ?? emptyDonutColor;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { act, render, fireEvent } from '@testing-library/react';
import React from 'react';
import { useQueryToggle } from '../../../../common/containers/query_toggle';
import { TestProviders } from '../../../../common/mock';
import { AlertsSummaryChartsPanel } from '.';

jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/containers/query_toggle');

jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) };
});

describe('AlertsChartsPanel', () => {
const defaultProps = {
signalIndexName: 'signalIndexName',
};

const mockSetToggle = jest.fn();
const mockUseQueryToggle = useQueryToggle as jest.Mock;
beforeEach(() => {
mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: mockSetToggle });
});

afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});

test('renders correctly', async () => {
await act(async () => {
const { container } = render(
<TestProviders>
<AlertsSummaryChartsPanel {...defaultProps} />
</TestProviders>
);
expect(container.querySelector('[data-test-subj="alerts-charts-panel"]')).toBeInTheDocument();
});
});

test('it renders the header with the specified `alignHeader` alignment', async () => {
await act(async () => {
const { container } = render(
<TestProviders>
<AlertsSummaryChartsPanel {...defaultProps} alignHeader="flexEnd" />
</TestProviders>
);
expect(
container.querySelector('[data-test-subj="headerSectionInnerFlexGroup"]')?.classList[1]
).toContain('flexEnd');
});
});

describe('Query', () => {
test('it render with a illegal KQL', async () => {
await act(async () => {
jest.mock('@kbn/es-query', () => ({
buildEsQuery: jest.fn().mockImplementation(() => {
throw new Error('Something went wrong');
}),
}));
const props = { ...defaultProps, query: { query: 'host.name: "', language: 'kql' } };
const { container } = render(
<TestProviders>
<AlertsSummaryChartsPanel {...props} />
</TestProviders>
);
expect(container.querySelector('[data-test-subj="severty-chart"]')).toBeInTheDocument();
});
});
});

describe('toggleQuery', () => {
test('toggles', async () => {
await act(async () => {
const { container } = render(
<TestProviders>
<AlertsSummaryChartsPanel {...defaultProps} />
</TestProviders>
);
const element = container.querySelector('[data-test-subj="query-toggle-header"]');
if (element) {
fireEvent.click(element);
}
expect(mockSetToggle).toBeCalledWith(false);
});
});

test('toggleStatus=true, render', async () => {
await act(async () => {
const { container } = render(
<TestProviders>
<AlertsSummaryChartsPanel {...defaultProps} />
</TestProviders>
);
expect(
container.querySelector('[data-test-subj="alerts-charts-container"]')
).toBeInTheDocument();
});
});

test('toggleStatus=false, hide', async () => {
await act(async () => {
mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: mockSetToggle });
const { container } = render(
<TestProviders>
<AlertsSummaryChartsPanel {...defaultProps} />
</TestProviders>
);
expect(
container.querySelector('[data-test-subj="alerts-charts-container"]')
).not.toBeInTheDocument();
});
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui';
import React, { useMemo, useCallback, useState, useEffect } from 'react';
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import type { Filter, Query } from '@kbn/es-query';
import uuid from 'uuid';
import * as i18n from './translations';
import { KpiPanel } from '../common/components';
import { HeaderSection } from '../../../../common/components/header_section';
import { useQueryToggle } from '../../../../common/containers/query_toggle';
import { useSeverityChartData } from './severity_donut/use_severity_chart_data';
import { SeverityLevelChart } from './severity_donut/severity_level_chart';

const DETECTIONS_ALERTS_CHARTS_ID = 'detections-alerts-charts';

const PlaceHolder = ({ title }: { title: string }) => {
return (
<EuiFlexItem>
<EuiPanel>
<EuiTitle size="xs">
<h4>{title}</h4>
</EuiTitle>
</EuiPanel>
</EuiFlexItem>
);
};

interface Props {
alignHeader?: 'center' | 'baseline' | 'stretch' | 'flexStart' | 'flexEnd';
filters?: Filter[];
addFilter?: ({ field, value }: { field: string; value: string | number }) => void;
panelHeight?: number;
query?: Query;
signalIndexName: string | null;
title?: React.ReactNode;
runtimeMappings?: MappingRuntimeFields;
}

export const AlertsSummaryChartsPanel: React.FC<Props> = ({
alignHeader,
filters,
addFilter,
panelHeight,
query,
runtimeMappings,
signalIndexName,
title = i18n.CHARTS_TITLE,
}: Props) => {
// create a unique, but stable (across re-renders) query id
const uniqueQueryId = useMemo(() => `${DETECTIONS_ALERTS_CHARTS_ID}-${uuid.v4()}`, []);

const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTIONS_ALERTS_CHARTS_ID);
const [querySkip, setQuerySkip] = useState(!toggleStatus);
useEffect(() => {
setQuerySkip(!toggleStatus);
}, [toggleStatus]);
const toggleQuery = useCallback(
(status: boolean) => {
setToggleStatus(status);
// toggle on = skipQuery false
setQuerySkip(!status);
},
[setQuerySkip, setToggleStatus]
);

const { items: severityData, isLoading: isSeverityLoading } = useSeverityChartData({
filters,
query,
signalIndexName,
runtimeMappings,
skip: querySkip,
uniqueQueryId,
});

return (
<KpiPanel
$toggleStatus={toggleStatus}
data-test-subj="alerts-charts-panel"
hasBorder
height={panelHeight}
>
<HeaderSection
alignHeader={alignHeader}
outerDirection="row"
title={title}
titleSize="s"
hideSubtitle
showInspectButton={false}
toggleStatus={toggleStatus}
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>
)}
</KpiPanel>
);
};

AlertsSummaryChartsPanel.displayName = 'AlertsSummaryChartsPanel';

0 comments on commit b9291ea

Please sign in to comment.