mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[Security Solution][Case] Alerts comment UI (#84450)
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com>
This commit is contained in:
parent
44688d9595
commit
a740a3f8ca
15 changed files with 398 additions and 22 deletions
|
@ -276,6 +276,7 @@ export enum TimelineId {
|
|||
detectionsPage = 'detections-page',
|
||||
networkPageExternalAlerts = 'network-page-external-alerts',
|
||||
active = 'timeline-1',
|
||||
casePage = 'timeline-case',
|
||||
test = 'test', // Reserved for testing purposes
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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,
|
||||
|
@ -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 () => {
|
||||
|
@ -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 }) => {
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,6 +4,10 @@
|
|||
* 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,
|
||||
|
@ -11,9 +15,6 @@ import {
|
|||
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';
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
|
@ -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) => {
|
||||
|
@ -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(
|
||||
() => ({
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -18,6 +18,8 @@ import { TestProviders } from '../../../common/mock';
|
|||
const fetchUserActions = jest.fn();
|
||||
const onUpdateField = jest.fn();
|
||||
const updateCase = jest.fn();
|
||||
const onShowAlertDetails = jest.fn();
|
||||
|
||||
const defaultProps = {
|
||||
caseServices: {},
|
||||
caseUserActions: [],
|
||||
|
@ -29,6 +31,8 @@ const defaultProps = {
|
|||
onUpdateField,
|
||||
updateCase,
|
||||
userCanCrud: true,
|
||||
alerts: {},
|
||||
onShowAlertDetails,
|
||||
};
|
||||
const useUpdateCommentMock = useUpdateComment as jest.Mock;
|
||||
jest.mock('../../containers/use_update_comment');
|
||||
|
|
|
@ -22,16 +22,17 @@ import { Case, CaseUserActions } from '../../containers/types';
|
|||
import { useUpdateComment } from '../../containers/use_update_comment';
|
||||
import { useCurrentUser } from '../../../common/lib/kibana';
|
||||
import { AddComment, AddCommentRefObject } from '../add_comment';
|
||||
import { ActionConnector } from '../../../../../case/common/api/cases';
|
||||
import { ActionConnector, CommentType } from '../../../../../case/common/api/cases';
|
||||
import { CaseServices } from '../../containers/use_get_case_user_actions';
|
||||
import { parseString } from '../../containers/utils';
|
||||
import { OnUpdateFields } from '../case_view';
|
||||
import { Alert, OnUpdateFields } from '../case_view';
|
||||
import {
|
||||
getConnectorLabelTitle,
|
||||
getLabelTitle,
|
||||
getPushedServiceLabelTitle,
|
||||
getPushInfo,
|
||||
getUpdateAction,
|
||||
getAlertComment,
|
||||
} from './helpers';
|
||||
import { UserActionAvatar } from './user_action_avatar';
|
||||
import { UserActionMarkdown } from './user_action_markdown';
|
||||
|
@ -50,6 +51,8 @@ export interface UserActionTreeProps {
|
|||
onUpdateField: ({ key, value, onSuccess, onError }: OnUpdateFields) => void;
|
||||
updateCase: (newCase: Case) => void;
|
||||
userCanCrud: boolean;
|
||||
alerts: Record<string, Alert>;
|
||||
onShowAlertDetails: (alertId: string, index: string) => void;
|
||||
}
|
||||
|
||||
const MyEuiFlexGroup = styled(EuiFlexGroup)`
|
||||
|
@ -78,6 +81,17 @@ const MyEuiCommentList = styled(EuiCommentList)`
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& .comment-alert .euiCommentEvent {
|
||||
background-color: ${theme.eui.euiColorLightestShade};
|
||||
border: ${theme.eui.euiFlyoutBorder};
|
||||
padding: 10px;
|
||||
border-radius: ${theme.eui.paddingSizes.xs};
|
||||
}
|
||||
|
||||
& .comment-alert .euiCommentEvent__headerData {
|
||||
flex-grow: 1;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
@ -96,6 +110,8 @@ export const UserActionTree = React.memo(
|
|||
onUpdateField,
|
||||
updateCase,
|
||||
userCanCrud,
|
||||
alerts,
|
||||
onShowAlertDetails,
|
||||
}: UserActionTreeProps) => {
|
||||
const { commentId } = useParams<{ commentId?: string }>();
|
||||
const handlerTimeoutId = useRef(0);
|
||||
|
@ -105,6 +121,7 @@ export const UserActionTree = React.memo(
|
|||
const { isLoadingIds, patchComment } = useUpdateComment();
|
||||
const currentUser = useCurrentUser();
|
||||
const [manageMarkdownEditIds, setManangeMardownEditIds] = useState<string[]>([]);
|
||||
|
||||
const handleManageMarkdownEditId = useCallback(
|
||||
(id: string) => {
|
||||
if (!manageMarkdownEditIds.includes(id)) {
|
||||
|
@ -264,7 +281,7 @@ export const UserActionTree = React.memo(
|
|||
// Comment creation
|
||||
if (action.commentId != null && action.action === 'create') {
|
||||
const comment = caseData.comments.find((c) => c.id === action.commentId);
|
||||
if (comment != null) {
|
||||
if (comment != null && comment.type === CommentType.user) {
|
||||
return [
|
||||
...comments,
|
||||
{
|
||||
|
@ -316,6 +333,9 @@ export const UserActionTree = React.memo(
|
|||
),
|
||||
},
|
||||
];
|
||||
} else if (comment != null && comment.type === CommentType.alert) {
|
||||
const alert = alerts[comment.alertId];
|
||||
return [...comments, getAlertComment({ action, alert, onShowAlertDetails })];
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -380,7 +400,7 @@ export const UserActionTree = React.memo(
|
|||
];
|
||||
}
|
||||
|
||||
// title, description, comments, tags, status
|
||||
// title, description, comment updates, tags
|
||||
if (
|
||||
action.actionField.length === 1 &&
|
||||
['title', 'description', 'comment', 'tags', 'status'].includes(action.actionField[0])
|
||||
|
@ -412,6 +432,8 @@ export const UserActionTree = React.memo(
|
|||
manageMarkdownEditIds,
|
||||
selectedOutlineCommentId,
|
||||
userCanCrud,
|
||||
alerts,
|
||||
onShowAlertDetails,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -33,3 +33,31 @@ export const MOVE_TO_ORIGINAL_COMMENT = i18n.translate(
|
|||
defaultMessage: 'Highlight the referenced comment',
|
||||
}
|
||||
);
|
||||
|
||||
export const ALERT_COMMENT_LABEL_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.case.caseView.alertCommentLabelTitle',
|
||||
{
|
||||
defaultMessage: 'added an alert from',
|
||||
}
|
||||
);
|
||||
|
||||
export const ALERT_RULE_DELETED_COMMENT_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.case.caseView.alertRuleDeletedLabelTitle',
|
||||
{
|
||||
defaultMessage: 'added an alert',
|
||||
}
|
||||
);
|
||||
|
||||
export const SHOW_ALERT_TOOLTIP = i18n.translate(
|
||||
'xpack.securitySolution.case.caseView.showAlertTooltip',
|
||||
{
|
||||
defaultMessage: 'Show alert details',
|
||||
}
|
||||
);
|
||||
|
||||
export const ALERT_NOT_FOUND_TOOLTIP = i18n.translate(
|
||||
'xpack.securitySolution.case.caseView.showAlertDeletedTooltip',
|
||||
{
|
||||
defaultMessage: 'Alert not found',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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 React, { memo, useCallback } from 'react';
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
|
||||
import { APP_ID } from '../../../../common/constants';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import { getRuleDetailsUrl, useFormatUrl } from '../../../common/components/link_to';
|
||||
import { SecurityPageName } from '../../../app/types';
|
||||
|
||||
import { Alert } from '../case_view';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface Props {
|
||||
alert: Alert | undefined;
|
||||
}
|
||||
|
||||
const AlertCommentEventComponent: React.FC<Props> = ({ alert }) => {
|
||||
const ruleName = alert?.rule?.name ?? null;
|
||||
const ruleId = alert?.rule?.id ?? null;
|
||||
const { navigateToApp } = useKibana().services.application;
|
||||
const { formatUrl } = useFormatUrl(SecurityPageName.detections);
|
||||
|
||||
const onLinkClick = useCallback(
|
||||
(ev: { preventDefault: () => void }) => {
|
||||
ev.preventDefault();
|
||||
navigateToApp(`${APP_ID}:${SecurityPageName.detections}`, {
|
||||
path: formatUrl(getRuleDetailsUrl(ruleId ?? '')),
|
||||
});
|
||||
},
|
||||
[ruleId, formatUrl, navigateToApp]
|
||||
);
|
||||
|
||||
return ruleId != null && ruleName != null ? (
|
||||
<>
|
||||
{`${i18n.ALERT_COMMENT_LABEL_TITLE} `}
|
||||
<EuiLink onClick={onLinkClick}>{ruleName}</EuiLink>
|
||||
</>
|
||||
) : (
|
||||
<>{i18n.ALERT_RULE_DELETED_COMMENT_LABEL}</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertCommentEvent = memo(AlertCommentEventComponent);
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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 React, { memo, useCallback } from 'react';
|
||||
import { EuiToolTip, EuiButtonIcon } from '@elastic/eui';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
|
||||
import { Alert } from '../case_view';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface UserActionShowAlertProps {
|
||||
id: string;
|
||||
alert: Alert;
|
||||
onShowAlertDetails: (alertId: string, index: string) => void;
|
||||
}
|
||||
|
||||
const UserActionShowAlertComponent = ({
|
||||
id,
|
||||
alert,
|
||||
onShowAlertDetails,
|
||||
}: UserActionShowAlertProps) => {
|
||||
const onClick = useCallback(() => onShowAlertDetails(alert._id, alert._index), [
|
||||
alert._id,
|
||||
alert._index,
|
||||
onShowAlertDetails,
|
||||
]);
|
||||
return (
|
||||
<EuiToolTip position="top" content={<p>{i18n.SHOW_ALERT_TOOLTIP}</p>}>
|
||||
<EuiButtonIcon
|
||||
aria-label={i18n.SHOW_ALERT_TOOLTIP}
|
||||
data-test-subj={`comment-action-show-alert-${id}`}
|
||||
onClick={onClick}
|
||||
iconType="arrowRight"
|
||||
id={`${id}-show-alert`}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserActionShowAlert = memo(
|
||||
UserActionShowAlertComponent,
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.id === nextProps.id &&
|
||||
deepEqual(prevProps.alert, nextProps.alert) &&
|
||||
prevProps.onShowAlertDetails === nextProps.onShowAlertDetails
|
||||
);
|
|
@ -191,8 +191,8 @@ export const elasticUserSnake = {
|
|||
email: 'leslie.knope@elastic.co',
|
||||
};
|
||||
export const basicCommentSnake: CommentResponse = {
|
||||
...basicComment,
|
||||
comment: 'Solve this fast!',
|
||||
type: CommentType.user,
|
||||
id: basicCommentId,
|
||||
created_at: basicCreatedAt,
|
||||
created_by: elasticUserSnake,
|
||||
|
@ -200,6 +200,7 @@ export const basicCommentSnake: CommentResponse = {
|
|||
pushed_by: null,
|
||||
updated_at: null,
|
||||
updated_by: null,
|
||||
version: 'WzQ3LDFc',
|
||||
};
|
||||
|
||||
export const basicCaseSnake: CaseResponse = {
|
||||
|
|
|
@ -9,24 +9,22 @@ import {
|
|||
UserActionField,
|
||||
UserAction,
|
||||
CaseConnector,
|
||||
CommentType,
|
||||
CommentRequest,
|
||||
CaseStatuses,
|
||||
} from '../../../../case/common/api';
|
||||
|
||||
export { CaseConnector, ActionConnector } from '../../../../case/common/api';
|
||||
|
||||
export interface Comment {
|
||||
export type Comment = CommentRequest & {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
createdBy: ElasticUser;
|
||||
comment: string;
|
||||
type: CommentType.user;
|
||||
pushedAt: string | null;
|
||||
pushedBy: string | null;
|
||||
updatedAt: string | null;
|
||||
updatedBy: ElasticUser | null;
|
||||
version: string;
|
||||
}
|
||||
};
|
||||
export interface CaseUserActions {
|
||||
actionId: string;
|
||||
actionField: UserActionField;
|
||||
|
|
|
@ -20,7 +20,7 @@ import {
|
|||
} from './mock';
|
||||
import * as api from './api';
|
||||
import { CaseServices } from './use_get_case_user_actions';
|
||||
import { CaseConnector, ConnectorTypes } from '../../../../case/common/api/connectors';
|
||||
import { CaseConnector, ConnectorTypes, CommentType } from '../../../../case/common/api';
|
||||
|
||||
jest.mock('./api');
|
||||
|
||||
|
@ -53,7 +53,7 @@ describe('usePostPushToService', () => {
|
|||
comments: [
|
||||
{
|
||||
commentId: basicComment.id,
|
||||
comment: basicComment.comment,
|
||||
comment: basicComment.type === CommentType.user ? basicComment.comment : '',
|
||||
createdAt: basicComment.createdAt,
|
||||
createdBy: serviceConnectorUser,
|
||||
updatedAt: null,
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
ServiceConnectorCaseResponse,
|
||||
ServiceConnectorCaseParams,
|
||||
CaseConnector,
|
||||
CommentType,
|
||||
} from '../../../../case/common/api';
|
||||
import {
|
||||
errorToToaster,
|
||||
|
@ -177,7 +178,7 @@ export const formatServiceRequestData = (
|
|||
)
|
||||
.map((c) => ({
|
||||
commentId: c.id,
|
||||
comment: c.comment,
|
||||
comment: c.type === CommentType.user ? c.comment : '',
|
||||
createdAt: c.createdAt,
|
||||
createdBy: {
|
||||
fullName: c.createdBy.fullName ?? null,
|
||||
|
|
|
@ -12,7 +12,7 @@ import { navTabs } from '../../../app/home/home_navigations';
|
|||
import { APP_ID } from '../../../../common/constants';
|
||||
import { useKibana } from '../../lib/kibana';
|
||||
|
||||
export { getDetectionEngineUrl } from './redirect_to_detection_engine';
|
||||
export { getDetectionEngineUrl, getRuleDetailsUrl } from './redirect_to_detection_engine';
|
||||
export { getAppOverviewUrl } from './redirect_to_overview';
|
||||
export { getHostDetailsUrl, getHostsUrl } from './redirect_to_hosts';
|
||||
export { getNetworkUrl, getNetworkDetailsUrl } from './redirect_to_network';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue