[Security Solution][Detection Page] Status filter refactor (#107249)

This commit is contained in:
Davis Plumlee 2021-08-09 23:00:58 -04:00 committed by GitHub
parent 4f535c6621
commit 6b29ca1ce8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 242 additions and 55 deletions

View file

@ -9,7 +9,7 @@ import React from 'react';
import { waitFor, act } from '@testing-library/react';
import { mount } from 'enzyme';
import { esQuery } from '../../../../../../../../src/plugins/data/public';
import { esQuery, Filter } from '../../../../../../../../src/plugins/data/public';
import { TestProviders } from '../../../../common/mock';
import { SecurityPageName } from '../../../../app/types';
@ -78,6 +78,11 @@ describe('AlertsHistogramPanel', () => {
updateDateRange: jest.fn(),
};
afterEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
});
it('renders correctly', () => {
const wrapper = mount(
<TestProviders>
@ -157,7 +162,7 @@ describe('AlertsHistogramPanel', () => {
combinedQueries:
'{"bool":{"must":[],"filter":[{"match_all":{}},{"exists":{"field":"process.name"}}],"should":[],"must_not":[]}}',
};
mount(
const wrapper = mount(
<TestProviders>
<AlertsHistogramPanel {...props} />
</TestProviders>
@ -180,6 +185,60 @@ describe('AlertsHistogramPanel', () => {
],
]);
});
wrapper.unmount();
});
});
describe('Filters', () => {
it('filters props is valid, alerts query include filter', async () => {
const mockGetAlertsHistogramQuery = jest.spyOn(helpers, 'getAlertsHistogramQuery');
const statusFilter: Filter = {
meta: {
alias: null,
disabled: false,
key: 'signal.status',
negate: false,
params: {
query: 'open',
},
type: 'phrase',
},
query: {
term: {
'signal.status': 'open',
},
},
};
const props = {
...defaultProps,
query: { query: '', language: 'kql' },
filters: [statusFilter],
};
const wrapper = mount(
<TestProviders>
<AlertsHistogramPanel {...props} />
</TestProviders>
);
await waitFor(() => {
expect(mockGetAlertsHistogramQuery.mock.calls[1]).toEqual([
'signal.rule.name',
'2020-07-07T08:20:18.966Z',
'2020-07-08T08:20:18.966Z',
[
{
bool: {
filter: [{ term: { 'signal.status': 'open' } }],
must: [],
must_not: [],
should: [],
},
},
],
]);
});
wrapper.unmount();
});
});

View file

@ -6,7 +6,9 @@
*/
import { EuiFilterButton, EuiFilterGroup } from '@elastic/eui';
import { rgba } from 'polished';
import React, { useCallback, useState } from 'react';
import styled from 'styled-components';
import { Status } from '../../../../../common/detection_engine/schemas/common/schemas';
import * as i18n from '../translations';
@ -14,6 +16,17 @@ export const FILTER_OPEN: Status = 'open';
export const FILTER_CLOSED: Status = 'closed';
export const FILTER_IN_PROGRESS: Status = 'in-progress';
const StatusFilterButton = styled(EuiFilterButton)<{ isActive: boolean }>`
background: ${({ isActive, theme }) => (isActive ? theme.eui.euiColorPrimary : '')};
`;
const StatusFilterGroup = styled(EuiFilterGroup)`
background: ${({ theme }) => rgba(theme.eui.euiColorPrimary, 0.2)};
.euiButtonEmpty--ghost:enabled:focus {
background-color: ${({ theme }) => theme.eui.euiColorPrimary};
}
`;
interface Props {
onFilterGroupChanged: (filterGroup: Status) => void;
}
@ -37,33 +50,39 @@ const AlertsTableFilterGroupComponent: React.FC<Props> = ({ onFilterGroupChanged
}, [setFilterGroup, onFilterGroupChanged]);
return (
<EuiFilterGroup data-test-subj="alerts-table-filter-group">
<EuiFilterButton
<StatusFilterGroup data-test-subj="alerts-table-filter-group">
<StatusFilterButton
data-test-subj="openAlerts"
hasActiveFilters={filterGroup === FILTER_OPEN}
isActive={filterGroup === FILTER_OPEN}
onClick={onClickOpenFilterCallback}
withNext
color={filterGroup === FILTER_OPEN ? 'ghost' : 'primary'}
>
{i18n.OPEN_ALERTS}
</EuiFilterButton>
</StatusFilterButton>
<EuiFilterButton
<StatusFilterButton
data-test-subj="inProgressAlerts"
hasActiveFilters={filterGroup === FILTER_IN_PROGRESS}
isActive={filterGroup === FILTER_IN_PROGRESS}
onClick={onClickInProgressFilterCallback}
withNext
color={filterGroup === FILTER_IN_PROGRESS ? 'ghost' : 'primary'}
>
{i18n.IN_PROGRESS_ALERTS}
</EuiFilterButton>
</StatusFilterButton>
<EuiFilterButton
<StatusFilterButton
data-test-subj="closedAlerts"
hasActiveFilters={filterGroup === FILTER_CLOSED}
isActive={filterGroup === FILTER_CLOSED}
onClick={onClickCloseFilterCallback}
color={filterGroup === FILTER_CLOSED ? 'ghost' : 'primary'}
>
{i18n.CLOSED_ALERTS}
</EuiFilterButton>
</EuiFilterGroup>
</StatusFilterButton>
</StatusFilterGroup>
);
};

View file

@ -35,9 +35,7 @@ describe('AlertsTableComponent', () => {
isSelectAllChecked={false}
clearSelected={jest.fn()}
setEventsLoading={jest.fn()}
clearEventsLoading={jest.fn()}
setEventsDeleted={jest.fn()}
clearEventsDeleted={jest.fn()}
showBuildingBlockAlerts={false}
onShowBuildingBlockAlertsChanged={jest.fn()}
showOnlyThreatIndicatorAlerts={false}

View file

@ -31,7 +31,6 @@ import {
alertsDefaultModelRuleRegistry,
buildAlertStatusFilterRuleRegistry,
} from './default_config';
import { FILTER_OPEN, AlertsTableFilterGroup } from './alerts_filter_group';
import { AlertsUtilityBar } from './alerts_utility_bar';
import * as i18nCommon from '../../../common/translations';
import * as i18n from './translations';
@ -68,13 +67,12 @@ interface OwnProps {
showOnlyThreatIndicatorAlerts: boolean;
timelineId: TimelineIdLiteral;
to: string;
filterGroup?: Status;
}
type AlertsTableComponentProps = OwnProps & PropsFromRedux;
export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
clearEventsDeleted,
clearEventsLoading,
clearSelected,
defaultFilters,
from,
@ -95,10 +93,10 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
showOnlyThreatIndicatorAlerts,
timelineId,
to,
filterGroup = 'open',
}) => {
const dispatch = useDispatch();
const [showClearSelectionAction, setShowClearSelectionAction] = useState(false);
const [filterGroup, setFilterGroup] = useState<Status>(FILTER_OPEN);
const {
browserFields,
indexPattern: indexPatterns,
@ -216,17 +214,6 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
}
}, [dispatch, isSelectAllChecked, timelineId]);
// Callback for when open/closed filter changes
const onFilterGroupChangedCallback = useCallback(
(newFilterGroup: Status) => {
clearEventsLoading!({ id: timelineId });
clearEventsDeleted!({ id: timelineId });
clearSelected!({ id: timelineId });
setFilterGroup(newFilterGroup);
},
[clearEventsLoading, clearEventsDeleted, clearSelected, setFilterGroup, timelineId]
);
// Callback for clearing entire selection from utility bar
const clearSelectionCallback = useCallback(() => {
clearSelected!({ id: timelineId });
@ -372,11 +359,6 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
);
}, [dispatch, defaultTimelineModel, filterManager, tGridEnabled, timelineId]);
const headerFilterGroup = useMemo(
() => <AlertsTableFilterGroup onFilterGroupChanged={onFilterGroupChangedCallback} />,
[onFilterGroupChangedCallback]
);
if (loading || indexPatternsLoading || isEmpty(selectedPatterns)) {
return (
<EuiPanel hasBorder>
@ -393,7 +375,6 @@ export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
defaultModel={defaultTimelineModel}
end={to}
currentFilter={filterGroup}
headerFilterGroup={headerFilterGroup}
id={timelineId}
onRuleChange={onRuleChange}
renderCellValue={RenderCellValue}
@ -438,8 +419,6 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
eventIds: string[];
isLoading: boolean;
}) => dispatch(timelineActions.setEventsLoading({ id, eventIds, isLoading })),
clearEventsLoading: ({ id }: { id: string }) =>
dispatch(timelineActions.clearEventsLoading({ id })),
setEventsDeleted: ({
id,
eventIds,
@ -449,8 +428,6 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
eventIds: string[];
isDeleted: boolean;
}) => dispatch(timelineActions.setEventsDeleted({ id, eventIds, isDeleted })),
clearEventsDeleted: ({ id }: { id: string }) =>
dispatch(timelineActions.clearEventsDeleted({ id })),
});
const connector = connect(makeMapStateToProps, mapDispatchToProps);

View file

@ -5,11 +5,19 @@
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiWindowEvent } from '@elastic/eui';
import {
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiWindowEvent,
EuiHorizontalRule,
} from '@elastic/eui';
import styled from 'styled-components';
import { noop } from 'lodash/fp';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { connect, ConnectedProps, useDispatch } from 'react-redux';
import { Dispatch } from 'redux';
import { Status } from '../../../../common/detection_engine/schemas/common/schemas';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
import { isTab } from '../../../../../timelines/public';
import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector';
@ -44,9 +52,11 @@ import {
resetKeyboardFocus,
showGlobalFilters,
} from '../../../timelines/components/timeline/helpers';
import { timelineSelectors } from '../../../timelines/store/timeline';
import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline';
import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
import {
buildAlertStatusFilter,
buildAlertStatusFilterRuleRegistry,
buildShowBuildingBlockFilter,
buildShowBuildingBlockFilterRuleRegistry,
buildThreatMatchFilter,
@ -58,6 +68,10 @@ import { MissingPrivilegesCallOut } from '../../components/callouts/missing_priv
import { useKibana } from '../../../common/lib/kibana';
import { AlertsCountPanel } from '../../components/alerts_kpis/alerts_count_panel';
import { CHART_HEIGHT } from '../../components/alerts_kpis/common/config';
import {
AlertsTableFilterGroup,
FILTER_OPEN,
} from '../../components/alerts_table/alerts_filter_group';
/**
* Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space.
@ -68,7 +82,13 @@ const StyledFullHeightContainer = styled.div`
flex: 1 1 auto;
`;
const DetectionEnginePageComponent = () => {
type DetectionEngineComponentProps = PropsFromRedux;
const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
clearEventsDeleted,
clearEventsLoading,
clearSelected,
}) => {
const dispatch = useDispatch();
const containerElement = useRef<HTMLDivElement | null>(null);
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
@ -108,6 +128,7 @@ const DetectionEnginePageComponent = () => {
const [showOnlyThreatIndicatorAlerts, setShowOnlyThreatIndicatorAlerts] = useState(false);
const loading = userInfoLoading || listsConfigLoading;
const { navigateToUrl } = useKibana().services.application;
const [filterGroup, setFilterGroup] = useState<Status>(FILTER_OPEN);
const updateDateRangeCallback = useCallback<UpdateDateRange>(
({ x }) => {
@ -134,23 +155,51 @@ const DetectionEnginePageComponent = () => {
[formatUrl, navigateToUrl]
);
// Callback for when open/closed filter changes
const onFilterGroupChangedCallback = useCallback(
(newFilterGroup: Status) => {
const timelineId = TimelineId.detectionsPage;
clearEventsLoading!({ id: timelineId });
clearEventsDeleted!({ id: timelineId });
clearSelected!({ id: timelineId });
setFilterGroup(newFilterGroup);
},
[clearEventsLoading, clearEventsDeleted, clearSelected, setFilterGroup]
);
const alertsHistogramDefaultFilters = useMemo(
() => [
...filters,
...(ruleRegistryEnabled
? buildShowBuildingBlockFilterRuleRegistry(showBuildingBlockAlerts) // TODO: Once we are past experimental phase this code should be removed
: buildShowBuildingBlockFilter(showBuildingBlockAlerts)),
? [
// TODO: Once we are past experimental phase this code should be removed
...buildShowBuildingBlockFilterRuleRegistry(showBuildingBlockAlerts),
...buildAlertStatusFilterRuleRegistry(filterGroup),
]
: [
...buildShowBuildingBlockFilter(showBuildingBlockAlerts),
...buildAlertStatusFilter(filterGroup),
]),
...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts),
],
[filters, ruleRegistryEnabled, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts]
[
filters,
ruleRegistryEnabled,
showBuildingBlockAlerts,
showOnlyThreatIndicatorAlerts,
filterGroup,
]
);
// AlertsTable manages global filters itself, so not including `filters`
const alertsTableDefaultFilters = useMemo(
() => [
...(ruleRegistryEnabled
? buildShowBuildingBlockFilterRuleRegistry(showBuildingBlockAlerts) // TODO: Once we are past experimental phase this code should be removed
: buildShowBuildingBlockFilter(showBuildingBlockAlerts)),
? [
// TODO: Once we are past experimental phase this code should be removed
...buildShowBuildingBlockFilterRuleRegistry(showBuildingBlockAlerts),
]
: [...buildShowBuildingBlockFilter(showBuildingBlockAlerts)]),
...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts),
],
[ruleRegistryEnabled, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts]
@ -254,6 +303,9 @@ const DetectionEnginePageComponent = () => {
{i18n.BUTTON_MANAGE_RULES}
</LinkButton>
</DetectionEngineHeaderPage>
<EuiHorizontalRule margin="m" />
<AlertsTableFilterGroup onFilterGroupChanged={onFilterGroupChangedCallback} />
<EuiSpacer size="m" />
<EuiFlexGroup wrap>
<EuiFlexItem grow={2}>
<AlertsHistogramPanel
@ -291,6 +343,7 @@ const DetectionEnginePageComponent = () => {
showOnlyThreatIndicatorAlerts={showOnlyThreatIndicatorAlerts}
onShowOnlyThreatIndicatorAlertsChanged={onShowOnlyThreatIndicatorAlertsCallback}
to={to}
filterGroup={filterGroup}
/>
</SecuritySolutionPageWrapper>
</StyledFullHeightContainer>
@ -304,4 +357,16 @@ const DetectionEnginePageComponent = () => {
);
};
export const DetectionEnginePage = React.memo(DetectionEnginePageComponent);
const mapDispatchToProps = (dispatch: Dispatch) => ({
clearSelected: ({ id }: { id: string }) => dispatch(timelineActions.clearSelected({ id })),
clearEventsLoading: ({ id }: { id: string }) =>
dispatch(timelineActions.clearEventsLoading({ id })),
clearEventsDeleted: ({ id }: { id: string }) =>
dispatch(timelineActions.clearEventsDeleted({ id })),
});
const connector = connect(null, mapDispatchToProps);
type PropsFromRedux = ConnectedProps<typeof connector>;
export const DetectionEnginePage = connector(React.memo(DetectionEnginePageComponent));

View file

@ -23,7 +23,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { noop } from 'lodash/fp';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { connect, ConnectedProps, useDispatch } from 'react-redux';
import styled from 'styled-components';
import deepEqual from 'fast-deep-equal';
import {
@ -31,6 +31,7 @@ import {
ExceptionListIdentifiers,
} from '@kbn/securitysolution-io-ts-list-types';
import { Dispatch } from 'redux';
import { isTab } from '../../../../../../../timelines/public';
import {
useDeepEqualSelector,
@ -63,6 +64,8 @@ import { StepDefineRule } from '../../../../components/rules/step_define_rule';
import { StepScheduleRule } from '../../../../components/rules/step_schedule_rule';
import {
buildAlertsRuleIdFilter,
buildAlertStatusFilter,
buildAlertStatusFilterRuleRegistry,
buildShowBuildingBlockFilter,
buildShowBuildingBlockFilterRuleRegistry,
buildThreatMatchFilter,
@ -98,7 +101,7 @@ import {
resetKeyboardFocus,
showGlobalFilters,
} from '../../../../../timelines/components/timeline/helpers';
import { timelineSelectors } from '../../../../../timelines/store/timeline';
import { timelineActions, timelineSelectors } from '../../../../../timelines/store/timeline';
import { timelineDefaults } from '../../../../../timelines/store/timeline/defaults';
import { useSourcererScope } from '../../../../../common/containers/sourcerer';
import { SourcererScopeName } from '../../../../../common/store/sourcerer/model';
@ -118,6 +121,11 @@ import { MissingPrivilegesCallOut } from '../../../../components/callouts/missin
import { useRuleWithFallback } from '../../../../containers/detection_engine/rules/use_rule_with_fallback';
import { BadgeOptions } from '../../../../../common/components/header_page/types';
import { AlertsStackByField } from '../../../../components/alerts_kpis/common/types';
import { Status } from '../../../../../../common/detection_engine/schemas/common/schemas';
import {
AlertsTableFilterGroup,
FILTER_OPEN,
} from '../../../../components/alerts_table/alerts_filter_group';
/**
* Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space.
@ -155,7 +163,13 @@ const ruleDetailTabs = [
},
];
const RuleDetailsPageComponent = () => {
type DetectionEngineComponentProps = PropsFromRedux;
const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
clearEventsDeleted,
clearEventsLoading,
clearSelected,
}) => {
const { navigateToApp } = useKibana().services.application;
const dispatch = useDispatch();
const containerElement = useRef<HTMLDivElement | null>(null);
@ -226,6 +240,7 @@ const RuleDetailsPageComponent = () => {
const mlCapabilities = useMlCapabilities();
const { formatUrl } = useFormatUrl(SecurityPageName.rules);
const { globalFullScreen } = useGlobalFullScreen();
const [filterGroup, setFilterGroup] = useState<Status>(FILTER_OPEN);
// TODO: Once we are past experimental phase this code should be removed
const ruleRegistryEnabled = useIsExperimentalFeatureEnabled('ruleRegistryEnabled');
@ -315,6 +330,18 @@ const RuleDetailsPageComponent = () => {
[rule, ruleLoading]
);
// Callback for when open/closed filter changes
const onFilterGroupChangedCallback = useCallback(
(newFilterGroup: Status) => {
const timelineId = TimelineId.detectionsPage;
clearEventsLoading!({ id: timelineId });
clearEventsDeleted!({ id: timelineId });
clearSelected!({ id: timelineId });
setFilterGroup(newFilterGroup);
},
[clearEventsLoading, clearEventsDeleted, clearSelected, setFilterGroup]
);
// Set showBuildingBlockAlerts if rule is a Building Block Rule otherwise we won't show alerts
useEffect(() => {
setShowBuildingBlockAlerts(rule?.building_block_type != null);
@ -324,11 +351,38 @@ const RuleDetailsPageComponent = () => {
() => [
...buildAlertsRuleIdFilter(ruleId),
...(ruleRegistryEnabled
? buildShowBuildingBlockFilterRuleRegistry(showBuildingBlockAlerts) // TODO: Once we are past experimental phase this code should be removed
: buildShowBuildingBlockFilter(showBuildingBlockAlerts)),
? [
...buildShowBuildingBlockFilterRuleRegistry(showBuildingBlockAlerts), // TODO: Once we are past experimental phase this code should be removed
...buildAlertStatusFilterRuleRegistry(filterGroup),
]
: [
...buildShowBuildingBlockFilter(showBuildingBlockAlerts),
...buildAlertStatusFilter(filterGroup),
]),
...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts),
],
[ruleId, ruleRegistryEnabled, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts]
[
ruleId,
ruleRegistryEnabled,
showBuildingBlockAlerts,
showOnlyThreatIndicatorAlerts,
filterGroup,
]
);
const alertsTableDefaultFilters = useMemo(
() => [
...buildAlertsRuleIdFilter(ruleId),
...filters,
...(ruleRegistryEnabled
? [
// TODO: Once we are past experimental phase this code should be removed
...buildShowBuildingBlockFilterRuleRegistry(showBuildingBlockAlerts),
]
: [...buildShowBuildingBlockFilter(showBuildingBlockAlerts)]),
...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts),
],
[ruleId, filters, ruleRegistryEnabled, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts]
);
const alertMergedFilters = useMemo(() => [...alertDefaultFilters, ...filters], [
@ -705,6 +759,8 @@ const RuleDetailsPageComponent = () => {
</Display>
{ruleDetailTab === RuleDetailTabs.alerts && (
<>
<AlertsTableFilterGroup onFilterGroupChanged={onFilterGroupChangedCallback} />
<EuiSpacer size="m" />
<Display show={!globalFullScreen}>
<AlertsHistogramPanel
filters={alertMergedFilters}
@ -717,8 +773,9 @@ const RuleDetailsPageComponent = () => {
</Display>
{ruleId != null && (
<AlertsTable
filterGroup={filterGroup}
timelineId={TimelineId.detectionsRulesDetailsPage}
defaultFilters={alertDefaultFilters}
defaultFilters={alertsTableDefaultFilters}
hasIndexWrite={hasIndexWrite ?? false}
hasIndexMaintenance={hasIndexMaintenance ?? false}
from={from}
@ -760,8 +817,20 @@ const RuleDetailsPageComponent = () => {
);
};
const mapDispatchToProps = (dispatch: Dispatch) => ({
clearSelected: ({ id }: { id: string }) => dispatch(timelineActions.clearSelected({ id })),
clearEventsLoading: ({ id }: { id: string }) =>
dispatch(timelineActions.clearEventsLoading({ id })),
clearEventsDeleted: ({ id }: { id: string }) =>
dispatch(timelineActions.clearEventsDeleted({ id })),
});
const connector = connect(null, mapDispatchToProps);
type PropsFromRedux = ConnectedProps<typeof connector>;
RuleDetailsPageComponent.displayName = 'RuleDetailsPageComponent';
export const RuleDetailsPage = React.memo(RuleDetailsPageComponent);
export const RuleDetailsPage = connector(React.memo(RuleDetailsPageComponent));
RuleDetailsPage.displayName = 'RuleDetailsPage';