[Security Solution] Detection rules for case UI (#91434)
* Adding type field to client * Removing context and adding association type * Handle alerts from multiple indices * Adding flow for adding a sub case * Making progress on creating alerts from rules * Refactored add comment to handle case and sub case * Starting sub case API and refactoring of case client * Fleshing out find cases * Finished the find cases api * Filtering comments by association type * Fixing tests and types * Updating snapshots * Cleaning up comment references * Working unit tests * Fixing integration tests and got ES to work * Unit tests and api integration test working * Refactoring find and get_status * Starting patch, and update * script for sub cases * Removing converted_by and fixing type errors * Adding docs for script * Removing converted_by and fixing integration test * init expanded rows * Adding sub case id to comment routes * Removing stringify comparison * styling * clean up * add status column * styling * hide actions if it has sub-cases * Adding delete api and tests * generated alert * Updating license * missed license files * Integration tests passing * Adding more tests for sub cases * wip * Find int tests, scoped client, patch sub user actions * fixing types and call cluster * fixing get sub case param issue * Adding user actions for sub cases * Preventing alerts on collections and refactoring user * Allowing type to be updated for ind cases * subcases attached to api * combine enum on UI for simplification * Refactoring and writing tests * Fixing sub case status filtering * add alerts count * Adding more tests not allowing gen alerts patch * Working unit tests * Push to connector gets all sub case comments * Writing more tests and cleaning up * Updating push functionality for generated alerts and sub cases * Adding comment about updating collection sync * use CaseType to check if it is a sub-case * fix types and disable selection if it has subcases * isEmpty * Detection rule correctly adding alerts to sub case * update api and functionality to accept sub case * integration part I * fix integration with case connector * Fix manual attach * Fix types * Fix bug when updating * Fix bug with user actions * Fix react key error * Fix bug when pushing a lot of alerts * fix lint error * Fix limit * fix title on sub case * fix unit tests * rm bazel * fix unit tests and cypress test * enable delete case icon * revert change * review * Fix the scripts alerts generation code * temp work * Fix rule types and add migration * fix types * fix types error * Remove query alerts * Fix rules * fix types * fix lint error * fix types * delete a sub case * rm unused i18n * fix delete cases * fix unit tests * fix unit test * update Case type * fix types * fix unit test * final integration between rule and case * fix integration test * fix unit test + bring back connector in action of rule Co-authored-by: Jonathan Buttner <jonathan.buttner@elastic.co> Co-authored-by: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Christos Nasikas <christos.nasikas@elastic.co>
This commit is contained in:
parent
9180ed112c
commit
97d391a636
|
@ -123,6 +123,7 @@ export const CaseResponseRt = rt.intersection([
|
|||
version: rt.string,
|
||||
}),
|
||||
rt.partial({
|
||||
subCaseIds: rt.array(rt.string),
|
||||
subCases: rt.array(SubCaseResponseRt),
|
||||
comments: rt.array(CommentResponseRt),
|
||||
}),
|
||||
|
|
|
@ -52,7 +52,11 @@ export const ContextTypeUserRt = rt.type({
|
|||
export const AlertCommentRequestRt = rt.type({
|
||||
type: rt.union([rt.literal(CommentType.generatedAlert), rt.literal(CommentType.alert)]),
|
||||
alertId: rt.union([rt.array(rt.string), rt.string]),
|
||||
index: rt.string,
|
||||
index: rt.union([rt.array(rt.string), rt.string]),
|
||||
rule: rt.type({
|
||||
id: rt.union([rt.string, rt.null]),
|
||||
name: rt.union([rt.string, rt.null]),
|
||||
}),
|
||||
});
|
||||
|
||||
const AttributesTypeUserRt = rt.intersection([ContextTypeUserRt, CommentAttributesBasicRt]);
|
||||
|
@ -108,6 +112,7 @@ export const CommentsResponseRt = rt.type({
|
|||
|
||||
export const AllCommentsResponseRt = rt.array(CommentResponseRt);
|
||||
|
||||
export type AttributesTypeAlerts = rt.TypeOf<typeof AttributesTypeAlertsRt>;
|
||||
export type CommentAttributes = rt.TypeOf<typeof CommentAttributesRt>;
|
||||
export type CommentRequest = rt.TypeOf<typeof CommentRequestRt>;
|
||||
export type CommentResponse = rt.TypeOf<typeof CommentResponseRt>;
|
||||
|
|
|
@ -49,6 +49,7 @@ const CaseUserActionResponseRT = rt.intersection([
|
|||
case_id: rt.string,
|
||||
comment_id: rt.union([rt.string, rt.null]),
|
||||
}),
|
||||
rt.partial({ sub_case_id: rt.string }),
|
||||
]);
|
||||
|
||||
export const CaseUserActionAttributesRt = CaseUserActionBasicRT;
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
SUB_CASE_DETAILS_URL,
|
||||
SUB_CASES_URL,
|
||||
CASE_PUSH_URL,
|
||||
SUB_CASE_USER_ACTIONS_URL,
|
||||
} from '../constants';
|
||||
|
||||
export const getCaseDetailsUrl = (id: string): string => {
|
||||
|
@ -38,6 +39,11 @@ export const getCaseCommentDetailsUrl = (caseId: string, commentId: string): str
|
|||
export const getCaseUserActionUrl = (id: string): string => {
|
||||
return CASE_USER_ACTIONS_URL.replace('{case_id}', id);
|
||||
};
|
||||
|
||||
export const getSubCaseUserActionUrl = (caseID: string, subCaseID: string): string => {
|
||||
return SUB_CASE_USER_ACTIONS_URL.replace('{case_id}', caseID).replace('{sub_case_id}', subCaseID);
|
||||
};
|
||||
|
||||
export const getCasePushUrl = (caseId: string, connectorId: string): string => {
|
||||
return CASE_PUSH_URL.replace('{case_id}', caseId).replace('{connector_id}', connectorId);
|
||||
};
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { DEFAULT_MAX_SIGNALS } from '../../security_solution/common/constants';
|
||||
|
||||
export const APP_ID = 'case';
|
||||
|
||||
/**
|
||||
|
@ -19,6 +21,7 @@ export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`;
|
|||
export const SUB_CASES_PATCH_DEL_URL = `${CASES_URL}/sub_cases`;
|
||||
export const SUB_CASES_URL = `${CASE_DETAILS_URL}/sub_cases`;
|
||||
export const SUB_CASE_DETAILS_URL = `${CASE_DETAILS_URL}/sub_cases/{sub_case_id}`;
|
||||
export const SUB_CASE_USER_ACTIONS_URL = `${SUB_CASE_DETAILS_URL}/user_actions`;
|
||||
|
||||
export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments`;
|
||||
export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}`;
|
||||
|
@ -45,3 +48,10 @@ export const SUPPORTED_CONNECTORS = [
|
|||
JIRA_ACTION_TYPE_ID,
|
||||
RESILIENT_ACTION_TYPE_ID,
|
||||
];
|
||||
|
||||
/**
|
||||
* Alerts
|
||||
*/
|
||||
|
||||
export const MAX_ALERTS_PER_SUB_CASE = 5000;
|
||||
export const MAX_GENERATED_ALERTS_PER_SUB_CASE = MAX_ALERTS_PER_SUB_CASE / DEFAULT_MAX_SIGNALS;
|
||||
|
|
|
@ -76,6 +76,7 @@ describe('create', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "open",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"defacement",
|
||||
|
@ -174,6 +175,7 @@ describe('create', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "open",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"defacement",
|
||||
|
@ -239,6 +241,7 @@ describe('create', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "open",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"defacement",
|
||||
|
|
|
@ -26,19 +26,24 @@ export const get = async ({
|
|||
includeComments = false,
|
||||
includeSubCaseComments = false,
|
||||
}: GetParams): Promise<CaseResponse> => {
|
||||
const theCase = await caseService.getCase({
|
||||
client: savedObjectsClient,
|
||||
id,
|
||||
});
|
||||
const [theCase, subCasesForCaseId] = await Promise.all([
|
||||
caseService.getCase({
|
||||
client: savedObjectsClient,
|
||||
id,
|
||||
}),
|
||||
caseService.findSubCasesByCaseId({ client: savedObjectsClient, ids: [id] }),
|
||||
]);
|
||||
|
||||
const subCaseIds = subCasesForCaseId.saved_objects.map((so) => so.id);
|
||||
|
||||
if (!includeComments) {
|
||||
return CaseResponseRt.encode(
|
||||
flattenCaseSavedObject({
|
||||
savedObject: theCase,
|
||||
subCaseIds,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const theComments = await caseService.getAllCaseComments({
|
||||
client: savedObjectsClient,
|
||||
id,
|
||||
|
@ -53,6 +58,7 @@ export const get = async ({
|
|||
flattenCaseSavedObject({
|
||||
savedObject: theCase,
|
||||
comments: theComments.saved_objects,
|
||||
subCaseIds,
|
||||
totalComment: theComments.total,
|
||||
totalAlerts: countAlertsForID({ comments: theComments, id }),
|
||||
})
|
||||
|
|
|
@ -54,6 +54,10 @@ export const commentAlert: CommentResponse = {
|
|||
id: 'mock-comment-1',
|
||||
alertId: 'alert-id-1',
|
||||
index: 'alert-index-1',
|
||||
rule: {
|
||||
id: 'rule-id-1',
|
||||
name: 'rule-name-1',
|
||||
},
|
||||
type: CommentType.alert as const,
|
||||
created_at: '2019-11-25T21:55:00.177Z',
|
||||
created_by: {
|
||||
|
|
|
@ -71,6 +71,7 @@ describe('update', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "closed",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"defacement",
|
||||
|
@ -166,6 +167,7 @@ describe('update', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "open",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"defacement",
|
||||
|
@ -233,6 +235,7 @@ describe('update', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "in-progress",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"LOLBins",
|
||||
|
@ -300,6 +303,7 @@ describe('update', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "closed",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"defacement",
|
||||
|
@ -371,6 +375,7 @@ describe('update', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "open",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"LOLBins",
|
||||
|
|
|
@ -314,6 +314,7 @@ export const getCommentContextFromAttributes = (
|
|||
type: attributes.type,
|
||||
alertId: attributes.alertId,
|
||||
index: attributes.index,
|
||||
rule: attributes.rule,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
|
|
|
@ -75,6 +75,10 @@ describe('addComment', () => {
|
|||
type: CommentType.alert,
|
||||
alertId: 'test-id',
|
||||
index: 'test-index',
|
||||
rule: {
|
||||
id: 'test-rule1',
|
||||
name: 'test-rule',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -94,6 +98,10 @@ describe('addComment', () => {
|
|||
"index": "test-index",
|
||||
"pushed_at": null,
|
||||
"pushed_by": null,
|
||||
"rule": Object {
|
||||
"id": "test-rule1",
|
||||
"name": "test-rule",
|
||||
},
|
||||
"type": "alert",
|
||||
"updated_at": null,
|
||||
"updated_by": null,
|
||||
|
@ -231,6 +239,10 @@ describe('addComment', () => {
|
|||
type: CommentType.alert,
|
||||
alertId: 'test-alert',
|
||||
index: 'test-index',
|
||||
rule: {
|
||||
id: 'test-rule1',
|
||||
name: 'test-rule',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -265,6 +277,10 @@ describe('addComment', () => {
|
|||
type: CommentType.alert,
|
||||
alertId: 'test-alert',
|
||||
index: 'test-index',
|
||||
rule: {
|
||||
id: 'test-rule1',
|
||||
name: 'test-rule',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -406,6 +422,10 @@ describe('addComment', () => {
|
|||
type: CommentType.alert,
|
||||
index: 'test-index',
|
||||
alertId: 'test-id',
|
||||
rule: {
|
||||
id: 'test-rule1',
|
||||
name: 'test-rule',
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((e) => {
|
||||
|
@ -478,6 +498,10 @@ describe('addComment', () => {
|
|||
type: CommentType.alert,
|
||||
alertId: 'test-alert',
|
||||
index: 'test-index',
|
||||
rule: {
|
||||
id: 'test-rule1',
|
||||
name: 'test-rule',
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch((e) => {
|
||||
|
|
|
@ -38,6 +38,8 @@ import {
|
|||
import { CaseServiceSetup, CaseUserActionServiceSetup } from '../../services';
|
||||
import { CommentableCase } from '../../common';
|
||||
import { CaseClientHandler } from '..';
|
||||
import { CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types';
|
||||
import { MAX_GENERATED_ALERTS_PER_SUB_CASE } from '../../../common/constants';
|
||||
|
||||
async function getSubCase({
|
||||
caseService,
|
||||
|
@ -56,7 +58,20 @@ async function getSubCase({
|
|||
}): Promise<SavedObject<SubCaseAttributes>> {
|
||||
const mostRecentSubCase = await caseService.getMostRecentSubCase(savedObjectsClient, caseId);
|
||||
if (mostRecentSubCase && mostRecentSubCase.attributes.status !== CaseStatuses.closed) {
|
||||
return mostRecentSubCase;
|
||||
const subCaseAlertsAttachement = await caseService.getAllSubCaseComments({
|
||||
client: savedObjectsClient,
|
||||
id: mostRecentSubCase.id,
|
||||
options: {
|
||||
fields: [],
|
||||
filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: ${CommentType.generatedAlert}`,
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (subCaseAlertsAttachement.total <= MAX_GENERATED_ALERTS_PER_SUB_CASE) {
|
||||
return mostRecentSubCase;
|
||||
}
|
||||
}
|
||||
|
||||
const newSubCase = await caseService.createSubCase({
|
||||
|
@ -160,7 +175,11 @@ const addGeneratedAlerts = async ({
|
|||
await caseClient.updateAlertsStatus({
|
||||
ids,
|
||||
status: subCase.attributes.status,
|
||||
indices: new Set([newComment.attributes.index]),
|
||||
indices: new Set([
|
||||
...(Array.isArray(newComment.attributes.index)
|
||||
? newComment.attributes.index
|
||||
: [newComment.attributes.index]),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -282,7 +301,11 @@ export const addComment = async ({
|
|||
await caseClient.updateAlertsStatus({
|
||||
ids,
|
||||
status: updatedCase.status,
|
||||
indices: new Set([newComment.attributes.index]),
|
||||
indices: new Set([
|
||||
...(Array.isArray(newComment.attributes.index)
|
||||
? newComment.attributes.index
|
||||
: [newComment.attributes.index]),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -59,6 +59,7 @@ export interface CaseClientGetAlerts {
|
|||
|
||||
export interface CaseClientGetUserActions {
|
||||
caseId: string;
|
||||
subCaseId?: string;
|
||||
}
|
||||
|
||||
export interface MappingsClient {
|
||||
|
|
|
@ -6,7 +6,11 @@
|
|||
*/
|
||||
|
||||
import { SavedObjectsClientContract } from 'kibana/server';
|
||||
import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types';
|
||||
import {
|
||||
CASE_SAVED_OBJECT,
|
||||
CASE_COMMENT_SAVED_OBJECT,
|
||||
SUB_CASE_SAVED_OBJECT,
|
||||
} from '../../saved_object_types';
|
||||
import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common/api';
|
||||
import { CaseUserActionServiceSetup } from '../../services';
|
||||
|
||||
|
@ -14,24 +18,36 @@ interface GetParams {
|
|||
savedObjectsClient: SavedObjectsClientContract;
|
||||
userActionService: CaseUserActionServiceSetup;
|
||||
caseId: string;
|
||||
subCaseId?: string;
|
||||
}
|
||||
|
||||
export const get = async ({
|
||||
savedObjectsClient,
|
||||
userActionService,
|
||||
caseId,
|
||||
subCaseId,
|
||||
}: GetParams): Promise<CaseUserActionsResponse> => {
|
||||
const userActions = await userActionService.getUserActions({
|
||||
client: savedObjectsClient,
|
||||
caseId,
|
||||
subCaseId,
|
||||
});
|
||||
|
||||
return CaseUserActionsResponseRt.encode(
|
||||
userActions.saved_objects.map((ua) => ({
|
||||
...ua.attributes,
|
||||
action_id: ua.id,
|
||||
case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '',
|
||||
comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null,
|
||||
}))
|
||||
userActions.saved_objects.reduce<CaseUserActionsResponse>((acc, ua) => {
|
||||
if (subCaseId == null && ua.references.some((uar) => uar.type === SUB_CASE_SAVED_OBJECT)) {
|
||||
return acc;
|
||||
}
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
...ua.attributes,
|
||||
action_id: ua.id,
|
||||
case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '',
|
||||
comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null,
|
||||
sub_case_id: ua.references.find((r) => r.type === SUB_CASE_SAVED_OBJECT)?.id ?? '',
|
||||
},
|
||||
];
|
||||
}, [])
|
||||
);
|
||||
};
|
||||
|
|
|
@ -99,6 +99,10 @@ describe('common utils', () => {
|
|||
alertId: ['a', 'b', 'c'],
|
||||
index: '',
|
||||
type: CommentType.generatedAlert,
|
||||
rule: {
|
||||
id: 'rule-id-1',
|
||||
name: 'rule-name-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -118,6 +122,10 @@ describe('common utils', () => {
|
|||
alertId: ['a', 'b', 'c'],
|
||||
index: '',
|
||||
type: CommentType.alert,
|
||||
rule: {
|
||||
id: 'rule-id-1',
|
||||
name: 'rule-name-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -139,6 +147,10 @@ describe('common utils', () => {
|
|||
alertId: ['a', 'b'],
|
||||
index: '',
|
||||
type: CommentType.alert,
|
||||
rule: {
|
||||
id: 'rule-id-1',
|
||||
name: 'rule-name-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
comment: '',
|
||||
|
@ -164,6 +176,10 @@ describe('common utils', () => {
|
|||
alertId: ['a', 'b'],
|
||||
index: '',
|
||||
type: CommentType.alert,
|
||||
rule: {
|
||||
id: 'rule-id-1',
|
||||
name: 'rule-name-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -197,6 +213,10 @@ describe('common utils', () => {
|
|||
alertId: ['a', 'b'],
|
||||
index: '',
|
||||
type: CommentType.alert,
|
||||
rule: {
|
||||
id: 'rule-id-1',
|
||||
name: 'rule-name-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -224,6 +244,10 @@ describe('common utils', () => {
|
|||
alertId: ['a', 'b'],
|
||||
index: '',
|
||||
type: CommentType.alert,
|
||||
rule: {
|
||||
id: 'rule-id-1',
|
||||
name: 'rule-name-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -717,6 +717,10 @@ describe('case connector', () => {
|
|||
type: CommentType.alert,
|
||||
alertId: 'test-id',
|
||||
index: 'test-index',
|
||||
rule: {
|
||||
id: null,
|
||||
name: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -122,23 +122,48 @@ async function executor(
|
|||
/**
|
||||
* This converts a connector style generated alert ({_id: string} | {_id: string}[]) to the expected format of addComment.
|
||||
*/
|
||||
interface AttachmentAlerts {
|
||||
ids: string[];
|
||||
indices: string[];
|
||||
rule: { id: string | null; name: string | null };
|
||||
}
|
||||
export const transformConnectorComment = (comment: CommentSchemaType): CommentRequest => {
|
||||
if (isCommentGeneratedAlert(comment)) {
|
||||
const alertId: string[] = [];
|
||||
if (Array.isArray(comment.alerts)) {
|
||||
alertId.push(
|
||||
...comment.alerts.map((alert: { _id: string }) => {
|
||||
return alert._id;
|
||||
})
|
||||
try {
|
||||
const genAlerts: Array<{
|
||||
_id: string;
|
||||
_index: string;
|
||||
ruleId: string | undefined;
|
||||
ruleName: string | undefined;
|
||||
}> = JSON.parse(
|
||||
`${comment.alerts.substring(0, comment.alerts.lastIndexOf('__SEPARATOR__'))}]`.replace(
|
||||
/__SEPARATOR__/gi,
|
||||
','
|
||||
)
|
||||
);
|
||||
} else {
|
||||
alertId.push(comment.alerts._id);
|
||||
|
||||
const { ids, indices, rule } = genAlerts.reduce<AttachmentAlerts>(
|
||||
(acc, { _id, _index, ruleId, ruleName }) => {
|
||||
// Mutation is faster than destructing.
|
||||
// Mutation usually leads to side effects but for this scenario it's ok to do it.
|
||||
acc.ids.push(_id);
|
||||
acc.indices.push(_index);
|
||||
// We assume one rule per batch of alerts
|
||||
acc.rule = { id: ruleId ?? null, name: ruleName ?? null };
|
||||
return acc;
|
||||
},
|
||||
{ ids: [], indices: [], rule: { id: null, name: null } }
|
||||
);
|
||||
|
||||
return {
|
||||
type: CommentType.generatedAlert,
|
||||
alertId: ids,
|
||||
index: indices,
|
||||
rule,
|
||||
};
|
||||
} catch (e) {
|
||||
throw new Error(`Error parsing generated alert in case connector -> ${e.message}`);
|
||||
}
|
||||
return {
|
||||
type: CommentType.generatedAlert,
|
||||
alertId,
|
||||
index: comment.index,
|
||||
};
|
||||
} else {
|
||||
return comment;
|
||||
}
|
||||
|
|
|
@ -17,17 +17,9 @@ const ContextTypeUserSchema = schema.object({
|
|||
comment: schema.string(),
|
||||
});
|
||||
|
||||
const AlertIDSchema = schema.object(
|
||||
{
|
||||
_id: schema.string(),
|
||||
},
|
||||
{ unknowns: 'ignore' }
|
||||
);
|
||||
|
||||
const ContextTypeAlertGroupSchema = schema.object({
|
||||
type: schema.literal(CommentType.generatedAlert),
|
||||
alerts: schema.oneOf([schema.arrayOf(AlertIDSchema), AlertIDSchema]),
|
||||
index: schema.string(),
|
||||
alerts: schema.string(),
|
||||
});
|
||||
|
||||
export type ContextTypeGeneratedAlertType = typeof ContextTypeAlertGroupSchema.type;
|
||||
|
@ -37,6 +29,10 @@ const ContextTypeAlertSchema = schema.object({
|
|||
// allowing either an array or a single value to preserve the previous API of attaching a single alert ID
|
||||
alertId: schema.oneOf([schema.arrayOf(schema.string()), schema.string()]),
|
||||
index: schema.string(),
|
||||
rule: schema.object({
|
||||
id: schema.nullable(schema.string()),
|
||||
name: schema.nullable(schema.string()),
|
||||
}),
|
||||
});
|
||||
|
||||
export type ContextTypeAlertSchemaType = typeof ContextTypeAlertSchema.type;
|
||||
|
|
|
@ -65,3 +65,27 @@ export const isCommentAlert = (
|
|||
): comment is ContextTypeAlertSchemaType => {
|
||||
return comment.type === CommentType.alert;
|
||||
};
|
||||
|
||||
/**
|
||||
* Separator field for the case connector alerts string parser.
|
||||
*/
|
||||
const separator = '__SEPARATOR__';
|
||||
|
||||
interface AlertIDIndex {
|
||||
_id: string;
|
||||
_index: string;
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the format that the connector's parser is expecting, it should result in something like this:
|
||||
* [{"_id":"1","_index":"index1"}__SEPARATOR__{"_id":"id2","_index":"index2"}__SEPARATOR__]
|
||||
*
|
||||
* This should only be used for testing purposes.
|
||||
*/
|
||||
export function createAlertsString(alerts: AlertIDIndex[]) {
|
||||
return `[${alerts.reduce((acc, alert) => {
|
||||
return `${acc}${JSON.stringify(alert)}${separator}`;
|
||||
}, '')}]`;
|
||||
}
|
||||
|
|
|
@ -346,6 +346,10 @@ export const mockCaseComments: Array<SavedObject<CommentAttributes>> = [
|
|||
},
|
||||
pushed_at: null,
|
||||
pushed_by: null,
|
||||
rule: {
|
||||
id: 'rule-id-1',
|
||||
name: 'rule-name-1',
|
||||
},
|
||||
updated_at: '2019-11-25T22:32:30.608Z',
|
||||
updated_by: {
|
||||
full_name: 'elastic',
|
||||
|
@ -379,6 +383,10 @@ export const mockCaseComments: Array<SavedObject<CommentAttributes>> = [
|
|||
},
|
||||
pushed_at: null,
|
||||
pushed_by: null,
|
||||
rule: {
|
||||
id: 'rule-id-2',
|
||||
name: 'rule-name-2',
|
||||
},
|
||||
updated_at: '2019-11-25T22:32:30.608Z',
|
||||
updated_by: {
|
||||
full_name: 'elastic',
|
||||
|
|
|
@ -69,6 +69,10 @@ describe('PATCH comment', () => {
|
|||
type: CommentType.alert,
|
||||
alertId: 'new-id',
|
||||
index: 'test-index',
|
||||
rule: {
|
||||
id: 'rule-id',
|
||||
name: 'rule',
|
||||
},
|
||||
id: commentID,
|
||||
version: 'WzYsMV0=',
|
||||
},
|
||||
|
@ -218,6 +222,10 @@ describe('PATCH comment', () => {
|
|||
type: CommentType.alert,
|
||||
alertId: 'test-id',
|
||||
index: 'test-index',
|
||||
rule: {
|
||||
id: 'rule-id',
|
||||
name: 'rule',
|
||||
},
|
||||
id: 'mock-comment-1',
|
||||
version: 'WzEsMV0=',
|
||||
},
|
||||
|
|
|
@ -68,6 +68,10 @@ describe('POST comment', () => {
|
|||
type: CommentType.alert,
|
||||
alertId: 'test-id',
|
||||
index: 'test-index',
|
||||
rule: {
|
||||
id: 'rule-id',
|
||||
name: 'rule-name',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -81,6 +81,7 @@ describe('PATCH cases', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "closed",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"defacement",
|
||||
|
@ -154,6 +155,7 @@ describe('PATCH cases', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "open",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"LOLBins",
|
||||
|
@ -222,6 +224,7 @@ describe('PATCH cases', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "in-progress",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"defacement",
|
||||
|
|
|
@ -213,6 +213,7 @@ describe('POST cases', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "open",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"defacement",
|
||||
|
|
|
@ -9,9 +9,9 @@ import { schema } from '@kbn/config-schema';
|
|||
|
||||
import { RouteDeps } from '../../types';
|
||||
import { wrapError } from '../../utils';
|
||||
import { CASE_USER_ACTIONS_URL } from '../../../../../common/constants';
|
||||
import { CASE_USER_ACTIONS_URL, SUB_CASE_USER_ACTIONS_URL } from '../../../../../common/constants';
|
||||
|
||||
export function initGetAllUserActionsApi({ router }: RouteDeps) {
|
||||
export function initGetAllCaseUserActionsApi({ router }: RouteDeps) {
|
||||
router.get(
|
||||
{
|
||||
path: CASE_USER_ACTIONS_URL,
|
||||
|
@ -39,3 +39,34 @@ export function initGetAllUserActionsApi({ router }: RouteDeps) {
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function initGetAllSubCaseUserActionsApi({ router }: RouteDeps) {
|
||||
router.get(
|
||||
{
|
||||
path: SUB_CASE_USER_ACTIONS_URL,
|
||||
validate: {
|
||||
params: schema.object({
|
||||
case_id: schema.string(),
|
||||
sub_case_id: schema.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
async (context, request, response) => {
|
||||
if (!context.case) {
|
||||
return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' });
|
||||
}
|
||||
|
||||
const caseClient = context.case.getCaseClient();
|
||||
const caseId = request.params.case_id;
|
||||
const subCaseId = request.params.sub_case_id;
|
||||
|
||||
try {
|
||||
return response.ok({
|
||||
body: await caseClient.getUserActions({ caseId, subCaseId }),
|
||||
});
|
||||
} catch (error) {
|
||||
return response.customError(wrapError(error));
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,7 +14,10 @@ import { initPushCaseApi } from './cases/push_case';
|
|||
import { initGetReportersApi } from './cases/reporters/get_reporters';
|
||||
import { initGetCasesStatusApi } from './cases/status/get_status';
|
||||
import { initGetTagsApi } from './cases/tags/get_tags';
|
||||
import { initGetAllUserActionsApi } from './cases/user_actions/get_all_user_actions';
|
||||
import {
|
||||
initGetAllCaseUserActionsApi,
|
||||
initGetAllSubCaseUserActionsApi,
|
||||
} from './cases/user_actions/get_all_user_actions';
|
||||
|
||||
import { initDeleteCommentApi } from './cases/comments/delete_comment';
|
||||
import { initDeleteAllCommentsApi } from './cases/comments/delete_all_comments';
|
||||
|
@ -52,7 +55,8 @@ export function initCaseApi(deps: RouteDeps) {
|
|||
initPatchCasesApi(deps);
|
||||
initPostCaseApi(deps);
|
||||
initPushCaseApi(deps);
|
||||
initGetAllUserActionsApi(deps);
|
||||
initGetAllCaseUserActionsApi(deps);
|
||||
initGetAllSubCaseUserActionsApi(deps);
|
||||
// Sub cases
|
||||
initGetSubCaseApi(deps);
|
||||
initPatchSubCasesApi(deps);
|
||||
|
|
|
@ -401,6 +401,7 @@ describe('Utils', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "open",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"defacement",
|
||||
|
@ -440,6 +441,7 @@ describe('Utils', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "open",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"Data Destruction",
|
||||
|
@ -483,6 +485,7 @@ describe('Utils', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "open",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"LOLBins",
|
||||
|
@ -530,6 +533,7 @@ describe('Utils', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "closed",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"LOLBins",
|
||||
|
@ -594,6 +598,7 @@ describe('Utils', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "open",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"LOLBins",
|
||||
|
@ -649,6 +654,7 @@ describe('Utils', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "open",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"LOLBins",
|
||||
|
@ -727,6 +733,7 @@ describe('Utils', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "open",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"LOLBins",
|
||||
|
@ -781,6 +788,7 @@ describe('Utils', () => {
|
|||
"syncAlerts": true,
|
||||
},
|
||||
"status": "open",
|
||||
"subCaseIds": undefined,
|
||||
"subCases": undefined,
|
||||
"tags": Array [
|
||||
"defacement",
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { isEmpty } from 'lodash';
|
||||
import { badRequest, boomify, isBoom } from '@hapi/boom';
|
||||
import { fold } from 'fp-ts/lib/Either';
|
||||
import { identity } from 'fp-ts/lib/function';
|
||||
|
@ -120,7 +121,8 @@ export interface AlertInfo {
|
|||
const accumulateIndicesAndIDs = (comment: CommentAttributes, acc: AlertInfo): AlertInfo => {
|
||||
if (isCommentRequestTypeAlertOrGenAlert(comment)) {
|
||||
acc.ids.push(...getAlertIds(comment));
|
||||
acc.indices.add(comment.index);
|
||||
const indices = Array.isArray(comment.index) ? comment.index : [comment.index];
|
||||
indices.forEach((index) => acc.indices.add(index));
|
||||
}
|
||||
return acc;
|
||||
};
|
||||
|
@ -249,12 +251,14 @@ export const flattenCaseSavedObject = ({
|
|||
totalComment = comments.length,
|
||||
totalAlerts = 0,
|
||||
subCases,
|
||||
subCaseIds,
|
||||
}: {
|
||||
savedObject: SavedObject<ESCaseAttributes>;
|
||||
comments?: Array<SavedObject<CommentAttributes>>;
|
||||
totalComment?: number;
|
||||
totalAlerts?: number;
|
||||
subCases?: SubCaseResponse[];
|
||||
subCaseIds?: string[];
|
||||
}): CaseResponse => ({
|
||||
id: savedObject.id,
|
||||
version: savedObject.version ?? '0',
|
||||
|
@ -264,6 +268,7 @@ export const flattenCaseSavedObject = ({
|
|||
...savedObject.attributes,
|
||||
connector: transformESConnectorToCaseConnector(savedObject.attributes.connector),
|
||||
subCases,
|
||||
subCaseIds: !isEmpty(subCaseIds) ? subCaseIds : undefined,
|
||||
});
|
||||
|
||||
export const flattenSubCaseSavedObject = ({
|
||||
|
|
|
@ -63,6 +63,16 @@ export const caseCommentSavedObjectType: SavedObjectsType = {
|
|||
},
|
||||
},
|
||||
},
|
||||
rule: {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'keyword',
|
||||
},
|
||||
name: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
updated_at: {
|
||||
type: 'date',
|
||||
},
|
||||
|
|
|
@ -173,8 +173,9 @@ interface SanitizedComment {
|
|||
type: CommentType;
|
||||
}
|
||||
|
||||
interface SanitizedCommentAssociationType {
|
||||
interface SanitizedCommentFoSubCases {
|
||||
associationType: AssociationType;
|
||||
rule: { id: string | null; name: string | null };
|
||||
}
|
||||
|
||||
export const commentsMigrations = {
|
||||
|
@ -192,11 +193,12 @@ export const commentsMigrations = {
|
|||
},
|
||||
'7.12.0': (
|
||||
doc: SavedObjectUnsanitizedDoc<UnsanitizedComment>
|
||||
): SavedObjectSanitizedDoc<SanitizedCommentAssociationType> => {
|
||||
): SavedObjectSanitizedDoc<SanitizedCommentFoSubCases> => {
|
||||
return {
|
||||
...doc,
|
||||
attributes: {
|
||||
...doc.attributes,
|
||||
rule: { id: null, name: null },
|
||||
associationType: AssociationType.case,
|
||||
},
|
||||
references: doc.references || [],
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { CommentType } from '../../../common/api/cases/comment';
|
||||
import { CASES_URL } from '../../../common/constants';
|
||||
import { ActionResult, ActionTypeExecutorResult } from '../../../../actions/common';
|
||||
import { ContextTypeGeneratedAlertType, createAlertsString } from '../../connectors';
|
||||
|
||||
main();
|
||||
|
||||
|
@ -105,6 +106,18 @@ async function handleGenGroupAlerts(argv: any) {
|
|||
}
|
||||
|
||||
console.log('Case id: ', caseID);
|
||||
const comment: ContextTypeGeneratedAlertType = {
|
||||
type: CommentType.generatedAlert,
|
||||
alerts: createAlertsString(
|
||||
argv.ids.map((id: string) => ({
|
||||
_id: id,
|
||||
_index: argv.signalsIndex,
|
||||
ruleId: argv.ruleID,
|
||||
ruleName: argv.ruleName,
|
||||
}))
|
||||
),
|
||||
};
|
||||
|
||||
const executeResp = await client.request<
|
||||
ActionTypeExecutorResult<CollectionWithSubCaseResponse>
|
||||
>({
|
||||
|
@ -115,11 +128,7 @@ async function handleGenGroupAlerts(argv: any) {
|
|||
subAction: 'addComment',
|
||||
subActionParams: {
|
||||
caseId: caseID,
|
||||
comment: {
|
||||
type: CommentType.generatedAlert,
|
||||
alerts: argv.ids.map((id: string) => ({ _id: id })),
|
||||
index: argv.signalsIndex,
|
||||
},
|
||||
comment,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -175,6 +184,18 @@ async function main() {
|
|||
type: 'string',
|
||||
default: '.siem-signals-default',
|
||||
},
|
||||
ruleID: {
|
||||
alias: 'ri',
|
||||
describe: 'siem signals rule id',
|
||||
type: 'string',
|
||||
default: 'rule-id',
|
||||
},
|
||||
ruleName: {
|
||||
alias: 'rn',
|
||||
describe: 'siem signals rule name',
|
||||
type: 'string',
|
||||
default: 'rule-name',
|
||||
},
|
||||
})
|
||||
.demandOption(['ids']);
|
||||
},
|
||||
|
|
|
@ -11,6 +11,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types';
|
|||
|
||||
import { ElasticsearchClient } from 'kibana/server';
|
||||
import { CaseStatuses } from '../../../common/api';
|
||||
import { MAX_ALERTS_PER_SUB_CASE } from '../../../common/constants';
|
||||
|
||||
export type AlertServiceContract = PublicMethodsOf<AlertService>;
|
||||
|
||||
|
@ -95,14 +96,14 @@ export class AlertService {
|
|||
query: {
|
||||
bool: {
|
||||
filter: {
|
||||
bool: {
|
||||
should: ids.map((_id) => ({ match: { _id } })),
|
||||
minimum_should_match: 1,
|
||||
ids: {
|
||||
values: ids,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
size: MAX_ALERTS_PER_SUB_CASE,
|
||||
ignore_unavailable: true,
|
||||
});
|
||||
|
||||
|
|
|
@ -13,11 +13,16 @@ import {
|
|||
} from 'kibana/server';
|
||||
|
||||
import { CaseUserActionAttributes } from '../../../common/api';
|
||||
import { CASE_USER_ACTION_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../saved_object_types';
|
||||
import {
|
||||
CASE_USER_ACTION_SAVED_OBJECT,
|
||||
CASE_SAVED_OBJECT,
|
||||
SUB_CASE_SAVED_OBJECT,
|
||||
} from '../../saved_object_types';
|
||||
import { ClientArgs } from '..';
|
||||
|
||||
interface GetCaseUserActionArgs extends ClientArgs {
|
||||
caseId: string;
|
||||
subCaseId?: string;
|
||||
}
|
||||
|
||||
export interface UserActionItem {
|
||||
|
@ -41,18 +46,20 @@ export interface CaseUserActionServiceSetup {
|
|||
export class CaseUserActionService {
|
||||
constructor(private readonly log: Logger) {}
|
||||
public setup = async (): Promise<CaseUserActionServiceSetup> => ({
|
||||
getUserActions: async ({ client, caseId }: GetCaseUserActionArgs) => {
|
||||
getUserActions: async ({ client, caseId, subCaseId }: GetCaseUserActionArgs) => {
|
||||
try {
|
||||
const id = subCaseId ?? caseId;
|
||||
const type = subCaseId ? SUB_CASE_SAVED_OBJECT : CASE_SAVED_OBJECT;
|
||||
const caseUserActionInfo = await client.find({
|
||||
type: CASE_USER_ACTION_SAVED_OBJECT,
|
||||
fields: [],
|
||||
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
|
||||
hasReference: { type, id },
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
});
|
||||
return await client.find({
|
||||
type: CASE_USER_ACTION_SAVED_OBJECT,
|
||||
hasReference: { type: CASE_SAVED_OBJECT, id: caseId },
|
||||
hasReference: { type, id },
|
||||
page: 1,
|
||||
perPage: caseUserActionInfo.total,
|
||||
sortField: 'action_at',
|
||||
|
|
|
@ -171,7 +171,7 @@ export const ML_GROUP_IDS = [ML_GROUP_ID, LEGACY_ML_GROUP_ID];
|
|||
/*
|
||||
Rule notifications options
|
||||
*/
|
||||
export const ENABLE_CASE_CONNECTOR = false;
|
||||
export const ENABLE_CASE_CONNECTOR = true;
|
||||
export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [
|
||||
'.email',
|
||||
'.slack',
|
||||
|
|
|
@ -79,7 +79,12 @@ describe('AddComment ', () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(onCommentSaving).toBeCalled();
|
||||
expect(postComment).toBeCalledWith(addCommentProps.caseId, sampleData, onCommentPosted);
|
||||
expect(postComment).toBeCalledWith({
|
||||
caseId: addCommentProps.caseId,
|
||||
data: sampleData,
|
||||
subCaseId: undefined,
|
||||
updateCase: onCommentPosted,
|
||||
});
|
||||
expect(wrapper.find(`[data-test-subj="add-comment"] textarea`).text()).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -39,11 +39,15 @@ interface AddCommentProps {
|
|||
onCommentSaving?: () => void;
|
||||
onCommentPosted: (newCase: Case) => void;
|
||||
showLoading?: boolean;
|
||||
subCaseId?: string;
|
||||
}
|
||||
|
||||
export const AddComment = React.memo(
|
||||
forwardRef<AddCommentRefObject, AddCommentProps>(
|
||||
({ caseId, disabled, showLoading = true, onCommentPosted, onCommentSaving }, ref) => {
|
||||
(
|
||||
{ caseId, disabled, onCommentPosted, onCommentSaving, showLoading = true, subCaseId },
|
||||
ref
|
||||
) => {
|
||||
const { isLoading, postComment } = usePostComment();
|
||||
|
||||
const { form } = useForm<AddCommentFormSchema>({
|
||||
|
@ -80,10 +84,15 @@ export const AddComment = React.memo(
|
|||
if (onCommentSaving != null) {
|
||||
onCommentSaving();
|
||||
}
|
||||
postComment(caseId, { ...data, type: CommentType.user }, onCommentPosted);
|
||||
postComment({
|
||||
caseId,
|
||||
data: { ...data, type: CommentType.user },
|
||||
updateCase: onCommentPosted,
|
||||
subCaseId,
|
||||
});
|
||||
reset();
|
||||
}
|
||||
}, [onCommentPosted, onCommentSaving, postComment, reset, submit, caseId]);
|
||||
}, [caseId, onCommentPosted, onCommentSaving, postComment, reset, submit, subCaseId]);
|
||||
|
||||
return (
|
||||
<span id="add-comment-permLink">
|
||||
|
|
|
@ -9,7 +9,7 @@ import { Dispatch } from 'react';
|
|||
import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
|
||||
|
||||
import { CaseStatuses } from '../../../../../case/common/api';
|
||||
import { Case } from '../../containers/types';
|
||||
import { Case, SubCase } from '../../containers/types';
|
||||
import { UpdateCase } from '../../containers/use_get_cases';
|
||||
import * as i18n from './translations';
|
||||
|
||||
|
@ -19,6 +19,9 @@ interface GetActions {
|
|||
deleteCaseOnClick: (deleteCase: Case) => void;
|
||||
}
|
||||
|
||||
const hasSubCases = (subCases: SubCase[] | null | undefined) =>
|
||||
subCases != null && subCases?.length > 0;
|
||||
|
||||
export const getActions = ({
|
||||
caseStatus,
|
||||
dispatchUpdate,
|
||||
|
@ -32,33 +35,34 @@ export const getActions = ({
|
|||
type: 'icon',
|
||||
'data-test-subj': 'action-delete',
|
||||
},
|
||||
caseStatus === CaseStatuses.open
|
||||
? {
|
||||
description: i18n.CLOSE_CASE,
|
||||
icon: 'folderCheck',
|
||||
name: i18n.CLOSE_CASE,
|
||||
onClick: (theCase: Case) =>
|
||||
dispatchUpdate({
|
||||
updateKey: 'status',
|
||||
updateValue: CaseStatuses.closed,
|
||||
caseId: theCase.id,
|
||||
version: theCase.version,
|
||||
}),
|
||||
type: 'icon',
|
||||
'data-test-subj': 'action-close',
|
||||
}
|
||||
: {
|
||||
description: i18n.REOPEN_CASE,
|
||||
icon: 'folderExclamation',
|
||||
name: i18n.REOPEN_CASE,
|
||||
onClick: (theCase: Case) =>
|
||||
dispatchUpdate({
|
||||
updateKey: 'status',
|
||||
updateValue: CaseStatuses.open,
|
||||
caseId: theCase.id,
|
||||
version: theCase.version,
|
||||
}),
|
||||
type: 'icon',
|
||||
'data-test-subj': 'action-open',
|
||||
},
|
||||
{
|
||||
available: (item) => caseStatus === CaseStatuses.open && !hasSubCases(item.subCases),
|
||||
description: i18n.CLOSE_CASE,
|
||||
icon: 'folderCheck',
|
||||
name: i18n.CLOSE_CASE,
|
||||
onClick: (theCase: Case) =>
|
||||
dispatchUpdate({
|
||||
updateKey: 'status',
|
||||
updateValue: CaseStatuses.closed,
|
||||
caseId: theCase.id,
|
||||
version: theCase.version,
|
||||
}),
|
||||
type: 'icon',
|
||||
'data-test-subj': 'action-close',
|
||||
},
|
||||
{
|
||||
available: (item) => caseStatus !== CaseStatuses.open && !hasSubCases(item.subCases),
|
||||
description: i18n.REOPEN_CASE,
|
||||
icon: 'folderExclamation',
|
||||
name: i18n.REOPEN_CASE,
|
||||
onClick: (theCase: Case) =>
|
||||
dispatchUpdate({
|
||||
updateKey: 'status',
|
||||
updateValue: CaseStatuses.open,
|
||||
caseId: theCase.id,
|
||||
version: theCase.version,
|
||||
}),
|
||||
type: 'icon',
|
||||
'data-test-subj': 'action-open',
|
||||
},
|
||||
];
|
||||
|
|
|
@ -14,17 +14,20 @@ import {
|
|||
EuiTableActionsColumnType,
|
||||
EuiTableComputedColumnType,
|
||||
EuiTableFieldDataColumnType,
|
||||
HorizontalAlignment,
|
||||
} from '@elastic/eui';
|
||||
import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services';
|
||||
import styled from 'styled-components';
|
||||
import { DefaultItemIconButtonAction } from '@elastic/eui/src/components/basic_table/action_types';
|
||||
|
||||
import { CaseStatuses } from '../../../../../case/common/api';
|
||||
import { getEmptyTagValue } from '../../../common/components/empty_value';
|
||||
import { Case } from '../../containers/types';
|
||||
import { Case, SubCase } from '../../containers/types';
|
||||
import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date';
|
||||
import { CaseDetailsLink } from '../../../common/components/links';
|
||||
import * as i18n from './translations';
|
||||
import { Status } from '../status';
|
||||
import { getSubCasesStatusCountsBadges, isSubCase } from './helpers';
|
||||
import { ALERTS } from '../../../app/home/translations';
|
||||
|
||||
export type CasesColumns =
|
||||
| EuiTableFieldDataColumnType<Case>
|
||||
|
@ -54,10 +57,14 @@ export const getCasesColumns = (
|
|||
const columns = [
|
||||
{
|
||||
name: i18n.NAME,
|
||||
render: (theCase: Case) => {
|
||||
render: (theCase: Case | SubCase) => {
|
||||
if (theCase.id != null && theCase.title != null) {
|
||||
const caseDetailsLinkComponent = !isModal ? (
|
||||
<CaseDetailsLink detailName={theCase.id} title={theCase.title}>
|
||||
<CaseDetailsLink
|
||||
detailName={isSubCase(theCase) ? theCase.caseParentId : theCase.id}
|
||||
title={theCase.title}
|
||||
subCaseId={isSubCase(theCase) ? theCase.id : undefined}
|
||||
>
|
||||
{theCase.title}
|
||||
</CaseDetailsLink>
|
||||
) : (
|
||||
|
@ -122,7 +129,17 @@ export const getCasesColumns = (
|
|||
truncateText: true,
|
||||
},
|
||||
{
|
||||
align: 'right' as HorizontalAlignment,
|
||||
align: RIGHT_ALIGNMENT,
|
||||
field: 'totalAlerts',
|
||||
name: ALERTS,
|
||||
sortable: true,
|
||||
render: (totalAlerts: Case['totalAlerts']) =>
|
||||
totalAlerts != null
|
||||
? renderStringField(`${totalAlerts}`, `case-table-column-alertsCount`)
|
||||
: getEmptyTagValue(),
|
||||
},
|
||||
{
|
||||
align: RIGHT_ALIGNMENT,
|
||||
field: 'totalComment',
|
||||
name: i18n.COMMENTS,
|
||||
sortable: true,
|
||||
|
@ -183,6 +200,24 @@ export const getCasesColumns = (
|
|||
return getEmptyTagValue();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: i18n.STATUS,
|
||||
render: (theCase: Case) => {
|
||||
if (theCase?.subCases == null || theCase.subCases.length === 0) {
|
||||
if (theCase.status == null) {
|
||||
return getEmptyTagValue();
|
||||
}
|
||||
return <Status type={theCase.status} />;
|
||||
}
|
||||
|
||||
const badges = getSubCasesStatusCountsBadges(theCase.subCases);
|
||||
return badges.map(({ color, count }, index) => (
|
||||
<EuiBadge key={index} color={color}>
|
||||
{count}
|
||||
</EuiBadge>
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: i18n.ACTIONS,
|
||||
actions,
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* 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 { EuiBasicTable as _EuiBasicTable } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import { Case } from '../../containers/types';
|
||||
import { CasesColumns } from './columns';
|
||||
import { AssociationType } from '../../../../../case/common/api';
|
||||
|
||||
type ExpandedRowMap = Record<string, Element> | {};
|
||||
|
||||
const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const BasicTable = styled(EuiBasicTable)`
|
||||
thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tbody {
|
||||
.euiTableCellContent {
|
||||
padding: 8px !important;
|
||||
}
|
||||
.euiTableRowCell {
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
BasicTable.displayName = 'BasicTable';
|
||||
|
||||
export const getExpandedRowMap = ({
|
||||
data,
|
||||
columns,
|
||||
}: {
|
||||
data: Case[] | null;
|
||||
columns: CasesColumns[];
|
||||
}): ExpandedRowMap => {
|
||||
if (data == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return data.reduce((acc, curr) => {
|
||||
if (curr.subCases != null) {
|
||||
const subCases = curr.subCases.map((subCase, index) => ({
|
||||
...subCase,
|
||||
caseParentId: curr.id,
|
||||
title: `${curr.title} ${index + 1}`,
|
||||
associationType: AssociationType.subCase,
|
||||
}));
|
||||
return {
|
||||
...acc,
|
||||
[curr.id]: (
|
||||
<BasicTable
|
||||
columns={columns}
|
||||
data-test-subj={`sub-cases-table-${curr.id}`}
|
||||
itemId="id"
|
||||
items={subCases}
|
||||
/>
|
||||
),
|
||||
};
|
||||
} else {
|
||||
return acc;
|
||||
}
|
||||
}, {});
|
||||
};
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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 { filter } from 'lodash/fp';
|
||||
import { AssociationType, CaseStatuses } from '../../../../../case/common/api';
|
||||
import { Case, SubCase } from '../../containers/types';
|
||||
import { statuses } from '../status';
|
||||
|
||||
export const isSubCase = (theCase: Case | SubCase): theCase is SubCase =>
|
||||
(theCase as SubCase).caseParentId !== undefined &&
|
||||
(theCase as SubCase).associationType === AssociationType.subCase;
|
||||
|
||||
export const getSubCasesStatusCountsBadges = (
|
||||
subCases: SubCase[]
|
||||
): Array<{ name: CaseStatuses; color: string; count: number }> => {
|
||||
return [
|
||||
{
|
||||
name: CaseStatuses.open,
|
||||
color: statuses[CaseStatuses.open].color,
|
||||
count: filter({ status: CaseStatuses.open }, subCases).length,
|
||||
},
|
||||
{
|
||||
name: CaseStatuses['in-progress'],
|
||||
color: statuses[CaseStatuses['in-progress']].color,
|
||||
count: filter({ status: CaseStatuses['in-progress'] }, subCases).length,
|
||||
},
|
||||
{
|
||||
name: CaseStatuses.closed,
|
||||
color: statuses[CaseStatuses.closed].color,
|
||||
count: filter({ status: CaseStatuses.closed }, subCases).length,
|
||||
},
|
||||
];
|
||||
};
|
|
@ -210,9 +210,12 @@ describe('AllCases', () => {
|
|||
id: null,
|
||||
createdAt: null,
|
||||
createdBy: null,
|
||||
status: null,
|
||||
subCases: null,
|
||||
tags: null,
|
||||
title: null,
|
||||
totalComment: null,
|
||||
totalAlerts: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -542,6 +545,7 @@ describe('AllCases', () => {
|
|||
},
|
||||
id: '1',
|
||||
status: 'open',
|
||||
subCaseIds: [],
|
||||
tags: ['coke', 'pepsi'],
|
||||
title: 'Another horrible breach!!',
|
||||
totalAlerts: 0,
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
EuiBasicTable,
|
||||
EuiBasicTable as _EuiBasicTable,
|
||||
EuiContextMenuPanel,
|
||||
EuiEmptyPrompt,
|
||||
EuiFlexGroup,
|
||||
|
@ -53,6 +53,7 @@ import { SecurityPageName } from '../../../app/types';
|
|||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import { APP_ID } from '../../../../common/constants';
|
||||
import { Stats } from '../status';
|
||||
import { getExpandedRowMap } from './expanded_row';
|
||||
|
||||
const Div = styled.div`
|
||||
margin-top: ${({ theme }) => theme.eui.paddingSizes.m};
|
||||
|
@ -83,6 +84,14 @@ const getSortField = (field: string): SortFieldCase => {
|
|||
return SortFieldCase.createdAt;
|
||||
};
|
||||
|
||||
const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const BasicTable = styled(EuiBasicTable)`
|
||||
.euiTableRow-isExpandedRow.euiTableRow-isSelectable .euiTableCellContent {
|
||||
padding: 8px 0 8px 32px;
|
||||
}
|
||||
`;
|
||||
BasicTable.displayName = 'BasicTable';
|
||||
|
||||
interface AllCasesProps {
|
||||
onRowClick?: (theCase?: Case) => void;
|
||||
isModal?: boolean;
|
||||
|
@ -130,7 +139,7 @@ export const AllCases = React.memo<AllCasesProps>(
|
|||
isUpdated,
|
||||
updateBulkStatus,
|
||||
} = useUpdateCases();
|
||||
const [deleteThisCase, setDeleteThisCase] = useState({
|
||||
const [deleteThisCase, setDeleteThisCase] = useState<DeleteCase>({
|
||||
title: '',
|
||||
id: '',
|
||||
});
|
||||
|
@ -190,7 +199,7 @@ export const AllCases = React.memo<AllCasesProps>(
|
|||
const toggleDeleteModal = useCallback(
|
||||
(deleteCase: Case) => {
|
||||
handleToggleModal();
|
||||
setDeleteThisCase(deleteCase);
|
||||
setDeleteThisCase({ id: deleteCase.id, title: deleteCase.title, type: deleteCase.type });
|
||||
},
|
||||
[handleToggleModal]
|
||||
);
|
||||
|
@ -201,7 +210,11 @@ export const AllCases = React.memo<AllCasesProps>(
|
|||
if (caseIds.length === 1) {
|
||||
const singleCase = selectedCases.find((theCase) => theCase.id === caseIds[0]);
|
||||
if (singleCase) {
|
||||
return setDeleteThisCase({ id: singleCase.id, title: singleCase.title });
|
||||
return setDeleteThisCase({
|
||||
id: singleCase.id,
|
||||
title: singleCase.title,
|
||||
type: singleCase.type,
|
||||
});
|
||||
}
|
||||
}
|
||||
const convertToDeleteCases: DeleteCase[] = caseIds.map((id) => ({ id }));
|
||||
|
@ -315,6 +328,16 @@ export const AllCases = React.memo<AllCasesProps>(
|
|||
() => getCasesColumns(userCanCrud ? actions : [], filterOptions.status, isModal),
|
||||
[actions, filterOptions.status, userCanCrud, isModal]
|
||||
);
|
||||
|
||||
const itemIdToExpandedRowMap = useMemo(
|
||||
() =>
|
||||
getExpandedRowMap({
|
||||
columns: memoizedGetCasesColumns,
|
||||
data: data.cases,
|
||||
}),
|
||||
[data.cases, memoizedGetCasesColumns]
|
||||
);
|
||||
|
||||
const memoizedPagination = useMemo(
|
||||
() => ({
|
||||
pageIndex: queryParams.page - 1,
|
||||
|
@ -330,7 +353,10 @@ export const AllCases = React.memo<AllCasesProps>(
|
|||
};
|
||||
|
||||
const euiBasicTableSelectionProps = useMemo<EuiTableSelectionType<Case>>(
|
||||
() => ({ onSelectionChange: setSelectedCases }),
|
||||
() => ({
|
||||
selectable: (theCase) => isEmpty(theCase.subCases),
|
||||
onSelectionChange: setSelectedCases,
|
||||
}),
|
||||
[setSelectedCases]
|
||||
);
|
||||
const isCasesLoading = useMemo(
|
||||
|
@ -472,12 +498,13 @@ export const AllCases = React.memo<AllCasesProps>(
|
|||
)}
|
||||
</UtilityBarSection>
|
||||
</UtilityBar>
|
||||
<EuiBasicTable
|
||||
<BasicTable
|
||||
columns={memoizedGetCasesColumns}
|
||||
data-test-subj="cases-table"
|
||||
isSelectable={userCanCrud && !isModal}
|
||||
itemId="id"
|
||||
items={data.cases}
|
||||
itemIdToExpandedRowMap={itemIdToExpandedRowMap}
|
||||
noItemsMessage={
|
||||
<EuiEmptyPrompt
|
||||
title={<h3>{i18n.NO_CASES}</h3>}
|
||||
|
|
|
@ -103,3 +103,7 @@ export const SERVICENOW_LINK_ARIA = i18n.translate(
|
|||
defaultMessage: 'click to view the incident on servicenow',
|
||||
}
|
||||
);
|
||||
|
||||
export const STATUS = i18n.translate('xpack.securitySolution.case.caseTable.status', {
|
||||
defaultMessage: 'Status',
|
||||
});
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
import { AssociationType, CommentType } from '../../../../../case/common/api';
|
||||
import { Comment } from '../../containers/types';
|
||||
|
||||
import { getRuleIdsFromComments, buildAlertsQuery } from './helpers';
|
||||
import { getManualAlertIdsWithNoRuleId, buildAlertsQuery } from './helpers';
|
||||
|
||||
const comments: Comment[] = [
|
||||
{
|
||||
|
@ -19,6 +19,10 @@ const comments: Comment[] = [
|
|||
id: 'comment-id',
|
||||
createdAt: '2020-02-19T23:06:33.798Z',
|
||||
createdBy: { username: 'elastic' },
|
||||
rule: {
|
||||
id: null,
|
||||
name: null,
|
||||
},
|
||||
pushedAt: null,
|
||||
pushedBy: null,
|
||||
updatedAt: null,
|
||||
|
@ -35,6 +39,10 @@ const comments: Comment[] = [
|
|||
createdBy: { username: 'elastic' },
|
||||
pushedAt: null,
|
||||
pushedBy: null,
|
||||
rule: {
|
||||
id: 'rule-id-2',
|
||||
name: 'rule-name-2',
|
||||
},
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
version: 'WzQ3LDFc',
|
||||
|
@ -42,9 +50,9 @@ const comments: Comment[] = [
|
|||
];
|
||||
|
||||
describe('Case view helpers', () => {
|
||||
describe('getRuleIdsFromComments', () => {
|
||||
it('it returns the rules ids from the comments', () => {
|
||||
expect(getRuleIdsFromComments(comments)).toEqual(['alert-id-1', 'alert-id-2']);
|
||||
describe('getAlertIdsFromComments', () => {
|
||||
it('it returns the alert id from the comments where rule is not defined', () => {
|
||||
expect(getManualAlertIdsWithNoRuleId(comments)).toEqual(['alert-id-1']);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -54,13 +62,13 @@ describe('Case view helpers', () => {
|
|||
query: {
|
||||
bool: {
|
||||
filter: {
|
||||
bool: {
|
||||
should: [{ match: { _id: 'alert-id-1' } }, { match: { _id: 'alert-id-2' } }],
|
||||
minimum_should_match: 1,
|
||||
ids: {
|
||||
values: ['alert-id-1', 'alert-id-2'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
size: 10000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,28 +5,37 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { isEmpty } from 'lodash';
|
||||
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) {
|
||||
export const getManualAlertIdsWithNoRuleId = (comments: Comment[]): string[] => {
|
||||
const dedupeAlerts = comments.reduce((alertIds, comment: Comment) => {
|
||||
if (comment.type === CommentType.alert && isEmpty(comment.rule.id)) {
|
||||
const ids = Array.isArray(comment.alertId) ? comment.alertId : [comment.alertId];
|
||||
return [...ruleIds, ...ids];
|
||||
ids.forEach((id) => alertIds.add(id));
|
||||
return alertIds;
|
||||
}
|
||||
return alertIds;
|
||||
}, new Set<string>());
|
||||
return [...dedupeAlerts];
|
||||
};
|
||||
|
||||
return ruleIds;
|
||||
}, []);
|
||||
|
||||
export const buildAlertsQuery = (ruleIds: string[]) => ({
|
||||
query: {
|
||||
bool: {
|
||||
filter: {
|
||||
bool: {
|
||||
should: ruleIds.map((_id) => ({ match: { _id } })),
|
||||
minimum_should_match: 1,
|
||||
// TODO we need to allow -> docValueFields: [{ field: "@timestamp" }],
|
||||
export const buildAlertsQuery = (alertIds: string[]) => {
|
||||
if (alertIds.length === 0) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
query: {
|
||||
bool: {
|
||||
filter: {
|
||||
ids: {
|
||||
values: alertIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
size: 10000,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -629,6 +629,14 @@ describe('CaseView ', () => {
|
|||
loading: true,
|
||||
data: { hits: { hits: [] } },
|
||||
}));
|
||||
useGetCaseUserActionsMock.mockReturnValue({
|
||||
caseServices: {},
|
||||
caseUserActions: [],
|
||||
hasDataToPush: false,
|
||||
isError: false,
|
||||
isLoading: true,
|
||||
participants: [],
|
||||
});
|
||||
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
|
|
|
@ -42,8 +42,6 @@ import {
|
|||
normalizeActionConnector,
|
||||
getNoneConnector,
|
||||
} from '../configure_cases/utils';
|
||||
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
|
||||
import { buildAlertsQuery, getRuleIdsFromComments } from './helpers';
|
||||
import { DetailsPanel } from '../../../timelines/components/side_panel';
|
||||
import { useSourcererScope } from '../../../common/containers/sourcerer';
|
||||
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
|
||||
|
@ -55,6 +53,7 @@ import * as i18n from './translations';
|
|||
|
||||
interface Props {
|
||||
caseId: string;
|
||||
subCaseId?: string;
|
||||
userCanCrud: boolean;
|
||||
}
|
||||
|
||||
|
@ -87,32 +86,8 @@ export interface CaseProps extends Props {
|
|||
updateCase: (newCase: Case) => void;
|
||||
}
|
||||
|
||||
interface Signal {
|
||||
rule: {
|
||||
id: string;
|
||||
name: string;
|
||||
to: string;
|
||||
from: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SignalHit {
|
||||
_id: string;
|
||||
_index: string;
|
||||
_source: {
|
||||
'@timestamp': string;
|
||||
signal: Signal;
|
||||
};
|
||||
}
|
||||
|
||||
export type Alert = {
|
||||
_id: string;
|
||||
_index: string;
|
||||
'@timestamp': string;
|
||||
} & Signal;
|
||||
|
||||
export const CaseComponent = React.memo<CaseProps>(
|
||||
({ caseId, caseData, fetchCase, updateCase, userCanCrud }) => {
|
||||
({ caseId, caseData, fetchCase, subCaseId, updateCase, userCanCrud }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { formatUrl, search } = useFormatUrl(SecurityPageName.case);
|
||||
const allCasesLink = getCaseUrl(search);
|
||||
|
@ -127,45 +102,18 @@ export const CaseComponent = React.memo<CaseProps>(
|
|||
hasDataToPush,
|
||||
isLoading: isLoadingUserActions,
|
||||
participants,
|
||||
} = useGetCaseUserActions(caseId, caseData.connector.id);
|
||||
} = useGetCaseUserActions(caseId, caseData.connector.id, subCaseId);
|
||||
|
||||
const { isLoading, updateKey, updateCaseProperty } = useUpdateCase({
|
||||
caseId,
|
||||
subCaseId,
|
||||
});
|
||||
|
||||
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,
|
||||
'@timestamp': _source['@timestamp'],
|
||||
..._source.signal,
|
||||
},
|
||||
}),
|
||||
{}
|
||||
) ?? {},
|
||||
[alertsData?.hits.hits]
|
||||
);
|
||||
const { browserFields, docValueFields } = useSourcererScope(SourcererScopeName.detections);
|
||||
|
||||
// Update Fields
|
||||
const onUpdateField = useCallback(
|
||||
|
@ -350,10 +298,10 @@ export const CaseComponent = React.memo<CaseProps>(
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (initLoadingData && !isLoadingUserActions && !isLoadingAlerts) {
|
||||
if (initLoadingData && !isLoadingUserActions) {
|
||||
setInitLoadingData(false);
|
||||
}
|
||||
}, [initLoadingData, isLoadingAlerts, isLoadingUserActions]);
|
||||
}, [initLoadingData, isLoadingUserActions]);
|
||||
|
||||
const backOptions = useMemo(
|
||||
() => ({
|
||||
|
@ -435,18 +383,17 @@ export const CaseComponent = React.memo<CaseProps>(
|
|||
{!initLoadingData && (
|
||||
<>
|
||||
<UserActionTree
|
||||
caseServices={caseServices}
|
||||
caseUserActions={caseUserActions}
|
||||
connectors={connectors}
|
||||
data={caseData}
|
||||
fetchUserActions={fetchCaseUserActions.bind(null, caseData.id)}
|
||||
caseServices={caseServices}
|
||||
isLoadingDescription={isLoading && updateKey === 'description'}
|
||||
isLoadingUserActions={isLoadingUserActions}
|
||||
onShowAlertDetails={showAlert}
|
||||
onUpdateField={onUpdateField}
|
||||
updateCase={updateCase}
|
||||
userCanCrud={userCanCrud}
|
||||
alerts={alerts}
|
||||
onShowAlertDetails={showAlert}
|
||||
/>
|
||||
<MyEuiHorizontalRule margin="s" />
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s" justifyContent="flexEnd">
|
||||
|
@ -513,8 +460,8 @@ export const CaseComponent = React.memo<CaseProps>(
|
|||
}
|
||||
);
|
||||
|
||||
export const CaseView = React.memo(({ caseId, userCanCrud }: Props) => {
|
||||
const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId);
|
||||
export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) => {
|
||||
const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId, subCaseId);
|
||||
if (isError) {
|
||||
return null;
|
||||
}
|
||||
|
@ -531,6 +478,7 @@ export const CaseView = React.memo(({ caseId, userCanCrud }: Props) => {
|
|||
return (
|
||||
<CaseComponent
|
||||
caseId={caseId}
|
||||
subCaseId={subCaseId}
|
||||
fetchCase={fetchCase}
|
||||
caseData={data}
|
||||
updateCase={updateCase}
|
||||
|
|
|
@ -10,7 +10,7 @@ import { EuiConfirmModal } from '@elastic/eui';
|
|||
import * as i18n from './translations';
|
||||
|
||||
interface ConfirmDeleteCaseModalProps {
|
||||
caseTitle: string;
|
||||
caseTitle?: string;
|
||||
isModalVisible: boolean;
|
||||
isPlural: boolean;
|
||||
onCancel: () => void;
|
||||
|
@ -36,7 +36,13 @@ const ConfirmDeleteCaseModalComp: React.FC<ConfirmDeleteCaseModalProps> = ({
|
|||
defaultFocusedButton="confirm"
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
title={isPlural ? i18n.DELETE_SELECTED_CASES : i18n.DELETE_TITLE(caseTitle)}
|
||||
title={
|
||||
isPlural
|
||||
? i18n.DELETE_SELECTED_CASES
|
||||
: caseTitle == null
|
||||
? i18n.DELETE_THIS_CASE
|
||||
: i18n.DELETE_TITLE(caseTitle)
|
||||
}
|
||||
>
|
||||
{isPlural ? i18n.CONFIRM_QUESTION_PLURAL : i18n.CONFIRM_QUESTION}
|
||||
</EuiConfirmModal>
|
||||
|
|
|
@ -14,6 +14,11 @@ export const DELETE_TITLE = (caseTitle: string) =>
|
|||
defaultMessage: 'Delete "{caseTitle}"',
|
||||
});
|
||||
|
||||
export const DELETE_THIS_CASE = (caseTitle: string) =>
|
||||
i18n.translate('xpack.securitySolution.case.confirmDeleteCase.deleteThisCase', {
|
||||
defaultMessage: 'Delete this case',
|
||||
});
|
||||
|
||||
export const CONFIRM_QUESTION = i18n.translate(
|
||||
'xpack.securitySolution.case.confirmDeleteCase.confirmQuestion',
|
||||
{
|
||||
|
|
|
@ -27,9 +27,7 @@ const Container = styled.div`
|
|||
|
||||
const defaultAlertComment = {
|
||||
type: CommentType.generatedAlert,
|
||||
alerts: '{{context.alerts}}',
|
||||
index: '{{context.rule.output_index}}',
|
||||
ruleId: '{{context.rule.id}}',
|
||||
alerts: `[{{#context.alerts}}{"_id": "{{_id}}", "_index": "{{_index}}", "ruleId": "{{rule.id}}", "ruleName": "{{rule.name}}"}__SEPARATOR__{{/context.alerts}}]`,
|
||||
};
|
||||
|
||||
const CaseParamsFields: React.FunctionComponent<ActionParamsProps<CaseActionParams>> = ({
|
||||
|
|
|
@ -5,10 +5,22 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo, useCallback } from 'react';
|
||||
import { useGetCases } from '../../../containers/use_get_cases';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonIcon,
|
||||
EuiCallOut,
|
||||
EuiTextColor,
|
||||
EuiLoadingSpinner,
|
||||
} from '@elastic/eui';
|
||||
import { isEmpty } from 'lodash';
|
||||
import React, { memo, useEffect, useCallback, useState } from 'react';
|
||||
import { CaseType } from '../../../../../../case/common/api';
|
||||
import { Case } from '../../../containers/types';
|
||||
import { useDeleteCases } from '../../../containers/use_delete_cases';
|
||||
import { useGetCase } from '../../../containers/use_get_case';
|
||||
import { ConfirmDeleteCaseModal } from '../../confirm_delete_case';
|
||||
import { useCreateCaseModal } from '../../use_create_case_modal';
|
||||
import { CasesDropdown, ADD_CASE_BUTTON_ID } from './cases_dropdown';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface ExistingCaseProps {
|
||||
selectedCase: string | null;
|
||||
|
@ -16,37 +28,72 @@ interface ExistingCaseProps {
|
|||
}
|
||||
|
||||
const ExistingCaseComponent: React.FC<ExistingCaseProps> = ({ onCaseChanged, selectedCase }) => {
|
||||
const { data: cases, loading: isLoadingCases, refetchCases } = useGetCases();
|
||||
const { data, isLoading, isError } = useGetCase(selectedCase ?? '');
|
||||
const [createdCase, setCreatedCase] = useState<Case | null>(null);
|
||||
|
||||
const onCaseCreated = useCallback(() => refetchCases(), [refetchCases]);
|
||||
|
||||
const { modal, openModal } = useCreateCaseModal({ onCaseCreated });
|
||||
|
||||
const onChange = useCallback(
|
||||
(id: string) => {
|
||||
if (id === ADD_CASE_BUTTON_ID) {
|
||||
openModal();
|
||||
return;
|
||||
}
|
||||
|
||||
onCaseChanged(id);
|
||||
const onCaseCreated = useCallback(
|
||||
(newCase: Case) => {
|
||||
onCaseChanged(newCase.id);
|
||||
setCreatedCase(newCase);
|
||||
},
|
||||
[onCaseChanged, openModal]
|
||||
[onCaseChanged]
|
||||
);
|
||||
|
||||
const isCasesLoading = useMemo(
|
||||
() => isLoadingCases.includes('cases') || isLoadingCases.includes('caseUpdate'),
|
||||
[isLoadingCases]
|
||||
);
|
||||
const { modal, openModal } = useCreateCaseModal({ caseType: CaseType.collection, onCaseCreated });
|
||||
|
||||
// Delete case
|
||||
const {
|
||||
dispatchResetIsDeleted,
|
||||
handleOnDeleteConfirm,
|
||||
handleToggleModal,
|
||||
isLoading: isDeleting,
|
||||
isDeleted,
|
||||
isDisplayConfirmDeleteModal,
|
||||
} = useDeleteCases();
|
||||
|
||||
useEffect(() => {
|
||||
if (isDeleted) {
|
||||
setCreatedCase(null);
|
||||
onCaseChanged('');
|
||||
dispatchResetIsDeleted();
|
||||
}
|
||||
}, [isDeleted, dispatchResetIsDeleted, onCaseChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isError && data != null) {
|
||||
setCreatedCase(data);
|
||||
onCaseChanged(data.id);
|
||||
}
|
||||
}, [data, isLoading, isError, onCaseChanged]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CasesDropdown
|
||||
isLoading={isCasesLoading}
|
||||
cases={cases.cases}
|
||||
selectedCase={selectedCase ?? undefined}
|
||||
onCaseChanged={onChange}
|
||||
/>
|
||||
{createdCase == null && isEmpty(selectedCase) && (
|
||||
<EuiButton fill fullWidth onClick={openModal}>
|
||||
{i18n.CREATE_CASE}
|
||||
</EuiButton>
|
||||
)}
|
||||
{createdCase == null && isLoading && <EuiLoadingSpinner size="m" />}
|
||||
{createdCase != null && !isLoading && (
|
||||
<>
|
||||
<EuiCallOut title={i18n.CONNECTED_CASE} color="success">
|
||||
<EuiTextColor color="default">
|
||||
{createdCase.title}{' '}
|
||||
{!isDeleting && (
|
||||
<EuiButtonIcon color="danger" onClick={handleToggleModal} iconType="trash" />
|
||||
)}
|
||||
{isDeleting && <EuiLoadingSpinner size="m" />}
|
||||
</EuiTextColor>
|
||||
</EuiCallOut>
|
||||
<ConfirmDeleteCaseModal
|
||||
caseTitle={createdCase.title}
|
||||
isModalVisible={isDisplayConfirmDeleteModal}
|
||||
isPlural={false}
|
||||
onCancel={handleToggleModal}
|
||||
onConfirm={handleOnDeleteConfirm.bind(null, [createdCase])}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -85,3 +85,17 @@ export const CASE_CONNECTOR_ADD_NEW_CASE = i18n.translate(
|
|||
defaultMessage: 'Add new case',
|
||||
}
|
||||
);
|
||||
|
||||
export const CREATE_CASE = i18n.translate(
|
||||
'xpack.securitySolution.case.components.connectors.case.createCaseLabel',
|
||||
{
|
||||
defaultMessage: 'Create case',
|
||||
}
|
||||
);
|
||||
|
||||
export const CONNECTED_CASE = i18n.translate(
|
||||
'xpack.securitySolution.case.components.connectors.case.connectedCaseLabel',
|
||||
{
|
||||
defaultMessage: 'Connected case',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -19,6 +19,7 @@ import { usePostPushToService } from '../../containers/use_post_push_to_service'
|
|||
import { useConnectors } from '../../containers/configure/use_connectors';
|
||||
import { useCaseConfigure } from '../../containers/configure/use_configure';
|
||||
import { Case } from '../../containers/types';
|
||||
import { CaseType } from '../../../../../case/common/api';
|
||||
|
||||
const initialCaseValue: FormProps = {
|
||||
description: '',
|
||||
|
@ -30,10 +31,15 @@ const initialCaseValue: FormProps = {
|
|||
};
|
||||
|
||||
interface Props {
|
||||
caseType?: CaseType;
|
||||
onSuccess?: (theCase: Case) => void;
|
||||
}
|
||||
|
||||
export const FormContext: React.FC<Props> = ({ children, onSuccess }) => {
|
||||
export const FormContext: React.FC<Props> = ({
|
||||
caseType = CaseType.individual,
|
||||
children,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { connectors } = useConnectors();
|
||||
const { connector: configurationConnector } = useCaseConfigure();
|
||||
const { postCase } = usePostCase();
|
||||
|
@ -61,6 +67,7 @@ export const FormContext: React.FC<Props> = ({ children, onSuccess }) => {
|
|||
|
||||
const updatedCase = await postCase({
|
||||
...dataWithoutConnectorId,
|
||||
type: caseType,
|
||||
connector: connectorToUpdate,
|
||||
settings: { syncAlerts },
|
||||
});
|
||||
|
@ -77,7 +84,7 @@ export const FormContext: React.FC<Props> = ({ children, onSuccess }) => {
|
|||
}
|
||||
}
|
||||
},
|
||||
[connectors, postCase, onSuccess, pushCaseToExternalService]
|
||||
[caseType, connectors, postCase, onSuccess, pushCaseToExternalService]
|
||||
);
|
||||
|
||||
const { form } = useForm<FormProps>({
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { CasePostRequest } from '../../../../../case/common/api';
|
||||
import { CasePostRequest, CaseType } from '../../../../../case/common/api';
|
||||
import { ConnectorTypes } from '../../../../../case/common/api/connectors';
|
||||
import { choices } from '../connectors/mock';
|
||||
|
||||
|
@ -14,6 +14,7 @@ export const sampleData: CasePostRequest = {
|
|||
description: 'what a great description',
|
||||
tags: sampleTags,
|
||||
title: 'what a cool title',
|
||||
type: CaseType.individual,
|
||||
connector: {
|
||||
fields: null,
|
||||
id: 'none',
|
||||
|
|
|
@ -163,10 +163,14 @@ describe('AddToCaseAction', () => {
|
|||
|
||||
wrapper.find(`[data-test-subj="form-context-on-success"]`).first().simulate('click');
|
||||
|
||||
expect(postComment.mock.calls[0][0]).toBe('new-case');
|
||||
expect(postComment.mock.calls[0][1]).toEqual({
|
||||
expect(postComment.mock.calls[0][0].caseId).toBe('new-case');
|
||||
expect(postComment.mock.calls[0][0].data).toEqual({
|
||||
alertId: 'test-id',
|
||||
index: 'test-index',
|
||||
rule: {
|
||||
id: null,
|
||||
name: null,
|
||||
},
|
||||
type: 'alert',
|
||||
});
|
||||
});
|
||||
|
@ -196,10 +200,14 @@ describe('AddToCaseAction', () => {
|
|||
|
||||
wrapper.find(`[data-test-subj="all-cases-modal-button"]`).first().simulate('click');
|
||||
|
||||
expect(postComment.mock.calls[0][0]).toBe('selected-case');
|
||||
expect(postComment.mock.calls[0][1]).toEqual({
|
||||
expect(postComment.mock.calls[0][0].caseId).toBe('selected-case');
|
||||
expect(postComment.mock.calls[0][0].data).toEqual({
|
||||
alertId: 'test-id',
|
||||
index: 'test-index',
|
||||
rule: {
|
||||
id: null,
|
||||
name: null,
|
||||
},
|
||||
type: 'alert',
|
||||
});
|
||||
});
|
||||
|
@ -208,7 +216,7 @@ describe('AddToCaseAction', () => {
|
|||
usePostCommentMock.mockImplementation(() => {
|
||||
return {
|
||||
...defaultPostComment,
|
||||
postComment: jest.fn().mockImplementation((caseId, data, updateCase) => updateCase()),
|
||||
postComment: jest.fn().mockImplementation(({ caseId, data, updateCase }) => updateCase()),
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
@ -44,6 +44,7 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
|
|||
}) => {
|
||||
const eventId = ecsRowData._id;
|
||||
const eventIndex = ecsRowData._index;
|
||||
const rule = ecsRowData.signal?.rule;
|
||||
|
||||
const { navigateToApp } = useKibana().services.application;
|
||||
const [, dispatchToaster] = useStateToaster();
|
||||
|
@ -71,21 +72,25 @@ const AddToCaseActionComponent: React.FC<AddToCaseActionProps> = ({
|
|||
const attachAlertToCase = useCallback(
|
||||
(theCase: Case) => {
|
||||
closeCaseFlyoutOpen();
|
||||
postComment(
|
||||
theCase.id,
|
||||
{
|
||||
postComment({
|
||||
caseId: theCase.id,
|
||||
data: {
|
||||
type: CommentType.alert,
|
||||
alertId: eventId,
|
||||
index: eventIndex ?? '',
|
||||
rule: {
|
||||
id: rule?.id != null ? rule.id[0] : null,
|
||||
name: rule?.name != null ? rule.name[0] : null,
|
||||
},
|
||||
},
|
||||
() =>
|
||||
updateCase: () =>
|
||||
dispatchToaster({
|
||||
type: 'addToaster',
|
||||
toast: createUpdateSuccessToaster(theCase, onViewCaseClick),
|
||||
})
|
||||
);
|
||||
}),
|
||||
});
|
||||
},
|
||||
[closeCaseFlyoutOpen, postComment, eventId, eventIndex, dispatchToaster, onViewCaseClick]
|
||||
[closeCaseFlyoutOpen, postComment, eventId, eventIndex, rule, dispatchToaster, onViewCaseClick]
|
||||
);
|
||||
|
||||
const onCaseClicked = useCallback(
|
||||
|
|
|
@ -14,11 +14,13 @@ import { CreateCaseForm } from '../create/form';
|
|||
import { SubmitCaseButton } from '../create/submit_button';
|
||||
import { Case } from '../../containers/types';
|
||||
import * as i18n from '../../translations';
|
||||
import { CaseType } from '../../../../../case/common/api';
|
||||
|
||||
export interface CreateCaseModalProps {
|
||||
isModalOpen: boolean;
|
||||
onCloseCaseModal: () => void;
|
||||
onSuccess: (theCase: Case) => void;
|
||||
caseType?: CaseType;
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
|
@ -32,6 +34,7 @@ const CreateModalComponent: React.FC<CreateCaseModalProps> = ({
|
|||
isModalOpen,
|
||||
onCloseCaseModal,
|
||||
onSuccess,
|
||||
caseType = CaseType.individual,
|
||||
}) => {
|
||||
return isModalOpen ? (
|
||||
<EuiModal onClose={onCloseCaseModal} data-test-subj="all-cases-modal">
|
||||
|
@ -39,7 +42,7 @@ const CreateModalComponent: React.FC<CreateCaseModalProps> = ({
|
|||
<EuiModalHeaderTitle>{i18n.CREATE_TITLE}</EuiModalHeaderTitle>
|
||||
</EuiModalHeader>
|
||||
<EuiModalBody>
|
||||
<FormContext onSuccess={onSuccess}>
|
||||
<FormContext caseType={caseType} onSuccess={onSuccess}>
|
||||
<CreateCaseForm withSteps={false} />
|
||||
<Container>
|
||||
<SubmitCaseButton />
|
||||
|
|
|
@ -6,11 +6,13 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { CaseType } from '../../../../../case/common/api';
|
||||
import { Case } from '../../containers/types';
|
||||
import { CreateCaseModal } from './create_case_modal';
|
||||
|
||||
export interface UseCreateCaseModalProps {
|
||||
onCaseCreated: (theCase: Case) => void;
|
||||
caseType?: CaseType;
|
||||
}
|
||||
export interface UseCreateCaseModalReturnedValues {
|
||||
modal: JSX.Element;
|
||||
|
@ -19,7 +21,10 @@ export interface UseCreateCaseModalReturnedValues {
|
|||
openModal: () => void;
|
||||
}
|
||||
|
||||
export const useCreateCaseModal = ({ onCaseCreated }: UseCreateCaseModalProps) => {
|
||||
export const useCreateCaseModal = ({
|
||||
caseType = CaseType.individual,
|
||||
onCaseCreated,
|
||||
}: UseCreateCaseModalProps) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const closeModal = useCallback(() => setIsModalOpen(false), []);
|
||||
const openModal = useCallback(() => setIsModalOpen(true), []);
|
||||
|
@ -35,6 +40,7 @@ export const useCreateCaseModal = ({ onCaseCreated }: UseCreateCaseModalProps) =
|
|||
() => ({
|
||||
modal: (
|
||||
<CreateCaseModal
|
||||
caseType={caseType}
|
||||
isModalOpen={isModalOpen}
|
||||
onCloseCaseModal={closeModal}
|
||||
onSuccess={onSuccess}
|
||||
|
@ -44,7 +50,7 @@ export const useCreateCaseModal = ({ onCaseCreated }: UseCreateCaseModalProps) =
|
|||
closeModal,
|
||||
openModal,
|
||||
}),
|
||||
[isModalOpen, closeModal, onSuccess, openModal]
|
||||
[caseType, closeModal, isModalOpen, onSuccess, openModal]
|
||||
);
|
||||
|
||||
return state;
|
||||
|
|
|
@ -5,13 +5,16 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiCommentProps, EuiIconTip } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiLink, EuiCommentProps } from '@elastic/eui';
|
||||
import { isObject, get, isString, isNumber, isEmpty } from 'lodash';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { SearchResponse } from 'elasticsearch';
|
||||
import {
|
||||
CaseFullExternalService,
|
||||
ActionConnector,
|
||||
CaseStatuses,
|
||||
CommentType,
|
||||
} from '../../../../../case/common/api';
|
||||
import { CaseUserActions } from '../../containers/types';
|
||||
import { CaseServices } from '../../containers/use_get_case_user_actions';
|
||||
|
@ -24,8 +27,16 @@ import { UserActionMoveToReference } from './user_action_move_to_reference';
|
|||
import { Status, statuses } from '../status';
|
||||
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';
|
||||
import { InvestigateInTimelineAction } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_timeline_action';
|
||||
import { Ecs } from '../../../../common/ecs';
|
||||
import { TimelineNonEcsData } from '../../../../common/search_strategy';
|
||||
import { useSourcererScope } from '../../../common/containers/sourcerer';
|
||||
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
|
||||
import { buildAlertsQuery } from '../case_view/helpers';
|
||||
import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query';
|
||||
import { KibanaServices } from '../../../common/lib/kibana';
|
||||
import { DETECTION_ENGINE_QUERY_SIGNALS_URL } from '../../../../common/constants';
|
||||
|
||||
interface LabelTitle {
|
||||
action: CaseUserActions;
|
||||
|
@ -194,14 +205,22 @@ export const getUpdateAction = ({
|
|||
),
|
||||
});
|
||||
|
||||
export const getAlertComment = ({
|
||||
export const getAlertAttachment = ({
|
||||
action,
|
||||
alert,
|
||||
alertId,
|
||||
index,
|
||||
loadingAlertData,
|
||||
ruleId,
|
||||
ruleName,
|
||||
onShowAlertDetails,
|
||||
}: {
|
||||
action: CaseUserActions;
|
||||
alert: Alert | undefined;
|
||||
onShowAlertDetails: (alertId: string, index: string) => void;
|
||||
alertId: string;
|
||||
index: string;
|
||||
loadingAlertData: boolean;
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
}): EuiCommentProps => {
|
||||
return {
|
||||
username: (
|
||||
|
@ -212,7 +231,15 @@ export const getAlertComment = ({
|
|||
),
|
||||
className: 'comment-alert',
|
||||
type: 'update',
|
||||
event: <AlertCommentEvent alert={alert} />,
|
||||
event: (
|
||||
<AlertCommentEvent
|
||||
alertId={alertId}
|
||||
loadingAlertData={loadingAlertData}
|
||||
ruleId={ruleId}
|
||||
ruleName={ruleName}
|
||||
commentType={CommentType.alert}
|
||||
/>
|
||||
),
|
||||
'data-test-subj': `${action.actionField[0]}-${action.action}-action-${action.actionId}`,
|
||||
timestamp: <UserActionTimestamp createdAt={action.actionAt} />,
|
||||
timelineIcon: 'bell',
|
||||
|
@ -222,23 +249,188 @@ export const getAlertComment = ({
|
|||
<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}
|
||||
/>
|
||||
)}
|
||||
<UserActionShowAlert
|
||||
id={action.actionId}
|
||||
alertId={alertId}
|
||||
index={index}
|
||||
onShowAlertDetails={onShowAlertDetails}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const toStringArray = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.reduce<string[]>((acc, v) => {
|
||||
if (v != null) {
|
||||
switch (typeof v) {
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
return [...acc, v.toString()];
|
||||
case 'object':
|
||||
try {
|
||||
return [...acc, JSON.stringify(v)];
|
||||
} catch {
|
||||
return [...acc, 'Invalid Object'];
|
||||
}
|
||||
case 'string':
|
||||
return [...acc, v];
|
||||
default:
|
||||
return [...acc, `${v}`];
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
} else if (value == null) {
|
||||
return [];
|
||||
} else if (!Array.isArray(value) && typeof value === 'object') {
|
||||
try {
|
||||
return [JSON.stringify(value)];
|
||||
} catch {
|
||||
return ['Invalid Object'];
|
||||
}
|
||||
} else {
|
||||
return [`${value}`];
|
||||
}
|
||||
};
|
||||
|
||||
export const formatAlertToEcsSignal = (alert: {}): Ecs =>
|
||||
Object.keys(alert).reduce<Ecs>((accumulator, key) => {
|
||||
const item = get(alert, key);
|
||||
if (item != null && isObject(item)) {
|
||||
return { ...accumulator, [key]: formatAlertToEcsSignal(item) };
|
||||
} else if (Array.isArray(item) || isString(item) || isNumber(item)) {
|
||||
return { ...accumulator, [key]: toStringArray(item) };
|
||||
}
|
||||
return accumulator;
|
||||
}, {} as Ecs);
|
||||
|
||||
const EMPTY_ARRAY: TimelineNonEcsData[] = [];
|
||||
export const getGeneratedAlertsAttachment = ({
|
||||
action,
|
||||
alertIds,
|
||||
ruleId,
|
||||
ruleName,
|
||||
}: {
|
||||
action: CaseUserActions;
|
||||
alertIds: string[];
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
}): EuiCommentProps => {
|
||||
const fetchEcsAlertsData = async (fetchAlertIds?: string[]): Promise<Ecs[]> => {
|
||||
if (isEmpty(fetchAlertIds)) {
|
||||
return [];
|
||||
}
|
||||
const alertResponse = await KibanaServices.get().http.fetch<
|
||||
SearchResponse<{ '@timestamp': string; [key: string]: unknown }>
|
||||
>(DETECTION_ENGINE_QUERY_SIGNALS_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(buildAlertsQuery(fetchAlertIds ?? [])),
|
||||
});
|
||||
return (
|
||||
alertResponse?.hits.hits.reduce<Ecs[]>(
|
||||
(acc, { _id, _index, _source }) => [
|
||||
...acc,
|
||||
{
|
||||
...formatAlertToEcsSignal(_source as {}),
|
||||
_id,
|
||||
_index,
|
||||
timestamp: _source['@timestamp'],
|
||||
},
|
||||
],
|
||||
[]
|
||||
) ?? []
|
||||
);
|
||||
};
|
||||
return {
|
||||
username: <EuiIcon type="logoSecurity" size="m" />,
|
||||
className: 'comment-alert',
|
||||
type: 'update',
|
||||
event: (
|
||||
<AlertCommentEvent
|
||||
alertId={alertIds[0]}
|
||||
ruleId={ruleId}
|
||||
ruleName={ruleName}
|
||||
alertsCount={alertIds.length}
|
||||
commentType={CommentType.generatedAlert}
|
||||
/>
|
||||
),
|
||||
'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>
|
||||
<InvestigateInTimelineAction
|
||||
ariaLabel={i18n.SEND_ALERT_TO_TIMELINE}
|
||||
alertIds={alertIds}
|
||||
key="investigate-in-timeline"
|
||||
ecsRowData={null}
|
||||
fetchEcsAlertsData={fetchEcsAlertsData}
|
||||
nonEcsRowData={EMPTY_ARRAY}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
interface Signal {
|
||||
rule: {
|
||||
id: string;
|
||||
name: string;
|
||||
to: string;
|
||||
from: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SignalHit {
|
||||
_id: string;
|
||||
_index: string;
|
||||
_source: {
|
||||
'@timestamp': string;
|
||||
signal: Signal;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
_id: string;
|
||||
_index: string;
|
||||
'@timestamp': string;
|
||||
signal: Signal;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const useFetchAlertData = (alertIds: string[]): [boolean, Record<string, Ecs>] => {
|
||||
const { selectedPatterns } = useSourcererScope(SourcererScopeName.detections);
|
||||
const alertsQuery = useMemo(() => buildAlertsQuery(alertIds), [alertIds]);
|
||||
|
||||
const { loading: isLoadingAlerts, data: alertsData } = useQueryAlerts<SignalHit, unknown>(
|
||||
alertsQuery,
|
||||
selectedPatterns[0]
|
||||
);
|
||||
|
||||
const alerts = useMemo(
|
||||
() =>
|
||||
alertsData?.hits.hits.reduce<Record<string, Ecs>>(
|
||||
(acc, { _id, _index, _source }) => ({
|
||||
...acc,
|
||||
[_id]: {
|
||||
...formatAlertToEcsSignal(_source),
|
||||
_id,
|
||||
_index,
|
||||
timestamp: _source['@timestamp'],
|
||||
},
|
||||
}),
|
||||
{}
|
||||
) ?? {},
|
||||
[alertsData?.hits.hits]
|
||||
);
|
||||
|
||||
return [isLoadingAlerts, alerts];
|
||||
};
|
||||
|
|
|
@ -5,8 +5,6 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import {
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
|
@ -14,9 +12,12 @@ import {
|
|||
EuiCommentList,
|
||||
EuiCommentProps,
|
||||
} from '@elastic/eui';
|
||||
import classNames from 'classnames';
|
||||
import { isEmpty } from 'lodash';
|
||||
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
import { isRight } from 'fp-ts/Either';
|
||||
|
||||
import * as i18n from './translations';
|
||||
|
||||
|
@ -24,23 +25,31 @@ 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, CommentType } from '../../../../../case/common/api';
|
||||
import {
|
||||
ActionConnector,
|
||||
AlertCommentRequestRt,
|
||||
CommentType,
|
||||
ContextTypeUserRt,
|
||||
} from '../../../../../case/common/api';
|
||||
import { CaseServices } from '../../containers/use_get_case_user_actions';
|
||||
import { parseString } from '../../containers/utils';
|
||||
import { Alert, OnUpdateFields } from '../case_view';
|
||||
import { OnUpdateFields } from '../case_view';
|
||||
import {
|
||||
getConnectorLabelTitle,
|
||||
getLabelTitle,
|
||||
getPushedServiceLabelTitle,
|
||||
getPushInfo,
|
||||
getUpdateAction,
|
||||
getAlertComment,
|
||||
getAlertAttachment,
|
||||
getGeneratedAlertsAttachment,
|
||||
useFetchAlertData,
|
||||
} from './helpers';
|
||||
import { UserActionAvatar } from './user_action_avatar';
|
||||
import { UserActionMarkdown } from './user_action_markdown';
|
||||
import { UserActionTimestamp } from './user_action_timestamp';
|
||||
import { UserActionUsername } from './user_action_username';
|
||||
import { UserActionContentToolbar } from './user_action_content_toolbar';
|
||||
import { getManualAlertIdsWithNoRuleId } from '../case_view/helpers';
|
||||
|
||||
export interface UserActionTreeProps {
|
||||
caseServices: CaseServices;
|
||||
|
@ -53,7 +62,6 @@ 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;
|
||||
}
|
||||
|
||||
|
@ -112,10 +120,9 @@ export const UserActionTree = React.memo(
|
|||
onUpdateField,
|
||||
updateCase,
|
||||
userCanCrud,
|
||||
alerts,
|
||||
onShowAlertDetails,
|
||||
}: UserActionTreeProps) => {
|
||||
const { commentId } = useParams<{ commentId?: string }>();
|
||||
const { commentId, subCaseId } = useParams<{ commentId?: string; subCaseId?: string }>();
|
||||
const handlerTimeoutId = useRef(0);
|
||||
const addCommentRef = useRef<AddCommentRefObject>(null);
|
||||
const [initLoading, setInitLoading] = useState(true);
|
||||
|
@ -124,6 +131,10 @@ export const UserActionTree = React.memo(
|
|||
const currentUser = useCurrentUser();
|
||||
const [manageMarkdownEditIds, setManangeMardownEditIds] = useState<string[]>([]);
|
||||
|
||||
const [loadingAlertData, manualAlertsData] = useFetchAlertData(
|
||||
getManualAlertIdsWithNoRuleId(caseData.comments)
|
||||
);
|
||||
|
||||
const handleManageMarkdownEditId = useCallback(
|
||||
(id: string) => {
|
||||
if (!manageMarkdownEditIds.includes(id)) {
|
||||
|
@ -218,9 +229,10 @@ export const UserActionTree = React.memo(
|
|||
onCommentPosted={handleUpdate}
|
||||
onCommentSaving={handleManageMarkdownEditId.bind(null, NEW_ID)}
|
||||
showLoading={false}
|
||||
subCaseId={subCaseId}
|
||||
/>
|
||||
),
|
||||
[caseData.id, handleUpdate, userCanCrud, handleManageMarkdownEditId]
|
||||
[caseData.id, handleUpdate, userCanCrud, handleManageMarkdownEditId, subCaseId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -279,11 +291,16 @@ export const UserActionTree = React.memo(
|
|||
const userActions: EuiCommentProps[] = useMemo(
|
||||
() =>
|
||||
caseUserActions.reduce<EuiCommentProps[]>(
|
||||
// eslint-disable-next-line complexity
|
||||
(comments, action, index) => {
|
||||
// Comment creation
|
||||
if (action.commentId != null && action.action === 'create') {
|
||||
const comment = caseData.comments.find((c) => c.id === action.commentId);
|
||||
if (comment != null && comment.type === CommentType.user) {
|
||||
if (
|
||||
comment != null &&
|
||||
isRight(ContextTypeUserRt.decode(comment)) &&
|
||||
comment.type === CommentType.user
|
||||
) {
|
||||
return [
|
||||
...comments,
|
||||
{
|
||||
|
@ -335,16 +352,65 @@ export const UserActionTree = React.memo(
|
|||
),
|
||||
},
|
||||
];
|
||||
// TODO: need to handle CommentType.generatedAlert here to
|
||||
} else if (comment != null && comment.type === CommentType.alert) {
|
||||
} else if (
|
||||
comment != null &&
|
||||
isRight(AlertCommentRequestRt.decode(comment)) &&
|
||||
comment.type === CommentType.alert
|
||||
) {
|
||||
// TODO: clean this up
|
||||
const alertId = Array.isArray(comment.alertId)
|
||||
? comment.alertId.length > 0
|
||||
? comment.alertId[0]
|
||||
: ''
|
||||
: comment.alertId;
|
||||
const alert = alerts[alertId];
|
||||
return [...comments, getAlertComment({ action, alert, onShowAlertDetails })];
|
||||
|
||||
const alertIndex = Array.isArray(comment.index)
|
||||
? comment.index.length > 0
|
||||
? comment.index[0]
|
||||
: ''
|
||||
: comment.index;
|
||||
|
||||
if (isEmpty(alertId)) {
|
||||
return comments;
|
||||
}
|
||||
|
||||
const ruleId = comment?.rule?.id ?? manualAlertsData[alertId]?.rule?.id?.[0] ?? '';
|
||||
const ruleName =
|
||||
comment?.rule?.name ??
|
||||
manualAlertsData[alertId]?.rule?.name?.[0] ??
|
||||
i18n.UNKNOWN_RULE;
|
||||
|
||||
return [
|
||||
...comments,
|
||||
getAlertAttachment({
|
||||
action,
|
||||
alertId,
|
||||
index: alertIndex,
|
||||
loadingAlertData,
|
||||
ruleId,
|
||||
ruleName,
|
||||
onShowAlertDetails,
|
||||
}),
|
||||
];
|
||||
} else if (comment != null && comment.type === CommentType.generatedAlert) {
|
||||
// TODO: clean this up
|
||||
const alertIds = Array.isArray(comment.alertId)
|
||||
? comment.alertId
|
||||
: [comment.alertId];
|
||||
|
||||
if (isEmpty(alertIds)) {
|
||||
return comments;
|
||||
}
|
||||
|
||||
return [
|
||||
...comments,
|
||||
getGeneratedAlertsAttachment({
|
||||
action,
|
||||
alertIds,
|
||||
ruleId: comment.rule?.id ?? '',
|
||||
ruleName: comment.rule?.name ?? i18n.UNKNOWN_RULE,
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -438,10 +504,11 @@ export const UserActionTree = React.memo(
|
|||
handleManageQuote,
|
||||
handleSaveComment,
|
||||
isLoadingIds,
|
||||
loadingAlertData,
|
||||
manualAlertsData,
|
||||
manageMarkdownEditIds,
|
||||
selectedOutlineCommentId,
|
||||
userCanCrud,
|
||||
alerts,
|
||||
onShowAlertDetails,
|
||||
]
|
||||
);
|
||||
|
|
|
@ -42,6 +42,19 @@ export const ALERT_COMMENT_LABEL_TITLE = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const GENERATED_ALERT_COMMENT_LABEL_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.case.caseView.generatedAlertCommentLabelTitle',
|
||||
{
|
||||
defaultMessage: 'were added from',
|
||||
}
|
||||
);
|
||||
|
||||
export const GENERATED_ALERT_COUNT_COMMENT_LABEL_TITLE = (totalCount: number) =>
|
||||
i18n.translate('xpack.securitySolution.case.caseView.generatedAlertCountCommentLabelTitle', {
|
||||
values: { totalCount },
|
||||
defaultMessage: `{totalCount} {totalCount, plural, =1 {alert} other {alerts}}`,
|
||||
});
|
||||
|
||||
export const ALERT_RULE_DELETED_COMMENT_LABEL = i18n.translate(
|
||||
'xpack.securitySolution.case.caseView.alertRuleDeletedLabelTitle',
|
||||
{
|
||||
|
@ -56,9 +69,16 @@ export const SHOW_ALERT_TOOLTIP = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const ALERT_NOT_FOUND_TOOLTIP = i18n.translate(
|
||||
'xpack.securitySolution.case.caseView.showAlertDeletedTooltip',
|
||||
export const SEND_ALERT_TO_TIMELINE = i18n.translate(
|
||||
'xpack.securitySolution.case.caseView.sendAlertToTimelineTooltip',
|
||||
{
|
||||
defaultMessage: 'Alert not found',
|
||||
defaultMessage: 'Investigate in timeline',
|
||||
}
|
||||
);
|
||||
|
||||
export const UNKNOWN_RULE = i18n.translate(
|
||||
'xpack.securitySolution.case.caseView.unknownRule.label',
|
||||
{
|
||||
defaultMessage: 'Unknown rule',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -11,19 +11,14 @@ import { mount } from 'enzyme';
|
|||
import { TestProviders } from '../../../common/mock';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import { AlertCommentEvent } from './user_action_alert_comment_event';
|
||||
import { CommentType } from '../../../../../case/common/api';
|
||||
|
||||
const props = {
|
||||
alert: {
|
||||
_id: 'alert-id-1',
|
||||
_index: 'alert-index-1',
|
||||
'@timestamp': '2021-01-07T13:58:31.487Z',
|
||||
rule: {
|
||||
id: 'rule-id-1',
|
||||
name: 'Awesome rule',
|
||||
from: '2021-01-07T13:58:31.487Z',
|
||||
to: '2021-01-07T14:58:31.487Z',
|
||||
},
|
||||
},
|
||||
alertId: 'alert-id-1',
|
||||
ruleId: 'rule-id-1',
|
||||
ruleName: 'Awesome rule',
|
||||
alertsCount: 1,
|
||||
commentType: CommentType.alert,
|
||||
};
|
||||
|
||||
jest.mock('../../../common/lib/kibana');
|
||||
|
@ -54,7 +49,8 @@ describe('UserActionAvatar ', () => {
|
|||
it('does NOT render the link when the alert is undefined', async () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<AlertCommentEvent alert={undefined} />
|
||||
{/* @ts-expect-error */}
|
||||
<AlertCommentEvent alert={undefined} commentType={CommentType.alert} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -62,13 +58,13 @@ describe('UserActionAvatar ', () => {
|
|||
wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists()
|
||||
).toBeFalsy();
|
||||
|
||||
expect(wrapper.text()).toBe('added an alert');
|
||||
expect(wrapper.text()).toBe('added an alert from ');
|
||||
});
|
||||
|
||||
it('does NOT render the link when the rule is undefined', async () => {
|
||||
const alert = {
|
||||
_id: 'alert-id-1',
|
||||
_index: 'alert-index-1',
|
||||
alertId: 'alert-id-1',
|
||||
commentType: CommentType.alert,
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
|
@ -82,7 +78,7 @@ describe('UserActionAvatar ', () => {
|
|||
wrapper.find(`[data-test-subj="alert-rule-link-alert-id-1"]`).first().exists()
|
||||
).toBeFalsy();
|
||||
|
||||
expect(wrapper.text()).toBe('added an alert');
|
||||
expect(wrapper.text()).toBe('added an alert from ');
|
||||
});
|
||||
|
||||
it('navigate to app on link click', async () => {
|
||||
|
|
|
@ -6,24 +6,36 @@
|
|||
*/
|
||||
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { EuiLink } from '@elastic/eui';
|
||||
import { EuiText, EuiLoadingSpinner } from '@elastic/eui';
|
||||
|
||||
import { APP_ID } from '../../../../common/constants';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import { getRuleDetailsUrl } from '../../../common/components/link_to';
|
||||
import { getRuleDetailsUrl, useFormatUrl } from '../../../common/components/link_to';
|
||||
import { SecurityPageName } from '../../../app/types';
|
||||
|
||||
import { Alert } from '../case_view';
|
||||
import * as i18n from './translations';
|
||||
import { CommentType } from '../../../../../case/common/api';
|
||||
import { LinkAnchor } from '../../../common/components/links';
|
||||
|
||||
interface Props {
|
||||
alert: Alert | undefined;
|
||||
alertId: string;
|
||||
commentType: CommentType;
|
||||
ruleId: string;
|
||||
ruleName: string;
|
||||
alertsCount?: number;
|
||||
loadingAlertData?: boolean;
|
||||
}
|
||||
|
||||
const AlertCommentEventComponent: React.FC<Props> = ({ alert }) => {
|
||||
const ruleName = alert?.rule?.name ?? null;
|
||||
const ruleId = alert?.rule?.id ?? null;
|
||||
const AlertCommentEventComponent: React.FC<Props> = ({
|
||||
alertId,
|
||||
loadingAlertData = false,
|
||||
ruleId,
|
||||
ruleName,
|
||||
alertsCount,
|
||||
commentType,
|
||||
}) => {
|
||||
const { navigateToApp } = useKibana().services.application;
|
||||
const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.detections);
|
||||
|
||||
const onLinkClick = useCallback(
|
||||
(ev: { preventDefault: () => void }) => {
|
||||
|
@ -35,15 +47,37 @@ const AlertCommentEventComponent: React.FC<Props> = ({ alert }) => {
|
|||
[ruleId, navigateToApp]
|
||||
);
|
||||
|
||||
return ruleId != null && ruleName != null ? (
|
||||
return commentType !== CommentType.generatedAlert ? (
|
||||
<>
|
||||
{`${i18n.ALERT_COMMENT_LABEL_TITLE} `}
|
||||
<EuiLink onClick={onLinkClick} data-test-subj={`alert-rule-link-${alert?._id ?? 'deleted'}`}>
|
||||
{ruleName}
|
||||
</EuiLink>
|
||||
{loadingAlertData && <EuiLoadingSpinner size="m" />}
|
||||
{!loadingAlertData && ruleId !== '' && (
|
||||
<LinkAnchor
|
||||
onClick={onLinkClick}
|
||||
href={formatUrl(getRuleDetailsUrl(ruleId ?? '', urlSearch))}
|
||||
data-test-subj={`alert-rule-link-${alertId ?? 'deleted'}`}
|
||||
>
|
||||
{ruleName}
|
||||
</LinkAnchor>
|
||||
)}
|
||||
{!loadingAlertData && ruleId === '' && <EuiText>{ruleName}</EuiText>}
|
||||
</>
|
||||
) : (
|
||||
<>{i18n.ALERT_RULE_DELETED_COMMENT_LABEL}</>
|
||||
<>
|
||||
<b>{i18n.GENERATED_ALERT_COUNT_COMMENT_LABEL_TITLE(alertsCount ?? 0)}</b>{' '}
|
||||
{i18n.GENERATED_ALERT_COMMENT_LABEL_TITLE}{' '}
|
||||
{loadingAlertData && <EuiLoadingSpinner size="m" />}
|
||||
{!loadingAlertData && ruleId !== '' && (
|
||||
<LinkAnchor
|
||||
onClick={onLinkClick}
|
||||
href={formatUrl(getRuleDetailsUrl(ruleId ?? '', urlSearch))}
|
||||
data-test-subj={`alert-rule-link-${alertId ?? 'deleted'}`}
|
||||
>
|
||||
{ruleName}
|
||||
</LinkAnchor>
|
||||
)}
|
||||
{!loadingAlertData && ruleId === '' && <EuiText>{ruleName}</EuiText>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -18,24 +18,26 @@ interface UserActionCopyLinkProps {
|
|||
id: string;
|
||||
}
|
||||
|
||||
const UserActionCopyLinkComponent = ({ id }: UserActionCopyLinkProps) => {
|
||||
const { detailName: caseId } = useParams<{ detailName: string }>();
|
||||
const UserActionCopyLinkComponent = ({ id: commentId }: UserActionCopyLinkProps) => {
|
||||
const { detailName: caseId, subCaseId } = useParams<{ detailName: string; subCaseId?: string }>();
|
||||
const { formatUrl } = useFormatUrl(SecurityPageName.case);
|
||||
|
||||
const handleAnchorLink = useCallback(() => {
|
||||
copy(
|
||||
formatUrl(getCaseDetailsUrlWithCommentId({ id: caseId, commentId: id }), { absolute: true })
|
||||
formatUrl(getCaseDetailsUrlWithCommentId({ id: caseId, commentId, subCaseId }), {
|
||||
absolute: true,
|
||||
})
|
||||
);
|
||||
}, [caseId, formatUrl, id]);
|
||||
}, [caseId, commentId, formatUrl, subCaseId]);
|
||||
|
||||
return (
|
||||
<EuiToolTip position="top" content={<p>{i18n.COPY_REFERENCE_LINK}</p>}>
|
||||
<EuiButtonIcon
|
||||
aria-label={i18n.COPY_REFERENCE_LINK}
|
||||
data-test-subj={`copy-link-${id}`}
|
||||
data-test-subj={`copy-link-${commentId}`}
|
||||
onClick={handleAnchorLink}
|
||||
iconType="link"
|
||||
id={`${id}-permLink`}
|
||||
id={`${commentId}-permLink`}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
);
|
||||
|
|
|
@ -8,20 +8,24 @@
|
|||
import React from 'react';
|
||||
import { mount, ReactWrapper } from 'enzyme';
|
||||
import { UserActionShowAlert } from './user_action_show_alert';
|
||||
import { RuleEcs } from '../../../../common/ecs/rule';
|
||||
|
||||
const props = {
|
||||
id: 'action-id',
|
||||
alertId: 'alert-id',
|
||||
index: 'alert-index',
|
||||
alert: {
|
||||
_id: 'alert-id',
|
||||
_index: 'alert-index',
|
||||
'@timestamp': '2021-01-07T13:58:31.487Z',
|
||||
timestamp: '2021-01-07T13:58:31.487Z',
|
||||
rule: {
|
||||
id: 'rule-id',
|
||||
name: 'Awesome Rule',
|
||||
from: '2021-01-07T13:58:31.487Z',
|
||||
to: '2021-01-07T14:58:31.487Z',
|
||||
},
|
||||
id: ['rule-id'],
|
||||
name: ['Awesome Rule'],
|
||||
from: ['2021-01-07T13:58:31.487Z'],
|
||||
to: ['2021-01-07T14:58:31.487Z'],
|
||||
} as RuleEcs,
|
||||
},
|
||||
onShowAlertDetails: jest.fn(),
|
||||
};
|
||||
|
||||
describe('UserActionShowAlert ', () => {
|
||||
|
|
|
@ -7,25 +7,24 @@
|
|||
|
||||
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;
|
||||
alertId: string;
|
||||
index: string;
|
||||
onShowAlertDetails: (alertId: string, index: string) => void;
|
||||
}
|
||||
|
||||
const UserActionShowAlertComponent = ({
|
||||
id,
|
||||
alert,
|
||||
alertId,
|
||||
index,
|
||||
onShowAlertDetails,
|
||||
}: UserActionShowAlertProps) => {
|
||||
const onClick = useCallback(() => onShowAlertDetails(alert._id, alert._index), [
|
||||
alert._id,
|
||||
alert._index,
|
||||
const onClick = useCallback(() => onShowAlertDetails(alertId, index), [
|
||||
alertId,
|
||||
index,
|
||||
onShowAlertDetails,
|
||||
]);
|
||||
return (
|
||||
|
@ -41,10 +40,4 @@ const UserActionShowAlertComponent = ({
|
|||
);
|
||||
};
|
||||
|
||||
export const UserActionShowAlert = memo(
|
||||
UserActionShowAlertComponent,
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.id === nextProps.id &&
|
||||
deepEqual(prevProps.alert, nextProps.alert) &&
|
||||
prevProps.onShowAlertDetails === nextProps.onShowAlertDetails
|
||||
);
|
||||
export const UserActionShowAlert = memo(UserActionShowAlertComponent);
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { assign } from 'lodash';
|
||||
|
||||
import {
|
||||
CasePatchRequest,
|
||||
CasePostRequest,
|
||||
|
@ -16,6 +18,9 @@ import {
|
|||
CaseUserActionsResponse,
|
||||
CommentRequest,
|
||||
CommentType,
|
||||
SubCasePatchRequest,
|
||||
SubCaseResponse,
|
||||
SubCasesResponse,
|
||||
User,
|
||||
} from '../../../../case/common/api';
|
||||
|
||||
|
@ -25,6 +30,8 @@ import {
|
|||
CASE_STATUS_URL,
|
||||
CASE_TAGS_URL,
|
||||
CASES_URL,
|
||||
SUB_CASE_DETAILS_URL,
|
||||
SUB_CASES_PATCH_DEL_URL,
|
||||
} from '../../../../case/common/constants';
|
||||
|
||||
import {
|
||||
|
@ -32,6 +39,8 @@ import {
|
|||
getCasePushUrl,
|
||||
getCaseDetailsUrl,
|
||||
getCaseUserActionUrl,
|
||||
getSubCaseDetailsUrl,
|
||||
getSubCaseUserActionUrl,
|
||||
} from '../../../../case/common/api/helpers';
|
||||
|
||||
import { KibanaServices } from '../../common/lib/kibana';
|
||||
|
@ -73,6 +82,34 @@ export const getCase = async (
|
|||
return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response));
|
||||
};
|
||||
|
||||
export const getSubCase = async (
|
||||
caseId: string,
|
||||
subCaseId: string,
|
||||
includeComments: boolean = true,
|
||||
signal: AbortSignal
|
||||
): Promise<Case> => {
|
||||
const [caseResponse, subCaseResponse] = await Promise.all([
|
||||
KibanaServices.get().http.fetch<CaseResponse>(getCaseDetailsUrl(caseId), {
|
||||
method: 'GET',
|
||||
query: {
|
||||
includeComments: false,
|
||||
},
|
||||
signal,
|
||||
}),
|
||||
KibanaServices.get().http.fetch<SubCaseResponse>(getSubCaseDetailsUrl(caseId, subCaseId), {
|
||||
method: 'GET',
|
||||
query: {
|
||||
includeComments,
|
||||
},
|
||||
signal,
|
||||
}),
|
||||
]);
|
||||
const response = assign<CaseResponse, SubCaseResponse>(caseResponse, subCaseResponse);
|
||||
const subCaseIndex = response.subCaseIds?.findIndex((scId) => scId === response.id) ?? -1;
|
||||
response.title = `${response.title}${subCaseIndex >= 0 ? ` ${subCaseIndex + 1}` : ''}`;
|
||||
return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response));
|
||||
};
|
||||
|
||||
export const getCasesStatus = async (signal: AbortSignal): Promise<CasesStatus> => {
|
||||
const response = await KibanaServices.get().http.fetch<CasesStatusResponse>(CASE_STATUS_URL, {
|
||||
method: 'GET',
|
||||
|
@ -111,6 +148,21 @@ export const getCaseUserActions = async (
|
|||
return convertArrayToCamelCase(decodeCaseUserActionsResponse(response)) as CaseUserActions[];
|
||||
};
|
||||
|
||||
export const getSubCaseUserActions = async (
|
||||
caseId: string,
|
||||
subCaseId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<CaseUserActions[]> => {
|
||||
const response = await KibanaServices.get().http.fetch<CaseUserActionsResponse>(
|
||||
getSubCaseUserActionUrl(caseId, subCaseId),
|
||||
{
|
||||
method: 'GET',
|
||||
signal,
|
||||
}
|
||||
);
|
||||
return convertArrayToCamelCase(decodeCaseUserActionsResponse(response)) as CaseUserActions[];
|
||||
};
|
||||
|
||||
export const getCases = async ({
|
||||
filterOptions = {
|
||||
search: '',
|
||||
|
@ -167,6 +219,35 @@ export const patchCase = async (
|
|||
return convertToCamelCase<CasesResponse, Case[]>(decodeCasesResponse(response));
|
||||
};
|
||||
|
||||
export const patchSubCase = async (
|
||||
caseId: string,
|
||||
subCaseId: string,
|
||||
updatedSubCase: Pick<SubCasePatchRequest, 'status'>,
|
||||
version: string,
|
||||
signal: AbortSignal
|
||||
): Promise<Case[]> => {
|
||||
const subCaseResponse = await KibanaServices.get().http.fetch<SubCasesResponse>(
|
||||
SUB_CASE_DETAILS_URL,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ cases: [{ ...updatedSubCase, id: caseId, version }] }),
|
||||
signal,
|
||||
}
|
||||
);
|
||||
const caseResponse = await KibanaServices.get().http.fetch<CaseResponse>(
|
||||
getCaseDetailsUrl(caseId),
|
||||
{
|
||||
method: 'GET',
|
||||
query: {
|
||||
includeComments: false,
|
||||
},
|
||||
signal,
|
||||
}
|
||||
);
|
||||
const response = subCaseResponse.map((subCaseResp) => assign(caseResponse, subCaseResp));
|
||||
return convertToCamelCase<CasesResponse, Case[]>(decodeCasesResponse(response));
|
||||
};
|
||||
|
||||
export const patchCasesStatus = async (
|
||||
cases: BulkUpdateStatus[],
|
||||
signal: AbortSignal
|
||||
|
@ -182,13 +263,15 @@ export const patchCasesStatus = async (
|
|||
export const postComment = async (
|
||||
newComment: CommentRequest,
|
||||
caseId: string,
|
||||
signal: AbortSignal
|
||||
signal: AbortSignal,
|
||||
subCaseId?: string
|
||||
): Promise<Case> => {
|
||||
const response = await KibanaServices.get().http.fetch<CaseResponse>(
|
||||
`${CASES_URL}/${caseId}/comments`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(newComment),
|
||||
...(subCaseId ? { query: { subCaseId } } : {}),
|
||||
signal,
|
||||
}
|
||||
);
|
||||
|
@ -200,7 +283,8 @@ export const patchComment = async (
|
|||
commentId: string,
|
||||
commentUpdate: string,
|
||||
version: string,
|
||||
signal: AbortSignal
|
||||
signal: AbortSignal,
|
||||
subCaseId?: string
|
||||
): Promise<Case> => {
|
||||
const response = await KibanaServices.get().http.fetch<CaseResponse>(getCaseCommentsUrl(caseId), {
|
||||
method: 'PATCH',
|
||||
|
@ -210,6 +294,7 @@ export const patchComment = async (
|
|||
id: commentId,
|
||||
version,
|
||||
}),
|
||||
...(subCaseId ? { query: { subCaseId } } : {}),
|
||||
signal,
|
||||
});
|
||||
return convertToCamelCase<CaseResponse, Case>(decodeCaseResponse(response));
|
||||
|
@ -224,6 +309,15 @@ export const deleteCases = async (caseIds: string[], signal: AbortSignal): Promi
|
|||
return response;
|
||||
};
|
||||
|
||||
export const deleteSubCases = async (caseIds: string[], signal: AbortSignal): Promise<string> => {
|
||||
const response = await KibanaServices.get().http.fetch<string>(SUB_CASES_PATCH_DEL_URL, {
|
||||
method: 'DELETE',
|
||||
query: { ids: JSON.stringify(caseIds) },
|
||||
signal,
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
export const pushCase = async (
|
||||
caseId: string,
|
||||
connectorId: string,
|
||||
|
|
|
@ -26,6 +26,7 @@ import { ConnectorTypes } from '../../../../case/common/api/connectors';
|
|||
export { connectorsMock } from './configure/mock';
|
||||
|
||||
export const basicCaseId = 'basic-case-id';
|
||||
export const basicSubCaseId = 'basic-sub-case-id';
|
||||
const basicCommentId = 'basic-comment-id';
|
||||
const basicCreatedAt = '2020-02-19T23:06:33.798Z';
|
||||
const basicUpdatedAt = '2020-02-20T15:02:57.995Z';
|
||||
|
@ -63,6 +64,10 @@ export const alertComment: Comment = {
|
|||
createdBy: elasticUser,
|
||||
pushedAt: null,
|
||||
pushedBy: null,
|
||||
rule: {
|
||||
id: 'rule-id-1',
|
||||
name: 'Awesome rule',
|
||||
},
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
version: 'WzQ3LDFc',
|
||||
|
@ -95,6 +100,7 @@ export const basicCase: Case = {
|
|||
settings: {
|
||||
syncAlerts: true,
|
||||
},
|
||||
subCaseIds: [],
|
||||
};
|
||||
|
||||
export const basicCasePost: Case = {
|
||||
|
@ -217,7 +223,7 @@ export const basicCaseSnake: CaseResponse = {
|
|||
external_service: null,
|
||||
updated_at: basicUpdatedAt,
|
||||
updated_by: elasticUserSnake,
|
||||
};
|
||||
} as CaseResponse;
|
||||
|
||||
export const casesStatusSnake: CasesStatusResponse = {
|
||||
count_closed_cases: 130,
|
||||
|
|
|
@ -18,7 +18,9 @@ import {
|
|||
AssociationType,
|
||||
} from '../../../../case/common/api';
|
||||
|
||||
export { CaseConnector, ActionConnector } from '../../../../case/common/api';
|
||||
export { CaseConnector, ActionConnector, CaseStatuses } from '../../../../case/common/api';
|
||||
|
||||
export type AllCaseType = AssociationType & CaseType;
|
||||
|
||||
export type Comment = CommentRequest & {
|
||||
associationType: AssociationType;
|
||||
|
@ -52,26 +54,37 @@ export interface CaseExternalService {
|
|||
externalTitle: string;
|
||||
externalUrl: string;
|
||||
}
|
||||
export interface Case {
|
||||
|
||||
interface BasicCase {
|
||||
id: string;
|
||||
closedAt: string | null;
|
||||
closedBy: ElasticUser | null;
|
||||
comments: Comment[];
|
||||
connector: CaseConnector;
|
||||
createdAt: string;
|
||||
createdBy: ElasticUser;
|
||||
description: string;
|
||||
externalService: CaseExternalService | null;
|
||||
status: CaseStatuses;
|
||||
tags: string[];
|
||||
title: string;
|
||||
totalAlerts: number;
|
||||
totalComment: number;
|
||||
type: CaseType;
|
||||
updatedAt: string | null;
|
||||
updatedBy: ElasticUser | null;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface SubCase extends BasicCase {
|
||||
associationType: AssociationType;
|
||||
caseParentId: string;
|
||||
}
|
||||
|
||||
export interface Case extends BasicCase {
|
||||
connector: CaseConnector;
|
||||
description: string;
|
||||
externalService: CaseExternalService | null;
|
||||
subCases?: SubCase[] | null;
|
||||
subCaseIds: string[];
|
||||
settings: CaseAttributes['settings'];
|
||||
tags: string[];
|
||||
type: CaseType;
|
||||
}
|
||||
|
||||
export interface QueryParams {
|
||||
|
@ -138,6 +151,7 @@ export interface ActionLicense {
|
|||
export interface DeleteCase {
|
||||
id: string;
|
||||
title?: string;
|
||||
type?: CaseType;
|
||||
}
|
||||
|
||||
export interface FieldMappings {
|
||||
|
@ -153,7 +167,7 @@ export type UpdateKey = keyof Pick<
|
|||
export interface UpdateByKey {
|
||||
updateKey: UpdateKey;
|
||||
updateValue: CasePatchRequest[UpdateKey];
|
||||
fetchCaseUserActions?: (caseId: string) => void;
|
||||
fetchCaseUserActions?: (caseId: string, subCaseId?: string) => void;
|
||||
updateCase?: (newCase: Case) => void;
|
||||
caseData: Case;
|
||||
onSuccess?: () => void;
|
||||
|
|
|
@ -12,7 +12,7 @@ import {
|
|||
useStateToaster,
|
||||
} from '../../common/components/toasters';
|
||||
import * as i18n from './translations';
|
||||
import { deleteCases } from './api';
|
||||
import { deleteCases, deleteSubCases } from './api';
|
||||
import { DeleteCase } from './types';
|
||||
|
||||
interface DeleteState {
|
||||
|
@ -87,7 +87,13 @@ export const useDeleteCases = (): UseDeleteCase => {
|
|||
try {
|
||||
dispatch({ type: 'FETCH_INIT' });
|
||||
const caseIds = cases.map((theCase) => theCase.id);
|
||||
await deleteCases(caseIds, abortCtrl.signal);
|
||||
// We don't allow user batch delete sub cases on UI at the moment.
|
||||
if (cases[0].type != null || cases.length > 1) {
|
||||
await deleteCases(caseIds, abortCtrl.signal);
|
||||
} else {
|
||||
await deleteSubCases(caseIds, abortCtrl.signal);
|
||||
}
|
||||
|
||||
if (!cancel) {
|
||||
dispatch({ type: 'FETCH_SUCCESS', payload: true });
|
||||
displaySuccessToast(
|
||||
|
|
|
@ -5,13 +5,14 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useEffect, useReducer, useCallback } from 'react';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { useEffect, useReducer, useCallback, useRef } from 'react';
|
||||
import { CaseStatuses, CaseType } from '../../../../case/common/api';
|
||||
|
||||
import { Case } from './types';
|
||||
import * as i18n from './translations';
|
||||
import { errorToToaster, useStateToaster } from '../../common/components/toasters';
|
||||
import { getCase } from './api';
|
||||
import { getCase, getSubCase } from './api';
|
||||
import { getNoneConnector } from '../components/configure_cases/utils';
|
||||
|
||||
interface CaseState {
|
||||
|
@ -77,6 +78,7 @@ export const initialData: Case = {
|
|||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
version: '',
|
||||
subCaseIds: [],
|
||||
settings: {
|
||||
syncAlerts: true,
|
||||
},
|
||||
|
@ -87,31 +89,32 @@ export interface UseGetCase extends CaseState {
|
|||
updateCase: (newCase: Case) => void;
|
||||
}
|
||||
|
||||
export const useGetCase = (caseId: string): UseGetCase => {
|
||||
export const useGetCase = (caseId: string, subCaseId?: string): UseGetCase => {
|
||||
const [state, dispatch] = useReducer(dataFetchReducer, {
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
data: initialData,
|
||||
});
|
||||
const [, dispatchToaster] = useStateToaster();
|
||||
const abortCtrl = useRef(new AbortController());
|
||||
const didCancel = useRef(false);
|
||||
|
||||
const updateCase = useCallback((newCase: Case) => {
|
||||
dispatch({ type: 'UPDATE_CASE', payload: newCase });
|
||||
}, []);
|
||||
|
||||
const callFetch = useCallback(async () => {
|
||||
let didCancel = false;
|
||||
const abortCtrl = new AbortController();
|
||||
|
||||
const fetchData = async () => {
|
||||
dispatch({ type: 'FETCH_INIT' });
|
||||
try {
|
||||
const response = await getCase(caseId, true, abortCtrl.signal);
|
||||
if (!didCancel) {
|
||||
const response = await (subCaseId
|
||||
? getSubCase(caseId, subCaseId, true, abortCtrl.current.signal)
|
||||
: getCase(caseId, true, abortCtrl.current.signal));
|
||||
if (!didCancel.current) {
|
||||
dispatch({ type: 'FETCH_SUCCESS', payload: response });
|
||||
}
|
||||
} catch (error) {
|
||||
if (!didCancel) {
|
||||
if (!didCancel.current) {
|
||||
errorToToaster({
|
||||
title: i18n.ERROR_TITLE,
|
||||
error: error.body && error.body.message ? new Error(error.body.message) : error,
|
||||
|
@ -121,17 +124,22 @@ export const useGetCase = (caseId: string): UseGetCase => {
|
|||
}
|
||||
}
|
||||
};
|
||||
didCancel.current = false;
|
||||
abortCtrl.current.abort();
|
||||
abortCtrl.current = new AbortController();
|
||||
fetchData();
|
||||
return () => {
|
||||
didCancel = true;
|
||||
abortCtrl.abort();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [caseId]);
|
||||
}, [caseId, subCaseId]);
|
||||
|
||||
useEffect(() => {
|
||||
callFetch();
|
||||
if (!isEmpty(caseId)) {
|
||||
callFetch();
|
||||
}
|
||||
return () => {
|
||||
didCancel.current = true;
|
||||
abortCtrl.current.abort();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [caseId]);
|
||||
}, [caseId, subCaseId]);
|
||||
return { ...state, fetchCase: callFetch, updateCase };
|
||||
};
|
||||
|
|
|
@ -6,12 +6,12 @@
|
|||
*/
|
||||
|
||||
import { isEmpty, uniqBy } from 'lodash/fp';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import deepEqual from 'fast-deep-equal';
|
||||
|
||||
import { errorToToaster, useStateToaster } from '../../common/components/toasters';
|
||||
import { CaseFullExternalService } from '../../../../case/common/api/cases';
|
||||
import { getCaseUserActions } from './api';
|
||||
import { getCaseUserActions, getSubCaseUserActions } from './api';
|
||||
import * as i18n from './translations';
|
||||
import { CaseConnector, CaseExternalService, CaseUserActions, ElasticUser } from './types';
|
||||
import { convertToCamelCase, parseString } from './utils';
|
||||
|
@ -46,7 +46,7 @@ export const initialData: CaseUserActionsState = {
|
|||
};
|
||||
|
||||
export interface UseGetCaseUserActions extends CaseUserActionsState {
|
||||
fetchCaseUserActions: (caseId: string) => void;
|
||||
fetchCaseUserActions: (caseId: string, subCaseId?: string) => void;
|
||||
}
|
||||
|
||||
const getExternalService = (value: string): CaseExternalService | null =>
|
||||
|
@ -238,26 +238,29 @@ export const getPushedInfo = (
|
|||
|
||||
export const useGetCaseUserActions = (
|
||||
caseId: string,
|
||||
caseConnectorId: string
|
||||
caseConnectorId: string,
|
||||
subCaseId?: string
|
||||
): UseGetCaseUserActions => {
|
||||
const [caseUserActionsState, setCaseUserActionsState] = useState<CaseUserActionsState>(
|
||||
initialData
|
||||
);
|
||||
|
||||
const abortCtrl = useRef(new AbortController());
|
||||
const didCancel = useRef(false);
|
||||
const [, dispatchToaster] = useStateToaster();
|
||||
|
||||
const fetchCaseUserActions = useCallback(
|
||||
(thisCaseId: string) => {
|
||||
let didCancel = false;
|
||||
const abortCtrl = new AbortController();
|
||||
(thisCaseId: string, thisSubCaseId?: string) => {
|
||||
const fetchData = async () => {
|
||||
setCaseUserActionsState({
|
||||
...caseUserActionsState,
|
||||
isLoading: true,
|
||||
});
|
||||
try {
|
||||
const response = await getCaseUserActions(thisCaseId, abortCtrl.signal);
|
||||
if (!didCancel) {
|
||||
setCaseUserActionsState({
|
||||
...caseUserActionsState,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
const response = await (thisSubCaseId
|
||||
? getSubCaseUserActions(thisCaseId, thisSubCaseId, abortCtrl.current.signal)
|
||||
: getCaseUserActions(thisCaseId, abortCtrl.current.signal));
|
||||
if (!didCancel.current) {
|
||||
// Attention Future developer
|
||||
// We are removing the first item because it will always be the creation of the case
|
||||
// and we do not want it to simplify our life
|
||||
|
@ -265,7 +268,11 @@ export const useGetCaseUserActions = (
|
|||
? uniqBy('actionBy.username', response).map((cau) => cau.actionBy)
|
||||
: [];
|
||||
|
||||
const caseUserActions = !isEmpty(response) ? response.slice(1) : [];
|
||||
const caseUserActions = !isEmpty(response)
|
||||
? thisSubCaseId
|
||||
? response
|
||||
: response.slice(1)
|
||||
: [];
|
||||
setCaseUserActionsState({
|
||||
caseUserActions,
|
||||
...getPushedInfo(caseUserActions, caseConnectorId),
|
||||
|
@ -275,7 +282,7 @@ export const useGetCaseUserActions = (
|
|||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (!didCancel) {
|
||||
if (!didCancel.current) {
|
||||
errorToToaster({
|
||||
title: i18n.ERROR_TITLE,
|
||||
error: error.body && error.body.message ? new Error(error.body.message) : error,
|
||||
|
@ -292,21 +299,24 @@ export const useGetCaseUserActions = (
|
|||
}
|
||||
}
|
||||
};
|
||||
abortCtrl.current.abort();
|
||||
abortCtrl.current = new AbortController();
|
||||
fetchData();
|
||||
return () => {
|
||||
didCancel = true;
|
||||
abortCtrl.abort();
|
||||
};
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[caseUserActionsState, caseConnectorId]
|
||||
[caseConnectorId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEmpty(caseId)) {
|
||||
fetchCaseUserActions(caseId);
|
||||
fetchCaseUserActions(caseId, subCaseId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
didCancel.current = true;
|
||||
abortCtrl.current.abort();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [caseId, caseConnectorId]);
|
||||
}, [caseId, subCaseId]);
|
||||
return { ...caseUserActionsState, fetchCaseUserActions };
|
||||
};
|
||||
|
|
|
@ -9,7 +9,7 @@ import { renderHook, act } from '@testing-library/react-hooks';
|
|||
|
||||
import { CommentType } from '../../../../case/common/api';
|
||||
import { usePostComment, UsePostComment } from './use_post_comment';
|
||||
import { basicCaseId } from './mock';
|
||||
import { basicCaseId, basicSubCaseId } from './mock';
|
||||
import * as api from './api';
|
||||
|
||||
jest.mock('./api');
|
||||
|
@ -40,7 +40,7 @@ describe('usePostComment', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('calls postComment with correct arguments', async () => {
|
||||
it('calls postComment with correct arguments - case', async () => {
|
||||
const spyOnPostCase = jest.spyOn(api, 'postComment');
|
||||
|
||||
await act(async () => {
|
||||
|
@ -49,9 +49,38 @@ describe('usePostComment', () => {
|
|||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
result.current.postComment(basicCaseId, samplePost, updateCaseCallback);
|
||||
result.current.postComment({
|
||||
caseId: basicCaseId,
|
||||
data: samplePost,
|
||||
updateCase: updateCaseCallback,
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
expect(spyOnPostCase).toBeCalledWith(samplePost, basicCaseId, abortCtrl.signal);
|
||||
expect(spyOnPostCase).toBeCalledWith(samplePost, basicCaseId, abortCtrl.signal, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls postComment with correct arguments - sub case', async () => {
|
||||
const spyOnPostCase = jest.spyOn(api, 'postComment');
|
||||
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<string, UsePostComment>(() =>
|
||||
usePostComment()
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
result.current.postComment({
|
||||
caseId: basicCaseId,
|
||||
data: samplePost,
|
||||
updateCase: updateCaseCallback,
|
||||
subCaseId: basicSubCaseId,
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
expect(spyOnPostCase).toBeCalledWith(
|
||||
samplePost,
|
||||
basicCaseId,
|
||||
abortCtrl.signal,
|
||||
basicSubCaseId
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -61,7 +90,11 @@ describe('usePostComment', () => {
|
|||
usePostComment()
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
result.current.postComment(basicCaseId, samplePost, updateCaseCallback);
|
||||
result.current.postComment({
|
||||
caseId: basicCaseId,
|
||||
data: samplePost,
|
||||
updateCase: updateCaseCallback,
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
expect(result.current).toEqual({
|
||||
isLoading: false,
|
||||
|
@ -77,7 +110,11 @@ describe('usePostComment', () => {
|
|||
usePostComment()
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
result.current.postComment(basicCaseId, samplePost, updateCaseCallback);
|
||||
result.current.postComment({
|
||||
caseId: basicCaseId,
|
||||
data: samplePost,
|
||||
updateCase: updateCaseCallback,
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
|
@ -94,7 +131,11 @@ describe('usePostComment', () => {
|
|||
usePostComment()
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
result.current.postComment(basicCaseId, samplePost, updateCaseCallback);
|
||||
result.current.postComment({
|
||||
caseId: basicCaseId,
|
||||
data: samplePost,
|
||||
updateCase: updateCaseCallback,
|
||||
});
|
||||
|
||||
expect(result.current).toEqual({
|
||||
isLoading: false,
|
||||
|
|
|
@ -42,8 +42,14 @@ const dataFetchReducer = (state: NewCommentState, action: Action): NewCommentSta
|
|||
}
|
||||
};
|
||||
|
||||
interface PostComment {
|
||||
caseId: string;
|
||||
data: CommentRequest;
|
||||
updateCase?: (newCase: Case) => void;
|
||||
subCaseId?: string;
|
||||
}
|
||||
export interface UsePostComment extends NewCommentState {
|
||||
postComment: (caseId: string, data: CommentRequest, updateCase?: (newCase: Case) => void) => void;
|
||||
postComment: (args: PostComment) => void;
|
||||
}
|
||||
|
||||
export const usePostComment = (): UsePostComment => {
|
||||
|
@ -54,13 +60,13 @@ export const usePostComment = (): UsePostComment => {
|
|||
const [, dispatchToaster] = useStateToaster();
|
||||
|
||||
const postMyComment = useCallback(
|
||||
async (caseId: string, data: CommentRequest, updateCase?: (newCase: Case) => void) => {
|
||||
async ({ caseId, data, updateCase, subCaseId }: PostComment) => {
|
||||
let cancel = false;
|
||||
const abortCtrl = new AbortController();
|
||||
|
||||
try {
|
||||
dispatch({ type: 'FETCH_INIT' });
|
||||
const response = await postComment(data, caseId, abortCtrl.signal);
|
||||
const response = await postComment(data, caseId, abortCtrl.signal, subCaseId);
|
||||
if (!cancel) {
|
||||
dispatch({ type: 'FETCH_SUCCESS' });
|
||||
if (updateCase) {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { useUpdateCase, UseUpdateCase } from './use_update_case';
|
||||
import { basicCase } from './mock';
|
||||
import { basicCase, basicSubCaseId } from './mock';
|
||||
import * as api from './api';
|
||||
import { UpdateKey } from './types';
|
||||
|
||||
|
@ -84,7 +84,27 @@ describe('useUpdateCase', () => {
|
|||
isError: false,
|
||||
updateCaseProperty: result.current.updateCaseProperty,
|
||||
});
|
||||
expect(fetchCaseUserActions).toBeCalledWith(basicCase.id);
|
||||
expect(fetchCaseUserActions).toBeCalledWith(basicCase.id, undefined);
|
||||
expect(updateCase).toBeCalledWith(basicCase);
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('patch sub case', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<string, UseUpdateCase>(() =>
|
||||
useUpdateCase({ caseId: basicCase.id, subCaseId: basicSubCaseId })
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
result.current.updateCaseProperty(sampleUpdate);
|
||||
await waitForNextUpdate();
|
||||
expect(result.current).toEqual({
|
||||
updateKey: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
updateCaseProperty: result.current.updateCaseProperty,
|
||||
});
|
||||
expect(fetchCaseUserActions).toBeCalledWith(basicCase.id, basicSubCaseId);
|
||||
expect(updateCase).toBeCalledWith(basicCase);
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
@ -5,12 +5,12 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useReducer, useCallback } from 'react';
|
||||
import { useReducer, useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { errorToToaster, useStateToaster } from '../../common/components/toasters';
|
||||
|
||||
import { patchCase } from './api';
|
||||
import { UpdateKey, UpdateByKey } from './types';
|
||||
import { patchCase, patchSubCase } from './api';
|
||||
import { UpdateKey, UpdateByKey, CaseStatuses } from './types';
|
||||
import * as i18n from './translations';
|
||||
import { createUpdateSuccessToaster } from './utils';
|
||||
|
||||
|
@ -57,13 +57,21 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState =>
|
|||
export interface UseUpdateCase extends NewCaseState {
|
||||
updateCaseProperty: (updates: UpdateByKey) => void;
|
||||
}
|
||||
export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase => {
|
||||
export const useUpdateCase = ({
|
||||
caseId,
|
||||
subCaseId,
|
||||
}: {
|
||||
caseId: string;
|
||||
subCaseId?: string;
|
||||
}): UseUpdateCase => {
|
||||
const [state, dispatch] = useReducer(dataFetchReducer, {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
updateKey: null,
|
||||
});
|
||||
const [, dispatchToaster] = useStateToaster();
|
||||
const abortCtrl = useRef(new AbortController());
|
||||
const didCancel = useRef(false);
|
||||
|
||||
const dispatchUpdateCaseProperty = useCallback(
|
||||
async ({
|
||||
|
@ -75,20 +83,27 @@ export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase =>
|
|||
onSuccess,
|
||||
onError,
|
||||
}: UpdateByKey) => {
|
||||
let cancel = false;
|
||||
const abortCtrl = new AbortController();
|
||||
|
||||
try {
|
||||
didCancel.current = false;
|
||||
abortCtrl.current = new AbortController();
|
||||
dispatch({ type: 'FETCH_INIT', payload: updateKey });
|
||||
const response = await patchCase(
|
||||
caseId,
|
||||
{ [updateKey]: updateValue },
|
||||
caseData.version,
|
||||
abortCtrl.signal
|
||||
);
|
||||
if (!cancel) {
|
||||
const response = await (updateKey === 'status' && subCaseId
|
||||
? patchSubCase(
|
||||
caseId,
|
||||
subCaseId,
|
||||
{ status: updateValue as CaseStatuses },
|
||||
caseData.version,
|
||||
abortCtrl.current.signal
|
||||
)
|
||||
: patchCase(
|
||||
caseId,
|
||||
{ [updateKey]: updateValue },
|
||||
caseData.version,
|
||||
abortCtrl.current.signal
|
||||
));
|
||||
if (!didCancel.current) {
|
||||
if (fetchCaseUserActions != null) {
|
||||
fetchCaseUserActions(caseId);
|
||||
fetchCaseUserActions(caseId, subCaseId);
|
||||
}
|
||||
if (updateCase != null) {
|
||||
updateCase(response[0]);
|
||||
|
@ -104,26 +119,31 @@ export const useUpdateCase = ({ caseId }: { caseId: string }): UseUpdateCase =>
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancel) {
|
||||
errorToToaster({
|
||||
title: i18n.ERROR_TITLE,
|
||||
error: error.body && error.body.message ? new Error(error.body.message) : error,
|
||||
dispatchToaster,
|
||||
});
|
||||
if (!didCancel.current) {
|
||||
if (error.name !== 'AbortError') {
|
||||
errorToToaster({
|
||||
title: i18n.ERROR_TITLE,
|
||||
error: error.body && error.body.message ? new Error(error.body.message) : error,
|
||||
dispatchToaster,
|
||||
});
|
||||
}
|
||||
dispatch({ type: 'FETCH_FAILURE' });
|
||||
if (onError) {
|
||||
onError();
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
cancel = true;
|
||||
abortCtrl.abort();
|
||||
};
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
[caseId, subCaseId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
didCancel.current = true;
|
||||
abortCtrl.current.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ...state, updateCaseProperty: dispatchUpdateCaseProperty };
|
||||
};
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { useUpdateComment, UseUpdateComment } from './use_update_comment';
|
||||
import { basicCase, basicCaseCommentPatch } from './mock';
|
||||
import { basicCase, basicCaseCommentPatch, basicSubCaseId } from './mock';
|
||||
import * as api from './api';
|
||||
|
||||
jest.mock('./api');
|
||||
|
@ -43,7 +43,7 @@ describe('useUpdateComment', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('calls patchComment with correct arguments', async () => {
|
||||
it('calls patchComment with correct arguments - case', async () => {
|
||||
const spyOnPatchComment = jest.spyOn(api, 'patchComment');
|
||||
|
||||
await act(async () => {
|
||||
|
@ -59,7 +59,30 @@ describe('useUpdateComment', () => {
|
|||
basicCase.comments[0].id,
|
||||
'updated comment',
|
||||
basicCase.comments[0].version,
|
||||
abortCtrl.signal
|
||||
abortCtrl.signal,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls patchComment with correct arguments - sub case', async () => {
|
||||
const spyOnPatchComment = jest.spyOn(api, 'patchComment');
|
||||
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook<string, UseUpdateComment>(() =>
|
||||
useUpdateComment()
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
result.current.patchComment({ ...sampleUpdate, subCaseId: basicSubCaseId });
|
||||
await waitForNextUpdate();
|
||||
expect(spyOnPatchComment).toBeCalledWith(
|
||||
basicCase.id,
|
||||
basicCase.comments[0].id,
|
||||
'updated comment',
|
||||
basicCase.comments[0].version,
|
||||
abortCtrl.signal,
|
||||
basicSubCaseId
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -57,6 +57,7 @@ interface UpdateComment {
|
|||
commentId: string;
|
||||
commentUpdate: string;
|
||||
fetchUserActions: () => void;
|
||||
subCaseId?: string;
|
||||
updateCase: (newCase: Case) => void;
|
||||
version: string;
|
||||
}
|
||||
|
@ -78,6 +79,7 @@ export const useUpdateComment = (): UseUpdateComment => {
|
|||
commentId,
|
||||
commentUpdate,
|
||||
fetchUserActions,
|
||||
subCaseId,
|
||||
updateCase,
|
||||
version,
|
||||
}: UpdateComment) => {
|
||||
|
@ -90,7 +92,8 @@ export const useUpdateComment = (): UseUpdateComment => {
|
|||
commentId,
|
||||
commentUpdate,
|
||||
version,
|
||||
abortCtrl.signal
|
||||
abortCtrl.signal,
|
||||
subCaseId
|
||||
);
|
||||
if (!cancel) {
|
||||
updateCase(response);
|
||||
|
|
|
@ -21,7 +21,10 @@ import { savedObjectReadOnlyErrorMessage, CaseCallOut } from '../components/call
|
|||
export const CaseDetailsPage = React.memo(() => {
|
||||
const history = useHistory();
|
||||
const userPermissions = useGetUserSavedObjectPermissions();
|
||||
const { detailName: caseId } = useParams<{ detailName?: string }>();
|
||||
const { detailName: caseId, subCaseId } = useParams<{
|
||||
detailName?: string;
|
||||
subCaseId?: string;
|
||||
}>();
|
||||
const search = useGetUrlSearch(navTabs.case);
|
||||
|
||||
if (userPermissions != null && !userPermissions.read) {
|
||||
|
@ -38,7 +41,11 @@ export const CaseDetailsPage = React.memo(() => {
|
|||
messages={[{ ...savedObjectReadOnlyErrorMessage }]}
|
||||
/>
|
||||
)}
|
||||
<CaseView caseId={caseId} userCanCrud={userPermissions?.crud ?? false} />
|
||||
<CaseView
|
||||
caseId={caseId}
|
||||
subCaseId={subCaseId}
|
||||
userCanCrud={userPermissions?.crud ?? false}
|
||||
/>
|
||||
</WrapperPage>
|
||||
<SpyRoute pageName={SecurityPageName.case} />
|
||||
</>
|
||||
|
|
|
@ -15,7 +15,9 @@ import { ConfigureCasesPage } from './configure_cases';
|
|||
|
||||
const casesPagePath = '';
|
||||
const caseDetailsPagePath = `${casesPagePath}/:detailName`;
|
||||
const caseDetailsPagePathWithCommentId = `${casesPagePath}/:detailName/:commentId`;
|
||||
const subCaseDetailsPagePath = `${caseDetailsPagePath}/sub-cases/:subCaseId`;
|
||||
const caseDetailsPagePathWithCommentId = `${caseDetailsPagePath}/:commentId`;
|
||||
const subCaseDetailsPagePathWithCommentId = `${subCaseDetailsPagePath}/:commentId`;
|
||||
const createCasePagePath = `${casesPagePath}/create`;
|
||||
const configureCasesPagePath = `${casesPagePath}/configure`;
|
||||
|
||||
|
@ -27,7 +29,13 @@ const CaseContainerComponent: React.FC = () => (
|
|||
<Route path={configureCasesPagePath}>
|
||||
<ConfigureCasesPage />
|
||||
</Route>
|
||||
<Route path={caseDetailsPagePathWithCommentId}>
|
||||
<Route exact path={subCaseDetailsPagePathWithCommentId}>
|
||||
<CaseDetailsPage />
|
||||
</Route>
|
||||
<Route exact path={caseDetailsPagePathWithCommentId}>
|
||||
<CaseDetailsPage />
|
||||
</Route>
|
||||
<Route exact path={subCaseDetailsPagePath}>
|
||||
<CaseDetailsPage />
|
||||
</Route>
|
||||
<Route path={caseDetailsPagePath}>
|
||||
|
|
|
@ -9,19 +9,43 @@ import { appendSearch } from './helpers';
|
|||
|
||||
export const getCaseUrl = (search?: string | null) => `${appendSearch(search ?? undefined)}`;
|
||||
|
||||
export const getCaseDetailsUrl = ({ id, search }: { id: string; search?: string | null }) =>
|
||||
`/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`;
|
||||
export const getCaseDetailsUrl = ({
|
||||
id,
|
||||
search,
|
||||
subCaseId,
|
||||
}: {
|
||||
id: string;
|
||||
search?: string | null;
|
||||
subCaseId?: string;
|
||||
}) => {
|
||||
if (subCaseId) {
|
||||
return `/${encodeURIComponent(id)}/sub-cases/${encodeURIComponent(subCaseId)}${appendSearch(
|
||||
search ?? undefined
|
||||
)}`;
|
||||
}
|
||||
return `/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`;
|
||||
};
|
||||
|
||||
export const getCaseDetailsUrlWithCommentId = ({
|
||||
id,
|
||||
commentId,
|
||||
search,
|
||||
subCaseId,
|
||||
}: {
|
||||
id: string;
|
||||
commentId: string;
|
||||
search?: string | null;
|
||||
}) =>
|
||||
`/${encodeURIComponent(id)}/${encodeURIComponent(commentId)}${appendSearch(search ?? undefined)}`;
|
||||
subCaseId?: string;
|
||||
}) => {
|
||||
if (subCaseId) {
|
||||
return `/${encodeURIComponent(id)}/sub-cases/${encodeURIComponent(
|
||||
subCaseId
|
||||
)}/${encodeURIComponent(commentId)}${appendSearch(search ?? undefined)}`;
|
||||
}
|
||||
return `/${encodeURIComponent(id)}/${encodeURIComponent(commentId)}${appendSearch(
|
||||
search ?? undefined
|
||||
)}`;
|
||||
};
|
||||
|
||||
export const getCreateCaseUrl = (search?: string | null) =>
|
||||
`/create${appendSearch(search ?? undefined)}`;
|
||||
|
|
|
@ -164,24 +164,25 @@ export const NetworkDetailsLink = React.memo(NetworkDetailsLinkComponent);
|
|||
const CaseDetailsLinkComponent: React.FC<{
|
||||
children?: React.ReactNode;
|
||||
detailName: string;
|
||||
subCaseId?: string;
|
||||
title?: string;
|
||||
}> = ({ children, detailName, title }) => {
|
||||
}> = ({ children, detailName, subCaseId, title }) => {
|
||||
const { formatUrl, search } = useFormatUrl(SecurityPageName.case);
|
||||
const { navigateToApp } = useKibana().services.application;
|
||||
const goToCaseDetails = useCallback(
|
||||
(ev) => {
|
||||
ev.preventDefault();
|
||||
navigateToApp(`${APP_ID}:${SecurityPageName.case}`, {
|
||||
path: getCaseDetailsUrl({ id: detailName, search }),
|
||||
path: getCaseDetailsUrl({ id: detailName, search, subCaseId }),
|
||||
});
|
||||
},
|
||||
[detailName, navigateToApp, search]
|
||||
[detailName, navigateToApp, search, subCaseId]
|
||||
);
|
||||
|
||||
return (
|
||||
<LinkAnchor
|
||||
onClick={goToCaseDetails}
|
||||
href={formatUrl(getCaseDetailsUrl({ id: detailName }))}
|
||||
href={formatUrl(getCaseDetailsUrl({ id: detailName, subCaseId }))}
|
||||
data-test-subj="case-details-link"
|
||||
aria-label={i18n.CASE_DETAILS_LINK_ARIA(title ?? detailName)}
|
||||
>
|
||||
|
|
|
@ -421,7 +421,7 @@ describe('alert actions', () => {
|
|||
...mockEcsDataWithAlert,
|
||||
timestamp: '2020-03-20T17:59:46.349Z',
|
||||
};
|
||||
const result = determineToAndFrom({ ecsData: ecsDataMock });
|
||||
const result = determineToAndFrom({ ecs: ecsDataMock });
|
||||
|
||||
expect(result.from).toEqual('2020-03-20T17:54:46.349Z');
|
||||
expect(result.to).toEqual('2020-03-20T17:59:46.349Z');
|
||||
|
@ -431,7 +431,7 @@ describe('alert actions', () => {
|
|||
const { timestamp, ...ecsDataMock } = {
|
||||
...mockEcsDataWithAlert,
|
||||
};
|
||||
const result = determineToAndFrom({ ecsData: ecsDataMock });
|
||||
const result = determineToAndFrom({ ecs: ecsDataMock });
|
||||
|
||||
expect(result.from).toEqual('2020-03-01T17:54:46.349Z');
|
||||
expect(result.to).toEqual('2020-03-01T17:59:46.349Z');
|
||||
|
|
|
@ -39,6 +39,7 @@ import {
|
|||
} from './helpers';
|
||||
import { KueryFilterQueryKind } from '../../../common/store';
|
||||
import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider';
|
||||
import { esFilters } from '../../../../../../../src/plugins/data/public';
|
||||
|
||||
export const getUpdateAlertsQuery = (eventIds: Readonly<string[]>) => {
|
||||
return {
|
||||
|
@ -102,17 +103,32 @@ export const updateAlertStatusAction = async ({
|
|||
}
|
||||
};
|
||||
|
||||
export const determineToAndFrom = ({ ecsData }: { ecsData: Ecs }) => {
|
||||
export const determineToAndFrom = ({ ecs }: { ecs: Ecs[] | Ecs }) => {
|
||||
if (Array.isArray(ecs)) {
|
||||
const timestamps = ecs.reduce<number[]>((acc, item) => {
|
||||
if (item.timestamp != null) {
|
||||
const dateTimestamp = new Date(item.timestamp);
|
||||
if (!acc.includes(dateTimestamp.valueOf())) {
|
||||
return [...acc, dateTimestamp.valueOf()];
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
return {
|
||||
from: new Date(Math.min(...timestamps)).toISOString(),
|
||||
to: new Date(Math.max(...timestamps)).toISOString(),
|
||||
};
|
||||
}
|
||||
const ecsData = ecs as Ecs;
|
||||
const ellapsedTimeRule = moment.duration(
|
||||
moment().diff(
|
||||
dateMath.parse(ecsData.signal?.rule?.from != null ? ecsData.signal?.rule?.from[0] : 'now-0s')
|
||||
dateMath.parse(ecsData?.signal?.rule?.from != null ? ecsData.signal?.rule?.from[0] : 'now-0s')
|
||||
)
|
||||
);
|
||||
|
||||
const from = moment(ecsData.timestamp ?? new Date())
|
||||
const from = moment(ecsData?.timestamp ?? new Date())
|
||||
.subtract(ellapsedTimeRule)
|
||||
.toISOString();
|
||||
const to = moment(ecsData.timestamp ?? new Date()).toISOString();
|
||||
const to = moment(ecsData?.timestamp ?? new Date()).toISOString();
|
||||
|
||||
return { to, from };
|
||||
};
|
||||
|
@ -128,37 +144,41 @@ const getFiltersFromRule = (filters: string[]): Filter[] =>
|
|||
}, [] as Filter[]);
|
||||
|
||||
export const getThresholdAggregationDataProvider = (
|
||||
ecsData: Ecs,
|
||||
ecsData: Ecs | Ecs[],
|
||||
nonEcsData: TimelineNonEcsData[]
|
||||
): DataProvider[] => {
|
||||
const aggregationField = ecsData.signal?.rule?.threshold?.field!;
|
||||
const aggregationValue =
|
||||
get(aggregationField, ecsData) ?? find(['field', aggregationField], nonEcsData)?.value;
|
||||
const dataProviderValue = Array.isArray(aggregationValue)
|
||||
? aggregationValue[0]
|
||||
: aggregationValue;
|
||||
const thresholdEcsData: Ecs[] = Array.isArray(ecsData) ? ecsData : [ecsData];
|
||||
return thresholdEcsData.reduce<DataProvider[]>((acc, tresholdData) => {
|
||||
const aggregationField = tresholdData.signal?.rule?.threshold?.field!;
|
||||
const aggregationValue =
|
||||
get(aggregationField, tresholdData) ?? find(['field', aggregationField], nonEcsData)?.value;
|
||||
const dataProviderValue = Array.isArray(aggregationValue)
|
||||
? aggregationValue[0]
|
||||
: aggregationValue;
|
||||
|
||||
if (!dataProviderValue) {
|
||||
return [];
|
||||
}
|
||||
if (!dataProviderValue) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const aggregationFieldId = aggregationField.replace('.', '-');
|
||||
const aggregationFieldId = aggregationField.replace('.', '-');
|
||||
|
||||
return [
|
||||
{
|
||||
and: [],
|
||||
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-${aggregationFieldId}-${dataProviderValue}`,
|
||||
name: aggregationField,
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
queryMatch: {
|
||||
field: aggregationField,
|
||||
value: dataProviderValue,
|
||||
operator: ':',
|
||||
return [
|
||||
...acc,
|
||||
{
|
||||
and: [],
|
||||
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-${aggregationFieldId}-${dataProviderValue}`,
|
||||
name: aggregationField,
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
queryMatch: {
|
||||
field: aggregationField,
|
||||
value: dataProviderValue,
|
||||
operator: ':',
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
];
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const isEqlRuleWithGroupId = (ecsData: Ecs) =>
|
||||
|
@ -169,20 +189,134 @@ export const isEqlRuleWithGroupId = (ecsData: Ecs) =>
|
|||
export const isThresholdRule = (ecsData: Ecs) =>
|
||||
ecsData.signal?.rule?.type?.length && ecsData.signal?.rule?.type[0] === 'threshold';
|
||||
|
||||
export const buildAlertsKqlFilter = (
|
||||
key: '_id' | 'signal.group.id',
|
||||
alertIds: string[]
|
||||
): Filter[] => {
|
||||
return [
|
||||
{
|
||||
query: {
|
||||
bool: {
|
||||
filter: {
|
||||
ids: {
|
||||
values: alertIds,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
alias: 'Alert Ids',
|
||||
negate: false,
|
||||
disabled: false,
|
||||
type: 'phrases',
|
||||
key,
|
||||
value: alertIds.join(),
|
||||
params: alertIds,
|
||||
},
|
||||
$state: {
|
||||
store: esFilters.FilterStateStore.APP_STATE,
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const buildTimelineDataProviderOrFilter = (
|
||||
alertsIds: string[],
|
||||
_id: string
|
||||
): { filters: Filter[]; dataProviders: DataProvider[] } => {
|
||||
if (!isEmpty(alertsIds)) {
|
||||
return {
|
||||
dataProviders: [],
|
||||
filters: buildAlertsKqlFilter('_id', alertsIds),
|
||||
};
|
||||
}
|
||||
return {
|
||||
filters: [],
|
||||
dataProviders: [
|
||||
{
|
||||
and: [],
|
||||
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${_id}`,
|
||||
name: _id,
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
queryMatch: {
|
||||
field: '_id',
|
||||
value: _id,
|
||||
operator: ':' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
export const buildEqlDataProviderOrFilter = (
|
||||
alertsIds: string[],
|
||||
ecs: Ecs[] | Ecs
|
||||
): { filters: Filter[]; dataProviders: DataProvider[] } => {
|
||||
if (!isEmpty(alertsIds) && Array.isArray(ecs)) {
|
||||
return {
|
||||
dataProviders: [],
|
||||
filters: buildAlertsKqlFilter(
|
||||
'signal.group.id',
|
||||
ecs.reduce<string[]>((acc, ecsData) => {
|
||||
const signalGroupId = ecsData.signal?.group?.id?.length
|
||||
? ecsData.signal?.group?.id[0]
|
||||
: 'unknown-signal-group-id';
|
||||
if (!acc.includes(signalGroupId)) {
|
||||
return [...acc, signalGroupId];
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
),
|
||||
};
|
||||
} else if (!Array.isArray(ecs)) {
|
||||
const signalGroupId = ecs.signal?.group?.id?.length
|
||||
? ecs.signal?.group?.id[0]
|
||||
: 'unknown-signal-group-id';
|
||||
return {
|
||||
dataProviders: [
|
||||
{
|
||||
and: [],
|
||||
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${signalGroupId}`,
|
||||
name: ecs._id,
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
queryMatch: {
|
||||
field: 'signal.group.id',
|
||||
value: signalGroupId,
|
||||
operator: ':' as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
filters: [],
|
||||
};
|
||||
}
|
||||
return { filters: [], dataProviders: [] };
|
||||
};
|
||||
|
||||
export const sendAlertToTimelineAction = async ({
|
||||
apolloClient,
|
||||
createTimeline,
|
||||
ecsData,
|
||||
ecsData: ecs,
|
||||
nonEcsData,
|
||||
updateTimelineIsLoading,
|
||||
searchStrategyClient,
|
||||
}: SendAlertToTimelineActionProps) => {
|
||||
/* FUTURE DEVELOPER
|
||||
* We are making an assumption here that if you have an array of ecs data they are all coming from the same rule
|
||||
* but we still want to determine the filter for each alerts
|
||||
*/
|
||||
const ecsData: Ecs = Array.isArray(ecs) && ecs.length > 0 ? ecs[0] : (ecs as Ecs);
|
||||
const alertIds = Array.isArray(ecs) ? ecs.map((d) => d._id) : [];
|
||||
const noteContent = ecsData.signal?.rule?.note != null ? ecsData.signal?.rule?.note[0] : '';
|
||||
const timelineId =
|
||||
ecsData.signal?.rule?.timeline_id != null ? ecsData.signal?.rule?.timeline_id[0] : '';
|
||||
const { to, from } = determineToAndFrom({ ecsData });
|
||||
const { to, from } = determineToAndFrom({ ecs });
|
||||
|
||||
if (!isEmpty(timelineId) && apolloClient != null) {
|
||||
// For now we do not want to populate the template timeline if we have alertIds
|
||||
if (!isEmpty(timelineId) && apolloClient != null && isEmpty(alertIds)) {
|
||||
try {
|
||||
updateTimelineIsLoading({ id: TimelineId.active, isLoading: true });
|
||||
const [responseTimeline, eventDataResp] = await Promise.all([
|
||||
|
@ -275,7 +409,7 @@ export const sendAlertToTimelineAction = async ({
|
|||
...timelineDefaults,
|
||||
description: `_id: ${ecsData._id}`,
|
||||
filters: getFiltersFromRule(ecsData.signal?.rule?.filters as string[]),
|
||||
dataProviders: [...getThresholdAggregationDataProvider(ecsData, nonEcsData)],
|
||||
dataProviders: [...getThresholdAggregationDataProvider(ecs, nonEcsData)],
|
||||
id: TimelineId.active,
|
||||
indexNames: [],
|
||||
dateRange: {
|
||||
|
@ -301,36 +435,11 @@ export const sendAlertToTimelineAction = async ({
|
|||
ruleNote: noteContent,
|
||||
});
|
||||
} else {
|
||||
let dataProviders = [
|
||||
{
|
||||
and: [],
|
||||
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${ecsData._id}`,
|
||||
name: ecsData._id,
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
queryMatch: {
|
||||
field: '_id',
|
||||
value: ecsData._id,
|
||||
operator: ':' as const,
|
||||
},
|
||||
},
|
||||
];
|
||||
let { dataProviders, filters } = buildTimelineDataProviderOrFilter(alertIds ?? [], ecsData._id);
|
||||
if (isEqlRuleWithGroupId(ecsData)) {
|
||||
const signalGroupId = ecsData.signal?.group?.id?.length
|
||||
? ecsData.signal?.group?.id[0]
|
||||
: 'unknown-signal-group-id';
|
||||
dataProviders = [
|
||||
{
|
||||
...dataProviders[0],
|
||||
id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-${TimelineId.active}-alert-id-${signalGroupId}`,
|
||||
queryMatch: {
|
||||
field: 'signal.group.id',
|
||||
value: signalGroupId,
|
||||
operator: ':' as const,
|
||||
},
|
||||
},
|
||||
];
|
||||
const tempEql = buildEqlDataProviderOrFilter(alertIds ?? [], ecs);
|
||||
dataProviders = tempEql.dataProviders;
|
||||
filters = tempEql.filters;
|
||||
}
|
||||
|
||||
return createTimeline({
|
||||
|
@ -346,6 +455,7 @@ export const sendAlertToTimelineAction = async ({
|
|||
end: to,
|
||||
},
|
||||
eventType: 'all',
|
||||
filters,
|
||||
kqlQuery: {
|
||||
filterQuery: {
|
||||
kuery: {
|
||||
|
|
|
@ -24,14 +24,18 @@ import {
|
|||
} from '../translations';
|
||||
|
||||
interface InvestigateInTimelineActionProps {
|
||||
ariaLabel?: string;
|
||||
ecsRowData: Ecs;
|
||||
ecsRowData: Ecs | Ecs[] | null;
|
||||
nonEcsRowData: TimelineNonEcsData[];
|
||||
ariaLabel?: string;
|
||||
alertIds?: string[];
|
||||
fetchEcsAlertsData?: (alertIds?: string[]) => Promise<Ecs[]>;
|
||||
}
|
||||
|
||||
const InvestigateInTimelineActionComponent: React.FC<InvestigateInTimelineActionProps> = ({
|
||||
ariaLabel = ACTION_INVESTIGATE_IN_TIMELINE_ARIA_LABEL,
|
||||
alertIds,
|
||||
ecsRowData,
|
||||
fetchEcsAlertsData,
|
||||
nonEcsRowData,
|
||||
}) => {
|
||||
const {
|
||||
|
@ -66,25 +70,42 @@ const InvestigateInTimelineActionComponent: React.FC<InvestigateInTimelineAction
|
|||
[dispatch, updateTimelineIsLoading]
|
||||
);
|
||||
|
||||
const investigateInTimelineAlertClick = useCallback(
|
||||
() =>
|
||||
sendAlertToTimelineAction({
|
||||
apolloClient,
|
||||
createTimeline,
|
||||
ecsData: ecsRowData,
|
||||
nonEcsData: nonEcsRowData,
|
||||
searchStrategyClient,
|
||||
updateTimelineIsLoading,
|
||||
}),
|
||||
[
|
||||
apolloClient,
|
||||
createTimeline,
|
||||
ecsRowData,
|
||||
nonEcsRowData,
|
||||
searchStrategyClient,
|
||||
updateTimelineIsLoading,
|
||||
]
|
||||
);
|
||||
const investigateInTimelineAlertClick = useCallback(async () => {
|
||||
try {
|
||||
if (ecsRowData != null) {
|
||||
await sendAlertToTimelineAction({
|
||||
apolloClient,
|
||||
createTimeline,
|
||||
ecsData: ecsRowData,
|
||||
nonEcsData: nonEcsRowData,
|
||||
searchStrategyClient,
|
||||
updateTimelineIsLoading,
|
||||
});
|
||||
}
|
||||
if (ecsRowData == null && fetchEcsAlertsData) {
|
||||
const alertsEcsData = await fetchEcsAlertsData(alertIds);
|
||||
await sendAlertToTimelineAction({
|
||||
apolloClient,
|
||||
createTimeline,
|
||||
ecsData: alertsEcsData,
|
||||
nonEcsData: nonEcsRowData,
|
||||
searchStrategyClient,
|
||||
updateTimelineIsLoading,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// TODO show a toaster that something went wrong
|
||||
}
|
||||
}, [
|
||||
alertIds,
|
||||
apolloClient,
|
||||
createTimeline,
|
||||
ecsRowData,
|
||||
fetchEcsAlertsData,
|
||||
nonEcsRowData,
|
||||
searchStrategyClient,
|
||||
updateTimelineIsLoading,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ActionIconItem
|
||||
|
|
|
@ -55,7 +55,7 @@ export interface UpdateAlertStatusActionProps {
|
|||
export interface SendAlertToTimelineActionProps {
|
||||
apolloClient?: ApolloClient<{}>;
|
||||
createTimeline: CreateTimeline;
|
||||
ecsData: Ecs;
|
||||
ecsData: Ecs | Ecs[];
|
||||
nonEcsData: TimelineNonEcsData[];
|
||||
updateTimelineIsLoading: UpdateTimelineLoading;
|
||||
searchStrategyClient: ISearchStart;
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { isEmpty } from 'lodash';
|
||||
import React, { SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
import { fetchQueryAlerts } from './api';
|
||||
|
@ -80,7 +81,9 @@ export const useQueryAlerts = <Hit, Aggs>(
|
|||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
if (!isEmpty(query)) {
|
||||
fetchData();
|
||||
}
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
abortCtrl.abort();
|
||||
|
|
|
@ -172,8 +172,10 @@ export const singleBulkCreate = async ({
|
|||
logger.debug(buildRuleMessage(`took property says bulk took: ${response.took} milliseconds`));
|
||||
|
||||
const createdItems = filteredEvents.hits.hits
|
||||
.map((doc) =>
|
||||
buildBulkBody({
|
||||
.map((doc, index) => ({
|
||||
_id: response.items[index].create?._id ?? '',
|
||||
_index: response.items[index].create?._index ?? '',
|
||||
...buildBulkBody({
|
||||
doc,
|
||||
ruleParams,
|
||||
id,
|
||||
|
@ -187,8 +189,8 @@ export const singleBulkCreate = async ({
|
|||
enabled,
|
||||
tags,
|
||||
throttle,
|
||||
})
|
||||
)
|
||||
}),
|
||||
}))
|
||||
.filter((_, index) => get(response.items[index], 'create.status') === 201);
|
||||
const createdItemsCount = createdItems.length;
|
||||
const duplicateSignalsCount = countBy(response.items, 'create.status')['409'];
|
||||
|
@ -263,7 +265,11 @@ export const bulkInsertSignals = async (
|
|||
|
||||
const createdItemsCount = countBy(response.items, 'create.status')['201'] ?? 0;
|
||||
const createdItems = signals
|
||||
.map((doc) => doc._source)
|
||||
.map((doc, index) => ({
|
||||
...doc._source,
|
||||
_id: response.items[index].create?._id ?? '',
|
||||
_index: response.items[index].create?._index ?? '',
|
||||
}))
|
||||
.filter((_, index) => get(response.items[index], 'create.status') === 201);
|
||||
logger.debug(`bulk created ${createdItemsCount} signals`);
|
||||
return { bulkCreateDuration: makeFloatString(end - start), createdItems, createdItemsCount };
|
||||
|
|
|
@ -17250,7 +17250,6 @@
|
|||
"xpack.securitySolution.case.caseView.reporterLabel": "報告者",
|
||||
"xpack.securitySolution.case.caseView.requiredUpdateToExternalService": "{ externalService }インシデントの更新が必要です",
|
||||
"xpack.securitySolution.case.caseView.sendEmalLinkAria": "クリックすると、{user}に電子メールを送信します",
|
||||
"xpack.securitySolution.case.caseView.showAlertDeletedTooltip": "アラートが見つかりません",
|
||||
"xpack.securitySolution.case.caseView.showAlertTooltip": "アラートの詳細を表示",
|
||||
"xpack.securitySolution.case.caseView.statusLabel": "ステータス",
|
||||
"xpack.securitySolution.case.caseView.tags": "タグ",
|
||||
|
|
|
@ -17293,7 +17293,6 @@
|
|||
"xpack.securitySolution.case.caseView.reporterLabel": "报告者",
|
||||
"xpack.securitySolution.case.caseView.requiredUpdateToExternalService": "需要更新 { externalService } 事件",
|
||||
"xpack.securitySolution.case.caseView.sendEmalLinkAria": "单击可向 {user} 发送电子邮件",
|
||||
"xpack.securitySolution.case.caseView.showAlertDeletedTooltip": "未找到告警",
|
||||
"xpack.securitySolution.case.caseView.showAlertTooltip": "显示告警详情",
|
||||
"xpack.securitySolution.case.caseView.statusLabel": "状态",
|
||||
"xpack.securitySolution.case.caseView.tags": "标签",
|
||||
|
|
|
@ -95,6 +95,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
type: CommentType.alert,
|
||||
alertId: 'test-id',
|
||||
index: 'test-index',
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
@ -110,6 +114,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
type: CommentType.generatedAlert,
|
||||
alerts: [{ _id: 'id1' }],
|
||||
index: 'test-index',
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
@ -167,6 +175,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
type: CommentType.alert,
|
||||
alertId: 'new-id',
|
||||
index: postCommentAlertReq.index,
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
|
@ -230,6 +242,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
type: CommentType.alert,
|
||||
alertId: 'test-id',
|
||||
index: 'test-index',
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
@ -302,6 +318,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
type: CommentType.alert,
|
||||
index: 'test-index',
|
||||
alertId: 'test-id',
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
};
|
||||
|
||||
for (const attribute of ['alertId', 'index']) {
|
||||
|
@ -341,6 +361,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
type: CommentType.alert,
|
||||
index: 'test-index',
|
||||
alertId: 'test-id',
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
[attribute]: attribute,
|
||||
})
|
||||
.expect(400);
|
||||
|
|
|
@ -148,6 +148,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
type: CommentType.alert,
|
||||
index: 'test-index',
|
||||
alertId: 'test-id',
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
};
|
||||
|
||||
for (const attribute of ['alertId', 'index']) {
|
||||
|
@ -176,6 +180,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
[attribute]: attribute,
|
||||
alertId: 'test-id',
|
||||
index: 'test-index',
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
})
|
||||
.expect(400);
|
||||
}
|
||||
|
@ -296,6 +304,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
.send({
|
||||
alertId: alert._id,
|
||||
index: alert._index,
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
type: CommentType.alert,
|
||||
})
|
||||
.expect(200);
|
||||
|
@ -346,6 +358,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
.send({
|
||||
alertId: alert._id,
|
||||
index: alert._index,
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
type: CommentType.alert,
|
||||
})
|
||||
.expect(200);
|
||||
|
|
|
@ -402,6 +402,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
.send({
|
||||
alertId: alert._id,
|
||||
index: alert._index,
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
type: CommentType.alert,
|
||||
})
|
||||
.expect(200);
|
||||
|
@ -453,6 +457,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
alertId: alert._id,
|
||||
index: alert._index,
|
||||
type: CommentType.alert,
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
|
@ -503,6 +511,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
.send({
|
||||
alertId: alert._id,
|
||||
index: alert._index,
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
type: CommentType.alert,
|
||||
})
|
||||
.expect(200);
|
||||
|
@ -570,6 +582,10 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
alertId: alert._id,
|
||||
index: alert._index,
|
||||
type: CommentType.alert,
|
||||
rule: {
|
||||
id: 'id',
|
||||
name: 'name',
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
|
|
|
@ -736,7 +736,12 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
subAction: 'addComment',
|
||||
subActionParams: {
|
||||
caseId: caseRes.body.id,
|
||||
comment: { alertId: alert._id, index: alert._index, type: CommentType.alert },
|
||||
comment: {
|
||||
alertId: alert._id,
|
||||
index: alert._index,
|
||||
type: CommentType.alert,
|
||||
rule: { id: 'id', name: 'name' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -784,7 +789,12 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
.expect(200);
|
||||
|
||||
createdActionId = createdAction.id;
|
||||
const comment = { alertId: 'test-id', index: 'test-index', type: CommentType.alert };
|
||||
const comment = {
|
||||
alertId: 'test-id',
|
||||
index: 'test-index',
|
||||
type: CommentType.alert,
|
||||
rule: { id: 'id', name: 'name' },
|
||||
};
|
||||
const params = {
|
||||
subAction: 'addComment',
|
||||
subActionParams: {
|
||||
|
@ -876,7 +886,12 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
subAction: 'addComment',
|
||||
subActionParams: {
|
||||
caseId: '123',
|
||||
comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert },
|
||||
comment: {
|
||||
alertId: 'test-id',
|
||||
index: 'test-index',
|
||||
type: CommentType.alert,
|
||||
rule: { id: 'id', name: 'name' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -1017,7 +1032,12 @@ export default ({ getService }: FtrProviderContext): void => {
|
|||
subAction: 'addComment',
|
||||
subActionParams: {
|
||||
caseId: caseRes.body.id,
|
||||
comment: { alertId: 'test-id', index: 'test-index', type: CommentType.alert },
|
||||
comment: {
|
||||
alertId: 'test-id',
|
||||
index: 'test-index',
|
||||
type: CommentType.alert,
|
||||
rule: { id: 'id', name: 'name' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import {
|
||||
CommentSchemaType,
|
||||
ContextTypeGeneratedAlertType,
|
||||
createAlertsString,
|
||||
isCommentGeneratedAlert,
|
||||
transformConnectorComment,
|
||||
} from '../../../../plugins/case/server/connectors';
|
||||
|
@ -70,12 +71,15 @@ export const postCommentUserReq: CommentRequestUserType = {
|
|||
export const postCommentAlertReq: CommentRequestAlertType = {
|
||||
alertId: 'test-id',
|
||||
index: 'test-index',
|
||||
rule: { id: 'test-rule-id', name: 'test-index-id' },
|
||||
type: CommentType.alert,
|
||||
};
|
||||
|
||||
export const postCommentGenAlertReq: ContextTypeGeneratedAlertType = {
|
||||
alerts: [{ _id: 'test-id' }, { _id: 'test-id2' }],
|
||||
index: 'test-index',
|
||||
alerts: createAlertsString([
|
||||
{ _id: 'test-id', _index: 'test-index', ruleId: 'rule-id', ruleName: 'rule name' },
|
||||
{ _id: 'test-id2', _index: 'test-index', ruleId: 'rule-id', ruleName: 'rule name' },
|
||||
]),
|
||||
type: CommentType.generatedAlert,
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in a new issue