141253 further updates to alerts on detail pages (#142669)

* fix language on page as well as font size

* allow component to take additional filters

* add ability for AlertByStatus to accept additional filters

* update tests for Alert by Status

* remove unused import

* changes from code review

* update constant value from imported version

* fix bad test :(

Co-authored-by: Kristof-Pierre Cummings <kristofpierre.cummings@elastic.co>
This commit is contained in:
Kristof C 2022-10-04 21:26:40 -05:00 committed by GitHub
parent 98f019a3ac
commit 055f1c5705
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 472 additions and 80 deletions

View file

@ -11,9 +11,15 @@ import type { EuiBasicTableColumn } from '@elastic/eui';
import { EuiBasicTable, EuiEmptyPrompt, EuiLink, EuiPanel, EuiToolTip } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import type { ESBoolQuery } from '../../../../common/typed_json';
import type { Status } from '../../../../common/detection_engine/schemas/common';
import { SecurityPageName } from '../../../../common/constants';
import type { Filter } from '../../../overview/components/detection_response/hooks/use_navigate_to_timeline';
import { useNavigateToTimeline } from '../../../overview/components/detection_response/hooks/use_navigate_to_timeline';
import {
SIGNAL_RULE_NAME_FIELD_NAME,
SIGNAL_STATUS_FIELD_NAME,
} from '../../../timelines/components/timeline/body/renderers/constants';
import { useQueryToggle } from '../../containers/query_toggle';
import { FormattedCount } from '../formatted_number';
import { HeaderSection } from '../header_section';
@ -33,6 +39,7 @@ interface EntityFilter {
}
interface AlertCountByStatusProps {
entityFilter: EntityFilter;
additionalFilters?: ESBoolQuery[];
signalIndexName: string | null;
}
@ -44,7 +51,6 @@ type GetTableColumns = (
openRuleInTimelineWithAdditionalFields: (ruleName: string) => void
) => Array<EuiBasicTableColumn<AlertCountByRuleByStatusItem>>;
const KIBANA_RULE_ALERT_FIELD = 'kibana.alert.rule.name';
const STATUSES = ['open', 'acknowledged', 'closed'] as const;
const ALERT_COUNT_BY_RULE_BY_STATUS = 'alerts-by-status-by-rule';
const LOCAL_STORAGE_KEY = 'alertCountByFieldNameWidgetSettings';
@ -58,21 +64,13 @@ const StyledEuiPanel = euiStyled(EuiPanel)`
`;
export const AlertCountByRuleByStatus = React.memo(
({ entityFilter, signalIndexName }: AlertCountByStatusProps) => {
({ entityFilter, signalIndexName, additionalFilters }: AlertCountByStatusProps) => {
const { field, value } = entityFilter;
const queryId = `${ALERT_COUNT_BY_RULE_BY_STATUS}-by-${field}`;
const { toggleStatus, setToggleStatus } = useQueryToggle(queryId);
const { openEntityInTimeline } = useNavigateToTimeline();
const columns = useMemo(
() =>
getTableColumns((ruleName: string) =>
openEntityInTimeline([{ field: KIBANA_RULE_ALERT_FIELD, value: ruleName }, entityFilter])
),
[entityFilter, openEntityInTimeline]
);
const { openTimelineWithFilters } = useNavigateToTimeline();
const [selectedStatusesByField, setSelectedStatusesByField] = useLocalStorage<StatusSelection>({
defaultValue: {
@ -84,6 +82,24 @@ export const AlertCountByRuleByStatus = React.memo(
},
});
const columns = useMemo(() => {
return getTableColumns((ruleName: string) => {
const timelineFilters: Filter[][] = [];
for (const status of selectedStatusesByField[field]) {
timelineFilters.push([
entityFilter,
{ field: SIGNAL_RULE_NAME_FIELD_NAME, value: ruleName },
{
field: SIGNAL_STATUS_FIELD_NAME,
value: status,
},
]);
}
openTimelineWithFilters(timelineFilters);
});
}, [entityFilter, field, openTimelineWithFilters, selectedStatusesByField]);
const updateSelection = useCallback(
(selection: Status[]) => {
setSelectedStatusesByField({
@ -95,6 +111,7 @@ export const AlertCountByRuleByStatus = React.memo(
);
const { items, isLoading, updatedAt } = useAlertCountByRuleByStatus({
additionalFilters,
field,
value,
queryId,
@ -110,7 +127,7 @@ export const AlertCountByRuleByStatus = React.memo(
<HeaderSection
id={queryId}
title={i18n.ALERTS_BY_RULE}
titleSize="s"
titleSize="m"
toggleStatus={toggleStatus}
toggleQuery={setToggleStatus}
subtitle={<LastUpdatedAt updatedAt={updatedAt} isUpdating={isLoading} />}

View file

@ -7,6 +7,7 @@
import { useCallback, useEffect, useState } from 'react';
import type { ESBoolQuery } from '../../../../common/typed_json';
import type { Status } from '../../../../common/detection_engine/schemas/common';
import type { GenericBuckets } from '../../../../common/search_strategy';
import { ALERTS_QUERY_NAMES } from '../../../detections/containers/detection_engine/alerts/constants';
@ -21,6 +22,7 @@ export interface AlertCountByRuleByStatusItem {
}
export interface UseAlertCountByRuleByStatusProps {
additionalFilters?: ESBoolQuery[];
field: string;
value: string;
queryId: string;
@ -37,6 +39,7 @@ export type UseAlertCountByRuleByStatus = (props: UseAlertCountByRuleByStatusPro
const ALERTS_BY_RULE_AGG = 'alertsByRuleAggregation';
export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({
additionalFilters,
field,
value,
queryId,
@ -58,6 +61,7 @@ export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({
refetch: refetchQuery,
} = useQueryAlerts({
query: buildRuleAlertsByEntityQuery({
additionalFilters,
from,
to,
field,
@ -72,6 +76,7 @@ export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({
useEffect(() => {
setAlertsQuery(
buildRuleAlertsByEntityQuery({
additionalFilters,
from,
to,
field,
@ -79,7 +84,7 @@ export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({
statuses,
})
);
}, [setAlertsQuery, from, to, field, value, statuses]);
}, [setAlertsQuery, from, to, field, value, statuses, additionalFilters]);
useEffect(() => {
if (!data) {
@ -112,12 +117,14 @@ export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({
};
export const buildRuleAlertsByEntityQuery = ({
additionalFilters = [],
from,
to,
field,
value,
statuses,
}: {
additionalFilters?: ESBoolQuery[];
from: string;
to: string;
statuses: string[];
@ -128,6 +135,7 @@ export const buildRuleAlertsByEntityQuery = ({
query: {
bool: {
filter: [
...additionalFilters,
{
range: {
'@timestamp': {

View file

@ -17,6 +17,7 @@ import React, { useEffect, useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import type { Filter } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import { AlertsByStatus } from '../../../overview/components/detection_response/alerts_by_status';
@ -40,7 +41,6 @@ import { SiemSearchBar } from '../../../common/components/search_bar';
import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper';
import { useGlobalTime } from '../../../common/containers/use_global_time';
import { useKibana } from '../../../common/lib/kibana';
import { convertToBuildEsQuery } from '../../../common/lib/kuery';
import { inputsSelectors } from '../../../common/store';
import { setHostDetailsTablesActivePageToZero } from '../../store/actions';
import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions';
@ -88,12 +88,14 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta
const { signalIndexName } = useSignalIndex();
const capabilities = useMlCapabilities();
const kibana = useKibana();
const {
services: { uiSettings },
} = useKibana();
const hostDetailsPageFilters: Filter[] = useMemo(
() => getHostDetailsPageFilters(detailName),
[detailName]
);
const getFilters = () => [...hostDetailsPageFilters, ...filters];
const isEnterprisePlus = useLicense().isEnterprise();
@ -119,14 +121,31 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta
indexNames: selectedPatterns,
skip: selectedPatterns.length === 0,
});
const [filterQuery, kqlError] = convertToBuildEsQuery({
config: getEsQueryConfig(kibana.services.uiSettings),
indexPattern,
queries: [query],
filters: getFilters(),
});
useInvalidFilterQuery({ id: ID, filterQuery, kqlError, query, startDate: from, endDate: to });
const [rawFilteredQuery, kqlError] = useMemo(() => {
try {
return [
buildEsQuery(
indexPattern,
[query],
[...hostDetailsPageFilters, ...filters],
getEsQueryConfig(uiSettings)
),
];
} catch (e) {
return [undefined, e];
}
}, [filters, indexPattern, query, uiSettings, hostDetailsPageFilters]);
const stringifiedAdditionalFilters = JSON.stringify(rawFilteredQuery);
useInvalidFilterQuery({
id: ID,
filterQuery: stringifiedAdditionalFilters,
kqlError,
query,
startDate: from,
endDate: to,
});
useEffect(() => {
dispatch(setHostDetailsTablesActivePageToZero());
@ -206,12 +225,14 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta
<AlertsByStatus
signalIndexName={signalIndexName}
entityFilter={entityFilter}
additionalFilters={rawFilteredQuery ? [rawFilteredQuery] : []}
/>
</EuiFlexItem>
<EuiFlexItem>
<AlertCountByRuleByStatus
entityFilter={entityFilter}
signalIndexName={signalIndexName}
additionalFilters={rawFilteredQuery ? [rawFilteredQuery] : []}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -241,7 +262,7 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta
detailName={detailName}
type={type}
setQuery={setQuery}
filterQuery={filterQuery}
filterQuery={stringifiedAdditionalFilters}
hostDetailsPagePath={hostDetailsPagePath}
indexPattern={indexPattern}
/>

View file

@ -12,6 +12,7 @@ import { useParams } from 'react-router-dom';
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer } from '@elastic/eui';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import { buildEsQuery } from '@kbn/es-query';
import { AlertsByStatus } from '../../../overview/components/detection_response/alerts_by_status';
import { useSignalIndex } from '../../../detections/containers/detection_engine/alerts/use_signal_index';
import { InputsModelId } from '../../../common/store/inputs/constants';
@ -33,7 +34,6 @@ import { SecuritySolutionPageWrapper } from '../../../common/components/page_wra
import { useNetworkDetails, ID } from '../../containers/details';
import { useKibana } from '../../../common/lib/kibana';
import { decodeIpv6 } from '../../../common/lib/helpers';
import { convertToBuildEsQuery } from '../../../common/lib/kuery';
import { inputsSelectors } from '../../../common/store';
import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions';
import { setNetworkDetailsTablesActivePageToZero } from '../../store/actions';
@ -103,23 +103,34 @@ const NetworkDetailsComponent: React.FC = () => {
const { indicesExist, indexPattern, selectedPatterns } = useSourcererDataView();
const ip = decodeIpv6(detailName);
const queryFilters = useMemo(
() => [...getNetworkDetailsPageFilter(ip), ...filters],
[filters, ip]
);
const [rawFilteredQuery, kqlError] = useMemo(() => {
try {
return [
buildEsQuery(
indexPattern,
[query],
[...getNetworkDetailsPageFilter(ip), ...filters],
getEsQueryConfig(uiSettings)
),
];
} catch (e) {
return [undefined, e];
}
}, [filters, indexPattern, ip, query, uiSettings]);
const [filterQuery, kqlError] = convertToBuildEsQuery({
config: getEsQueryConfig(uiSettings),
indexPattern,
queries: [query],
filters: queryFilters,
const stringifiedAdditionalFilters = JSON.stringify(rawFilteredQuery);
useInvalidFilterQuery({
id: ID,
filterQuery: stringifiedAdditionalFilters,
kqlError,
query,
startDate: from,
endDate: to,
});
useInvalidFilterQuery({ id: ID, filterQuery, kqlError, query, startDate: from, endDate: to });
const [loading, { id, inspect, networkDetails, refetch }] = useNetworkDetails({
skip: isInitializing,
filterQuery,
filterQuery: stringifiedAdditionalFilters,
indexNames: selectedPatterns,
ip,
});
@ -141,8 +152,8 @@ const NetworkDetailsComponent: React.FC = () => {
// When the filterQuery comes back as undefined, it means an error has been thrown and the request should be skipped
const shouldSkip = useMemo(
() => isInitializing || filterQuery === undefined,
[isInitializing, filterQuery]
() => isInitializing || rawFilteredQuery === undefined,
[isInitializing, rawFilteredQuery]
);
const entityFilter = useMemo(
@ -204,12 +215,17 @@ const NetworkDetailsComponent: React.FC = () => {
<>
<EuiFlexGroup>
<EuiFlexItem>
<AlertsByStatus signalIndexName={signalIndexName} entityFilter={entityFilter} />
<AlertsByStatus
signalIndexName={signalIndexName}
entityFilter={entityFilter}
additionalFilters={rawFilteredQuery ? [rawFilteredQuery] : []}
/>
</EuiFlexItem>
<EuiFlexItem>
<AlertCountByRuleByStatus
entityFilter={entityFilter}
signalIndexName={signalIndexName}
additionalFilters={rawFilteredQuery ? [rawFilteredQuery] : []}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -225,7 +241,7 @@ const NetworkDetailsComponent: React.FC = () => {
ip={ip}
endDate={to}
startDate={from}
filterQuery={filterQuery}
filterQuery={stringifiedAdditionalFilters}
indexNames={selectedPatterns}
skip={shouldSkip}
setQuery={setQuery}

View file

@ -74,6 +74,28 @@ describe('AlertsByStatus', () => {
).toBeInTheDocument();
});
test('shows correct names when no entity filter provided', () => {
const { getByText, getByTestId } = render(
<TestProviders>
<AlertsByStatus {...props} />
</TestProviders>
);
expect(getByText('Alerts')).toBeInTheDocument();
expect(getByTestId('view-details-button')).toHaveTextContent('View alerts');
});
test('shows correct names when entity filter IS provided', () => {
const { getByText, getByTestId } = render(
<TestProviders>
<AlertsByStatus {...props} entityFilter={{ field: 'name', value: 'val' }} />
</TestProviders>
);
expect(getByText('Alerts by Severity')).toBeInTheDocument();
expect(getByTestId('view-details-button')).toHaveTextContent('Investigate in Timeline');
});
test('render HeaderSection', () => {
const { container } = render(
<TestProviders>

View file

@ -19,6 +19,8 @@ import React, { useCallback, useMemo } from 'react';
import type { ShapeTreeNode } from '@elastic/charts';
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
import styled from 'styled-components';
import type { ESBoolQuery } from '../../../../../common/typed_json';
import type { FillColor } from '../../../../common/components/charts/donutchart';
import { DonutChart } from '../../../../common/components/charts/donutchart';
import { SecurityPageName } from '../../../../../common/constants';
@ -31,7 +33,8 @@ import { useAlertsByStatus } from './use_alerts_by_status';
import {
ALERTS,
ALERTS_TEXT,
ALERTS_BY_STATUS_TEXT,
ALERTS_BY_SEVERITY_TEXT,
INVESTIGATE_IN_TIMELINE,
STATUS_ACKNOWLEDGED,
STATUS_CLOSED,
STATUS_CRITICAL_LABEL,
@ -63,6 +66,7 @@ const StyledLegendFlexItem = styled(EuiFlexItem)`
interface AlertsByStatusProps {
signalIndexName: string | null;
entityFilter?: EntityFilter;
additionalFilters?: ESBoolQuery[];
}
const legendField = 'kibana.alert.severity';
@ -79,9 +83,13 @@ const eventKindSignalFilter: EntityFilter = {
value: 'signal',
};
export const AlertsByStatus = ({ signalIndexName, entityFilter }: AlertsByStatusProps) => {
export const AlertsByStatus = ({
additionalFilters,
signalIndexName,
entityFilter,
}: AlertsByStatusProps) => {
const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTION_RESPONSE_ALERTS_BY_STATUS_ID);
const { openEntityInTimeline } = useNavigateToTimeline();
const { openTimelineWithFilters } = useNavigateToTimeline();
const { onClick: goToAlerts, href } = useGetSecuritySolutionLinkProps()({
deepLinkId: SecurityPageName.alerts,
});
@ -92,13 +100,13 @@ export const AlertsByStatus = ({ signalIndexName, entityFilter }: AlertsByStatus
const detailsButtonOptions = useMemo(
() => ({
name: VIEW_ALERTS,
name: entityFilter ? INVESTIGATE_IN_TIMELINE : VIEW_ALERTS,
href: entityFilter ? undefined : href,
onClick: entityFilter
? () => openEntityInTimeline([entityFilter, eventKindSignalFilter])
? () => openTimelineWithFilters([[entityFilter, eventKindSignalFilter]])
: goToAlerts,
}),
[entityFilter, href, goToAlerts, openEntityInTimeline]
[entityFilter, href, goToAlerts, openTimelineWithFilters]
);
const {
@ -106,6 +114,7 @@ export const AlertsByStatus = ({ signalIndexName, entityFilter }: AlertsByStatus
isLoading: loading,
updatedAt,
} = useAlertsByStatus({
additionalFilters,
entityFilter,
signalIndexName,
skip: !toggleStatus,
@ -146,8 +155,8 @@ export const AlertsByStatus = ({ signalIndexName, entityFilter }: AlertsByStatus
)}
<HeaderSection
id={DETECTION_RESPONSE_ALERTS_BY_STATUS_ID}
title={entityFilter ? ALERTS_BY_STATUS_TEXT : ALERTS_TEXT}
titleSize="s"
title={entityFilter ? ALERTS_BY_SEVERITY_TEXT : ALERTS_TEXT}
titleSize="m"
subtitle={<LastUpdatedAt isUpdating={loading} updatedAt={updatedAt} />}
inspectMultiple
toggleStatus={toggleStatus}

View file

@ -7,6 +7,8 @@
import { useCallback, useEffect, useState } from 'react';
import type { Severity } from '@kbn/securitysolution-io-ts-alerting-types';
import type { ESBoolQuery } from '../../../../../common/typed_json';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { useQueryAlerts } from '../../../../detections/containers/detection_engine/alerts/use_query';
import { ALERTS_QUERY_NAMES } from '../../../../detections/containers/detection_engine/alerts/constants';
@ -32,6 +34,7 @@ export interface EntityFilter {
}
export const getAlertsByStatusQuery = ({
additionalFilters = [],
from,
to,
entityFilter,
@ -39,11 +42,13 @@ export const getAlertsByStatusQuery = ({
from: string;
to: string;
entityFilter?: EntityFilter;
additionalFilters?: ESBoolQuery[];
}) => ({
size: 0,
query: {
bool: {
filter: [
...additionalFilters,
{ range: { '@timestamp': { gte: from, lte: to } } },
...(entityFilter
? [
@ -104,6 +109,7 @@ export interface UseAlertsByStatusProps {
signalIndexName: string | null;
skip?: boolean;
entityFilter?: EntityFilter;
additionalFilters?: ESBoolQuery[];
}
export type UseAlertsByStatus = (props: UseAlertsByStatusProps) => {
@ -113,6 +119,7 @@ export type UseAlertsByStatus = (props: UseAlertsByStatusProps) => {
};
export const useAlertsByStatus: UseAlertsByStatus = ({
additionalFilters,
entityFilter,
queryId,
signalIndexName,
@ -134,6 +141,7 @@ export const useAlertsByStatus: UseAlertsByStatus = ({
from,
to,
entityFilter,
additionalFilters,
}),
indexName: signalIndexName,
skip,
@ -146,9 +154,10 @@ export const useAlertsByStatus: UseAlertsByStatus = ({
from,
to,
entityFilter,
additionalFilters,
})
);
}, [setAlertsQuery, from, to, entityFilter]);
}, [setAlertsQuery, from, to, entityFilter, additionalFilters]);
useEffect(() => {
if (data == null) {

View file

@ -0,0 +1,158 @@
/*
* 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 { QueryOperator } from '../../../../../common/types/timeline';
export const hostFilter = {
field: 'host.hostname',
value: 'Host-u6ou715rzy',
};
export const ANDFilterGroup1 = [
hostFilter,
{ field: 'kibana.alerts.workflow_status', value: 'open' },
];
const ANDFilterGroup2 = [hostFilter, { field: 'kibana.alerts.workflow_status', value: 'closed' }];
const ANDFilterGroup3 = [
hostFilter,
{ field: 'kibana.alerts.workflow_status', value: 'acknowledged' },
];
export const ORFilterGroup = [ANDFilterGroup1, ANDFilterGroup2, ANDFilterGroup3];
export const dataProviderWithOneFilter = [
{
and: [],
enabled: true,
id: '',
name: 'host.hostname',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'host.hostname',
value: 'Host-u6ou715rzy',
operator: ':' as QueryOperator,
},
},
];
export const dataProviderWithAndFilters = [
{
and: [
{
and: [],
enabled: true,
excluded: false,
id: '',
kqlQuery: '',
name: 'kibana.alerts.workflow_status',
queryMatch: {
field: 'kibana.alerts.workflow_status',
operator: ':' as QueryOperator,
value: 'open',
},
},
],
enabled: true,
id: '',
name: 'host.hostname',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'host.hostname',
value: 'Host-u6ou715rzy',
operator: ':' as QueryOperator,
},
},
];
export const dataProviderWithOrFilters = [
{
and: [
{
and: [],
enabled: true,
id: '',
name: 'kibana.alerts.workflow_status',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'kibana.alerts.workflow_status',
value: 'open',
operator: ':' as QueryOperator,
},
},
],
enabled: true,
id: '',
name: 'host.hostname',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'host.hostname',
value: 'Host-u6ou715rzy',
operator: ':' as QueryOperator,
},
},
{
and: [
{
and: [],
enabled: true,
id: '',
name: 'kibana.alerts.workflow_status',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'kibana.alerts.workflow_status',
value: 'closed',
operator: ':' as QueryOperator,
},
},
],
enabled: true,
id: '',
name: 'host.hostname',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'host.hostname',
value: 'Host-u6ou715rzy',
operator: ':' as QueryOperator,
},
},
{
and: [
{
and: [],
enabled: true,
id: '',
name: 'kibana.alerts.workflow_status',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'kibana.alerts.workflow_status',
value: 'acknowledged',
operator: ':' as QueryOperator,
},
},
],
enabled: true,
id: '',
name: 'host.hostname',
excluded: false,
kqlQuery: '',
queryMatch: {
field: 'host.hostname',
value: 'Host-u6ou715rzy',
operator: ':' as QueryOperator,
},
},
];

View file

@ -0,0 +1,99 @@
/*
* 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 { useDeepEqualSelector } from '../../../../common/hooks/use_selector';
import { updateProviders } from '../../../../timelines/store/timeline/actions';
import { useNavigateToTimeline } from './use_navigate_to_timeline';
import * as mock from './mock_data';
jest.mock('../../../../timelines/components/timeline/properties/use_create_timeline', () => ({
useCreateTimeline: () => jest.fn(),
}));
jest.mock('../../../../common/hooks/use_selector');
(useDeepEqualSelector as jest.Mock).mockImplementation(() => ({
defaultDataView: {
id: 'someId',
},
}));
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
useSelector: () => jest.fn(),
};
});
const id = 'timeline-1';
const renderUseNavigatgeToTimeline = () => renderHook(() => useNavigateToTimeline());
describe('useAlertCountByRuleByStatus', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should handle an empty array', () => {
const { result } = renderUseNavigatgeToTimeline();
const openTimelineWithFilters = result.current.openTimelineWithFilters;
openTimelineWithFilters([]);
expect(mockDispatch.mock.calls[0][0]).toEqual(
updateProviders({
id,
providers: [],
})
);
});
it('should handle 1 filter passed', () => {
const { result } = renderUseNavigatgeToTimeline();
const openTimelineWithFilters = result.current.openTimelineWithFilters;
openTimelineWithFilters([[mock.hostFilter]]);
expect(mockDispatch.mock.calls[0][0]).toEqual(
updateProviders({
id,
providers: mock.dataProviderWithOneFilter,
})
);
});
it('should handle many filter passed ( AND query )', () => {
const { result } = renderUseNavigatgeToTimeline();
const openTimelineWithFilters = result.current.openTimelineWithFilters;
openTimelineWithFilters([mock.ANDFilterGroup1]);
expect(mockDispatch.mock.calls[0][0]).toEqual(
updateProviders({
id,
providers: mock.dataProviderWithAndFilters,
})
);
});
it('should handle many AND filter groups passed ( OR query with ANDS )', () => {
const { result } = renderUseNavigatgeToTimeline();
const openTimelineWithFilters = result.current.openTimelineWithFilters;
openTimelineWithFilters(mock.ORFilterGroup);
expect(mockDispatch.mock.calls[0][0]).toEqual(
updateProviders({
id,
providers: mock.dataProviderWithOrFilters,
})
);
});
});

View file

@ -39,14 +39,14 @@ export const useNavigateToTimeline = () => {
timelineType: TimelineType.default,
});
const navigateToTimeline = (dataProvider: DataProvider) => {
const navigateToTimeline = (dataProviders: DataProvider[]) => {
// Reset the current timeline
clearTimeline();
// Update the timeline's providers to match the current prevalence field query
dispatch(
updateProviders({
id: TimelineId.active,
providers: [dataProvider],
providers: dataProviders,
})
);
@ -59,21 +59,32 @@ export const useNavigateToTimeline = () => {
);
};
const openEntityInTimeline = (entityFilters: [Filter, ...Filter[]]) => {
const mainFilter = entityFilters.shift();
/** *
* Open a timeline with the given filters prepopulated.
* It accepts an array of Filter[]s where each item represents a set of AND queries, and each top level comma represents an OR.
*
* [[filter1 & filter2] OR [filter3 & filter4]]
*
*/
const openTimelineWithFilters = (filters: Array<[...Filter[]]>) => {
const dataProviders = [];
if (mainFilter) {
const dataProvider = getDataProvider(mainFilter.field, '', mainFilter.value);
for (const orFilterGroup of filters) {
const mainFilter = orFilterGroup[0];
for (const filter of entityFilters) {
dataProvider.and.push(getDataProvider(filter.field, '', filter.value));
if (mainFilter) {
const dataProvider = getDataProvider(mainFilter.field, '', mainFilter.value);
for (const filter of orFilterGroup.slice(1)) {
dataProvider.and.push(getDataProvider(filter.field, '', filter.value));
}
dataProviders.push(dataProvider);
}
navigateToTimeline(dataProvider);
}
navigateToTimeline(dataProviders);
};
// TODO: Replace the usage of functions with openEntityInTimeline
// TODO: Replace the usage of functions with openTimelineWithFilters
const openHostInTimeline = ({ hostName, severity }: { hostName: string; severity?: string }) => {
const dataProvider = getDataProvider('host.name', '', hostName);
@ -82,7 +93,7 @@ export const useNavigateToTimeline = () => {
dataProvider.and.push(getDataProvider('kibana.alert.severity', '', severity));
}
navigateToTimeline(dataProvider);
navigateToTimeline([dataProvider]);
};
const openUserInTimeline = ({ userName, severity }: { userName: string; severity?: string }) => {
@ -91,17 +102,17 @@ export const useNavigateToTimeline = () => {
if (severity) {
dataProvider.and.push(getDataProvider('kibana.alert.severity', '', severity));
}
navigateToTimeline(dataProvider);
navigateToTimeline([dataProvider]);
};
const openRuleInTimeline = (ruleName: string) => {
const dataProvider = getDataProvider('kibana.alert.rule.name', '', ruleName);
navigateToTimeline(dataProvider);
navigateToTimeline([dataProvider]);
};
return {
openEntityInTimeline,
openTimelineWithFilters,
openHostInTimeline,
openRuleInTimeline,
openUserInTimeline,

View file

@ -60,10 +60,10 @@ export const ALERTS = (totalAlerts: number) =>
export const ALERTS_TEXT = i18n.translate('xpack.securitySolution.detectionResponse.alerts', {
defaultMessage: 'Alerts',
});
export const ALERTS_BY_STATUS_TEXT = i18n.translate(
'xpack.securitySolution.detectionResponse.alertsByStatus',
export const ALERTS_BY_SEVERITY_TEXT = i18n.translate(
'xpack.securitySolution.detectionResponse.alertsBySeverity',
{
defaultMessage: 'Alerts by Status',
defaultMessage: 'Alerts by Severity',
}
);
@ -255,3 +255,9 @@ export const USER_TOOLTIP = i18n.translate(
defaultMessage: 'Maximum of 100 users. Please consult Alerts page for further information.',
}
);
export const INVESTIGATE_IN_TIMELINE = i18n.translate(
'xpack.securitySolution.detectionResponse.investigateInTimeline',
{
defaultMessage: 'Investigate in Timeline',
}
);

View file

@ -18,6 +18,7 @@ import { useDispatch } from 'react-redux';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import type { Filter } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query';
import { AlertsByStatus } from '../../../overview/components/detection_response/alerts_by_status';
import { useSignalIndex } from '../../../detections/containers/detection_engine/alerts/use_signal_index';
@ -31,7 +32,6 @@ import { SiemSearchBar } from '../../../common/components/search_bar';
import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper';
import { useGlobalTime } from '../../../common/containers/use_global_time';
import { useKibana } from '../../../common/lib/kibana';
import { convertToBuildEsQuery } from '../../../common/lib/kuery';
import { inputsSelectors } from '../../../common/store';
import { useAlertsPrivileges } from '../../../detections/containers/detection_engine/alerts/use_alerts_privileges';
import { setUsersDetailsTablesActivePageToZero } from '../../store/actions';
@ -93,25 +93,36 @@ const UsersDetailsComponent: React.FC<UsersDetailsProps> = ({
const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime();
const { globalFullScreen } = useGlobalFullScreen();
const kibana = useKibana();
const {
services: { uiSettings },
} = useKibana();
const usersDetailsPageFilters: Filter[] = useMemo(
() => getUsersDetailsPageFilters(detailName),
[detailName]
);
const getFilters = () => [...usersDetailsPageFilters, ...filters];
const { indicesExist, indexPattern, selectedPatterns } = useSourcererDataView();
const [filterQuery, kqlError] = convertToBuildEsQuery({
config: getEsQueryConfig(kibana.services.uiSettings),
indexPattern,
queries: [query],
filters: getFilters(),
});
const [rawFilteredQuery, kqlError] = useMemo(() => {
try {
return [
buildEsQuery(
indexPattern,
[query],
[...usersDetailsPageFilters, ...filters],
getEsQueryConfig(uiSettings)
),
];
} catch (e) {
return [undefined, e];
}
}, [filters, indexPattern, query, uiSettings, usersDetailsPageFilters]);
const stringifiedAdditionalFilters = JSON.stringify(rawFilteredQuery);
useInvalidFilterQuery({
id: QUERY_ID,
filterQuery,
filterQuery: stringifiedAdditionalFilters,
kqlError,
query,
startDate: from,
@ -207,12 +218,17 @@ const UsersDetailsComponent: React.FC<UsersDetailsProps> = ({
<>
<EuiFlexGroup>
<EuiFlexItem>
<AlertsByStatus signalIndexName={signalIndexName} entityFilter={entityFilter} />
<AlertsByStatus
signalIndexName={signalIndexName}
entityFilter={entityFilter}
additionalFilters={rawFilteredQuery ? [rawFilteredQuery] : []}
/>
</EuiFlexItem>
<EuiFlexItem>
<AlertCountByRuleByStatus
entityFilter={entityFilter}
signalIndexName={signalIndexName}
additionalFilters={rawFilteredQuery ? [rawFilteredQuery] : []}
/>
</EuiFlexItem>
</EuiFlexGroup>
@ -231,7 +247,7 @@ const UsersDetailsComponent: React.FC<UsersDetailsProps> = ({
<UsersDetailsTabs
deleteQuery={deleteQuery}
detailName={detailName}
filterQuery={filterQuery}
filterQuery={stringifiedAdditionalFilters}
from={from}
indexNames={selectedPatterns}
indexPattern={indexPattern}