Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Security Solution][Case] Alerts comment UI #84450

Merged
merged 17 commits into from
Dec 10, 2020
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ export enum TimelineId {
detectionsPage = 'detections-page',
networkPageExternalAlerts = 'network-page-external-alerts',
active = 'timeline-1',
casePage = 'timeline-case',
test = 'test', // Reserved for testing purposes
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CommentType } from '../../../../../case/common/api';
import { Comment } from '../../containers/types';

export const getRuleIdsFromComments = (comments: Comment[]) =>
comments.reduce<string[]>((ruleIds, comment: Comment) => {
if (comment.type === CommentType.alert) {
return [...ruleIds, comment.alertId];
}

return ruleIds;
}, []);

export const buildAlertsQuery = (ruleIds: string[]) => ({
query: {
bool: {
filter: {
bool: {
should: ruleIds.map((_id) => ({ match: { _id } })),
minimum_should_match: 1,
},
},
},
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,32 @@ import { act, waitFor } from '@testing-library/react';

import { useConnectors } from '../../containers/configure/use_connectors';
import { connectorsMock } from '../../containers/configure/mock';

import { usePostPushToService } from '../../containers/use_post_push_to_service';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { ConnectorTypes } from '../../../../../case/common/api/connectors';

const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});

jest.mock('../../containers/use_update_case');
jest.mock('../../containers/use_get_case_user_actions');
jest.mock('../../containers/use_get_case');
jest.mock('../../containers/configure/use_connectors');
jest.mock('../../containers/use_post_push_to_service');
jest.mock('../../../detections/containers/detection_engine/alerts/use_query');
jest.mock('../user_action_tree/user_action_timestamp');

const useUpdateCaseMock = useUpdateCase as jest.Mock;
const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock;
const useConnectorsMock = useConnectors as jest.Mock;
const usePostPushToServiceMock = usePostPushToService as jest.Mock;
const useQueryAlertsMock = useQueryAlerts as jest.Mock;

export const caseProps: CaseProps = {
caseId: basicCase.id,
Expand Down Expand Up @@ -99,6 +110,10 @@ describe('CaseView ', () => {
useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions);
usePostPushToServiceMock.mockImplementation(() => ({ isLoading: false, postPushToService }));
useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, isLoading: false }));
useQueryAlertsMock.mockImplementation(() => ({
isLoading: false,
alerts: { hits: { hists: [] } },
}));
});

it('should render CaseComponent', async () => {
Expand Down Expand Up @@ -435,6 +450,7 @@ describe('CaseView ', () => {
).toBeTruthy();
});
});

// TO DO fix when the useEffects in edit_connector are cleaned up
it.skip('should revert to the initial connector in case of failure', async () => {
updateCaseProperty.mockImplementation(({ onError }) => {
Expand Down Expand Up @@ -486,6 +502,7 @@ describe('CaseView ', () => {
).toBe(connectorName);
});
});

// TO DO fix when the useEffects in edit_connector are cleaned up
it.skip('should update connector', async () => {
const wrapper = mount(
Expand Down Expand Up @@ -539,4 +556,27 @@ describe('CaseView ', () => {
},
});
});

it('it should create a new timeline on mount', async () => {
mount(
<TestProviders>
<Router history={mockHistory}>
<CaseComponent {...caseProps} />
</Router>
</TestProviders>
);

await waitFor(() => {
expect(mockDispatch).toHaveBeenCalledWith({
type: 'x-pack/security_solution/local/timeline/CREATE_TIMELINE',
payload: {
columns: [],
expandedEvent: {},
id: 'timeline-case',
indexNames: [],
show: false,
},
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
* you may not use this file except in compliance with the Elastic License.
*/

import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { isEmpty } from 'lodash/fp';
import {
EuiFlexGroup,
EuiFlexItem,
EuiLoadingContent,
EuiLoadingSpinner,
EuiHorizontalRule,
} from '@elastic/eui';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import styled from 'styled-components';
import { isEmpty } from 'lodash/fp';

import { CaseStatuses } from '../../../../../case/common/api';
import { Case, CaseConnector } from '../../containers/types';
Expand All @@ -40,6 +41,13 @@ import {
normalizeActionConnector,
getNoneConnector,
} from '../configure_cases/utils';
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
import { buildAlertsQuery, getRuleIdsFromComments } from './helpers';
import { EventDetailsFlyout } from '../../../common/components/events_viewer/event_details_flyout';
import { useSourcererScope } from '../../../common/containers/sourcerer';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { TimelineId } from '../../../../common/types/timeline';
import { timelineActions } from '../../../timelines/store/timeline';
import { StatusActionButton } from '../status/button';

import * as i18n from './translations';
Expand Down Expand Up @@ -78,12 +86,34 @@ export interface CaseProps extends Props {
updateCase: (newCase: Case) => void;
}

interface Signal {
rule: {
id: string;
name: string;
};
}

interface SignalHit {
_id: string;
_index: string;
_source: {
signal: Signal;
};
}

export type Alert = {
_id: string;
_index: string;
} & Signal;

export const CaseComponent = React.memo<CaseProps>(
({ caseId, caseData, fetchCase, updateCase, userCanCrud }) => {
const dispatch = useDispatch();
const { formatUrl, search } = useFormatUrl(SecurityPageName.case);
const allCasesLink = getCaseUrl(search);
const caseDetailsLink = formatUrl(getCaseDetailsUrl({ id: caseId }), { absolute: true });
const [initLoadingData, setInitLoadingData] = useState(true);
const init = useRef(true);

const {
caseUserActions,
Expand All @@ -98,6 +128,39 @@ export const CaseComponent = React.memo<CaseProps>(
caseId,
});

const alertsQuery = useMemo(() => buildAlertsQuery(getRuleIdsFromComments(caseData.comments)), [
caseData.comments,
]);

/**
* For the future developer: useSourcererScope is security solution dependent.
* You can use useSignalIndex as an alternative.
*/
const { browserFields, docValueFields, selectedPatterns } = useSourcererScope(
SourcererScopeName.detections
);

const { loading: isLoadingAlerts, data: alertsData } = useQueryAlerts<SignalHit, unknown>(
alertsQuery,
selectedPatterns[0]
);

const alerts = useMemo(
() =>
alertsData?.hits.hits.reduce<Record<string, Alert>>(
(acc, { _id, _index, _source }) => ({
...acc,
[_id]: {
_id,
_index,
..._source.signal,
},
}),
{}
) ?? {},
[alertsData?.hits.hits]
);

// Update Fields
const onUpdateField = useCallback(
({ key, value, onSuccess, onError }: OnUpdateFields) => {
Expand Down Expand Up @@ -266,10 +329,10 @@ export const CaseComponent = React.memo<CaseProps>(
);

useEffect(() => {
if (initLoadingData && !isLoadingUserActions) {
if (initLoadingData && !isLoadingUserActions && !isLoadingAlerts) {
setInitLoadingData(false);
}
}, [initLoadingData, isLoadingUserActions]);
}, [initLoadingData, isLoadingAlerts, isLoadingUserActions]);

const backOptions = useMemo(
() => ({
Expand All @@ -281,6 +344,39 @@ export const CaseComponent = React.memo<CaseProps>(
[allCasesLink]
);

const showAlert = useCallback(
(alertId: string, index: string) => {
dispatch(
timelineActions.toggleExpandedEvent({
timelineId: TimelineId.casePage,
event: {
eventId: alertId,
indexName: index,
loading: false,
},
})
);
},
[dispatch]
);

// useEffect used for component's initialization
useEffect(() => {
if (init.current) {
init.current = false;
// We need to create a timeline to show the details view
dispatch(
timelineActions.createTimeline({
id: TimelineId.casePage,
columns: [],
indexNames: [],
expandedEvent: {},
show: false,
})
);
}
}, [dispatch]);

return (
<>
<HeaderWrapper>
Expand Down Expand Up @@ -327,6 +423,8 @@ export const CaseComponent = React.memo<CaseProps>(
onUpdateField={onUpdateField}
updateCase={updateCase}
userCanCrud={userCanCrud}
alerts={alerts}
onShowAlertDetails={showAlert}
/>
<MyEuiHorizontalRule margin="s" />
<EuiFlexGroup alignItems="center" gutterSize="s" justifyContent="flexEnd">
Expand Down Expand Up @@ -381,6 +479,11 @@ export const CaseComponent = React.memo<CaseProps>(
</EuiFlexGroup>
</MyWrapper>
</WhitePageWrapper>
<EventDetailsFlyout
browserFields={browserFields}
docValueFields={docValueFields}
timelineId={TimelineId.casePage}
/>
<SpyRoute state={spyState} pageName={SecurityPageName.case} />
</>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiCommentProps } from '@elastic/eui';
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiCommentProps, EuiIconTip } from '@elastic/eui';

import {
CaseFullExternalService,
Expand All @@ -21,7 +21,10 @@ import { UserActionTimestamp } from './user_action_timestamp';
import { UserActionCopyLink } from './user_action_copy_link';
import { UserActionMoveToReference } from './user_action_move_to_reference';
import { Status, statuses } from '../status';
import * as i18n from '../case_view/translations';
import { UserActionShowAlert } from './user_action_show_alert';
import * as i18n from './translations';
import { Alert } from '../case_view';
import { AlertCommentEvent } from './user_action_alert_comment_event';

interface LabelTitle {
action: CaseUserActions;
Expand Down Expand Up @@ -182,3 +185,52 @@ export const getUpdateAction = ({
</EuiFlexGroup>
),
});

export const getAlertComment = ({
action,
alert,
onShowAlertDetails,
}: {
action: CaseUserActions;
alert: Alert | undefined;
onShowAlertDetails: (alertId: string, index: string) => void;
}): EuiCommentProps => {
return {
username: (
<UserActionUsernameWithAvatar
username={action.actionBy.username}
fullName={action.actionBy.fullName}
/>
),
className: 'comment-alert',
type: 'update',
event: <AlertCommentEvent alert={alert} />,
'data-test-subj': `${action.actionField[0]}-${action.action}-action-${action.actionId}`,
timestamp: <UserActionTimestamp createdAt={action.actionAt} />,
timelineIcon: 'bell',
actions: (
<EuiFlexGroup>
<EuiFlexItem>
<UserActionCopyLink id={action.actionId} />
</EuiFlexItem>
<EuiFlexItem>
{alert != null ? (
<UserActionShowAlert
id={action.actionId}
alert={alert}
onShowAlertDetails={onShowAlertDetails}
/>
) : (
<EuiIconTip
aria-label={i18n.ALERT_NOT_FOUND_TOOLTIP}
size="l"
type="alert"
color="danger"
content={i18n.ALERT_NOT_FOUND_TOOLTIP}
/>
)}
</EuiFlexItem>
</EuiFlexGroup>
),
};
};
Loading