Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
Loading