mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
## Summary
With this PR we introduce a new Alert User Assignment feature:
- It is possible to assign a user/s to alert/s
- There is a new "Assignees" column in the alerts table which displays
avatars of assigned users
- There is a bulk action to update assignees for multiple alerts
- It is possible to see and update assignees inside the alert details
flyout component
- There is an "Assignees" filter button on the Alerts page which allows
to filter alerts by assignees
We decided to develop this feature on a separate branch. This gives us
ability to make sure that it is thoroughly tested and we did not break
anything in production. Since there is a data scheme changes involved we
decided that it will be a better approach. cc @yctercero
## Testing notes
In order to test assignments you need to create a few users. Then for
users to appear in user profiles dropdown menu you need to activate them
by login into those account at least once.
8eeb13f3
-2d16-4fba-acdf-755024a59fc2
Main ticket https://github.com/elastic/security-team/issues/2504
## Bugfixes
- [x] https://github.com/elastic/security-team/issues/8028
- [x] https://github.com/elastic/security-team/issues/8034
- [x] https://github.com/elastic/security-team/issues/8006
- [x] https://github.com/elastic/security-team/issues/8025
## Enhancements
- [x] https://github.com/elastic/security-team/issues/8033
### Checklist
- [x] Functional changes are hidden behind a feature flag. If not
hidden, the PR explains why these changes are being implemented in a
long-living feature branch.
- [x] Functional changes are covered with a test plan and automated
tests.
- [x] https://github.com/elastic/kibana/issues/171306
- [x] https://github.com/elastic/kibana/issues/171307
- [x] Stability of new and changed tests is verified using the [Flaky
Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner).
- [x]
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/4091
- [x] Comprehensive manual testing is done by two engineers: the PR
author and one of the PR reviewers. Changes are tested in both ESS and
Serverless.
- [x] Mapping changes are accompanied by a technical design document. It
can be a GitHub issue or an RFC explaining the changes. The design
document is shared with and approved by the appropriate teams and
individual stakeholders.
* https://github.com/elastic/security-team/issues/7647
- [x] Functional changes are communicated to the Docs team. A ticket or
PR is opened in https://github.com/elastic/security-docs. The following
information is included: any feature flags used, affected environments
(Serverless, ESS, or both). **NOTE: as discussed we will wait until docs
are ready to merge this PR**.
* https://github.com/elastic/security-docs/issues/4226
* https://github.com/elastic/staging-serverless-security-docs/pull/232
---------
Co-authored-by: Marshall Main <marshall.main@elastic.co>
Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Sergi Massaneda <sergi.massaneda@gmail.com>
This commit is contained in:
parent
d24d43c7c1
commit
1ebdbc380d
137 changed files with 6337 additions and 218 deletions
|
@ -32,6 +32,7 @@ import {
|
|||
ALERT_TIME_RANGE,
|
||||
ALERT_URL,
|
||||
ALERT_UUID,
|
||||
ALERT_WORKFLOW_ASSIGNEE_IDS,
|
||||
ALERT_WORKFLOW_STATUS,
|
||||
ALERT_WORKFLOW_TAGS,
|
||||
SPACE_IDS,
|
||||
|
@ -190,6 +191,11 @@ export const alertFieldMap = {
|
|||
array: true,
|
||||
required: false,
|
||||
},
|
||||
[ALERT_WORKFLOW_ASSIGNEE_IDS]: {
|
||||
type: 'keyword',
|
||||
array: true,
|
||||
required: false,
|
||||
},
|
||||
[EVENT_ACTION]: {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
|
|
|
@ -98,6 +98,7 @@ const AlertOptional = rt.partial({
|
|||
'kibana.alert.start': schemaDate,
|
||||
'kibana.alert.time_range': schemaDateRange,
|
||||
'kibana.alert.url': schemaString,
|
||||
'kibana.alert.workflow_assignee_ids': schemaStringArray,
|
||||
'kibana.alert.workflow_status': schemaString,
|
||||
'kibana.alert.workflow_tags': schemaStringArray,
|
||||
'kibana.version': schemaString,
|
||||
|
|
|
@ -193,6 +193,7 @@ const SecurityAlertOptional = rt.partial({
|
|||
),
|
||||
'kibana.alert.time_range': schemaDateRange,
|
||||
'kibana.alert.url': schemaString,
|
||||
'kibana.alert.workflow_assignee_ids': schemaStringArray,
|
||||
'kibana.alert.workflow_reason': schemaString,
|
||||
'kibana.alert.workflow_status': schemaString,
|
||||
'kibana.alert.workflow_tags': schemaStringArray,
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
ALERT_RISK_SCORE,
|
||||
ALERT_SEVERITY,
|
||||
ALERT_RULE_PARAMETERS,
|
||||
ALERT_WORKFLOW_ASSIGNEE_IDS,
|
||||
ALERT_WORKFLOW_TAGS,
|
||||
} from '@kbn/rule-data-utils';
|
||||
|
||||
|
@ -46,6 +47,7 @@ export const ALERT_EVENTS_FIELDS = [
|
|||
ALERT_RULE_CONSUMER,
|
||||
'@timestamp',
|
||||
'kibana.alert.ancestors.index',
|
||||
ALERT_WORKFLOW_ASSIGNEE_IDS,
|
||||
'kibana.alert.workflow_status',
|
||||
ALERT_WORKFLOW_TAGS,
|
||||
'kibana.alert.group.id',
|
||||
|
|
|
@ -70,6 +70,9 @@ const ALERT_WORKFLOW_STATUS = `${ALERT_NAMESPACE}.workflow_status` as const;
|
|||
// kibana.alert.workflow_tags - user workflow alert tags
|
||||
const ALERT_WORKFLOW_TAGS = `${ALERT_NAMESPACE}.workflow_tags` as const;
|
||||
|
||||
// kibana.alert.workflow_assignee_ids - user workflow alert assignees
|
||||
const ALERT_WORKFLOW_ASSIGNEE_IDS = `${ALERT_NAMESPACE}.workflow_assignee_ids` as const;
|
||||
|
||||
// kibana.alert.rule.category - rule type name for rule that generated this alert
|
||||
const ALERT_RULE_CATEGORY = `${ALERT_RULE_NAMESPACE}.category` as const;
|
||||
|
||||
|
@ -135,6 +138,7 @@ const fields = {
|
|||
ALERT_TIME_RANGE,
|
||||
ALERT_URL,
|
||||
ALERT_UUID,
|
||||
ALERT_WORKFLOW_ASSIGNEE_IDS,
|
||||
ALERT_WORKFLOW_STATUS,
|
||||
ALERT_WORKFLOW_TAGS,
|
||||
SPACE_IDS,
|
||||
|
@ -174,6 +178,7 @@ export {
|
|||
ALERT_TIME_RANGE,
|
||||
ALERT_URL,
|
||||
ALERT_UUID,
|
||||
ALERT_WORKFLOW_ASSIGNEE_IDS,
|
||||
ALERT_WORKFLOW_STATUS,
|
||||
ALERT_WORKFLOW_TAGS,
|
||||
SPACE_IDS,
|
||||
|
|
|
@ -32,6 +32,7 @@ import {
|
|||
ALERT_STATUS,
|
||||
ALERT_TIME_RANGE,
|
||||
ALERT_UUID,
|
||||
ALERT_WORKFLOW_ASSIGNEE_IDS,
|
||||
ALERT_WORKFLOW_STATUS,
|
||||
ALERT_WORKFLOW_TAGS,
|
||||
SPACE_IDS,
|
||||
|
@ -174,6 +175,7 @@ const fields = {
|
|||
ALERT_STATUS,
|
||||
ALERT_SYSTEM_STATUS,
|
||||
ALERT_UUID,
|
||||
ALERT_WORKFLOW_ASSIGNEE_IDS,
|
||||
ALERT_WORKFLOW_REASON,
|
||||
ALERT_WORKFLOW_STATUS,
|
||||
ALERT_WORKFLOW_TAGS,
|
||||
|
|
|
@ -24,6 +24,7 @@ export type SignalEcsAAD = Exclude<SignalEcs, 'rule' | 'status'> & {
|
|||
building_block_type?: string[];
|
||||
workflow_status?: string[];
|
||||
workflow_tags?: string[];
|
||||
workflow_assignee_ids?: string[];
|
||||
suppression?: {
|
||||
docs_count: string[];
|
||||
};
|
||||
|
|
|
@ -14,3 +14,11 @@ export const UPGRADE_INVESTIGATION_GUIDE = (requiredLicense: string) =>
|
|||
requiredLicense,
|
||||
},
|
||||
});
|
||||
|
||||
export const UPGRADE_ALERT_ASSIGNMENTS = (requiredLicense: string) =>
|
||||
i18n.translate('securitySolutionPackages.alertAssignments.upsell', {
|
||||
defaultMessage: 'Upgrade to {requiredLicense} to make use of alert assignments',
|
||||
values: {
|
||||
requiredLicense,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -17,4 +17,4 @@ export type UpsellingSectionId =
|
|||
| 'osquery_automated_response_actions'
|
||||
| 'ruleDetailsEndpointExceptions';
|
||||
|
||||
export type UpsellingMessageId = 'investigation_guide';
|
||||
export type UpsellingMessageId = 'investigation_guide' | 'alert_assignments';
|
||||
|
|
|
@ -311,6 +311,9 @@ describe('mappingFromFieldMap', () => {
|
|||
workflow_tags: {
|
||||
type: 'keyword',
|
||||
},
|
||||
workflow_assignee_ids: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
space_ids: {
|
||||
|
|
|
@ -293,6 +293,11 @@ it('matches snapshot', () => {
|
|||
"required": true,
|
||||
"type": "keyword",
|
||||
},
|
||||
"kibana.alert.workflow_assignee_ids": Object {
|
||||
"array": true,
|
||||
"required": false,
|
||||
"type": "keyword",
|
||||
},
|
||||
"kibana.alert.workflow_reason": Object {
|
||||
"array": false,
|
||||
"required": false,
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export * from './set_alert_assignees_route.gen';
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export * from './set_alert_assignees_route.mock';
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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 { z } from 'zod';
|
||||
|
||||
/*
|
||||
* NOTICE: Do not edit this file manually.
|
||||
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
|
||||
*/
|
||||
|
||||
import { NonEmptyString } from '../model/rule_schema/common_attributes.gen';
|
||||
|
||||
export type AlertAssignees = z.infer<typeof AlertAssignees>;
|
||||
export const AlertAssignees = z.object({
|
||||
/**
|
||||
* A list of users ids to assign.
|
||||
*/
|
||||
add: z.array(NonEmptyString),
|
||||
/**
|
||||
* A list of users ids to unassign.
|
||||
*/
|
||||
remove: z.array(NonEmptyString),
|
||||
});
|
||||
|
||||
/**
|
||||
* A list of alerts ids.
|
||||
*/
|
||||
export type AlertIds = z.infer<typeof AlertIds>;
|
||||
export const AlertIds = z.array(NonEmptyString).min(1);
|
||||
|
||||
export type SetAlertAssigneesRequestBody = z.infer<typeof SetAlertAssigneesRequestBody>;
|
||||
export const SetAlertAssigneesRequestBody = z.object({
|
||||
/**
|
||||
* Details about the assignees to assign and unassign.
|
||||
*/
|
||||
assignees: AlertAssignees,
|
||||
/**
|
||||
* List of alerts ids to assign and unassign passed assignees.
|
||||
*/
|
||||
ids: AlertIds,
|
||||
});
|
||||
export type SetAlertAssigneesRequestBodyInput = z.input<typeof SetAlertAssigneesRequestBody>;
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { SetAlertAssigneesRequestBody } from './set_alert_assignees_route.gen';
|
||||
|
||||
export const getSetAlertAssigneesRequestMock = (
|
||||
assigneesToAdd: string[] = [],
|
||||
assigneesToRemove: string[] = [],
|
||||
ids: string[] = []
|
||||
): SetAlertAssigneesRequestBody => ({
|
||||
assignees: { add: assigneesToAdd, remove: assigneesToRemove },
|
||||
ids,
|
||||
});
|
|
@ -0,0 +1,58 @@
|
|||
openapi: 3.0.0
|
||||
info:
|
||||
title: Assign alerts API endpoint
|
||||
version: '2023-10-31'
|
||||
paths:
|
||||
/api/detection_engine/signals/assignees:
|
||||
summary: Assigns users to alerts
|
||||
post:
|
||||
operationId: SetAlertAssignees
|
||||
x-codegen-enabled: true
|
||||
description: Assigns users to alerts.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- assignees
|
||||
- ids
|
||||
properties:
|
||||
assignees:
|
||||
$ref: '#/components/schemas/AlertAssignees'
|
||||
description: Details about the assignees to assign and unassign.
|
||||
ids:
|
||||
$ref: '#/components/schemas/AlertIds'
|
||||
description: List of alerts ids to assign and unassign passed assignees.
|
||||
responses:
|
||||
200:
|
||||
description: Indicates a successful call.
|
||||
400:
|
||||
description: Invalid request.
|
||||
|
||||
components:
|
||||
schemas:
|
||||
AlertAssignees:
|
||||
type: object
|
||||
required:
|
||||
- add
|
||||
- remove
|
||||
properties:
|
||||
add:
|
||||
type: array
|
||||
items:
|
||||
$ref: '../model/rule_schema/common_attributes.schema.yaml#/components/schemas/NonEmptyString'
|
||||
description: A list of users ids to assign.
|
||||
remove:
|
||||
type: array
|
||||
items:
|
||||
$ref: '../model/rule_schema/common_attributes.schema.yaml#/components/schemas/NonEmptyString'
|
||||
description: A list of users ids to unassign.
|
||||
|
||||
AlertIds:
|
||||
type: array
|
||||
items:
|
||||
$ref: '../model/rule_schema/common_attributes.schema.yaml#/components/schemas/NonEmptyString'
|
||||
minItems: 1
|
||||
description: A list of alerts ids.
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export * from './alert_assignees';
|
||||
export * from './alert_tags';
|
||||
export * from './fleet_integrations';
|
||||
export * from './index_management';
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
|
||||
import type { AlertWithCommonFields800 } from '@kbn/rule-registry-plugin/common/schemas/8.0.0';
|
||||
import type {
|
||||
Ancestor890,
|
||||
BaseFields890,
|
||||
EqlBuildingBlockFields890,
|
||||
EqlShellFields890,
|
||||
NewTermsFields890,
|
||||
} from '../8.9.0';
|
||||
|
||||
/* DO NOT MODIFY THIS SCHEMA TO ADD NEW FIELDS. These types represent the alerts that shipped in 8.12.0.
|
||||
Any changes to these types should be bug fixes so the types more accurately represent the alerts from 8.12.0.
|
||||
If you are adding new fields for a new release of Kibana, create a new sibling folder to this one
|
||||
for the version to be released and add the field(s) to the schema in that folder.
|
||||
Then, update `../index.ts` to import from the new folder that has the latest schemas, add the
|
||||
new schemas to the union of all alert schemas, and re-export the new schemas as the `*Latest` schemas.
|
||||
*/
|
||||
|
||||
export type { Ancestor890 as Ancestor8120 };
|
||||
|
||||
export interface BaseFields8120 extends BaseFields890 {
|
||||
[ALERT_WORKFLOW_ASSIGNEE_IDS]: string[] | undefined;
|
||||
}
|
||||
|
||||
export interface WrappedFields8120<T extends BaseFields8120> {
|
||||
_id: string;
|
||||
_index: string;
|
||||
_source: T;
|
||||
}
|
||||
|
||||
export type GenericAlert8120 = AlertWithCommonFields800<BaseFields8120>;
|
||||
|
||||
export type EqlShellFields8120 = EqlShellFields890 & BaseFields8120;
|
||||
|
||||
export type EqlBuildingBlockFields8120 = EqlBuildingBlockFields890 & BaseFields8120;
|
||||
|
||||
export type NewTermsFields8120 = NewTermsFields890 & BaseFields8120;
|
||||
|
||||
export type NewTermsAlert8120 = NewTermsFields890 & BaseFields8120;
|
||||
|
||||
export type EqlBuildingBlockAlert8120 = AlertWithCommonFields800<EqlBuildingBlockFields890>;
|
||||
|
||||
export type EqlShellAlert8120 = AlertWithCommonFields800<EqlShellFields8120>;
|
||||
|
||||
export type DetectionAlert8120 =
|
||||
| GenericAlert8120
|
||||
| EqlShellAlert8120
|
||||
| EqlBuildingBlockAlert8120
|
||||
| NewTermsAlert8120;
|
|
@ -11,15 +11,16 @@ import type { DetectionAlert840 } from './8.4.0';
|
|||
import type { DetectionAlert860 } from './8.6.0';
|
||||
import type { DetectionAlert870 } from './8.7.0';
|
||||
import type { DetectionAlert880 } from './8.8.0';
|
||||
import type { DetectionAlert890 } from './8.9.0';
|
||||
import type {
|
||||
Ancestor890,
|
||||
BaseFields890,
|
||||
DetectionAlert890,
|
||||
EqlBuildingBlockFields890,
|
||||
EqlShellFields890,
|
||||
NewTermsFields890,
|
||||
WrappedFields890,
|
||||
} from './8.9.0';
|
||||
Ancestor8120,
|
||||
BaseFields8120,
|
||||
DetectionAlert8120,
|
||||
EqlBuildingBlockFields8120,
|
||||
EqlShellFields8120,
|
||||
NewTermsFields8120,
|
||||
WrappedFields8120,
|
||||
} from './8.12.0';
|
||||
|
||||
// When new Alert schemas are created for new Kibana versions, add the DetectionAlert type from the new version
|
||||
// here, e.g. `export type DetectionAlert = DetectionAlert800 | DetectionAlert820` if a new schema is created in 8.2.0
|
||||
|
@ -29,14 +30,15 @@ export type DetectionAlert =
|
|||
| DetectionAlert860
|
||||
| DetectionAlert870
|
||||
| DetectionAlert880
|
||||
| DetectionAlert890;
|
||||
| DetectionAlert890
|
||||
| DetectionAlert8120;
|
||||
|
||||
export type {
|
||||
Ancestor890 as AncestorLatest,
|
||||
BaseFields890 as BaseFieldsLatest,
|
||||
DetectionAlert890 as DetectionAlertLatest,
|
||||
WrappedFields890 as WrappedFieldsLatest,
|
||||
EqlBuildingBlockFields890 as EqlBuildingBlockFieldsLatest,
|
||||
EqlShellFields890 as EqlShellFieldsLatest,
|
||||
NewTermsFields890 as NewTermsFieldsLatest,
|
||||
Ancestor8120 as AncestorLatest,
|
||||
BaseFields8120 as BaseFieldsLatest,
|
||||
DetectionAlert8120 as DetectionAlertLatest,
|
||||
WrappedFields8120 as WrappedFieldsLatest,
|
||||
EqlBuildingBlockFields8120 as EqlBuildingBlockFieldsLatest,
|
||||
EqlShellFields8120 as EqlShellFieldsLatest,
|
||||
NewTermsFields8120 as NewTermsFieldsLatest,
|
||||
};
|
||||
|
|
|
@ -107,3 +107,6 @@ export const alert_tags = t.type({
|
|||
});
|
||||
|
||||
export type AlertTags = t.TypeOf<typeof alert_tags>;
|
||||
|
||||
export const user_search_term = t.string;
|
||||
export type UserSearchTerm = t.TypeOf<typeof user_search_term>;
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export * from './suggest_user_profiles_route.gen';
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* 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 { z } from 'zod';
|
||||
|
||||
/*
|
||||
* NOTICE: Do not edit this file manually.
|
||||
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
|
||||
*/
|
||||
|
||||
export type SuggestUserProfilesRequestQuery = z.infer<typeof SuggestUserProfilesRequestQuery>;
|
||||
export const SuggestUserProfilesRequestQuery = z.object({
|
||||
/**
|
||||
* Query string used to match name-related fields in user profiles. The following fields are treated as name-related: username, full_name and email
|
||||
*/
|
||||
searchTerm: z.string().optional(),
|
||||
});
|
||||
export type SuggestUserProfilesRequestQueryInput = z.input<typeof SuggestUserProfilesRequestQuery>;
|
|
@ -0,0 +1,23 @@
|
|||
openapi: 3.0.0
|
||||
info:
|
||||
title: Suggest user profiles API endpoint
|
||||
version: '2023-10-31'
|
||||
paths:
|
||||
/api/detection_engine/signals/_find:
|
||||
summary: Suggests user profiles based on provided search term
|
||||
post:
|
||||
operationId: SuggestUserProfiles
|
||||
x-codegen-enabled: true
|
||||
description: Suggests user profiles.
|
||||
parameters:
|
||||
- name: searchTerm
|
||||
in: query
|
||||
required: false
|
||||
description: "Query string used to match name-related fields in user profiles. The following fields are treated as name-related: username, full_name and email"
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
200:
|
||||
description: Indicates a successful call.
|
||||
400:
|
||||
description: Invalid request.
|
|
@ -319,6 +319,10 @@ export const DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL =
|
|||
export const DETECTION_ENGINE_SIGNALS_FINALIZE_MIGRATION_URL =
|
||||
`${DETECTION_ENGINE_SIGNALS_URL}/finalize_migration` as const;
|
||||
export const DETECTION_ENGINE_ALERT_TAGS_URL = `${DETECTION_ENGINE_SIGNALS_URL}/tags` as const;
|
||||
export const DETECTION_ENGINE_ALERT_ASSIGNEES_URL =
|
||||
`${DETECTION_ENGINE_SIGNALS_URL}/assignees` as const;
|
||||
export const DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL =
|
||||
`${DETECTION_ENGINE_SIGNALS_URL}/_find` as const;
|
||||
|
||||
export const ALERTS_AS_DATA_URL = '/internal/rac/alerts' as const;
|
||||
export const ALERTS_AS_DATA_FIND_URL = `${ALERTS_AS_DATA_URL}/find` as const;
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import type { EuiDataGridCellValueElementProps } from '@elastic/eui';
|
||||
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
|
||||
import type { ColumnHeaderOptions, RowRenderer } from '../..';
|
||||
import type { RenderCellValueContext } from '../../../../public/detections/configurations/security_solution_detections/fetch_page_context';
|
||||
import type { BrowserFields, TimelineNonEcsData } from '../../../search_strategy';
|
||||
|
||||
/** The following props are provided to the function called by `renderCellValue` */
|
||||
|
@ -28,4 +29,5 @@ export type CellValueElementProps = EuiDataGridCellValueElementProps & {
|
|||
truncate?: boolean;
|
||||
key?: string;
|
||||
closeCellPopover?: () => void;
|
||||
context?: RenderCellValueContext;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,216 @@
|
|||
# Alert User Assignment
|
||||
|
||||
This is a test plan for the Alert User Assignment feature
|
||||
|
||||
Status: `in progress`. The current test plan covers functionality described in [Alert User Assignment](https://github.com/elastic/security-team/issues/2504) epic.
|
||||
|
||||
## Useful information
|
||||
|
||||
### Tickets
|
||||
|
||||
- [Alert User Assignment](https://github.com/elastic/security-team/issues/2504) epic
|
||||
- [Add test coverage for Alert User Assignment](https://github.com/elastic/kibana/issues/171307)
|
||||
- [Write a test plan for Alert User Assignment](https://github.com/elastic/kibana/issues/171306)
|
||||
|
||||
### Terminology
|
||||
|
||||
- **Assignee**: The user assigned to an alert.
|
||||
|
||||
- **Assignees field**: The alert's `kibana.alert.workflow_assignee_ids` field which contains an array of assignees IDs. These ids conrespond to [User Profiles](https://www.elastic.co/guide/en/elasticsearch/reference/current/user-profile.html) endpoint.
|
||||
|
||||
- **Assignee's avatar**: The avatar of an assignee. Can be either user profile picture if uploaded by the user or initials of the user.
|
||||
|
||||
- **Assignees count badge**: The badge with the number of assignees.
|
||||
|
||||
### Assumptions
|
||||
|
||||
- The feature is **NOT** available under the Basic license
|
||||
- Assignees are stored as an array of users IDs in alert's `kibana.alert.workflow_assignee_ids` field
|
||||
- There are multiple (five or more) available users which could be assigned to alerts
|
||||
- User need to have editor or higher privileges to assign users to alerts
|
||||
- Mixed states are not supported by the current version of User Profiles component
|
||||
- "Displayed/Shown in UI" refers to "Alerts Table" and "Alert's Details Flyout"
|
||||
|
||||
## Scenarios
|
||||
|
||||
### Basic rendering
|
||||
|
||||
#### **Scenario: No assignees**
|
||||
|
||||
**Automation**: 2 e2e test + 2 unit test.
|
||||
|
||||
```Gherkin
|
||||
Given an alert doesn't have assignees
|
||||
Then no assignees' (represented by avatars) should be displayed in UI
|
||||
```
|
||||
|
||||
#### **Scenario: With assignees**
|
||||
|
||||
**Automation**: 2 e2e test + 2 unit test.
|
||||
|
||||
```Gherkin
|
||||
Given an alert has assignees
|
||||
Then assignees' (represented by avatars) for each assignee should be shown in UI
|
||||
```
|
||||
|
||||
#### **Scenario: Many assignees (Badge)**
|
||||
|
||||
**Automation**: 2 e2e test + 2 unit test.
|
||||
|
||||
```Gherkin
|
||||
Given an alert has more assignees than maximum number allowed to display
|
||||
Then assignees count badge is displayed in UI
|
||||
```
|
||||
|
||||
### Updating assignees (single alert)
|
||||
|
||||
#### **Scenario: Add new assignees**
|
||||
|
||||
**Automation**: 3 e2e test + 1 unit test + 1 integration test.
|
||||
|
||||
```Gherkin
|
||||
Given an alert
|
||||
When user adds new assignees
|
||||
Then assignees field should be updated
|
||||
And newly added assignees should be present
|
||||
```
|
||||
|
||||
#### **Scenario: Update assignees**
|
||||
|
||||
**Automation**: 3 e2e test + 1 unit test + 1 integration test.
|
||||
|
||||
```Gherkin
|
||||
Given an alert with assignees
|
||||
When user removes some of (or all) current assignees and adds new assignees
|
||||
Then assignees field should be updated
|
||||
And removed assignees should be absent
|
||||
And newly added assignees should be present
|
||||
```
|
||||
|
||||
#### **Scenario: Unassign alert**
|
||||
|
||||
**Automation**: 2 e2e test + 1 unit test.
|
||||
|
||||
```Gherkin
|
||||
Given an alert with assignees
|
||||
When user triggers "Unassign alert" action
|
||||
Then assignees field should be updated
|
||||
And assignees field should be empty
|
||||
```
|
||||
|
||||
### Updating assignees (bulk actions)
|
||||
|
||||
#### **Scenario: Add new assignees**
|
||||
|
||||
**Automation**: 1 e2e test + 1 unit test + 1 integration test.
|
||||
|
||||
```Gherkin
|
||||
Given multiple alerts
|
||||
When user adds new assignees
|
||||
Then assignees fields of all involved alerts should be updated
|
||||
And newly added assignees should be present
|
||||
```
|
||||
|
||||
#### **Scenario: Update assignees**
|
||||
|
||||
**Automation**: 1 e2e test + 1 unit test + 1 integration test.
|
||||
|
||||
```Gherkin
|
||||
Given multiple alerts with assignees
|
||||
When user removes some of (or all) current assignees and adds new assignees
|
||||
Then assignees fields of all involved alerts should be updated
|
||||
And removed assignees should be absent
|
||||
And newly added assignees should be present
|
||||
```
|
||||
|
||||
#### **Scenario: Unassign alert**
|
||||
|
||||
**Automation**: 1 e2e test + 1 unit test.
|
||||
|
||||
```Gherkin
|
||||
Given multiple alerts with assignees
|
||||
When user triggers "Unassign alert" action
|
||||
Then assignees fields of all involved alerts should be updated
|
||||
And assignees fields should be empty
|
||||
```
|
||||
|
||||
### Alerts filtering
|
||||
|
||||
#### **Scenario: By one assignee**
|
||||
|
||||
**Automation**: 1 e2e test + 1 unit test.
|
||||
|
||||
```Gherkin
|
||||
Given multiple alerts with and without assignees
|
||||
When user filters by one of the assignees
|
||||
Then only alerts with selected assignee in assignees field are displayed
|
||||
```
|
||||
|
||||
#### **Scenario: By multiple assignees**
|
||||
|
||||
**Automation**: 1 e2e test + 1 unit test.
|
||||
|
||||
```Gherkin
|
||||
Given multiple alerts with and without assignees
|
||||
When user filters by multiple assignees
|
||||
Then all alerts with either of selected assignees in assignees fields are displayed
|
||||
```
|
||||
|
||||
#### **Scenario: "No assignees" option**
|
||||
|
||||
**Automation**: 1 e2e test + 1 unit test.
|
||||
|
||||
```Gherkin
|
||||
Given filter by assignees UI is available
|
||||
Then there should be an option to filter alerts to see those which are not assigned to anyone
|
||||
```
|
||||
|
||||
#### **Scenario: By "No assignees"**
|
||||
|
||||
**Automation**: 1 e2e test + 1 unit test.
|
||||
|
||||
```Gherkin
|
||||
Given multiple alerts with and without assignees
|
||||
When user filters by "No assignees" option
|
||||
Then all alerts with empty assignees fields are displayed
|
||||
```
|
||||
|
||||
#### **Scenario: By assignee and alert status**
|
||||
|
||||
**Automation**: 1 e2e test + 1 unit test.
|
||||
|
||||
```Gherkin
|
||||
Given multiple alerts with and without assignees
|
||||
When user filters by one of the assignees
|
||||
AND alert's status
|
||||
Then only alerts with selected assignee in assignees field AND selected alert's status are displayed
|
||||
```
|
||||
|
||||
### Authorization / RBAC
|
||||
|
||||
#### **Scenario: Viewer role**
|
||||
|
||||
**Automation**: 1 e2e test + 1 unit test + 1 integration test.
|
||||
|
||||
```Gherkin
|
||||
Given user has "viewer/readonly" role
|
||||
Then there should not be a way to update assignees field for an alert
|
||||
```
|
||||
|
||||
#### **Scenario: Serverless roles**
|
||||
|
||||
**Automation**: 1 e2e test + 1 unit test + 1 integration test.
|
||||
|
||||
```Gherkin
|
||||
Given users 't1_analyst', 't2_analyst', 't3_analyst', 'rule_author', 'soc_manager', 'detections_admin', 'platform_engineer' roles
|
||||
Then update assignees functionality should be available
|
||||
```
|
||||
|
||||
#### **Scenario: Basic license**
|
||||
|
||||
**Automation**: 1 e2e test + 1 unit test + 1 integration test.
|
||||
|
||||
```Gherkin
|
||||
Given user runs Kibana under the Basic license
|
||||
Then update assignees functionality should not be available
|
||||
```
|
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import { ASSIGNEES_APPLY_BUTTON_TEST_ID, ASSIGNEES_APPLY_PANEL_TEST_ID } from './test_ids';
|
||||
import { AssigneesApplyPanel } from './assignees_apply_panel';
|
||||
|
||||
import { useGetCurrentUserProfile } from '../user_profiles/use_get_current_user_profile';
|
||||
import { useBulkGetUserProfiles } from '../user_profiles/use_bulk_get_user_profiles';
|
||||
import { useSuggestUsers } from '../user_profiles/use_suggest_users';
|
||||
import { TestProviders } from '../../mock';
|
||||
import * as i18n from './translations';
|
||||
import { mockUserProfiles } from './mocks';
|
||||
|
||||
jest.mock('../user_profiles/use_get_current_user_profile');
|
||||
jest.mock('../user_profiles/use_bulk_get_user_profiles');
|
||||
jest.mock('../user_profiles/use_suggest_users');
|
||||
|
||||
const renderAssigneesApplyPanel = (
|
||||
{
|
||||
assignedUserIds,
|
||||
showUnassignedOption,
|
||||
onSelectionChange,
|
||||
onAssigneesApply,
|
||||
}: {
|
||||
assignedUserIds: string[];
|
||||
showUnassignedOption?: boolean;
|
||||
onSelectionChange?: () => void;
|
||||
onAssigneesApply?: () => void;
|
||||
} = { assignedUserIds: [] }
|
||||
) => {
|
||||
const assignedProfiles = mockUserProfiles.filter((user) => assignedUserIds.includes(user.uid));
|
||||
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: assignedProfiles,
|
||||
});
|
||||
return render(
|
||||
<TestProviders>
|
||||
<AssigneesApplyPanel
|
||||
assignedUserIds={assignedUserIds}
|
||||
showUnassignedOption={showUnassignedOption}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onAssigneesApply={onAssigneesApply}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AssigneesApplyPanel />', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useGetCurrentUserProfile as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles[0],
|
||||
});
|
||||
(useSuggestUsers as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render component', () => {
|
||||
const { getByTestId, queryByTestId } = renderAssigneesApplyPanel();
|
||||
|
||||
expect(getByTestId(ASSIGNEES_APPLY_PANEL_TEST_ID)).toBeInTheDocument();
|
||||
expect(queryByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render apply button if `onAssigneesApply` callback provided', () => {
|
||||
const { getByTestId } = renderAssigneesApplyPanel({
|
||||
assignedUserIds: [],
|
||||
onAssigneesApply: jest.fn(),
|
||||
});
|
||||
|
||||
expect(getByTestId(ASSIGNEES_APPLY_PANEL_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render `no assignees` option', () => {
|
||||
const { getByTestId } = renderAssigneesApplyPanel({
|
||||
assignedUserIds: [],
|
||||
showUnassignedOption: true,
|
||||
onAssigneesApply: jest.fn(),
|
||||
});
|
||||
|
||||
const assigneesList = getByTestId('euiSelectableList');
|
||||
expect(assigneesList).toHaveTextContent(i18n.ASSIGNEES_NO_ASSIGNEES);
|
||||
});
|
||||
|
||||
it('should call `onAssigneesApply` on apply button click', () => {
|
||||
const onAssigneesApplyMock = jest.fn();
|
||||
const { getByText, getByTestId } = renderAssigneesApplyPanel({
|
||||
assignedUserIds: ['user-id-1'],
|
||||
onAssigneesApply: onAssigneesApplyMock,
|
||||
});
|
||||
|
||||
getByText(mockUserProfiles[1].user.full_name).click();
|
||||
getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID).click();
|
||||
|
||||
expect(onAssigneesApplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(onAssigneesApplyMock).toHaveBeenLastCalledWith(['user-id-2', 'user-id-1']);
|
||||
});
|
||||
|
||||
it('should call `onSelectionChange` on user selection', () => {
|
||||
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: [],
|
||||
});
|
||||
|
||||
const onSelectionChangeMock = jest.fn();
|
||||
const { getByText } = renderAssigneesApplyPanel({
|
||||
assignedUserIds: [],
|
||||
onSelectionChange: onSelectionChangeMock,
|
||||
});
|
||||
|
||||
getByText('User 1').click();
|
||||
getByText('User 2').click();
|
||||
getByText('User 3').click();
|
||||
getByText('User 3').click();
|
||||
getByText('User 2').click();
|
||||
getByText('User 1').click();
|
||||
|
||||
expect(onSelectionChangeMock).toHaveBeenCalledTimes(6);
|
||||
expect(onSelectionChangeMock.mock.calls).toEqual([
|
||||
[['user-id-1']],
|
||||
[['user-id-2', 'user-id-1']],
|
||||
[['user-id-3', 'user-id-2', 'user-id-1']],
|
||||
[['user-id-2', 'user-id-1']],
|
||||
[['user-id-1']],
|
||||
[[]],
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,156 @@
|
|||
/*
|
||||
* 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 { isEqual } from 'lodash/fp';
|
||||
import type { FC } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { EuiButton } from '@elastic/eui';
|
||||
import { UserProfilesSelectable } from '@kbn/user-profile-components';
|
||||
|
||||
import { isEmpty } from 'lodash';
|
||||
import { useGetCurrentUserProfile } from '../user_profiles/use_get_current_user_profile';
|
||||
import * as i18n from './translations';
|
||||
import type { AssigneesIdsSelection, AssigneesProfilesSelection } from './types';
|
||||
import { NO_ASSIGNEES_VALUE } from './constants';
|
||||
import { useSuggestUsers } from '../user_profiles/use_suggest_users';
|
||||
import { useBulkGetUserProfiles } from '../user_profiles/use_bulk_get_user_profiles';
|
||||
import { bringCurrentUserToFrontAndSort, removeNoAssigneesSelection } from './utils';
|
||||
import { ASSIGNEES_APPLY_BUTTON_TEST_ID, ASSIGNEES_APPLY_PANEL_TEST_ID } from './test_ids';
|
||||
|
||||
export interface AssigneesApplyPanelProps {
|
||||
/**
|
||||
* Identifier of search field.
|
||||
*/
|
||||
searchInputId?: string;
|
||||
|
||||
/**
|
||||
* Ids of the users assigned to the alert
|
||||
*/
|
||||
assignedUserIds: AssigneesIdsSelection[];
|
||||
|
||||
/**
|
||||
* Show "Unassigned" option if needed
|
||||
*/
|
||||
showUnassignedOption?: boolean;
|
||||
|
||||
/**
|
||||
* Callback to handle changing of the assignees selection
|
||||
*/
|
||||
onSelectionChange?: (users: AssigneesIdsSelection[]) => void;
|
||||
|
||||
/**
|
||||
* Callback to handle applying assignees. If provided will show "Apply assignees" button
|
||||
*/
|
||||
onAssigneesApply?: (selectedAssignees: AssigneesIdsSelection[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The popover to allow selection of users from a list
|
||||
*/
|
||||
export const AssigneesApplyPanel: FC<AssigneesApplyPanelProps> = memo(
|
||||
({
|
||||
searchInputId,
|
||||
assignedUserIds,
|
||||
showUnassignedOption,
|
||||
onSelectionChange,
|
||||
onAssigneesApply,
|
||||
}) => {
|
||||
const { data: currentUserProfile } = useGetCurrentUserProfile();
|
||||
const existingIds = useMemo(
|
||||
() => new Set(removeNoAssigneesSelection(assignedUserIds)),
|
||||
[assignedUserIds]
|
||||
);
|
||||
const { isLoading: isLoadingAssignedUsers, data: assignedUsers } = useBulkGetUserProfiles({
|
||||
uids: existingIds,
|
||||
});
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { isLoading: isLoadingSuggestedUsers, data: userProfiles } = useSuggestUsers({
|
||||
searchTerm,
|
||||
});
|
||||
|
||||
const searchResultProfiles = useMemo(() => {
|
||||
const sortedUsers = bringCurrentUserToFrontAndSort(currentUserProfile, userProfiles) ?? [];
|
||||
|
||||
if (showUnassignedOption && isEmpty(searchTerm)) {
|
||||
return [NO_ASSIGNEES_VALUE, ...sortedUsers];
|
||||
}
|
||||
|
||||
return sortedUsers;
|
||||
}, [currentUserProfile, searchTerm, showUnassignedOption, userProfiles]);
|
||||
|
||||
const [selectedAssignees, setSelectedAssignees] = useState<AssigneesProfilesSelection[]>([]);
|
||||
useEffect(() => {
|
||||
if (isLoadingAssignedUsers || !assignedUsers) {
|
||||
return;
|
||||
}
|
||||
const hasNoAssigneesSelection = assignedUserIds.find((uid) => uid === NO_ASSIGNEES_VALUE);
|
||||
const newAssignees =
|
||||
hasNoAssigneesSelection !== undefined
|
||||
? [NO_ASSIGNEES_VALUE, ...assignedUsers]
|
||||
: assignedUsers;
|
||||
setSelectedAssignees(newAssignees);
|
||||
}, [assignedUserIds, assignedUsers, isLoadingAssignedUsers]);
|
||||
|
||||
const handleSelectedAssignees = useCallback(
|
||||
(newAssignees: AssigneesProfilesSelection[]) => {
|
||||
if (!isEqual(newAssignees, selectedAssignees)) {
|
||||
setSelectedAssignees(newAssignees);
|
||||
onSelectionChange?.(newAssignees.map((assignee) => assignee?.uid ?? NO_ASSIGNEES_VALUE));
|
||||
}
|
||||
},
|
||||
[onSelectionChange, selectedAssignees]
|
||||
);
|
||||
|
||||
const handleApplyButtonClick = useCallback(() => {
|
||||
const selectedIds = selectedAssignees.map((assignee) => assignee?.uid ?? NO_ASSIGNEES_VALUE);
|
||||
onAssigneesApply?.(selectedIds);
|
||||
}, [onAssigneesApply, selectedAssignees]);
|
||||
|
||||
const selectedStatusMessage = useCallback(
|
||||
(total: number) => i18n.ASSIGNEES_SELECTION_STATUS_MESSAGE(total),
|
||||
[]
|
||||
);
|
||||
|
||||
const isLoading = isLoadingAssignedUsers || isLoadingSuggestedUsers;
|
||||
|
||||
return (
|
||||
<div data-test-subj={ASSIGNEES_APPLY_PANEL_TEST_ID}>
|
||||
<UserProfilesSelectable
|
||||
searchInputId={searchInputId}
|
||||
onSearchChange={(term: string) => {
|
||||
setSearchTerm(term);
|
||||
}}
|
||||
onChange={handleSelectedAssignees}
|
||||
selectedStatusMessage={selectedStatusMessage}
|
||||
options={searchResultProfiles}
|
||||
selectedOptions={selectedAssignees}
|
||||
isLoading={isLoading}
|
||||
height={'full'}
|
||||
singleSelection={false}
|
||||
searchPlaceholder={i18n.ASSIGNEES_SEARCH_USERS}
|
||||
clearButtonLabel={i18n.ASSIGNEES_CLEAR_FILTERS}
|
||||
nullOptionLabel={i18n.ASSIGNEES_NO_ASSIGNEES}
|
||||
/>
|
||||
{onAssigneesApply && (
|
||||
<EuiButton
|
||||
data-test-subj={ASSIGNEES_APPLY_BUTTON_TEST_ID}
|
||||
fullWidth
|
||||
size="s"
|
||||
onClick={handleApplyButtonClick}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
{i18n.ASSIGNEES_APPLY_BUTTON}
|
||||
</EuiButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AssigneesApplyPanel.displayName = 'AssigneesPanel';
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import { ASSIGNEES_APPLY_PANEL_TEST_ID } from './test_ids';
|
||||
import { AssigneesPopover } from './assignees_popover';
|
||||
|
||||
import { useGetCurrentUserProfile } from '../user_profiles/use_get_current_user_profile';
|
||||
import { useBulkGetUserProfiles } from '../user_profiles/use_bulk_get_user_profiles';
|
||||
import { useSuggestUsers } from '../user_profiles/use_suggest_users';
|
||||
import { TestProviders } from '../../mock';
|
||||
import { mockUserProfiles } from './mocks';
|
||||
import { EuiButton } from '@elastic/eui';
|
||||
|
||||
jest.mock('../user_profiles/use_get_current_user_profile');
|
||||
jest.mock('../user_profiles/use_bulk_get_user_profiles');
|
||||
jest.mock('../user_profiles/use_suggest_users');
|
||||
|
||||
const MOCK_BUTTON_TEST_ID = 'mock-assignees-button';
|
||||
|
||||
const renderAssigneesPopover = ({
|
||||
assignedUserIds,
|
||||
isPopoverOpen,
|
||||
}: {
|
||||
assignedUserIds: string[];
|
||||
isPopoverOpen: boolean;
|
||||
}) => {
|
||||
const assignedProfiles = mockUserProfiles.filter((user) => assignedUserIds.includes(user.uid));
|
||||
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: assignedProfiles,
|
||||
});
|
||||
return render(
|
||||
<TestProviders>
|
||||
<AssigneesPopover
|
||||
assignedUserIds={assignedUserIds}
|
||||
button={<EuiButton data-test-subj={MOCK_BUTTON_TEST_ID} />}
|
||||
isPopoverOpen={isPopoverOpen}
|
||||
closePopover={jest.fn()}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
};
|
||||
|
||||
describe('<AssigneesPopover />', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useGetCurrentUserProfile as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles[0],
|
||||
});
|
||||
(useSuggestUsers as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render closed popover component', () => {
|
||||
const { getByTestId, queryByTestId } = renderAssigneesPopover({
|
||||
assignedUserIds: [],
|
||||
isPopoverOpen: false,
|
||||
});
|
||||
|
||||
expect(getByTestId(MOCK_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
expect(queryByTestId(ASSIGNEES_APPLY_PANEL_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render opened popover component', () => {
|
||||
const { getByTestId } = renderAssigneesPopover({
|
||||
assignedUserIds: [],
|
||||
isPopoverOpen: true,
|
||||
});
|
||||
|
||||
expect(getByTestId(MOCK_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(ASSIGNEES_APPLY_PANEL_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render assignees', () => {
|
||||
const { getByTestId } = renderAssigneesPopover({
|
||||
assignedUserIds: [],
|
||||
isPopoverOpen: true,
|
||||
});
|
||||
|
||||
const assigneesList = getByTestId('euiSelectableList');
|
||||
expect(assigneesList).toHaveTextContent('User 1');
|
||||
expect(assigneesList).toHaveTextContent('user1@test.com');
|
||||
expect(assigneesList).toHaveTextContent('User 2');
|
||||
expect(assigneesList).toHaveTextContent('user2@test.com');
|
||||
expect(assigneesList).toHaveTextContent('User 3');
|
||||
expect(assigneesList).toHaveTextContent('user3@test.com');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { EuiPopover, useGeneratedHtmlId } from '@elastic/eui';
|
||||
|
||||
import { ASSIGNEES_PANEL_WIDTH } from './constants';
|
||||
import { AssigneesApplyPanel } from './assignees_apply_panel';
|
||||
import type { AssigneesIdsSelection } from './types';
|
||||
|
||||
export interface AssigneesPopoverProps {
|
||||
/**
|
||||
* Ids of the users assigned to the alert
|
||||
*/
|
||||
assignedUserIds: AssigneesIdsSelection[];
|
||||
|
||||
/**
|
||||
* Show "Unassigned" option if needed
|
||||
*/
|
||||
showUnassignedOption?: boolean;
|
||||
|
||||
/**
|
||||
* Triggering element for which to align the popover to
|
||||
*/
|
||||
button: NonNullable<ReactNode>;
|
||||
|
||||
/**
|
||||
* Boolean to allow popover to be opened or closed
|
||||
*/
|
||||
isPopoverOpen: boolean;
|
||||
|
||||
/**
|
||||
* Callback to handle hiding of the popover
|
||||
*/
|
||||
closePopover: () => void;
|
||||
|
||||
/**
|
||||
* Callback to handle changing of the assignees selection
|
||||
*/
|
||||
onSelectionChange?: (users: AssigneesIdsSelection[]) => void;
|
||||
|
||||
/**
|
||||
* Callback to handle applying assignees
|
||||
*/
|
||||
onAssigneesApply?: (selectedAssignees: AssigneesIdsSelection[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The popover to allow selection of users from a list
|
||||
*/
|
||||
export const AssigneesPopover: FC<AssigneesPopoverProps> = memo(
|
||||
({
|
||||
assignedUserIds,
|
||||
showUnassignedOption,
|
||||
button,
|
||||
isPopoverOpen,
|
||||
closePopover,
|
||||
onSelectionChange,
|
||||
onAssigneesApply,
|
||||
}) => {
|
||||
const searchInputId = useGeneratedHtmlId({
|
||||
prefix: 'searchInput',
|
||||
});
|
||||
|
||||
return (
|
||||
<EuiPopover
|
||||
panelPaddingSize="none"
|
||||
initialFocus={`#${searchInputId}`}
|
||||
button={button}
|
||||
isOpen={isPopoverOpen}
|
||||
panelStyle={{
|
||||
minWidth: ASSIGNEES_PANEL_WIDTH,
|
||||
}}
|
||||
closePopover={closePopover}
|
||||
>
|
||||
<AssigneesApplyPanel
|
||||
searchInputId={searchInputId}
|
||||
assignedUserIds={assignedUserIds}
|
||||
showUnassignedOption={showUnassignedOption}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onAssigneesApply={onAssigneesApply}
|
||||
/>
|
||||
</EuiPopover>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AssigneesPopover.displayName = 'AssigneesPopover';
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const ASSIGNEES_PANEL_WIDTH = 400;
|
||||
|
||||
export const NO_ASSIGNEES_VALUE = null;
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const mockUserProfiles = [
|
||||
{
|
||||
uid: 'user-id-1',
|
||||
enabled: true,
|
||||
user: { username: 'user1', full_name: 'User 1', email: 'user1@test.com' },
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
uid: 'user-id-2',
|
||||
enabled: true,
|
||||
user: { username: 'user2', full_name: 'User 2', email: 'user2@test.com' },
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
uid: 'user-id-3',
|
||||
enabled: true,
|
||||
user: { username: 'user3', full_name: 'User 3', email: 'user3@test.com' },
|
||||
data: {},
|
||||
},
|
||||
];
|
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const PREFIX = 'securitySolutionAssignees';
|
||||
|
||||
/* Apply Panel */
|
||||
export const ASSIGNEES_APPLY_PANEL_TEST_ID = `${PREFIX}ApplyPanel`;
|
||||
export const ASSIGNEES_APPLY_BUTTON_TEST_ID = `${PREFIX}ApplyButton`;
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const ASSIGNEES_SELECTION_STATUS_MESSAGE = (total: number) =>
|
||||
i18n.translate('xpack.securitySolution.assignees.totalUsersAssigned', {
|
||||
defaultMessage: '{total, plural, one {# filter} other {# filters}} selected',
|
||||
values: { total },
|
||||
});
|
||||
|
||||
export const ASSIGNEES_APPLY_BUTTON = i18n.translate(
|
||||
'xpack.securitySolution.assignees.applyButtonTitle',
|
||||
{
|
||||
defaultMessage: 'Apply',
|
||||
}
|
||||
);
|
||||
|
||||
export const ASSIGNEES_SEARCH_USERS = i18n.translate(
|
||||
'xpack.securitySolution.assignees.selectableSearchPlaceholder',
|
||||
{
|
||||
defaultMessage: 'Search users',
|
||||
}
|
||||
);
|
||||
|
||||
export const ASSIGNEES_CLEAR_FILTERS = i18n.translate(
|
||||
'xpack.securitySolution.assignees.clearFilters',
|
||||
{
|
||||
defaultMessage: 'Clear filters',
|
||||
}
|
||||
);
|
||||
|
||||
export const ASSIGNEES_NO_ASSIGNEES = i18n.translate(
|
||||
'xpack.securitySolution.assignees.noAssigneesLabel',
|
||||
{
|
||||
defaultMessage: 'No assignees',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
|
||||
|
||||
export type AssigneesIdsSelection = string | null;
|
||||
export type AssigneesProfilesSelection = UserProfileWithAvatar | null;
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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 { NO_ASSIGNEES_VALUE } from './constants';
|
||||
import { mockUserProfiles } from './mocks';
|
||||
import { bringCurrentUserToFrontAndSort, removeNoAssigneesSelection } from './utils';
|
||||
|
||||
describe('utils', () => {
|
||||
describe('removeNoAssigneesSelection', () => {
|
||||
it('should return user ids if `no assignees` has not been passed', () => {
|
||||
const assignees = ['user1', 'user2', 'user3'];
|
||||
const ids = removeNoAssigneesSelection(assignees);
|
||||
expect(ids).toEqual(assignees);
|
||||
});
|
||||
|
||||
it('should return user ids and remove `no assignees`', () => {
|
||||
const assignees = [NO_ASSIGNEES_VALUE, 'user1', 'user2', NO_ASSIGNEES_VALUE, 'user3'];
|
||||
const ids = removeNoAssigneesSelection(assignees);
|
||||
expect(ids).toEqual(['user1', 'user2', 'user3']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bringCurrentUserToFrontAndSort', () => {
|
||||
it('should return `undefined` if nothing has been passed', () => {
|
||||
const sortedProfiles = bringCurrentUserToFrontAndSort();
|
||||
expect(sortedProfiles).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return passed profiles if current user is `undefined`', () => {
|
||||
const sortedProfiles = bringCurrentUserToFrontAndSort(undefined, mockUserProfiles);
|
||||
expect(sortedProfiles).toEqual(mockUserProfiles);
|
||||
});
|
||||
|
||||
it('should return profiles with the current user on top', () => {
|
||||
const currentUser = mockUserProfiles[1];
|
||||
const sortedProfiles = bringCurrentUserToFrontAndSort(currentUser, mockUserProfiles);
|
||||
expect(sortedProfiles).toEqual([currentUser, mockUserProfiles[0], mockUserProfiles[2]]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* 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 { sortBy } from 'lodash';
|
||||
|
||||
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
|
||||
|
||||
import { NO_ASSIGNEES_VALUE } from './constants';
|
||||
import type { AssigneesIdsSelection } from './types';
|
||||
|
||||
const getSortField = (profile: UserProfileWithAvatar) =>
|
||||
profile.user?.full_name?.toLowerCase() ??
|
||||
profile.user?.email?.toLowerCase() ??
|
||||
profile.user?.username.toLowerCase();
|
||||
|
||||
const sortProfiles = (profiles?: UserProfileWithAvatar[]) => {
|
||||
if (!profiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
return sortBy(profiles, getSortField);
|
||||
};
|
||||
|
||||
const moveCurrentUserToBeginning = <T extends { uid: string }>(
|
||||
currentUserProfile?: T,
|
||||
profiles?: T[]
|
||||
) => {
|
||||
if (!profiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentUserProfile) {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
const currentProfileIndex = profiles.find((profile) => profile.uid === currentUserProfile.uid);
|
||||
|
||||
if (!currentProfileIndex) {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
const profilesWithoutCurrentUser = profiles.filter(
|
||||
(profile) => profile.uid !== currentUserProfile.uid
|
||||
);
|
||||
|
||||
return [currentUserProfile, ...profilesWithoutCurrentUser];
|
||||
};
|
||||
|
||||
export const bringCurrentUserToFrontAndSort = (
|
||||
currentUserProfile?: UserProfileWithAvatar,
|
||||
profiles?: UserProfileWithAvatar[]
|
||||
) => moveCurrentUserToBeginning(currentUserProfile, sortProfiles(profiles));
|
||||
|
||||
export const removeNoAssigneesSelection = (assignees: AssigneesIdsSelection[]): string[] =>
|
||||
assignees.filter<string>((assignee): assignee is string => assignee !== NO_ASSIGNEES_VALUE);
|
|
@ -24,6 +24,7 @@ export const TEST_IDS = {
|
|||
EDIT: 'filter-group__context--edit',
|
||||
DISCARD: `filter-group__context--discard`,
|
||||
},
|
||||
FILTER_BY_ASSIGNEES_BUTTON: 'filter-popover-button-assignees',
|
||||
};
|
||||
|
||||
export const COMMON_OPTIONS_LIST_CONTROL_INPUTS: Partial<AddOptionsListControlProps> = {
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import { FilterByAssigneesPopover } from './filter_by_assignees';
|
||||
import { TEST_IDS } from './constants';
|
||||
import { TestProviders } from '../../mock';
|
||||
import type { AssigneesIdsSelection } from '../assignees/types';
|
||||
|
||||
import { useGetCurrentUserProfile } from '../user_profiles/use_get_current_user_profile';
|
||||
import { useBulkGetUserProfiles } from '../user_profiles/use_bulk_get_user_profiles';
|
||||
import { useSuggestUsers } from '../user_profiles/use_suggest_users';
|
||||
import { useLicense } from '../../hooks/use_license';
|
||||
import { useUpsellingMessage } from '../../hooks/use_upselling';
|
||||
|
||||
jest.mock('../user_profiles/use_get_current_user_profile');
|
||||
jest.mock('../user_profiles/use_bulk_get_user_profiles');
|
||||
jest.mock('../user_profiles/use_suggest_users');
|
||||
jest.mock('../../hooks/use_license');
|
||||
jest.mock('../../hooks/use_upselling');
|
||||
|
||||
const mockUserProfiles = [
|
||||
{
|
||||
uid: 'user-id-1',
|
||||
enabled: true,
|
||||
user: { username: 'user1', full_name: 'User 1', email: 'user1@test.com' },
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
uid: 'user-id-2',
|
||||
enabled: true,
|
||||
user: { username: 'user2', full_name: 'User 2', email: 'user2@test.com' },
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
uid: 'user-id-3',
|
||||
enabled: true,
|
||||
user: { username: 'user3', full_name: 'User 3', email: 'user3@test.com' },
|
||||
data: {},
|
||||
},
|
||||
];
|
||||
|
||||
const renderFilterByAssigneesPopover = (
|
||||
alertAssignees: AssigneesIdsSelection[] = [],
|
||||
onUsersChange = jest.fn()
|
||||
) =>
|
||||
render(
|
||||
<TestProviders>
|
||||
<FilterByAssigneesPopover
|
||||
assignedUserIds={alertAssignees}
|
||||
onSelectionChange={onUsersChange}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
describe('<FilterByAssigneesPopover />', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useGetCurrentUserProfile as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles[0],
|
||||
});
|
||||
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: [],
|
||||
});
|
||||
(useSuggestUsers as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles,
|
||||
});
|
||||
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true });
|
||||
(useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!');
|
||||
});
|
||||
|
||||
it('should render closed popover component', () => {
|
||||
const { getByTestId, queryByTestId } = renderFilterByAssigneesPopover();
|
||||
|
||||
expect(getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON)).toBeInTheDocument();
|
||||
expect(queryByTestId('euiSelectableList')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render opened popover component', () => {
|
||||
const { getByTestId } = renderFilterByAssigneesPopover();
|
||||
|
||||
getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON).click();
|
||||
expect(getByTestId('euiSelectableList')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render assignees', () => {
|
||||
const { getByTestId } = renderFilterByAssigneesPopover();
|
||||
|
||||
getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON).click();
|
||||
|
||||
const assigneesList = getByTestId('euiSelectableList');
|
||||
expect(assigneesList).toHaveTextContent('User 1');
|
||||
expect(assigneesList).toHaveTextContent('user1@test.com');
|
||||
expect(assigneesList).toHaveTextContent('User 2');
|
||||
expect(assigneesList).toHaveTextContent('user2@test.com');
|
||||
expect(assigneesList).toHaveTextContent('User 3');
|
||||
expect(assigneesList).toHaveTextContent('user3@test.com');
|
||||
});
|
||||
|
||||
it('should call onUsersChange on closing the popover', () => {
|
||||
const onUsersChangeMock = jest.fn();
|
||||
const { getByTestId, getByText } = renderFilterByAssigneesPopover([], onUsersChangeMock);
|
||||
|
||||
getByTestId(TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON).click();
|
||||
|
||||
getByText('User 1').click();
|
||||
getByText('User 2').click();
|
||||
getByText('User 3').click();
|
||||
getByText('User 3').click();
|
||||
getByText('User 2').click();
|
||||
getByText('User 1').click();
|
||||
|
||||
expect(onUsersChangeMock).toHaveBeenCalledTimes(6);
|
||||
expect(onUsersChangeMock.mock.calls).toEqual([
|
||||
[['user-id-1']],
|
||||
[['user-id-2', 'user-id-1']],
|
||||
[['user-id-3', 'user-id-2', 'user-id-1']],
|
||||
[['user-id-2', 'user-id-1']],
|
||||
[['user-id-1']],
|
||||
[[]],
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { FC } from 'react';
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { EuiFilterButton, EuiFilterGroup, EuiToolTip } from '@elastic/eui';
|
||||
|
||||
import { TEST_IDS } from './constants';
|
||||
import { AssigneesPopover } from '../assignees/assignees_popover';
|
||||
import type { AssigneesIdsSelection } from '../assignees/types';
|
||||
import { useLicense } from '../../hooks/use_license';
|
||||
import { useUpsellingMessage } from '../../hooks/use_upselling';
|
||||
|
||||
export interface FilterByAssigneesPopoverProps {
|
||||
/**
|
||||
* Ids of the users assigned to the alert
|
||||
*/
|
||||
assignedUserIds: AssigneesIdsSelection[];
|
||||
|
||||
/**
|
||||
* Callback to handle changing of the assignees selection
|
||||
*/
|
||||
onSelectionChange?: (users: AssigneesIdsSelection[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The popover to filter alerts by assigned users
|
||||
*/
|
||||
export const FilterByAssigneesPopover: FC<FilterByAssigneesPopoverProps> = memo(
|
||||
({ assignedUserIds, onSelectionChange }) => {
|
||||
const isPlatinumPlus = useLicense().isPlatinumPlus();
|
||||
const upsellingMessage = useUpsellingMessage('alert_assignments');
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const togglePopover = useCallback(() => setIsPopoverOpen((value) => !value), []);
|
||||
|
||||
const [selectedAssignees, setSelectedAssignees] =
|
||||
useState<AssigneesIdsSelection[]>(assignedUserIds);
|
||||
const handleSelectionChange = useCallback(
|
||||
(users: AssigneesIdsSelection[]) => {
|
||||
setSelectedAssignees(users);
|
||||
onSelectionChange?.(users);
|
||||
},
|
||||
[onSelectionChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFilterGroup>
|
||||
<AssigneesPopover
|
||||
assignedUserIds={assignedUserIds}
|
||||
showUnassignedOption={true}
|
||||
button={
|
||||
<EuiToolTip
|
||||
position="bottom"
|
||||
content={
|
||||
upsellingMessage ??
|
||||
i18n.translate('xpack.securitySolution.filtersGroup.assignees.popoverTooltip', {
|
||||
defaultMessage: 'Filter by assignee',
|
||||
})
|
||||
}
|
||||
>
|
||||
<EuiFilterButton
|
||||
data-test-subj={TEST_IDS.FILTER_BY_ASSIGNEES_BUTTON}
|
||||
iconType="arrowDown"
|
||||
badgeColor="subdued"
|
||||
disabled={!isPlatinumPlus}
|
||||
onClick={togglePopover}
|
||||
isSelected={isPopoverOpen}
|
||||
hasActiveFilters={selectedAssignees.length > 0}
|
||||
numActiveFilters={selectedAssignees.length}
|
||||
>
|
||||
{i18n.translate('xpack.securitySolution.filtersGroup.assignees.buttonTitle', {
|
||||
defaultMessage: 'Assignees',
|
||||
})}
|
||||
</EuiFilterButton>
|
||||
</EuiToolTip>
|
||||
}
|
||||
isPopoverOpen={isPopoverOpen}
|
||||
closePopover={togglePopover}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
/>
|
||||
</EuiFilterGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FilterByAssigneesPopover.displayName = 'FilterByAssigneesPopover';
|
|
@ -0,0 +1,189 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { TimelineItem } from '@kbn/timelines-plugin/common';
|
||||
import { act, fireEvent, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { TestProviders } from '../../../mock';
|
||||
import { useGetCurrentUserProfile } from '../../user_profiles/use_get_current_user_profile';
|
||||
import { useBulkGetUserProfiles } from '../../user_profiles/use_bulk_get_user_profiles';
|
||||
import { useSuggestUsers } from '../../user_profiles/use_suggest_users';
|
||||
|
||||
import { BulkAlertAssigneesPanel } from './alert_bulk_assignees';
|
||||
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
|
||||
import { ASSIGNEES_APPLY_BUTTON_TEST_ID } from '../../assignees/test_ids';
|
||||
|
||||
jest.mock('../../user_profiles/use_get_current_user_profile');
|
||||
jest.mock('../../user_profiles/use_bulk_get_user_profiles');
|
||||
jest.mock('../../user_profiles/use_suggest_users');
|
||||
|
||||
const mockUserProfiles = [
|
||||
{ uid: 'user-id-1', enabled: true, user: { username: 'user1' }, data: {} },
|
||||
{ uid: 'user-id-2', enabled: true, user: { username: 'user2' }, data: {} },
|
||||
];
|
||||
|
||||
const mockSuggestedUserProfiles = [
|
||||
...mockUserProfiles,
|
||||
{ uid: 'user-id-3', enabled: true, user: { username: 'user3' }, data: {} },
|
||||
{ uid: 'user-id-4', enabled: true, user: { username: 'user4' }, data: {} },
|
||||
];
|
||||
|
||||
const mockAlertsWithAssignees = [
|
||||
{
|
||||
_id: 'test-id',
|
||||
data: [
|
||||
{
|
||||
field: ALERT_WORKFLOW_ASSIGNEE_IDS,
|
||||
value: ['user-id-1', 'user-id-2'],
|
||||
},
|
||||
],
|
||||
ecs: { _id: 'test-id' },
|
||||
},
|
||||
{
|
||||
_id: 'test-id',
|
||||
data: [
|
||||
{
|
||||
field: ALERT_WORKFLOW_ASSIGNEE_IDS,
|
||||
value: ['user-id-1', 'user-id-2'],
|
||||
},
|
||||
],
|
||||
ecs: { _id: 'test-id' },
|
||||
},
|
||||
];
|
||||
|
||||
(useGetCurrentUserProfile as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles[0],
|
||||
});
|
||||
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles,
|
||||
});
|
||||
(useSuggestUsers as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockSuggestedUserProfiles,
|
||||
});
|
||||
|
||||
const renderAssigneesMenu = (
|
||||
items: TimelineItem[],
|
||||
closePopover: () => void = jest.fn(),
|
||||
onSubmit: () => Promise<void> = jest.fn(),
|
||||
setIsLoading: () => void = jest.fn()
|
||||
) => {
|
||||
return render(
|
||||
<TestProviders>
|
||||
<BulkAlertAssigneesPanel
|
||||
alertItems={items}
|
||||
setIsLoading={setIsLoading}
|
||||
closePopoverMenu={closePopover}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
};
|
||||
|
||||
describe('BulkAlertAssigneesPanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('it renders', () => {
|
||||
const wrapper = renderAssigneesMenu(mockAlertsWithAssignees);
|
||||
|
||||
expect(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
expect(useSuggestUsers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('it calls expected functions on submit when nothing has changed', () => {
|
||||
const mockedClosePopover = jest.fn();
|
||||
const mockedOnSubmit = jest.fn();
|
||||
const mockedSetIsLoading = jest.fn();
|
||||
|
||||
const wrapper = renderAssigneesMenu(
|
||||
mockAlertsWithAssignees,
|
||||
mockedClosePopover,
|
||||
mockedOnSubmit,
|
||||
mockedSetIsLoading
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID));
|
||||
});
|
||||
expect(mockedClosePopover).toHaveBeenCalled();
|
||||
expect(mockedOnSubmit).not.toHaveBeenCalled();
|
||||
expect(mockedSetIsLoading).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('it updates state correctly', () => {
|
||||
const wrapper = renderAssigneesMenu(mockAlertsWithAssignees);
|
||||
|
||||
const deselectUser = (userName: string, index: number) => {
|
||||
expect(wrapper.getAllByRole('option')[index]).toHaveAttribute('title', userName);
|
||||
expect(wrapper.getAllByRole('option')[index]).toBeChecked();
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByText(userName));
|
||||
});
|
||||
expect(wrapper.getAllByRole('option')[index]).toHaveAttribute('title', userName);
|
||||
expect(wrapper.getAllByRole('option')[index]).not.toBeChecked();
|
||||
};
|
||||
|
||||
const selectUser = (userName: string, index = 0) => {
|
||||
expect(wrapper.getAllByRole('option')[index]).toHaveAttribute('title', userName);
|
||||
expect(wrapper.getAllByRole('option')[index]).not.toBeChecked();
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByText(userName));
|
||||
});
|
||||
expect(wrapper.getAllByRole('option')[index]).toHaveAttribute('title', userName);
|
||||
expect(wrapper.getAllByRole('option')[index]).toBeChecked();
|
||||
};
|
||||
|
||||
deselectUser('user1', 0);
|
||||
deselectUser('user2', 1);
|
||||
selectUser('user3', 2);
|
||||
selectUser('user4', 3);
|
||||
});
|
||||
|
||||
test('it calls expected functions on submit when alerts have changed', () => {
|
||||
const mockedClosePopover = jest.fn();
|
||||
const mockedOnSubmit = jest.fn();
|
||||
const mockedSetIsLoading = jest.fn();
|
||||
|
||||
const wrapper = renderAssigneesMenu(
|
||||
mockAlertsWithAssignees,
|
||||
mockedClosePopover,
|
||||
mockedOnSubmit,
|
||||
mockedSetIsLoading
|
||||
);
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByText('user1'));
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByText('user2'));
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByText('user3'));
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByText('user4'));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID));
|
||||
});
|
||||
expect(mockedClosePopover).toHaveBeenCalled();
|
||||
expect(mockedOnSubmit).toHaveBeenCalled();
|
||||
expect(mockedOnSubmit).toHaveBeenCalledWith(
|
||||
{
|
||||
add: ['user-id-4', 'user-id-3'],
|
||||
remove: ['user-id-1', 'user-id-2'],
|
||||
},
|
||||
['test-id', 'test-id'],
|
||||
expect.anything(), // An anonymous callback defined in the onSubmit function
|
||||
mockedSetIsLoading
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* 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 { intersection } from 'lodash';
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
|
||||
import type { TimelineItem } from '@kbn/timelines-plugin/common';
|
||||
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
|
||||
|
||||
import type { SetAlertAssigneesFunc } from './use_set_alert_assignees';
|
||||
import { AssigneesApplyPanel } from '../../assignees/assignees_apply_panel';
|
||||
import type { AssigneesIdsSelection } from '../../assignees/types';
|
||||
import { removeNoAssigneesSelection } from '../../assignees/utils';
|
||||
|
||||
interface BulkAlertAssigneesPanelComponentProps {
|
||||
alertItems: TimelineItem[];
|
||||
setIsLoading: (isLoading: boolean) => void;
|
||||
refresh?: () => void;
|
||||
clearSelection?: () => void;
|
||||
closePopoverMenu: () => void;
|
||||
onSubmit: SetAlertAssigneesFunc;
|
||||
}
|
||||
const BulkAlertAssigneesPanelComponent: React.FC<BulkAlertAssigneesPanelComponentProps> = ({
|
||||
alertItems,
|
||||
refresh,
|
||||
setIsLoading,
|
||||
clearSelection,
|
||||
closePopoverMenu,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const assignedUserIds = useMemo(
|
||||
() =>
|
||||
intersection(
|
||||
...alertItems.map(
|
||||
(item) =>
|
||||
item.data.find((data) => data.field === ALERT_WORKFLOW_ASSIGNEE_IDS)?.value ?? []
|
||||
)
|
||||
),
|
||||
[alertItems]
|
||||
);
|
||||
|
||||
const onAssigneesApply = useCallback(
|
||||
async (assigneesIds: AssigneesIdsSelection[]) => {
|
||||
const updatedIds = removeNoAssigneesSelection(assigneesIds);
|
||||
const assigneesToAddArray = updatedIds.filter((uid) => uid && !assignedUserIds.includes(uid));
|
||||
const assigneesToRemoveArray = assignedUserIds.filter(
|
||||
(uid) => uid && !updatedIds.includes(uid)
|
||||
);
|
||||
if (assigneesToAddArray.length === 0 && assigneesToRemoveArray.length === 0) {
|
||||
closePopoverMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = alertItems.map((item) => item._id);
|
||||
const assignees = {
|
||||
add: assigneesToAddArray,
|
||||
remove: assigneesToRemoveArray,
|
||||
};
|
||||
const onSuccess = () => {
|
||||
if (refresh) refresh();
|
||||
if (clearSelection) clearSelection();
|
||||
};
|
||||
if (onSubmit != null) {
|
||||
closePopoverMenu();
|
||||
await onSubmit(assignees, ids, onSuccess, setIsLoading);
|
||||
}
|
||||
},
|
||||
[alertItems, assignedUserIds, clearSelection, closePopoverMenu, onSubmit, refresh, setIsLoading]
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-test-subj="alert-assignees-selectable-menu">
|
||||
<AssigneesApplyPanel assignedUserIds={assignedUserIds} onAssigneesApply={onAssigneesApply} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BulkAlertAssigneesPanel = memo(BulkAlertAssigneesPanelComponent);
|
|
@ -211,3 +211,31 @@ export const ALERT_TAGS_CONTEXT_MENU_ITEM_TOOLTIP_INFO = i18n.translate(
|
|||
defaultMessage: 'Change alert tag options in Kibana Advanced Settings.',
|
||||
}
|
||||
);
|
||||
|
||||
export const UPDATE_ALERT_ASSIGNEES_SUCCESS_TOAST = (totalAlerts: number) =>
|
||||
i18n.translate('xpack.securitySolution.bulkActions.updateAlertAssigneesSuccessToastMessage', {
|
||||
values: { totalAlerts },
|
||||
defaultMessage:
|
||||
'Successfully updated assignees for {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.',
|
||||
});
|
||||
|
||||
export const UPDATE_ALERT_ASSIGNEES_FAILURE = i18n.translate(
|
||||
'xpack.securitySolution.bulkActions.updateAlertAssigneesFailedToastMessage',
|
||||
{
|
||||
defaultMessage: 'Failed to update alert assignees.',
|
||||
}
|
||||
);
|
||||
|
||||
export const ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.bulkActions.alertAssigneesContextMenuItemTitle',
|
||||
{
|
||||
defaultMessage: 'Assign alert',
|
||||
}
|
||||
);
|
||||
|
||||
export const REMOVE_ALERT_ASSIGNEES_CONTEXT_MENU_TITLE = i18n.translate(
|
||||
'xpack.securitySolution.bulkActions.removeAlertAssignessContextMenuTitle',
|
||||
{
|
||||
defaultMessage: 'Unassign alert',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -0,0 +1,192 @@
|
|||
/*
|
||||
* 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 { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
|
||||
import { TestProviders } from '@kbn/timelines-plugin/public/mock';
|
||||
import type { BulkActionsConfig } from '@kbn/triggers-actions-ui-plugin/public/types';
|
||||
import type { TimelineItem } from '@kbn/triggers-actions-ui-plugin/public/application/sections/alerts_table/bulk_actions/components/toolbar';
|
||||
import { act, fireEvent, render } from '@testing-library/react';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
|
||||
import type {
|
||||
UseBulkAlertAssigneesItemsProps,
|
||||
UseBulkAlertAssigneesPanel,
|
||||
} from './use_bulk_alert_assignees_items';
|
||||
import { useBulkAlertAssigneesItems } from './use_bulk_alert_assignees_items';
|
||||
import { useSetAlertAssignees } from './use_set_alert_assignees';
|
||||
import { useGetCurrentUserProfile } from '../../user_profiles/use_get_current_user_profile';
|
||||
import { useBulkGetUserProfiles } from '../../user_profiles/use_bulk_get_user_profiles';
|
||||
import { useSuggestUsers } from '../../user_profiles/use_suggest_users';
|
||||
import { ASSIGNEES_APPLY_BUTTON_TEST_ID } from '../../assignees/test_ids';
|
||||
import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges';
|
||||
import { useLicense } from '../../../hooks/use_license';
|
||||
|
||||
jest.mock('./use_set_alert_assignees');
|
||||
jest.mock('../../user_profiles/use_get_current_user_profile');
|
||||
jest.mock('../../user_profiles/use_bulk_get_user_profiles');
|
||||
jest.mock('../../user_profiles/use_suggest_users');
|
||||
jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges');
|
||||
jest.mock('../../../hooks/use_license');
|
||||
|
||||
const mockUserProfiles = [
|
||||
{ uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} },
|
||||
{ uid: 'user-id-2', enabled: true, user: { username: 'fakeUser2' }, data: {} },
|
||||
];
|
||||
|
||||
const defaultProps: UseBulkAlertAssigneesItemsProps = {
|
||||
onAssigneesUpdate: () => {},
|
||||
};
|
||||
|
||||
const mockAssigneeItems = [
|
||||
{
|
||||
_id: 'test-id',
|
||||
data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['user-id-1', 'user-id-2'] }],
|
||||
ecs: { _id: 'test-id', _index: 'test-index' },
|
||||
},
|
||||
];
|
||||
|
||||
const renderPanel = (panel: UseBulkAlertAssigneesPanel) => {
|
||||
const content = panel.renderContent({
|
||||
closePopoverMenu: jest.fn(),
|
||||
setIsBulkActionsLoading: jest.fn(),
|
||||
alertItems: mockAssigneeItems,
|
||||
});
|
||||
return render(content);
|
||||
};
|
||||
|
||||
describe('useBulkAlertAssigneesItems', () => {
|
||||
beforeEach(() => {
|
||||
(useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn());
|
||||
(useGetCurrentUserProfile as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles[0],
|
||||
});
|
||||
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles,
|
||||
});
|
||||
(useSuggestUsers as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles,
|
||||
});
|
||||
(useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true });
|
||||
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return two alert assignees action items and one panel', () => {
|
||||
const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(result.current.alertAssigneesItems.length).toEqual(2);
|
||||
expect(result.current.alertAssigneesPanels.length).toEqual(1);
|
||||
|
||||
expect(result.current.alertAssigneesItems[0]['data-test-subj']).toEqual(
|
||||
'alert-assignees-context-menu-item'
|
||||
);
|
||||
expect(result.current.alertAssigneesItems[1]['data-test-subj']).toEqual(
|
||||
'remove-alert-assignees-menu-item'
|
||||
);
|
||||
expect(result.current.alertAssigneesPanels[0]['data-test-subj']).toEqual(
|
||||
'alert-assignees-context-menu-panel'
|
||||
);
|
||||
});
|
||||
|
||||
it('should still render alert assignees panel when useSetAlertAssignees is null', () => {
|
||||
(useSetAlertAssignees as jest.Mock).mockReturnValue(null);
|
||||
const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(result.current.alertAssigneesPanels[0]['data-test-subj']).toEqual(
|
||||
'alert-assignees-context-menu-panel'
|
||||
);
|
||||
const wrapper = renderPanel(result.current.alertAssigneesPanels[0]);
|
||||
expect(wrapper.getByTestId('alert-assignees-selectable-menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call setAlertAssignees on submit', () => {
|
||||
const mockSetAlertAssignees = jest.fn();
|
||||
(useSetAlertAssignees as jest.Mock).mockReturnValue(mockSetAlertAssignees);
|
||||
const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
const wrapper = renderPanel(result.current.alertAssigneesPanels[0]);
|
||||
expect(wrapper.getByTestId('alert-assignees-selectable-menu')).toBeInTheDocument();
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByText('fakeUser2')); // Won't fire unless component assignees selection has been changed
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.click(wrapper.getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID));
|
||||
});
|
||||
expect(mockSetAlertAssignees).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call setAlertAssignees with the correct parameters on `Unassign alert` button click', () => {
|
||||
const mockSetAlertAssignees = jest.fn();
|
||||
(useSetAlertAssignees as jest.Mock).mockReturnValue(mockSetAlertAssignees);
|
||||
const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
const items: TimelineItem[] = [
|
||||
{
|
||||
_id: 'alert1',
|
||||
data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['user1', 'user2'] }],
|
||||
ecs: { _id: 'alert1', _index: 'index1' },
|
||||
},
|
||||
{
|
||||
_id: 'alert2',
|
||||
data: [{ field: ALERT_WORKFLOW_ASSIGNEE_IDS, value: ['user1', 'user3'] }],
|
||||
ecs: { _id: 'alert2', _index: 'index1' },
|
||||
},
|
||||
{
|
||||
_id: 'alert3',
|
||||
data: [],
|
||||
ecs: { _id: 'alert3', _index: 'index1' },
|
||||
},
|
||||
];
|
||||
|
||||
const setAlertLoadingMock = jest.fn();
|
||||
(
|
||||
result.current.alertAssigneesItems[1] as unknown as { onClick: BulkActionsConfig['onClick'] }
|
||||
).onClick?.(items, true, setAlertLoadingMock, jest.fn(), jest.fn());
|
||||
|
||||
expect(mockSetAlertAssignees).toHaveBeenCalled();
|
||||
expect(mockSetAlertAssignees).toHaveBeenCalledWith(
|
||||
{ add: [], remove: ['user1', 'user2', 'user3'] },
|
||||
['alert1', 'alert2', 'alert3'],
|
||||
expect.any(Function),
|
||||
setAlertLoadingMock
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 0 items for the VIEWER role', () => {
|
||||
(useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: false });
|
||||
|
||||
const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
expect(result.current.alertAssigneesItems.length).toEqual(0);
|
||||
expect(result.current.alertAssigneesPanels.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should return 0 items for the Basic license', () => {
|
||||
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => false });
|
||||
|
||||
const { result } = renderHook(() => useBulkAlertAssigneesItems(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
expect(result.current.alertAssigneesItems.length).toEqual(0);
|
||||
expect(result.current.alertAssigneesPanels.length).toEqual(0);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,158 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { union } from 'lodash';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
|
||||
import type {
|
||||
BulkActionsConfig,
|
||||
RenderContentPanelProps,
|
||||
} from '@kbn/triggers-actions-ui-plugin/public/types';
|
||||
|
||||
import { useLicense } from '../../../hooks/use_license';
|
||||
import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges';
|
||||
import { ASSIGNEES_PANEL_WIDTH } from '../../assignees/constants';
|
||||
import { BulkAlertAssigneesPanel } from './alert_bulk_assignees';
|
||||
import * as i18n from './translations';
|
||||
import { useSetAlertAssignees } from './use_set_alert_assignees';
|
||||
|
||||
export interface UseBulkAlertAssigneesItemsProps {
|
||||
onAssigneesUpdate?: () => void;
|
||||
}
|
||||
|
||||
export interface UseBulkAlertAssigneesPanel {
|
||||
id: number;
|
||||
title: JSX.Element;
|
||||
'data-test-subj': string;
|
||||
renderContent: (props: RenderContentPanelProps) => JSX.Element;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export const useBulkAlertAssigneesItems = ({
|
||||
onAssigneesUpdate,
|
||||
}: UseBulkAlertAssigneesItemsProps) => {
|
||||
const isPlatinumPlus = useLicense().isPlatinumPlus();
|
||||
|
||||
const { hasIndexWrite } = useAlertsPrivileges();
|
||||
const setAlertAssignees = useSetAlertAssignees();
|
||||
|
||||
const handleOnAlertAssigneesSubmit = useCallback(
|
||||
async (assignees, ids, onSuccess, setIsLoading) => {
|
||||
if (setAlertAssignees) {
|
||||
await setAlertAssignees(assignees, ids, onSuccess, setIsLoading);
|
||||
}
|
||||
},
|
||||
[setAlertAssignees]
|
||||
);
|
||||
|
||||
const onSuccess = useCallback(() => {
|
||||
onAssigneesUpdate?.();
|
||||
}, [onAssigneesUpdate]);
|
||||
|
||||
const onRemoveAllAssignees = useCallback<Required<BulkActionsConfig>['onClick']>(
|
||||
async (items, _, setAlertLoading) => {
|
||||
const ids: string[] = items.map((item) => item._id);
|
||||
const assignedUserIds = union(
|
||||
...items.map(
|
||||
(item) =>
|
||||
item.data.find((data) => data.field === ALERT_WORKFLOW_ASSIGNEE_IDS)?.value ?? []
|
||||
)
|
||||
);
|
||||
if (!assignedUserIds.length) {
|
||||
return;
|
||||
}
|
||||
const assignees = {
|
||||
add: [],
|
||||
remove: assignedUserIds,
|
||||
};
|
||||
if (setAlertAssignees) {
|
||||
await setAlertAssignees(assignees, ids, onSuccess, setAlertLoading);
|
||||
}
|
||||
},
|
||||
[onSuccess, setAlertAssignees]
|
||||
);
|
||||
|
||||
const alertAssigneesItems = useMemo(
|
||||
() =>
|
||||
hasIndexWrite && isPlatinumPlus
|
||||
? [
|
||||
{
|
||||
key: 'manage-alert-assignees',
|
||||
'data-test-subj': 'alert-assignees-context-menu-item',
|
||||
name: i18n.ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE,
|
||||
panel: 2,
|
||||
label: i18n.ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE,
|
||||
disableOnQuery: true,
|
||||
},
|
||||
{
|
||||
key: 'remove-all-alert-assignees',
|
||||
'data-test-subj': 'remove-alert-assignees-menu-item',
|
||||
name: i18n.REMOVE_ALERT_ASSIGNEES_CONTEXT_MENU_TITLE,
|
||||
label: i18n.REMOVE_ALERT_ASSIGNEES_CONTEXT_MENU_TITLE,
|
||||
disableOnQuery: true,
|
||||
onClick: onRemoveAllAssignees,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[hasIndexWrite, isPlatinumPlus, onRemoveAllAssignees]
|
||||
);
|
||||
|
||||
const TitleContent = useMemo(
|
||||
() => (
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem grow={false}>{i18n.ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const renderContent = useCallback(
|
||||
({
|
||||
alertItems,
|
||||
refresh,
|
||||
setIsBulkActionsLoading,
|
||||
clearSelection,
|
||||
closePopoverMenu,
|
||||
}: RenderContentPanelProps) => (
|
||||
<BulkAlertAssigneesPanel
|
||||
alertItems={alertItems}
|
||||
refresh={() => {
|
||||
onSuccess();
|
||||
refresh?.();
|
||||
}}
|
||||
setIsLoading={setIsBulkActionsLoading}
|
||||
clearSelection={clearSelection}
|
||||
closePopoverMenu={closePopoverMenu}
|
||||
onSubmit={handleOnAlertAssigneesSubmit}
|
||||
/>
|
||||
),
|
||||
[handleOnAlertAssigneesSubmit, onSuccess]
|
||||
);
|
||||
|
||||
const alertAssigneesPanels: UseBulkAlertAssigneesPanel[] = useMemo(
|
||||
() =>
|
||||
hasIndexWrite && isPlatinumPlus
|
||||
? [
|
||||
{
|
||||
id: 2,
|
||||
title: TitleContent,
|
||||
'data-test-subj': 'alert-assignees-context-menu-panel',
|
||||
renderContent,
|
||||
width: ASSIGNEES_PANEL_WIDTH,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
[TitleContent, hasIndexWrite, isPlatinumPlus, renderContent]
|
||||
);
|
||||
|
||||
return {
|
||||
alertAssigneesItems,
|
||||
alertAssigneesPanels,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { CoreStart } from '@kbn/core/public';
|
||||
import { useKibana } from '@kbn/kibana-react-plugin/public';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { AlertAssignees } from '../../../../../common/api/detection_engine';
|
||||
import { useAppToasts } from '../../../hooks/use_app_toasts';
|
||||
import * as i18n from './translations';
|
||||
import { setAlertAssignees } from '../../../containers/alert_assignees/api';
|
||||
|
||||
export type SetAlertAssigneesFunc = (
|
||||
assignees: AlertAssignees,
|
||||
ids: string[],
|
||||
onSuccess: () => void,
|
||||
setTableLoading: (param: boolean) => void
|
||||
) => Promise<void>;
|
||||
export type ReturnSetAlertAssignees = SetAlertAssigneesFunc | null;
|
||||
|
||||
/**
|
||||
* Update alert assignees by query
|
||||
*
|
||||
* @param assignees to add and/or remove from a batch of alerts
|
||||
* @param ids alert ids that will be used to create the update query.
|
||||
* @param onSuccess a callback function that will be called on successful api response
|
||||
* @param setTableLoading a function that sets the alert table in a loading state for bulk actions
|
||||
|
||||
*
|
||||
* @throws An error if response is not OK
|
||||
*/
|
||||
export const useSetAlertAssignees = (): ReturnSetAlertAssignees => {
|
||||
const { http } = useKibana<CoreStart>().services;
|
||||
const { addSuccess, addError } = useAppToasts();
|
||||
const setAlertAssigneesRef = useRef<SetAlertAssigneesFunc | null>(null);
|
||||
|
||||
const onUpdateSuccess = useCallback(
|
||||
(updated: number = 0) => addSuccess(i18n.UPDATE_ALERT_ASSIGNEES_SUCCESS_TOAST(updated)),
|
||||
[addSuccess]
|
||||
);
|
||||
|
||||
const onUpdateFailure = useCallback(
|
||||
(error: Error) => {
|
||||
addError(error.message, { title: i18n.UPDATE_ALERT_ASSIGNEES_FAILURE });
|
||||
},
|
||||
[addError]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
const abortCtrl = new AbortController();
|
||||
|
||||
const onSetAlertAssignees: SetAlertAssigneesFunc = async (
|
||||
assignees,
|
||||
ids,
|
||||
onSuccess,
|
||||
setTableLoading
|
||||
) => {
|
||||
try {
|
||||
setTableLoading(true);
|
||||
const response = await setAlertAssignees({ assignees, ids, signal: abortCtrl.signal });
|
||||
if (!ignore) {
|
||||
onSuccess();
|
||||
setTableLoading(false);
|
||||
onUpdateSuccess(response.updated);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!ignore) {
|
||||
setTableLoading(false);
|
||||
onUpdateFailure(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setAlertAssigneesRef.current = onSetAlertAssignees;
|
||||
return (): void => {
|
||||
ignore = true;
|
||||
abortCtrl.abort();
|
||||
};
|
||||
}, [http, onUpdateFailure, onUpdateSuccess]);
|
||||
|
||||
return setAlertAssigneesRef.current;
|
||||
};
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
|
||||
import { mockUserProfiles } from '../mock';
|
||||
|
||||
export const suggestUsers = async ({
|
||||
searchTerm,
|
||||
}: {
|
||||
searchTerm: string;
|
||||
}): Promise<UserProfileWithAvatar[]> => Promise.resolve(mockUserProfiles);
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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 { coreMock } from '@kbn/core/public/mocks';
|
||||
|
||||
import { mockUserProfiles } from './mock';
|
||||
import { suggestUsers } from './api';
|
||||
import { KibanaServices } from '../../lib/kibana';
|
||||
import { DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL } from '../../../../common/constants';
|
||||
|
||||
const mockKibanaServices = KibanaServices.get as jest.Mock;
|
||||
jest.mock('../../lib/kibana');
|
||||
|
||||
const coreStartMock = coreMock.createStart({ basePath: '/mock' });
|
||||
mockKibanaServices.mockReturnValue(coreStartMock);
|
||||
const fetchMock = coreStartMock.http.fetch;
|
||||
|
||||
describe('Detections Alerts API', () => {
|
||||
describe('suggestUsers', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockClear();
|
||||
fetchMock.mockResolvedValue(mockUserProfiles);
|
||||
});
|
||||
|
||||
test('check parameter url', async () => {
|
||||
await suggestUsers({ searchTerm: 'name1' });
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL,
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
version: '2023-10-31',
|
||||
query: { searchTerm: 'name1' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('happy path', async () => {
|
||||
const alertsResp = await suggestUsers({ searchTerm: '' });
|
||||
expect(alertsResp).toEqual(mockUserProfiles);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
|
||||
|
||||
import type { SuggestUsersProps } from './types';
|
||||
import { DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL } from '../../../../common/constants';
|
||||
import { KibanaServices } from '../../lib/kibana';
|
||||
|
||||
/**
|
||||
* Fetches suggested user profiles
|
||||
*/
|
||||
export const suggestUsers = async ({
|
||||
searchTerm,
|
||||
}: SuggestUsersProps): Promise<UserProfileWithAvatar[]> => {
|
||||
return KibanaServices.get().http.fetch<UserProfileWithAvatar[]>(
|
||||
DETECTION_ENGINE_ALERT_SUGGEST_USERS_URL,
|
||||
{
|
||||
method: 'GET',
|
||||
version: '2023-10-31',
|
||||
query: { searchTerm },
|
||||
}
|
||||
);
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const mockCurrentUserProfile = {
|
||||
uid: 'current-user',
|
||||
enabled: true,
|
||||
user: { username: 'current.user' },
|
||||
data: {},
|
||||
};
|
||||
|
||||
export const mockUserProfiles = [
|
||||
{ uid: 'user-id-1', enabled: true, user: { username: 'user1' }, data: {} },
|
||||
{ uid: 'user-id-2', enabled: true, user: { username: 'user2' }, data: {} },
|
||||
];
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const PREFIX = 'securitySolutionUsers';
|
||||
|
||||
/* Avatars */
|
||||
export const USER_AVATAR_ITEM_TEST_ID = (userName: string) => `${PREFIX}Avatar-${userName}`;
|
||||
export const USERS_AVATARS_PANEL_TEST_ID = `${PREFIX}AvatarsPanel`;
|
||||
export const USERS_AVATARS_COUNT_BADGE_TEST_ID = `${PREFIX}AvatarsCountBadge`;
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* 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 { i18n } from '@kbn/i18n';
|
||||
|
||||
export const CURRENT_USER_PROFILE_FAILURE = i18n.translate(
|
||||
'xpack.securitySolution.userProfiles.fetchCurrentUserProfile.failure',
|
||||
{ defaultMessage: 'Failed to find current user' }
|
||||
);
|
||||
|
||||
export const USER_PROFILES_FAILURE = i18n.translate(
|
||||
'xpack.securitySolution.userProfiles.fetchUserProfiles.failure',
|
||||
{
|
||||
defaultMessage: 'Failed to find users',
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Used whenever we need to display a user name and for some reason it is not available
|
||||
*/
|
||||
export const UNKNOWN_USER_PROFILE_NAME = i18n.translate(
|
||||
'xpack.securitySolution.userProfiles.unknownUser.displayName',
|
||||
{ defaultMessage: 'Unknown' }
|
||||
);
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface SuggestUsersProps {
|
||||
searchTerm: string;
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { securityMock } from '@kbn/security-plugin/public/mocks';
|
||||
|
||||
import { mockUserProfiles } from './mock';
|
||||
import { useBulkGetUserProfiles } from './use_bulk_get_user_profiles';
|
||||
import { useKibana } from '../../lib/kibana';
|
||||
import { useAppToasts } from '../../hooks/use_app_toasts';
|
||||
import { useAppToastsMock } from '../../hooks/use_app_toasts.mock';
|
||||
import { createStartServicesMock } from '../../lib/kibana/kibana_react.mock';
|
||||
import { TestProviders } from '../../mock';
|
||||
|
||||
jest.mock('../../lib/kibana');
|
||||
jest.mock('../../hooks/use_app_toasts');
|
||||
|
||||
describe('useBulkGetUserProfiles hook', () => {
|
||||
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
appToastsMock = useAppToastsMock.create();
|
||||
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
|
||||
const security = securityMock.createStart();
|
||||
security.userProfiles.bulkGet.mockReturnValue(Promise.resolve(mockUserProfiles));
|
||||
(useKibana as jest.Mock).mockReturnValue({
|
||||
services: {
|
||||
...createStartServicesMock(),
|
||||
security,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an array of userProfiles', async () => {
|
||||
const userProfiles = useKibana().services.security.userProfiles;
|
||||
const spyOnUserProfiles = jest.spyOn(userProfiles, 'bulkGet');
|
||||
const assigneesIds = new Set(['user1']);
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() => useBulkGetUserProfiles({ uids: assigneesIds }),
|
||||
{
|
||||
wrapper: TestProviders,
|
||||
}
|
||||
);
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(spyOnUserProfiles).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.isLoading).toEqual(false);
|
||||
expect(result.current.data).toEqual(mockUserProfiles);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { SecurityPluginStart } from '@kbn/security-plugin/public';
|
||||
import type { UserProfile } from '@kbn/security-plugin/common';
|
||||
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useKibana } from '../../lib/kibana';
|
||||
import { useAppToasts } from '../../hooks/use_app_toasts';
|
||||
import { USER_PROFILES_FAILURE } from './translations';
|
||||
|
||||
export interface BulkGetUserProfilesArgs {
|
||||
security: SecurityPluginStart;
|
||||
uids: Set<string>;
|
||||
}
|
||||
|
||||
export const bulkGetUserProfiles = async ({
|
||||
security,
|
||||
uids,
|
||||
}: BulkGetUserProfilesArgs): Promise<UserProfile[]> => {
|
||||
if (uids.size === 0) {
|
||||
return [];
|
||||
}
|
||||
return security.userProfiles.bulkGet({ uids, dataPath: 'avatar' });
|
||||
};
|
||||
|
||||
export const useBulkGetUserProfiles = ({ uids }: { uids: Set<string> }) => {
|
||||
const { security } = useKibana().services;
|
||||
const { addError } = useAppToasts();
|
||||
|
||||
return useQuery<UserProfileWithAvatar[]>(
|
||||
['useBulkGetUserProfiles', ...uids],
|
||||
async () => {
|
||||
return bulkGetUserProfiles({ security, uids });
|
||||
},
|
||||
{
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
onError: (e) => {
|
||||
addError(e, { title: USER_PROFILES_FAILURE });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { securityMock } from '@kbn/security-plugin/public/mocks';
|
||||
|
||||
import { mockCurrentUserProfile } from './mock';
|
||||
import { useGetCurrentUserProfile } from './use_get_current_user_profile';
|
||||
import { useKibana } from '../../lib/kibana';
|
||||
import { useAppToasts } from '../../hooks/use_app_toasts';
|
||||
import { useAppToastsMock } from '../../hooks/use_app_toasts.mock';
|
||||
import { createStartServicesMock } from '../../lib/kibana/kibana_react.mock';
|
||||
import { TestProviders } from '../../mock';
|
||||
|
||||
jest.mock('../../lib/kibana');
|
||||
jest.mock('../../hooks/use_app_toasts');
|
||||
|
||||
describe('useGetCurrentUserProfile hook', () => {
|
||||
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
appToastsMock = useAppToastsMock.create();
|
||||
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
|
||||
const security = securityMock.createStart();
|
||||
security.userProfiles.getCurrent.mockReturnValue(Promise.resolve(mockCurrentUserProfile));
|
||||
(useKibana as jest.Mock).mockReturnValue({
|
||||
services: {
|
||||
...createStartServicesMock(),
|
||||
security,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns current user', async () => {
|
||||
const userProfiles = useKibana().services.security.userProfiles;
|
||||
const spyOnUserProfiles = jest.spyOn(userProfiles, 'getCurrent');
|
||||
const { result, waitForNextUpdate } = renderHook(() => useGetCurrentUserProfile(), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(spyOnUserProfiles).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.isLoading).toEqual(false);
|
||||
expect(result.current.data).toEqual(mockCurrentUserProfile);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { SecurityPluginStart } from '@kbn/security-plugin/public';
|
||||
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
|
||||
|
||||
import { CURRENT_USER_PROFILE_FAILURE } from './translations';
|
||||
import { useKibana } from '../../lib/kibana';
|
||||
import { useAppToasts } from '../../hooks/use_app_toasts';
|
||||
|
||||
export const getCurrentUserProfile = async ({
|
||||
security,
|
||||
}: {
|
||||
security: SecurityPluginStart;
|
||||
}): Promise<UserProfileWithAvatar> => {
|
||||
return security.userProfiles.getCurrent({ dataPath: 'avatar' });
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches current user profile using `userProfiles` service via `security.userProfiles.getCurrent()`
|
||||
*
|
||||
* NOTE: There is a similar hook `useCurrentUser` which fetches current authenticated user via `security.authc.getCurrentUser()`
|
||||
*/
|
||||
export const useGetCurrentUserProfile = () => {
|
||||
const { security } = useKibana().services;
|
||||
const { addError } = useAppToasts();
|
||||
|
||||
return useQuery<UserProfileWithAvatar>(
|
||||
['useGetCurrentUserProfile'],
|
||||
async () => {
|
||||
return getCurrentUserProfile({ security });
|
||||
},
|
||||
{
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
onError: (e) => {
|
||||
addError(e, { title: CURRENT_USER_PROFILE_FAILURE });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { useSuggestUsers } from './use_suggest_users';
|
||||
|
||||
import * as api from './api';
|
||||
import { mockUserProfiles } from './mock';
|
||||
import { useAppToasts } from '../../hooks/use_app_toasts';
|
||||
import { useAppToastsMock } from '../../hooks/use_app_toasts.mock';
|
||||
import { TestProviders } from '../../mock';
|
||||
|
||||
jest.mock('./api');
|
||||
jest.mock('../../hooks/use_app_toasts');
|
||||
|
||||
describe('useSuggestUsers hook', () => {
|
||||
let appToastsMock: jest.Mocked<ReturnType<typeof useAppToastsMock.create>>;
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
appToastsMock = useAppToastsMock.create();
|
||||
(useAppToasts as jest.Mock).mockReturnValue(appToastsMock);
|
||||
});
|
||||
|
||||
it('returns an array of userProfiles', async () => {
|
||||
const spyOnUserProfiles = jest.spyOn(api, 'suggestUsers');
|
||||
const { result, waitForNextUpdate } = renderHook(() => useSuggestUsers({ searchTerm: '' }), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
expect(spyOnUserProfiles).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.isLoading).toEqual(false);
|
||||
expect(result.current.data).toEqual(mockUserProfiles);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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 { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
|
||||
|
||||
import { suggestUsers } from './api';
|
||||
import { USER_PROFILES_FAILURE } from './translations';
|
||||
import { useAppToasts } from '../../hooks/use_app_toasts';
|
||||
|
||||
export interface SuggestUserProfilesArgs {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
export const bulkGetUserProfiles = async ({
|
||||
searchTerm,
|
||||
}: {
|
||||
searchTerm: string;
|
||||
}): Promise<UserProfileWithAvatar[]> => {
|
||||
return suggestUsers({ searchTerm });
|
||||
};
|
||||
|
||||
export const useSuggestUsers = ({ searchTerm }: { searchTerm: string }) => {
|
||||
const { addError } = useAppToasts();
|
||||
|
||||
return useQuery<UserProfileWithAvatar[]>(
|
||||
['useSuggestUsers', searchTerm],
|
||||
async () => {
|
||||
return bulkGetUserProfiles({ searchTerm });
|
||||
},
|
||||
{
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
onError: (e) => {
|
||||
addError(e, { title: USER_PROFILES_FAILURE });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import { UsersAvatarsPanel } from './users_avatars_panel';
|
||||
|
||||
import { TestProviders } from '../../mock';
|
||||
import { mockUserProfiles } from '../assignees/mocks';
|
||||
import {
|
||||
USERS_AVATARS_COUNT_BADGE_TEST_ID,
|
||||
USERS_AVATARS_PANEL_TEST_ID,
|
||||
USER_AVATAR_ITEM_TEST_ID,
|
||||
} from './test_ids';
|
||||
|
||||
const renderUsersAvatarsPanel = (userProfiles = [mockUserProfiles[0]], maxVisibleAvatars = 1) =>
|
||||
render(
|
||||
<TestProviders>
|
||||
<UsersAvatarsPanel userProfiles={userProfiles} maxVisibleAvatars={maxVisibleAvatars} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
describe('<UsersAvatarsPanel />', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render component', () => {
|
||||
const { getByTestId } = renderUsersAvatarsPanel();
|
||||
|
||||
expect(getByTestId(USERS_AVATARS_PANEL_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render avatars for all assignees', () => {
|
||||
const assignees = [mockUserProfiles[0], mockUserProfiles[1]];
|
||||
const { getByTestId, queryByTestId } = renderUsersAvatarsPanel(assignees, 2);
|
||||
|
||||
expect(getByTestId(USER_AVATAR_ITEM_TEST_ID('user1'))).toBeInTheDocument();
|
||||
expect(getByTestId(USER_AVATAR_ITEM_TEST_ID('user2'))).toBeInTheDocument();
|
||||
|
||||
expect(queryByTestId(USERS_AVATARS_COUNT_BADGE_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render badge with number of assignees if exceeds `maxVisibleAvatars`', () => {
|
||||
const assignees = [mockUserProfiles[0], mockUserProfiles[1]];
|
||||
const { getByTestId, queryByTestId } = renderUsersAvatarsPanel(assignees, 1);
|
||||
|
||||
expect(getByTestId(USERS_AVATARS_COUNT_BADGE_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
expect(queryByTestId(USER_AVATAR_ITEM_TEST_ID('user1'))).not.toBeInTheDocument();
|
||||
expect(queryByTestId(USER_AVATAR_ITEM_TEST_ID('user2'))).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { FC } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiNotificationBadge, EuiToolTip } from '@elastic/eui';
|
||||
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
|
||||
import { UserAvatar } from '@kbn/user-profile-components';
|
||||
|
||||
import { UNKNOWN_USER_PROFILE_NAME } from './translations';
|
||||
import {
|
||||
USERS_AVATARS_COUNT_BADGE_TEST_ID,
|
||||
USERS_AVATARS_PANEL_TEST_ID,
|
||||
USER_AVATAR_ITEM_TEST_ID,
|
||||
} from './test_ids';
|
||||
|
||||
export type UserProfileOrUknown = UserProfileWithAvatar | undefined;
|
||||
|
||||
export interface UsersAvatarsPanelProps {
|
||||
/**
|
||||
* The array of user profiles
|
||||
*/
|
||||
userProfiles: UserProfileOrUknown[];
|
||||
|
||||
/**
|
||||
* Specifies how many avatars should be visible.
|
||||
* If more assignees passed, then badge with number of assignees will be shown instead.
|
||||
*/
|
||||
maxVisibleAvatars?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays users avatars
|
||||
*/
|
||||
export const UsersAvatarsPanel: FC<UsersAvatarsPanelProps> = memo(
|
||||
({ userProfiles, maxVisibleAvatars }) => {
|
||||
if (maxVisibleAvatars && userProfiles.length > maxVisibleAvatars) {
|
||||
return (
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
content={userProfiles.map((user) => (
|
||||
<div>{user ? user.user.email ?? user.user.username : UNKNOWN_USER_PROFILE_NAME}</div>
|
||||
))}
|
||||
repositionOnScroll={true}
|
||||
>
|
||||
<EuiNotificationBadge data-test-subj={USERS_AVATARS_COUNT_BADGE_TEST_ID} color="subdued">
|
||||
{userProfiles.length}
|
||||
</EuiNotificationBadge>
|
||||
</EuiToolTip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
data-test-subj={USERS_AVATARS_PANEL_TEST_ID}
|
||||
alignItems="center"
|
||||
direction="row"
|
||||
gutterSize="xs"
|
||||
>
|
||||
{userProfiles.map((user, index) => (
|
||||
<EuiFlexItem key={index} grow={false}>
|
||||
<UserAvatar
|
||||
data-test-subj={USER_AVATAR_ITEM_TEST_ID(user?.user.username ?? `Unknown-${index}`)}
|
||||
user={user?.user}
|
||||
avatar={user?.data.avatar}
|
||||
size={'s'}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
))}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
UsersAvatarsPanel.displayName = 'UsersAvatarsPanel';
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { estypes } from '@elastic/elasticsearch';
|
||||
import { DETECTION_ENGINE_ALERT_ASSIGNEES_URL } from '../../../../common/constants';
|
||||
import type { AlertAssignees } from '../../../../common/api/detection_engine';
|
||||
import { KibanaServices } from '../../lib/kibana';
|
||||
|
||||
export const setAlertAssignees = async ({
|
||||
assignees,
|
||||
ids,
|
||||
signal,
|
||||
}: {
|
||||
assignees: AlertAssignees;
|
||||
ids: string[];
|
||||
signal: AbortSignal | undefined;
|
||||
}): Promise<estypes.UpdateByQueryResponse> => {
|
||||
return KibanaServices.get().http.fetch<estypes.UpdateByQueryResponse>(
|
||||
DETECTION_ENGINE_ALERT_ASSIGNEES_URL,
|
||||
{
|
||||
method: 'POST',
|
||||
version: '2023-10-31',
|
||||
body: JSON.stringify({ assignees, ids }),
|
||||
signal,
|
||||
}
|
||||
);
|
||||
};
|
|
@ -23,6 +23,7 @@ import {
|
|||
import { getDataTablesInStorageByIds } from '../../../timelines/containers/local_storage';
|
||||
import { getColumns } from '../../../detections/configurations/security_solution_detections';
|
||||
import { getRenderCellValueHook } from '../../../detections/configurations/security_solution_detections/render_cell_value';
|
||||
import { useFetchPageContext } from '../../../detections/configurations/security_solution_detections/fetch_page_context';
|
||||
import { SourcererScopeName } from '../../store/sourcerer/model';
|
||||
|
||||
const registerAlertsTableConfiguration = (
|
||||
|
@ -64,6 +65,7 @@ const registerAlertsTableConfiguration = (
|
|||
sort,
|
||||
useFieldBrowserOptions: getUseTriggersActionsFieldBrowserOptions(SourcererScopeName.detections),
|
||||
showInspectButton: true,
|
||||
useFetchPageContext,
|
||||
});
|
||||
|
||||
// register Alert Table on RuleDetails Page
|
||||
|
@ -79,6 +81,7 @@ const registerAlertsTableConfiguration = (
|
|||
sort,
|
||||
useFieldBrowserOptions: getUseTriggersActionsFieldBrowserOptions(SourcererScopeName.detections),
|
||||
showInspectButton: true,
|
||||
useFetchPageContext,
|
||||
});
|
||||
|
||||
registerIfNotAlready(registry, {
|
||||
|
@ -91,6 +94,7 @@ const registerAlertsTableConfiguration = (
|
|||
useCellActions: getUseCellActionsHook(TableId.alertsOnCasePage),
|
||||
sort,
|
||||
showInspectButton: true,
|
||||
useFetchPageContext,
|
||||
});
|
||||
|
||||
registerIfNotAlready(registry, {
|
||||
|
@ -104,6 +108,7 @@ const registerAlertsTableConfiguration = (
|
|||
usePersistentControls: getPersistentControlsHook(TableId.alertsRiskInputs),
|
||||
sort,
|
||||
showInspectButton: true,
|
||||
useFetchPageContext,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import type { ExistsFilter, Filter } from '@kbn/es-query';
|
||||
import {
|
||||
buildAlertAssigneesFilter,
|
||||
buildAlertsFilter,
|
||||
buildAlertStatusesFilter,
|
||||
buildAlertStatusFilter,
|
||||
|
@ -158,6 +159,47 @@ describe('alerts default_config', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('buildAlertAssigneesFilter', () => {
|
||||
test('given an empty list of assignees ids will return an empty filter', () => {
|
||||
const filters: Filter[] = buildAlertAssigneesFilter([]);
|
||||
expect(filters).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('builds filter containing all assignees ids passed into function', () => {
|
||||
const filters = buildAlertAssigneesFilter(['user-id-1', 'user-id-2', 'user-id-3']);
|
||||
const expected = {
|
||||
meta: {
|
||||
alias: null,
|
||||
disabled: false,
|
||||
negate: false,
|
||||
},
|
||||
query: {
|
||||
bool: {
|
||||
should: [
|
||||
{
|
||||
term: {
|
||||
'kibana.alert.workflow_assignee_ids': 'user-id-1',
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
'kibana.alert.workflow_assignee_ids': 'user-id-2',
|
||||
},
|
||||
},
|
||||
{
|
||||
term: {
|
||||
'kibana.alert.workflow_assignee_ids': 'user-id-3',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(filters).toHaveLength(1);
|
||||
expect(filters[0]).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: move these tests to ../timelines/components/timeline/body/events/event_column_view.tsx
|
||||
// describe.skip('getAlertActions', () => {
|
||||
// let setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void;
|
||||
|
|
|
@ -9,11 +9,13 @@ import {
|
|||
ALERT_BUILDING_BLOCK_TYPE,
|
||||
ALERT_WORKFLOW_STATUS,
|
||||
ALERT_RULE_RULE_ID,
|
||||
ALERT_WORKFLOW_ASSIGNEE_IDS,
|
||||
} from '@kbn/rule-data-utils';
|
||||
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { tableDefaults } from '@kbn/securitysolution-data-table';
|
||||
import type { SubsetDataTableModel } from '@kbn/securitysolution-data-table';
|
||||
import type { AssigneesIdsSelection } from '../../../common/components/assignees/types';
|
||||
import type { Status } from '../../../../common/api/detection_engine';
|
||||
import {
|
||||
getColumns,
|
||||
|
@ -152,6 +154,36 @@ export const buildThreatMatchFilter = (showOnlyThreatIndicatorAlerts: boolean):
|
|||
]
|
||||
: [];
|
||||
|
||||
export const buildAlertAssigneesFilter = (assigneesIds: AssigneesIdsSelection[]): Filter[] => {
|
||||
if (!assigneesIds.length) {
|
||||
return [];
|
||||
}
|
||||
const combinedQuery = {
|
||||
bool: {
|
||||
should: assigneesIds.map((id) =>
|
||||
id
|
||||
? {
|
||||
term: {
|
||||
[ALERT_WORKFLOW_ASSIGNEE_IDS]: id,
|
||||
},
|
||||
}
|
||||
: { bool: { must_not: { exists: { field: ALERT_WORKFLOW_ASSIGNEE_IDS } } } }
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
meta: {
|
||||
alias: null,
|
||||
negate: false,
|
||||
disabled: false,
|
||||
},
|
||||
query: combinedQuery,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const getAlertsDefaultModel = (license?: LicenseService): SubsetDataTableModel => ({
|
||||
...tableDefaults,
|
||||
columns: getColumns(license),
|
||||
|
@ -177,6 +209,7 @@ export const requiredFieldsForActions = [
|
|||
'@timestamp',
|
||||
'kibana.alert.workflow_status',
|
||||
'kibana.alert.workflow_tags',
|
||||
'kibana.alert.workflow_assignee_ids',
|
||||
'kibana.alert.group.id',
|
||||
'kibana.alert.original_time',
|
||||
'kibana.alert.building_block_type',
|
||||
|
|
|
@ -28,6 +28,10 @@ jest.mock('../../../../common/hooks/use_experimental_features', () => ({
|
|||
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../common/hooks/use_license', () => ({
|
||||
useLicense: jest.fn().mockReturnValue({ isPlatinumPlus: () => true }),
|
||||
}));
|
||||
|
||||
const ecsRowData: Ecs = {
|
||||
_id: '1',
|
||||
agent: { type: ['blah'] },
|
||||
|
@ -105,6 +109,7 @@ const markAsAcknowledgedButton = '[data-test-subj="acknowledged-alert-status"]';
|
|||
const markAsClosedButton = '[data-test-subj="close-alert-status"]';
|
||||
const addEndpointEventFilterButton = '[data-test-subj="add-event-filter-menu-item"]';
|
||||
const applyAlertTagsButton = '[data-test-subj="alert-tags-context-menu-item"]';
|
||||
const applyAlertAssigneesButton = '[data-test-subj="alert-assignees-context-menu-item"]';
|
||||
|
||||
describe('Alert table context menu', () => {
|
||||
describe('Case actions', () => {
|
||||
|
@ -304,4 +309,16 @@ describe('Alert table context menu', () => {
|
|||
expect(wrapper.find(applyAlertTagsButton).first().exists()).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Assign alert action', () => {
|
||||
test('it renders the assign alert action button', () => {
|
||||
const wrapper = mount(<AlertContextMenu {...props} scopeId={TimelineId.active} />, {
|
||||
wrappingComponent: TestProviders,
|
||||
});
|
||||
|
||||
wrapper.find(actionMenuButton).simulate('click');
|
||||
|
||||
expect(wrapper.find(applyAlertAssigneesButton).first().exists()).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -50,6 +50,7 @@ import { isAlertFromEndpointAlert } from '../../../../common/utils/endpoint_aler
|
|||
import type { Rule } from '../../../../detection_engine/rule_management/logic/types';
|
||||
import type { AlertTableContextMenuItem } from '../types';
|
||||
import { useAlertTagsActions } from './use_alert_tags_actions';
|
||||
import { useAlertAssigneesActions } from './use_alert_assignees_actions';
|
||||
|
||||
interface AlertContextMenuProps {
|
||||
ariaLabel?: string;
|
||||
|
@ -224,6 +225,12 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
|
|||
refetch: refetchAll,
|
||||
});
|
||||
|
||||
const { alertAssigneesItems, alertAssigneesPanels } = useAlertAssigneesActions({
|
||||
closePopover,
|
||||
ecsRowData,
|
||||
refetch: refetchAll,
|
||||
});
|
||||
|
||||
const items: AlertTableContextMenuItem[] = useMemo(
|
||||
() =>
|
||||
!isEvent && ruleId
|
||||
|
@ -231,6 +238,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
|
|||
...addToCaseActionItems,
|
||||
...statusActionItems,
|
||||
...alertTagsItems,
|
||||
...alertAssigneesItems,
|
||||
...exceptionActionItems,
|
||||
...(agentId ? osqueryActionItems : []),
|
||||
]
|
||||
|
@ -250,6 +258,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
|
|||
eventFilterActionItems,
|
||||
canCreateEndpointEventFilters,
|
||||
alertTagsItems,
|
||||
alertAssigneesItems,
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -260,8 +269,9 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({
|
|||
items,
|
||||
},
|
||||
...alertTagsPanels,
|
||||
...alertAssigneesPanels,
|
||||
],
|
||||
[alertTagsPanels, items]
|
||||
[alertTagsPanels, alertAssigneesPanels, items]
|
||||
);
|
||||
|
||||
const osqueryFlyout = useMemo(() => {
|
||||
|
|
|
@ -0,0 +1,203 @@
|
|||
/*
|
||||
* 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 { TestProviders } from '@kbn/timelines-plugin/public/mock';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import type { UseAlertAssigneesActionsProps } from './use_alert_assignees_actions';
|
||||
import { useAlertAssigneesActions } from './use_alert_assignees_actions';
|
||||
import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges';
|
||||
import type { AlertTableContextMenuItem } from '../types';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import type { EuiContextMenuPanelDescriptor } from '@elastic/eui';
|
||||
import { EuiPopover, EuiContextMenu } from '@elastic/eui';
|
||||
import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees';
|
||||
import { useGetCurrentUserProfile } from '../../../../common/components/user_profiles/use_get_current_user_profile';
|
||||
import { useBulkGetUserProfiles } from '../../../../common/components/user_profiles/use_bulk_get_user_profiles';
|
||||
import { useSuggestUsers } from '../../../../common/components/user_profiles/use_suggest_users';
|
||||
import { useLicense } from '../../../../common/hooks/use_license';
|
||||
|
||||
jest.mock('../../../containers/detection_engine/alerts/use_alerts_privileges');
|
||||
jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees');
|
||||
jest.mock('../../../../common/components/user_profiles/use_get_current_user_profile');
|
||||
jest.mock('../../../../common/components/user_profiles/use_bulk_get_user_profiles');
|
||||
jest.mock('../../../../common/components/user_profiles/use_suggest_users');
|
||||
jest.mock('../../../../common/hooks/use_license');
|
||||
|
||||
const mockUserProfiles = [
|
||||
{ uid: 'user-id-1', enabled: true, user: { username: 'fakeUser1' }, data: {} },
|
||||
{ uid: 'user-id-2', enabled: true, user: { username: 'fakeUser2' }, data: {} },
|
||||
];
|
||||
|
||||
const defaultProps: UseAlertAssigneesActionsProps = {
|
||||
closePopover: jest.fn(),
|
||||
ecsRowData: {
|
||||
_id: '123',
|
||||
kibana: {
|
||||
alert: {
|
||||
workflow_assignee_ids: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
refetch: jest.fn(),
|
||||
};
|
||||
|
||||
const renderContextMenu = (
|
||||
items: AlertTableContextMenuItem[],
|
||||
panels: EuiContextMenuPanelDescriptor[]
|
||||
) => {
|
||||
const panelsToRender = [{ id: 0, items }, ...panels];
|
||||
return render(
|
||||
<EuiPopover
|
||||
isOpen={true}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
closePopover={() => {}}
|
||||
button={<></>}
|
||||
>
|
||||
<EuiContextMenu size="s" initialPanelId={2} panels={panelsToRender} />
|
||||
</EuiPopover>
|
||||
);
|
||||
};
|
||||
|
||||
describe('useAlertAssigneesActions', () => {
|
||||
beforeEach(() => {
|
||||
(useAlertsPrivileges as jest.Mock).mockReturnValue({
|
||||
hasIndexWrite: true,
|
||||
});
|
||||
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render alert assignees actions', () => {
|
||||
const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(result.current.alertAssigneesItems.length).toEqual(2);
|
||||
expect(result.current.alertAssigneesPanels.length).toEqual(1);
|
||||
expect(result.current.alertAssigneesItems[0]['data-test-subj']).toEqual(
|
||||
'alert-assignees-context-menu-item'
|
||||
);
|
||||
expect(result.current.alertAssigneesItems[1]['data-test-subj']).toEqual(
|
||||
'remove-alert-assignees-menu-item'
|
||||
);
|
||||
|
||||
expect(result.current.alertAssigneesPanels[0].content).toMatchInlineSnapshot(`
|
||||
<Memo(BulkAlertAssigneesPanelComponent)
|
||||
alertItems={
|
||||
Array [
|
||||
Object {
|
||||
"_id": "123",
|
||||
"_index": "",
|
||||
"data": Array [
|
||||
Object {
|
||||
"field": "kibana.alert.workflow_assignee_ids",
|
||||
"value": Array [],
|
||||
},
|
||||
],
|
||||
"ecs": Object {
|
||||
"_id": "123",
|
||||
"_index": "",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
closePopoverMenu={[MockFunction]}
|
||||
onSubmit={[Function]}
|
||||
refresh={[Function]}
|
||||
setIsLoading={[Function]}
|
||||
/>
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not render alert assignees actions if user doesn't have write permissions", () => {
|
||||
(useAlertsPrivileges as jest.Mock).mockReturnValue({
|
||||
hasIndexWrite: false,
|
||||
});
|
||||
const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
expect(result.current.alertAssigneesItems.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should not render alert assignees actions within Basic license', () => {
|
||||
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => false });
|
||||
const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
expect(result.current.alertAssigneesItems.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should still render if workflow_assignee_ids field does not exist', () => {
|
||||
const newProps = {
|
||||
...defaultProps,
|
||||
ecsRowData: {
|
||||
_id: '123',
|
||||
},
|
||||
};
|
||||
const { result } = renderHook(() => useAlertAssigneesActions(newProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
expect(result.current.alertAssigneesItems.length).toEqual(2);
|
||||
expect(result.current.alertAssigneesPanels.length).toEqual(1);
|
||||
expect(result.current.alertAssigneesPanels[0].content).toMatchInlineSnapshot(`
|
||||
<Memo(BulkAlertAssigneesPanelComponent)
|
||||
alertItems={
|
||||
Array [
|
||||
Object {
|
||||
"_id": "123",
|
||||
"_index": "",
|
||||
"data": Array [
|
||||
Object {
|
||||
"field": "kibana.alert.workflow_assignee_ids",
|
||||
"value": Array [],
|
||||
},
|
||||
],
|
||||
"ecs": Object {
|
||||
"_id": "123",
|
||||
"_index": "",
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
closePopoverMenu={[MockFunction]}
|
||||
onSubmit={[Function]}
|
||||
refresh={[Function]}
|
||||
setIsLoading={[Function]}
|
||||
/>
|
||||
`);
|
||||
});
|
||||
|
||||
it('should render the nested panel', async () => {
|
||||
(useSetAlertAssignees as jest.Mock).mockReturnValue(jest.fn());
|
||||
(useGetCurrentUserProfile as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles[0],
|
||||
});
|
||||
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles,
|
||||
});
|
||||
(useSuggestUsers as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAlertAssigneesActions(defaultProps), {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
const alertAssigneesItems = result.current.alertAssigneesItems;
|
||||
const alertAssigneesPanels = result.current.alertAssigneesPanels;
|
||||
const { getByTestId } = renderContextMenu(alertAssigneesItems, alertAssigneesPanels);
|
||||
|
||||
expect(getByTestId('alert-assignees-selectable-menu')).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* 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 { noop } from 'lodash';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import type { EuiContextMenuPanelDescriptor } from '@elastic/eui';
|
||||
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
|
||||
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
|
||||
|
||||
import { ASSIGNEES_PANEL_WIDTH } from '../../../../common/components/assignees/constants';
|
||||
import { useBulkAlertAssigneesItems } from '../../../../common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items';
|
||||
import { useAlertsPrivileges } from '../../../containers/detection_engine/alerts/use_alerts_privileges';
|
||||
import type { AlertTableContextMenuItem } from '../types';
|
||||
|
||||
export interface UseAlertAssigneesActionsProps {
|
||||
closePopover: () => void;
|
||||
ecsRowData: Ecs;
|
||||
refetch?: () => void;
|
||||
}
|
||||
|
||||
export const useAlertAssigneesActions = ({
|
||||
closePopover,
|
||||
ecsRowData,
|
||||
refetch,
|
||||
}: UseAlertAssigneesActionsProps) => {
|
||||
const { hasIndexWrite } = useAlertsPrivileges();
|
||||
|
||||
const alertId = ecsRowData._id;
|
||||
const alertAssigneeData = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
_id: alertId,
|
||||
_index: ecsRowData._index ?? '',
|
||||
data: [
|
||||
{
|
||||
field: ALERT_WORKFLOW_ASSIGNEE_IDS,
|
||||
value: ecsRowData?.kibana?.alert.workflow_assignee_ids ?? [],
|
||||
},
|
||||
],
|
||||
ecs: {
|
||||
_id: alertId,
|
||||
_index: ecsRowData._index ?? '',
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [alertId, ecsRowData._index, ecsRowData?.kibana?.alert.workflow_assignee_ids]);
|
||||
|
||||
const onAssigneesUpdate = useCallback(() => {
|
||||
closePopover();
|
||||
if (refetch) {
|
||||
refetch();
|
||||
}
|
||||
}, [closePopover, refetch]);
|
||||
|
||||
const { alertAssigneesItems, alertAssigneesPanels } = useBulkAlertAssigneesItems({
|
||||
onAssigneesUpdate,
|
||||
});
|
||||
|
||||
const itemsToReturn: AlertTableContextMenuItem[] = useMemo(
|
||||
() =>
|
||||
alertAssigneesItems.map((item) => ({
|
||||
name: item.name,
|
||||
panel: item.panel,
|
||||
'data-test-subj': item['data-test-subj'],
|
||||
key: item.key,
|
||||
onClick: () => item.onClick?.(alertAssigneeData, false, noop, noop, noop),
|
||||
})),
|
||||
[alertAssigneeData, alertAssigneesItems]
|
||||
);
|
||||
|
||||
const panelsToReturn: EuiContextMenuPanelDescriptor[] = useMemo(
|
||||
() =>
|
||||
alertAssigneesPanels.map((panel) => {
|
||||
const content = panel.renderContent({
|
||||
closePopoverMenu: closePopover,
|
||||
setIsBulkActionsLoading: () => {},
|
||||
alertItems: alertAssigneeData,
|
||||
refresh: onAssigneesUpdate,
|
||||
});
|
||||
return { title: panel.title, content, id: panel.id, width: ASSIGNEES_PANEL_WIDTH };
|
||||
}),
|
||||
[alertAssigneeData, alertAssigneesPanels, closePopover, onAssigneesUpdate]
|
||||
);
|
||||
|
||||
return {
|
||||
alertAssigneesItems: hasIndexWrite ? itemsToReturn : [],
|
||||
alertAssigneesPanels: panelsToReturn,
|
||||
};
|
||||
};
|
|
@ -95,6 +95,13 @@ export const ALERTS_HEADERS_RISK_SCORE = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
export const ALERTS_HEADERS_ASSIGNEES = i18n.translate(
|
||||
'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.assigneesTitle',
|
||||
{
|
||||
defaultMessage: 'Assignees',
|
||||
}
|
||||
);
|
||||
|
||||
export const ALERTS_HEADERS_THRESHOLD_COUNT = i18n.translate(
|
||||
'xpack.securitySolution.eventsViewer.alerts.defaultHeaders.thresholdCount',
|
||||
{
|
||||
|
|
|
@ -23,6 +23,35 @@ jest.mock('../../../common/components/filter_group');
|
|||
|
||||
jest.mock('../../../common/lib/kibana');
|
||||
|
||||
const mockUserProfiles = [
|
||||
{
|
||||
uid: 'user-id-1',
|
||||
enabled: true,
|
||||
user: { username: 'user1', full_name: 'User 1', email: 'user1@test.com' },
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
uid: 'user-id-2',
|
||||
enabled: true,
|
||||
user: { username: 'user2', full_name: 'User 2', email: 'user2@test.com' },
|
||||
data: {},
|
||||
},
|
||||
{
|
||||
uid: 'user-id-3',
|
||||
enabled: true,
|
||||
user: { username: 'user3', full_name: 'User 3', email: 'user3@test.com' },
|
||||
data: {},
|
||||
},
|
||||
];
|
||||
jest.mock('../../../common/components/user_profiles/use_suggest_users', () => {
|
||||
return {
|
||||
useSuggestUsers: () => ({
|
||||
loading: false,
|
||||
userProfiles: mockUserProfiles,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const basicKibanaServicesMock = createStartServicesMock();
|
||||
|
||||
const getFieldByNameMock = jest.fn(() => true);
|
||||
|
|
|
@ -37,7 +37,10 @@ import { getUserPrivilegesMockDefaultValue } from '../../../common/components/us
|
|||
import { allCasesPermissions } from '../../../cases_test_utils';
|
||||
import { HostStatus } from '../../../../common/endpoint/types';
|
||||
import { ENDPOINT_CAPABILITIES } from '../../../../common/endpoint/service/response_actions/constants';
|
||||
import { ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE } from '../../../common/components/toolbar/bulk_actions/translations';
|
||||
import {
|
||||
ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE,
|
||||
ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE,
|
||||
} from '../../../common/components/toolbar/bulk_actions/translations';
|
||||
|
||||
jest.mock('../../../common/components/user_privileges');
|
||||
|
||||
|
@ -58,6 +61,10 @@ jest.mock('../../../common/hooks/use_app_toasts', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../../common/hooks/use_license', () => ({
|
||||
useLicense: jest.fn().mockReturnValue({ isPlatinumPlus: () => true }),
|
||||
}));
|
||||
|
||||
jest.mock('../../../common/hooks/use_experimental_features', () => ({
|
||||
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
@ -254,6 +261,13 @@ describe('take action dropdown', () => {
|
|||
).toEqual(ALERT_TAGS_CONTEXT_MENU_ITEM_TITLE);
|
||||
});
|
||||
});
|
||||
test('should render "Assign alert"', async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
wrapper.find('[data-test-subj="alert-assignees-context-menu-item"]').first().text()
|
||||
).toEqual(ALERT_ASSIGNEES_CONTEXT_MENU_ITEM_TITLE);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('for Endpoint related actions', () => {
|
||||
|
|
|
@ -35,6 +35,7 @@ import { useKibana } from '../../../common/lib/kibana';
|
|||
import { getOsqueryActionItem } from '../osquery/osquery_action_item';
|
||||
import type { AlertTableContextMenuItem } from '../alerts_table/types';
|
||||
import { useAlertTagsActions } from '../alerts_table/timeline_actions/use_alert_tags_actions';
|
||||
import { useAlertAssigneesActions } from '../alerts_table/timeline_actions/use_alert_assignees_actions';
|
||||
|
||||
interface ActionsData {
|
||||
alertStatus: Status;
|
||||
|
@ -189,6 +190,20 @@ export const TakeActionDropdown = React.memo(
|
|||
refetch,
|
||||
});
|
||||
|
||||
const onAssigneesUpdate = useCallback(() => {
|
||||
if (refetch) {
|
||||
refetch();
|
||||
}
|
||||
if (refetchFlyoutData) {
|
||||
refetchFlyoutData();
|
||||
}
|
||||
}, [refetch, refetchFlyoutData]);
|
||||
const { alertAssigneesItems, alertAssigneesPanels } = useAlertAssigneesActions({
|
||||
closePopover: closePopoverHandler,
|
||||
ecsRowData: ecsData ?? { _id: actionsData.eventId },
|
||||
refetch: onAssigneesUpdate,
|
||||
});
|
||||
|
||||
const { investigateInTimelineActionItems } = useInvestigateInTimeline({
|
||||
ecsRowData: ecsData,
|
||||
onInvestigateInTimelineAlertClick: closePopoverHandler,
|
||||
|
@ -214,7 +229,12 @@ export const TakeActionDropdown = React.memo(
|
|||
const alertsActionItems = useMemo(
|
||||
() =>
|
||||
!isEvent && actionsData.ruleId
|
||||
? [...statusActionItems, ...alertTagsItems, ...exceptionActionItems]
|
||||
? [
|
||||
...statusActionItems,
|
||||
...alertTagsItems,
|
||||
...alertAssigneesItems,
|
||||
...exceptionActionItems,
|
||||
]
|
||||
: isEndpointEvent && canCreateEndpointEventFilters
|
||||
? eventFilterActionItems
|
||||
: [],
|
||||
|
@ -227,6 +247,7 @@ export const TakeActionDropdown = React.memo(
|
|||
isEvent,
|
||||
actionsData.ruleId,
|
||||
alertTagsItems,
|
||||
alertAssigneesItems,
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -271,6 +292,7 @@ export const TakeActionDropdown = React.memo(
|
|||
items,
|
||||
},
|
||||
...alertTagsPanels,
|
||||
...alertAssigneesPanels,
|
||||
];
|
||||
|
||||
const takeActionButton = useMemo(
|
||||
|
|
|
@ -28,6 +28,12 @@ const getBaseColumns = (
|
|||
> => {
|
||||
const isPlatinumPlus = license?.isPlatinumPlus?.() ?? false;
|
||||
return [
|
||||
{
|
||||
columnHeaderType: defaultColumnHeaderType,
|
||||
displayAsText: i18n.ALERTS_HEADERS_ASSIGNEES,
|
||||
id: 'kibana.alert.workflow_assignee_ids',
|
||||
initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH,
|
||||
},
|
||||
{
|
||||
columnHeaderType: defaultColumnHeaderType,
|
||||
displayAsText: i18n.ALERTS_HEADERS_SEVERITY,
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import type { UserProfileWithAvatar } from '@kbn/user-profile-components';
|
||||
import type { PreFetchPageContext } from '@kbn/triggers-actions-ui-plugin/public/types';
|
||||
import { useBulkGetUserProfiles } from '../../../common/components/user_profiles/use_bulk_get_user_profiles';
|
||||
|
||||
export interface RenderCellValueContext {
|
||||
profiles: UserProfileWithAvatar[] | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// Add new columns names to this array to render the user's display name instead of profile_uid
|
||||
export const profileUidColumns = [
|
||||
'kibana.alert.workflow_assignee_ids',
|
||||
'kibana.alert.workflow_user',
|
||||
];
|
||||
|
||||
export const useFetchPageContext: PreFetchPageContext<RenderCellValueContext> = ({
|
||||
alerts,
|
||||
columns,
|
||||
}) => {
|
||||
const uids = new Set<string>();
|
||||
alerts.forEach((alert) => {
|
||||
profileUidColumns.forEach((columnId) => {
|
||||
if (columns.find((column) => column.id === columnId) != null) {
|
||||
const userUids = alert[columnId];
|
||||
userUids?.forEach((uid) => uids.add(uid as string));
|
||||
}
|
||||
});
|
||||
});
|
||||
const result = useBulkGetUserProfiles({ uids });
|
||||
const returnVal = useMemo(
|
||||
() => ({ profiles: result.data, isLoading: result.isLoading }),
|
||||
[result.data, result.isLoading]
|
||||
);
|
||||
return returnVal;
|
||||
};
|
|
@ -33,6 +33,7 @@ import { SUPPRESSED_ALERT_TOOLTIP } from './translations';
|
|||
import { VIEW_SELECTION } from '../../../../common/constants';
|
||||
import { getAllFieldsByName } from '../../../common/containers/source';
|
||||
import { eventRenderedViewColumns, getColumns } from './columns';
|
||||
import type { RenderCellValueContext } from './fetch_page_context';
|
||||
|
||||
/**
|
||||
* This implementation of `EuiDataGrid`'s `renderCellValue`
|
||||
|
@ -95,7 +96,7 @@ export const getRenderCellValueHook = ({
|
|||
scopeId: SourcererScopeName;
|
||||
tableId: TableId;
|
||||
}) => {
|
||||
const useRenderCellValue: GetRenderCellValue = () => {
|
||||
const useRenderCellValue: GetRenderCellValue = ({ context }) => {
|
||||
const { browserFields } = useSourcererDataView(scopeId);
|
||||
const browserFieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]);
|
||||
const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []);
|
||||
|
@ -173,10 +174,11 @@ export const getRenderCellValueHook = ({
|
|||
scopeId={tableId}
|
||||
truncate={truncate}
|
||||
asPlainText={false}
|
||||
context={context as RenderCellValueContext}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[browserFieldsByName, browserFields, columnHeaders]
|
||||
[browserFieldsByName, columnHeaders, browserFields, context]
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
|
|
@ -30,3 +30,8 @@ export const CASES_FROM_ALERTS_FAILURE = i18n.translate(
|
|||
'xpack.securitySolution.endpoint.hostIsolation.casesFromAlerts.title',
|
||||
{ defaultMessage: 'Failed to find associated cases' }
|
||||
);
|
||||
|
||||
export const USER_PROFILES_FAILURE = i18n.translate(
|
||||
'xpack.securitySolution.containers.detectionEngine.users.userProfiles.title',
|
||||
{ defaultMessage: 'Failed to find users' }
|
||||
);
|
||||
|
|
|
@ -12,6 +12,7 @@ import { isEqual } from 'lodash';
|
|||
import type { Filter } from '@kbn/es-query';
|
||||
import { useCallback } from 'react';
|
||||
import type { TableId } from '@kbn/securitysolution-data-table';
|
||||
import { useBulkAlertAssigneesItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_assignees_items';
|
||||
import { useBulkAlertTagsItems } from '../../../common/components/toolbar/bulk_actions/use_bulk_alert_tags_items';
|
||||
import type { inputsModel, State } from '../../../common/store';
|
||||
import { useShallowEqualSelector } from '../../../common/hooks/use_selector';
|
||||
|
@ -93,7 +94,11 @@ export const getBulkActionHook =
|
|||
refetch: refetchGlobalQuery,
|
||||
});
|
||||
|
||||
const items = [...alertActions, timelineAction, ...alertTagsItems];
|
||||
const { alertAssigneesItems, alertAssigneesPanels } = useBulkAlertAssigneesItems({
|
||||
onAssigneesUpdate: refetchGlobalQuery,
|
||||
});
|
||||
|
||||
return [{ id: 0, items }, ...alertTagsPanels];
|
||||
const items = [...alertActions, timelineAction, ...alertTagsItems, ...alertAssigneesItems];
|
||||
|
||||
return [{ id: 0, items }, ...alertTagsPanels, ...alertAssigneesPanels];
|
||||
};
|
||||
|
|
|
@ -33,6 +33,7 @@ import { FilterGroup } from '../../../common/components/filter_group';
|
|||
import type { AlertsTableComponentProps } from '../../components/alerts_table/alerts_grouping';
|
||||
import { getMockedFilterGroupWithCustomFilters } from '../../../common/components/filter_group/mocks';
|
||||
import { TableId } from '@kbn/securitysolution-data-table';
|
||||
import { useUpsellingMessage } from '../../../common/hooks/use_upselling';
|
||||
|
||||
// Test will fail because we will to need to mock some core services to make the test work
|
||||
// For now let's forget about SiemSearchBar and QueryBar
|
||||
|
@ -219,6 +220,7 @@ jest.mock('../../components/alerts_table/timeline_actions/use_add_bulk_to_timeli
|
|||
|
||||
jest.mock('../../../common/components/visualization_actions/lens_embeddable');
|
||||
jest.mock('../../../common/components/page/use_refetch_by_session');
|
||||
jest.mock('../../../common/hooks/use_upselling');
|
||||
|
||||
describe('DetectionEnginePageComponent', () => {
|
||||
beforeAll(() => {
|
||||
|
@ -239,6 +241,7 @@ describe('DetectionEnginePageComponent', () => {
|
|||
(FilterGroup as jest.Mock).mockImplementation(() => {
|
||||
return <span />;
|
||||
});
|
||||
(useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!');
|
||||
});
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
|
@ -31,6 +31,9 @@ import {
|
|||
tableDefaults,
|
||||
TableId,
|
||||
} from '@kbn/securitysolution-data-table';
|
||||
import { isEqual } from 'lodash';
|
||||
import { FilterByAssigneesPopover } from '../../../common/components/filter_group/filter_by_assignees';
|
||||
import type { AssigneesIdsSelection } from '../../../common/components/assignees/types';
|
||||
import { ALERTS_TABLE_REGISTRY_CONFIG_IDS } from '../../../../common/constants';
|
||||
import { useDataTableFilters } from '../../../common/hooks/use_data_table_filters';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
|
||||
|
@ -62,6 +65,7 @@ import {
|
|||
showGlobalFilters,
|
||||
} from '../../../timelines/components/timeline/helpers';
|
||||
import {
|
||||
buildAlertAssigneesFilter,
|
||||
buildAlertStatusFilter,
|
||||
buildShowBuildingBlockFilter,
|
||||
buildThreatMatchFilter,
|
||||
|
@ -135,6 +139,16 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } =
|
||||
useListsConfig();
|
||||
|
||||
const [assignees, setAssignees] = useState<AssigneesIdsSelection[]>([]);
|
||||
const handleSelectedAssignees = useCallback(
|
||||
(newAssignees: AssigneesIdsSelection[]) => {
|
||||
if (!isEqual(newAssignees, assignees)) {
|
||||
setAssignees(newAssignees);
|
||||
}
|
||||
},
|
||||
[assignees]
|
||||
);
|
||||
|
||||
const arePageFiltersEnabled = useIsExperimentalFeatureEnabled('alertsPageFiltersEnabled');
|
||||
|
||||
// when arePageFiltersEnabled === false
|
||||
|
@ -176,8 +190,9 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
...filters,
|
||||
...buildShowBuildingBlockFilter(showBuildingBlockAlerts),
|
||||
...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts),
|
||||
...buildAlertAssigneesFilter(assignees),
|
||||
];
|
||||
}, [showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, filters]);
|
||||
}, [assignees, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, filters]);
|
||||
|
||||
const alertPageFilters = useMemo(() => {
|
||||
if (arePageFiltersEnabled) {
|
||||
|
@ -247,8 +262,9 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
...buildShowBuildingBlockFilter(showBuildingBlockAlerts),
|
||||
...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts),
|
||||
...(alertPageFilters ?? []),
|
||||
...buildAlertAssigneesFilter(assignees),
|
||||
],
|
||||
[showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, alertPageFilters]
|
||||
[assignees, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, alertPageFilters]
|
||||
);
|
||||
|
||||
const { signalIndexNeedsInit, pollForSignalIndex } = useSignalHelpers();
|
||||
|
@ -363,16 +379,16 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
/>
|
||||
),
|
||||
[
|
||||
topLevelFilters,
|
||||
arePageFiltersEnabled,
|
||||
statusFilter,
|
||||
from,
|
||||
onFilterGroupChangedCallback,
|
||||
pageFiltersUpdateHandler,
|
||||
showUpdating,
|
||||
from,
|
||||
query,
|
||||
showUpdating,
|
||||
statusFilter,
|
||||
timelinesUi,
|
||||
to,
|
||||
topLevelFilters,
|
||||
updatedAt,
|
||||
]
|
||||
);
|
||||
|
@ -448,14 +464,24 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
|
|||
>
|
||||
<Display show={!globalFullScreen}>
|
||||
<HeaderPage title={i18n.PAGE_TITLE}>
|
||||
<SecuritySolutionLinkButton
|
||||
onClick={goToRules}
|
||||
deepLinkId={SecurityPageName.rules}
|
||||
data-test-subj="manage-alert-detection-rules"
|
||||
fill
|
||||
>
|
||||
{i18n.BUTTON_MANAGE_RULES}
|
||||
</SecuritySolutionLinkButton>
|
||||
<EuiFlexGroup gutterSize="m">
|
||||
<EuiFlexItem>
|
||||
<FilterByAssigneesPopover
|
||||
assignedUserIds={assignees}
|
||||
onSelectionChange={handleSelectedAssignees}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<SecuritySolutionLinkButton
|
||||
onClick={goToRules}
|
||||
deepLinkId={SecurityPageName.rules}
|
||||
data-test-subj="manage-alert-detection-rules"
|
||||
fill
|
||||
>
|
||||
{i18n.BUTTON_MANAGE_RULES}
|
||||
</SecuritySolutionLinkButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</HeaderPage>
|
||||
<EuiHorizontalRule margin="none" />
|
||||
<EuiSpacer size="l" />
|
||||
|
|
|
@ -0,0 +1,166 @@
|
|||
/*
|
||||
* 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 { render } from '@testing-library/react';
|
||||
|
||||
import { ASSIGNEES_ADD_BUTTON_TEST_ID, ASSIGNEES_TITLE_TEST_ID } from './test_ids';
|
||||
import { Assignees } from './assignees';
|
||||
|
||||
import { useGetCurrentUserProfile } from '../../../../common/components/user_profiles/use_get_current_user_profile';
|
||||
import { useBulkGetUserProfiles } from '../../../../common/components/user_profiles/use_bulk_get_user_profiles';
|
||||
import { useSuggestUsers } from '../../../../common/components/user_profiles/use_suggest_users';
|
||||
import type { SetAlertAssigneesFunc } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees';
|
||||
import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { ASSIGNEES_APPLY_BUTTON_TEST_ID } from '../../../../common/components/assignees/test_ids';
|
||||
import { useLicense } from '../../../../common/hooks/use_license';
|
||||
import { useUpsellingMessage } from '../../../../common/hooks/use_upselling';
|
||||
import {
|
||||
USERS_AVATARS_COUNT_BADGE_TEST_ID,
|
||||
USERS_AVATARS_PANEL_TEST_ID,
|
||||
USER_AVATAR_ITEM_TEST_ID,
|
||||
} from '../../../../common/components/user_profiles/test_ids';
|
||||
import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges';
|
||||
|
||||
jest.mock('../../../../common/components/user_profiles/use_get_current_user_profile');
|
||||
jest.mock('../../../../common/components/user_profiles/use_bulk_get_user_profiles');
|
||||
jest.mock('../../../../common/components/user_profiles/use_suggest_users');
|
||||
jest.mock('../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees');
|
||||
jest.mock('../../../../common/hooks/use_license');
|
||||
jest.mock('../../../../common/hooks/use_upselling');
|
||||
jest.mock('../../../../detections/containers/detection_engine/alerts/use_alerts_privileges');
|
||||
|
||||
const mockUserProfiles = [
|
||||
{ uid: 'user-id-1', enabled: true, user: { username: 'user1', full_name: 'User 1' }, data: {} },
|
||||
{ uid: 'user-id-2', enabled: true, user: { username: 'user2', full_name: 'User 2' }, data: {} },
|
||||
{ uid: 'user-id-3', enabled: true, user: { username: 'user3', full_name: 'User 3' }, data: {} },
|
||||
];
|
||||
|
||||
const renderAssignees = (
|
||||
eventId = 'event-1',
|
||||
alertAssignees = ['user-id-1'],
|
||||
onAssigneesUpdated = jest.fn()
|
||||
) => {
|
||||
const assignedProfiles = mockUserProfiles.filter((user) => alertAssignees.includes(user.uid));
|
||||
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: assignedProfiles,
|
||||
});
|
||||
return render(
|
||||
<TestProviders>
|
||||
<Assignees
|
||||
eventId={eventId}
|
||||
assignedUserIds={alertAssignees}
|
||||
onAssigneesUpdated={onAssigneesUpdated}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
};
|
||||
|
||||
describe('<Assignees />', () => {
|
||||
let setAlertAssigneesMock: jest.Mocked<SetAlertAssigneesFunc>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useGetCurrentUserProfile as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles[0],
|
||||
});
|
||||
(useSuggestUsers as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: mockUserProfiles,
|
||||
});
|
||||
(useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: true });
|
||||
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => true });
|
||||
(useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!');
|
||||
|
||||
setAlertAssigneesMock = jest.fn().mockReturnValue(Promise.resolve());
|
||||
(useSetAlertAssignees as jest.Mock).mockReturnValue(setAlertAssigneesMock);
|
||||
});
|
||||
|
||||
it('should render component', () => {
|
||||
const { getByTestId } = renderAssignees();
|
||||
|
||||
expect(getByTestId(ASSIGNEES_TITLE_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(USERS_AVATARS_PANEL_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should render assignees avatars', () => {
|
||||
const assignees = ['user-id-1', 'user-id-2'];
|
||||
const { getByTestId, queryByTestId } = renderAssignees('test-event', assignees);
|
||||
|
||||
expect(getByTestId(USER_AVATAR_ITEM_TEST_ID('user1'))).toBeInTheDocument();
|
||||
expect(getByTestId(USER_AVATAR_ITEM_TEST_ID('user2'))).toBeInTheDocument();
|
||||
|
||||
expect(queryByTestId(USERS_AVATARS_COUNT_BADGE_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render badge with assignees count in case there are more than two users assigned to an alert', () => {
|
||||
const assignees = ['user-id-1', 'user-id-2', 'user-id-3'];
|
||||
const { getByTestId, queryByTestId } = renderAssignees('test-event', assignees);
|
||||
|
||||
const assigneesCountBadge = getByTestId(USERS_AVATARS_COUNT_BADGE_TEST_ID);
|
||||
expect(assigneesCountBadge).toBeInTheDocument();
|
||||
expect(assigneesCountBadge).toHaveTextContent(`${assignees.length}`);
|
||||
|
||||
expect(queryByTestId(USER_AVATAR_ITEM_TEST_ID('user1'))).not.toBeInTheDocument();
|
||||
expect(queryByTestId(USER_AVATAR_ITEM_TEST_ID('user2'))).not.toBeInTheDocument();
|
||||
expect(queryByTestId(USER_AVATAR_ITEM_TEST_ID('user3'))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call assignees update functionality with the right arguments', () => {
|
||||
const assignedProfiles = [mockUserProfiles[0], mockUserProfiles[1]];
|
||||
(useBulkGetUserProfiles as jest.Mock).mockReturnValue({
|
||||
isLoading: false,
|
||||
data: assignedProfiles,
|
||||
});
|
||||
|
||||
const assignees = assignedProfiles.map((assignee) => assignee.uid);
|
||||
const { getByTestId, getByText } = renderAssignees('test-event', assignees);
|
||||
|
||||
// Update assignees
|
||||
getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID).click();
|
||||
getByText('User 1').click();
|
||||
getByText('User 3').click();
|
||||
|
||||
// Apply assignees
|
||||
getByTestId(ASSIGNEES_APPLY_BUTTON_TEST_ID).click();
|
||||
|
||||
expect(setAlertAssigneesMock).toHaveBeenCalledWith(
|
||||
{
|
||||
add: ['user-id-3'],
|
||||
remove: ['user-id-1'],
|
||||
},
|
||||
['test-event'],
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('should render add assignees button as disabled if user has readonly priviliges', () => {
|
||||
(useAlertsPrivileges as jest.Mock).mockReturnValue({ hasIndexWrite: false });
|
||||
|
||||
const assignees = ['user-id-1', 'user-id-2'];
|
||||
const { getByTestId } = renderAssignees('test-event', assignees);
|
||||
|
||||
expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should render add assignees button as disabled within Basic license', () => {
|
||||
(useLicense as jest.Mock).mockReturnValue({ isPlatinumPlus: () => false });
|
||||
|
||||
const assignees = ['user-id-1', 'user-id-2'];
|
||||
const { getByTestId } = renderAssignees('test-event', assignees);
|
||||
|
||||
expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(ASSIGNEES_ADD_BUTTON_TEST_ID)).toBeDisabled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* 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 { noop } from 'lodash';
|
||||
import type { FC } from 'react';
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiToolTip } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
|
||||
import { useUpsellingMessage } from '../../../../common/hooks/use_upselling';
|
||||
import { useLicense } from '../../../../common/hooks/use_license';
|
||||
import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges';
|
||||
import { useBulkGetUserProfiles } from '../../../../common/components/user_profiles/use_bulk_get_user_profiles';
|
||||
import { removeNoAssigneesSelection } from '../../../../common/components/assignees/utils';
|
||||
import type { AssigneesIdsSelection } from '../../../../common/components/assignees/types';
|
||||
import { AssigneesPopover } from '../../../../common/components/assignees/assignees_popover';
|
||||
import { UsersAvatarsPanel } from '../../../../common/components/user_profiles/users_avatars_panel';
|
||||
import { useSetAlertAssignees } from '../../../../common/components/toolbar/bulk_actions/use_set_alert_assignees';
|
||||
import {
|
||||
ASSIGNEES_ADD_BUTTON_TEST_ID,
|
||||
ASSIGNEES_HEADER_TEST_ID,
|
||||
ASSIGNEES_TITLE_TEST_ID,
|
||||
} from './test_ids';
|
||||
|
||||
const UpdateAssigneesButton: FC<{
|
||||
isDisabled: boolean;
|
||||
toolTipMessage: string;
|
||||
togglePopover: () => void;
|
||||
}> = memo(({ togglePopover, isDisabled, toolTipMessage }) => (
|
||||
<EuiToolTip position="bottom" content={toolTipMessage}>
|
||||
<EuiButtonIcon
|
||||
aria-label="Update assignees"
|
||||
data-test-subj={ASSIGNEES_ADD_BUTTON_TEST_ID}
|
||||
iconType={'plusInCircle'}
|
||||
onClick={togglePopover}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</EuiToolTip>
|
||||
));
|
||||
UpdateAssigneesButton.displayName = 'UpdateAssigneesButton';
|
||||
|
||||
export interface AssigneesProps {
|
||||
/**
|
||||
* Id of the document
|
||||
*/
|
||||
eventId: string;
|
||||
|
||||
/**
|
||||
* The array of ids of the users assigned to the alert
|
||||
*/
|
||||
assignedUserIds: string[];
|
||||
|
||||
/**
|
||||
* Callback to handle the successful assignees update
|
||||
*/
|
||||
onAssigneesUpdated?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Document assignees details displayed in flyout right section header
|
||||
*/
|
||||
export const Assignees: FC<AssigneesProps> = memo(
|
||||
({ eventId, assignedUserIds, onAssigneesUpdated }) => {
|
||||
const isPlatinumPlus = useLicense().isPlatinumPlus();
|
||||
const upsellingMessage = useUpsellingMessage('alert_assignments');
|
||||
|
||||
const { hasIndexWrite } = useAlertsPrivileges();
|
||||
const setAlertAssignees = useSetAlertAssignees();
|
||||
|
||||
const uids = useMemo(() => new Set(assignedUserIds), [assignedUserIds]);
|
||||
const { data: assignedUsers } = useBulkGetUserProfiles({ uids });
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
const onSuccess = useCallback(() => {
|
||||
if (onAssigneesUpdated) onAssigneesUpdated();
|
||||
}, [onAssigneesUpdated]);
|
||||
|
||||
const togglePopover = useCallback(() => {
|
||||
setIsPopoverOpen((value) => !value);
|
||||
}, []);
|
||||
|
||||
const onAssigneesApply = useCallback(
|
||||
async (assigneesIds: AssigneesIdsSelection[]) => {
|
||||
setIsPopoverOpen(false);
|
||||
if (setAlertAssignees) {
|
||||
const updatedIds = removeNoAssigneesSelection(assigneesIds);
|
||||
const assigneesToAddArray = updatedIds.filter((uid) => !assignedUserIds.includes(uid));
|
||||
const assigneesToRemoveArray = assignedUserIds.filter((uid) => !updatedIds.includes(uid));
|
||||
|
||||
const assigneesToUpdate = {
|
||||
add: assigneesToAddArray,
|
||||
remove: assigneesToRemoveArray,
|
||||
};
|
||||
|
||||
await setAlertAssignees(assigneesToUpdate, [eventId], onSuccess, noop);
|
||||
}
|
||||
},
|
||||
[assignedUserIds, eventId, onSuccess, setAlertAssignees]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup
|
||||
data-test-subj={ASSIGNEES_HEADER_TEST_ID}
|
||||
alignItems="center"
|
||||
direction="row"
|
||||
gutterSize="xs"
|
||||
responsive={false}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="xxs" data-test-subj={ASSIGNEES_TITLE_TEST_ID}>
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.header.assignedTitle"
|
||||
defaultMessage="Assignees:"
|
||||
/>
|
||||
</h3>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
{assignedUsers && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<UsersAvatarsPanel userProfiles={assignedUsers} maxVisibleAvatars={2} />
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem grow={false}>
|
||||
<AssigneesPopover
|
||||
assignedUserIds={assignedUserIds}
|
||||
button={
|
||||
<UpdateAssigneesButton
|
||||
togglePopover={togglePopover}
|
||||
isDisabled={!hasIndexWrite || !isPlatinumPlus}
|
||||
toolTipMessage={
|
||||
upsellingMessage ??
|
||||
i18n.translate(
|
||||
'xpack.securitySolution.flyout.right.visualizations.assignees.popoverTooltip',
|
||||
{
|
||||
defaultMessage: 'Assign alert',
|
||||
}
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
isPopoverOpen={isPopoverOpen}
|
||||
closePopover={togglePopover}
|
||||
onAssigneesApply={onAssigneesApply}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Assignees.displayName = 'Assignees';
|
|
@ -6,26 +6,36 @@
|
|||
*/
|
||||
|
||||
import type { FC } from 'react';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
|
||||
import { DocumentStatus } from './status';
|
||||
import { DocumentSeverity } from './severity';
|
||||
import { RiskScore } from './risk_score';
|
||||
import { useRefetchByScope } from '../../../../timelines/components/side_panel/event_details/flyout/use_refetch_by_scope';
|
||||
import { useBasicDataFromDetailsData } from '../../../../timelines/components/side_panel/event_details/helpers';
|
||||
import { useRightPanelContext } from '../context';
|
||||
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';
|
||||
import { RenderRuleName } from '../../../../timelines/components/timeline/body/renderers/formatted_field_helpers';
|
||||
import { SIGNAL_RULE_NAME_FIELD_NAME } from '../../../../timelines/components/timeline/body/renderers/constants';
|
||||
import { FLYOUT_HEADER_TITLE_TEST_ID } from './test_ids';
|
||||
import { Assignees } from './assignees';
|
||||
import { FlyoutTitle } from '../../../shared/components/flyout_title';
|
||||
|
||||
/**
|
||||
* Document details flyout right section header
|
||||
*/
|
||||
export const HeaderTitle: FC = memo(() => {
|
||||
const { dataFormattedForFieldBrowser, eventId, scopeId, isPreview } = useRightPanelContext();
|
||||
const {
|
||||
dataFormattedForFieldBrowser,
|
||||
eventId,
|
||||
scopeId,
|
||||
isPreview,
|
||||
refetchFlyoutData,
|
||||
getFieldsData,
|
||||
} = useRightPanelContext();
|
||||
const { isAlert, ruleName, timestamp, ruleId } = useBasicDataFromDetailsData(
|
||||
dataFormattedForFieldBrowser
|
||||
);
|
||||
|
@ -72,6 +82,16 @@ export const HeaderTitle: FC = memo(() => {
|
|||
</EuiTitle>
|
||||
);
|
||||
|
||||
const { refetch } = useRefetchByScope({ scopeId });
|
||||
const alertAssignees = useMemo(
|
||||
() => (getFieldsData(ALERT_WORKFLOW_ASSIGNEE_IDS) as string[]) ?? [],
|
||||
[getFieldsData]
|
||||
);
|
||||
const onAssigneesUpdated = useCallback(() => {
|
||||
refetch();
|
||||
refetchFlyoutData();
|
||||
}, [refetch, refetchFlyoutData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentSeverity />
|
||||
|
@ -91,6 +111,15 @@ export const HeaderTitle: FC = memo(() => {
|
|||
<EuiFlexItem grow={false}>
|
||||
<RiskScore />
|
||||
</EuiFlexItem>
|
||||
{isAlert && !isPreview && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<Assignees
|
||||
eventId={eventId}
|
||||
assignedUserIds={alertAssignees}
|
||||
onAssigneesUpdated={onAssigneesUpdated}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -19,6 +19,10 @@ export const RISK_SCORE_VALUE_TEST_ID = `${FLYOUT_HEADER_TEST_ID}RiskScoreValue`
|
|||
export const SHARE_BUTTON_TEST_ID = `${FLYOUT_HEADER_TEST_ID}ShareButton` as const;
|
||||
export const CHAT_BUTTON_TEST_ID = 'newChatById' as const;
|
||||
|
||||
export const ASSIGNEES_HEADER_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesHeader` as const;
|
||||
export const ASSIGNEES_TITLE_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesTitle` as const;
|
||||
export const ASSIGNEES_ADD_BUTTON_TEST_ID = `${FLYOUT_HEADER_TEST_ID}AssigneesAddButton` as const;
|
||||
|
||||
/* About section */
|
||||
|
||||
export const ABOUT_SECTION_TEST_ID = `${PREFIX}AboutSection` as const;
|
||||
|
|
|
@ -50,7 +50,7 @@ Array [
|
|||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup emotion-euiFlexGroup-l-flexStart-flexEnd-column"
|
||||
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-flexEnd-column"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
|
@ -75,6 +75,63 @@ Array [
|
|||
class="euiFlexGroup emotion-euiFlexGroup-responsive-none-flexStart-center-row"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup emotion-euiFlexGroup-xs-flexStart-center-row"
|
||||
data-test-subj="securitySolutionFlyoutHeaderAssigneesHeader"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<h3
|
||||
class="euiTitle emotion-euiTitle-xxs"
|
||||
data-test-subj="securitySolutionFlyoutHeaderAssigneesTitle"
|
||||
>
|
||||
Assignees:
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup emotion-euiFlexGroup-responsive-xs-flexStart-center-row"
|
||||
data-test-subj="securitySolutionUsersAvatarsPanel"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<div
|
||||
class="euiPopover emotion-euiPopover"
|
||||
>
|
||||
<div
|
||||
class="euiPopover__anchor css-16vtueo-render"
|
||||
>
|
||||
<span
|
||||
class="euiToolTipAnchor emotion-euiToolTipAnchor-inlineBlock"
|
||||
>
|
||||
<button
|
||||
aria-label="Update assignees"
|
||||
class="euiButtonIcon emotion-euiButtonIcon-xs-empty-disabled-isDisabled"
|
||||
data-test-subj="securitySolutionFlyoutHeaderAssigneesAddButton"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="euiButtonIcon__icon"
|
||||
color="inherit"
|
||||
data-euiicon-type="plusInCircle"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
|
@ -199,7 +256,7 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should
|
|||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup emotion-euiFlexGroup-l-flexStart-flexEnd-column"
|
||||
class="euiFlexGroup emotion-euiFlexGroup-none-flexStart-flexEnd-column"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
|
@ -208,6 +265,63 @@ exports[`Details Panel Component DetailsPanel:EventDetails: rendering it should
|
|||
class="euiFlexGroup emotion-euiFlexGroup-responsive-none-flexStart-center-row"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup emotion-euiFlexGroup-xs-flexStart-center-row"
|
||||
data-test-subj="securitySolutionFlyoutHeaderAssigneesHeader"
|
||||
>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<h3
|
||||
class="euiTitle emotion-euiTitle-xxs"
|
||||
data-test-subj="securitySolutionFlyoutHeaderAssigneesTitle"
|
||||
>
|
||||
Assignees:
|
||||
</h3>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<div
|
||||
class="euiFlexGroup emotion-euiFlexGroup-responsive-xs-flexStart-center-row"
|
||||
data-test-subj="securitySolutionUsersAvatarsPanel"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="euiFlexItem emotion-euiFlexItem-growZero"
|
||||
>
|
||||
<div
|
||||
class="euiPopover emotion-euiPopover"
|
||||
>
|
||||
<div
|
||||
class="euiPopover__anchor css-16vtueo-render"
|
||||
>
|
||||
<span
|
||||
class="euiToolTipAnchor emotion-euiToolTipAnchor-inlineBlock"
|
||||
>
|
||||
<button
|
||||
aria-label="Update assignees"
|
||||
class="euiButtonIcon emotion-euiButtonIcon-xs-empty-disabled-isDisabled"
|
||||
data-test-subj="securitySolutionFlyoutHeaderAssigneesAddButton"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="euiButtonIcon__icon"
|
||||
color="inherit"
|
||||
data-euiicon-type="plusInCircle"
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -19,9 +19,13 @@ import {
|
|||
EuiSpacer,
|
||||
EuiCopy,
|
||||
} from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { ALERT_WORKFLOW_ASSIGNEE_IDS } from '@kbn/rule-data-utils';
|
||||
import { TableId } from '@kbn/securitysolution-data-table';
|
||||
import type { GetFieldsData } from '../../../../common/hooks/use_get_fields_data';
|
||||
import { Assignees } from '../../../../flyout/document_details/right/components/assignees';
|
||||
import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability';
|
||||
import type { TimelineTabs } from '../../../../../common/types/timeline';
|
||||
import type { BrowserFields } from '../../../../common/containers/source';
|
||||
|
@ -34,6 +38,7 @@ import {
|
|||
} from '../../../../common/components/event_details/translations';
|
||||
import { PreferenceFormattedDate } from '../../../../common/components/formatted_date';
|
||||
import { useGetAlertDetailsFlyoutLink } from './use_get_alert_details_flyout_link';
|
||||
import { useRefetchByScope } from './flyout/use_refetch_by_scope';
|
||||
|
||||
export type HandleOnEventClosed = () => void;
|
||||
interface Props {
|
||||
|
@ -61,6 +66,9 @@ interface ExpandableEventTitleProps {
|
|||
ruleName?: string;
|
||||
timestamp: string;
|
||||
handleOnEventClosed?: HandleOnEventClosed;
|
||||
scopeId: string;
|
||||
refetchFlyoutData: () => Promise<void>;
|
||||
getFieldsData: GetFieldsData;
|
||||
}
|
||||
|
||||
const StyledEuiFlexGroup = styled(EuiFlexGroup)`
|
||||
|
@ -89,6 +97,9 @@ export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
|
|||
promptContextId,
|
||||
ruleName,
|
||||
timestamp,
|
||||
scopeId,
|
||||
refetchFlyoutData,
|
||||
getFieldsData,
|
||||
}) => {
|
||||
const { hasAssistantPrivilege } = useAssistantAvailability();
|
||||
const alertDetailsLink = useGetAlertDetailsFlyoutLink({
|
||||
|
@ -97,6 +108,16 @@ export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
|
|||
timestamp,
|
||||
});
|
||||
|
||||
const { refetch } = useRefetchByScope({ scopeId });
|
||||
const alertAssignees = useMemo(
|
||||
() => (getFieldsData(ALERT_WORKFLOW_ASSIGNEE_IDS) as string[]) ?? [],
|
||||
[getFieldsData]
|
||||
);
|
||||
const onAssigneesUpdated = useCallback(() => {
|
||||
refetch();
|
||||
refetchFlyoutData();
|
||||
}, [refetch, refetchFlyoutData]);
|
||||
|
||||
return (
|
||||
<StyledEuiFlexGroup gutterSize="none" justifyContent="spaceBetween" wrap={true}>
|
||||
<EuiFlexItem grow={false}>
|
||||
|
@ -115,7 +136,7 @@ export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
|
|||
)}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup direction="column" alignItems="flexEnd">
|
||||
<EuiFlexGroup direction="column" alignItems="flexEnd" gutterSize="none">
|
||||
{handleOnEventClosed && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
|
@ -154,6 +175,15 @@ export const ExpandableEventTitle = React.memo<ExpandableEventTitleProps>(
|
|||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
{scopeId !== TableId.rulePreview && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<Assignees
|
||||
eventId={eventId}
|
||||
assignedUserIds={alertAssignees}
|
||||
onAssigneesUpdated={onAssigneesUpdated}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</StyledEuiFlexGroup>
|
||||
|
|
|
@ -8,8 +8,6 @@
|
|||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { EuiFlyoutFooter, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { find } from 'lodash/fp';
|
||||
import type { ConnectedProps } from 'react-redux';
|
||||
import { connect } from 'react-redux';
|
||||
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
|
||||
import { isActiveTimeline } from '../../../../../helpers';
|
||||
import { TakeActionDropdown } from '../../../../../detections/components/take_action_dropdown';
|
||||
|
@ -20,9 +18,9 @@ import { EventFiltersFlyout } from '../../../../../management/pages/event_filter
|
|||
import { useEventFilterModal } from '../../../../../detections/components/alerts_table/timeline_actions/use_event_filter_modal';
|
||||
import { getFieldValue } from '../../../../../detections/components/host_isolation/helpers';
|
||||
import type { Status } from '../../../../../../common/api/detection_engine';
|
||||
import type { inputsModel, State } from '../../../../../common/store';
|
||||
import { inputsSelectors } from '../../../../../common/store';
|
||||
import { OsqueryFlyout } from '../../../../../detections/components/osquery/osquery_flyout';
|
||||
import { useRefetchByScope } from './use_refetch_by_scope';
|
||||
|
||||
interface FlyoutFooterProps {
|
||||
detailsData: TimelineEventsDetailsItem[] | null;
|
||||
detailsEcsData: Ecs | null;
|
||||
|
@ -43,176 +41,142 @@ interface AddExceptionModalWrapperData {
|
|||
ruleName: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
export const FlyoutFooterComponent = React.memo(
|
||||
({
|
||||
detailsData,
|
||||
detailsEcsData,
|
||||
handleOnEventClosed,
|
||||
isHostIsolationPanelOpen,
|
||||
isReadOnly,
|
||||
loadingEventDetails,
|
||||
onAddIsolationStatusClick,
|
||||
scopeId,
|
||||
globalQuery,
|
||||
timelineQuery,
|
||||
refetchFlyoutData,
|
||||
}: FlyoutFooterProps & PropsFromRedux) => {
|
||||
const alertId = detailsEcsData?.kibana?.alert ? detailsEcsData?._id : null;
|
||||
const ruleIndexRaw = useMemo(
|
||||
() =>
|
||||
find({ category: 'signal', field: 'signal.rule.index' }, detailsData)?.values ??
|
||||
find({ category: 'kibana', field: 'kibana.alert.rule.parameters.index' }, detailsData)
|
||||
?.values,
|
||||
[detailsData]
|
||||
);
|
||||
const ruleIndex = useMemo(
|
||||
(): string[] | undefined => (Array.isArray(ruleIndexRaw) ? ruleIndexRaw : undefined),
|
||||
[ruleIndexRaw]
|
||||
);
|
||||
const ruleDataViewIdRaw = useMemo(
|
||||
() =>
|
||||
find({ category: 'signal', field: 'signal.rule.data_view_id' }, detailsData)?.values ??
|
||||
find(
|
||||
{ category: 'kibana', field: 'kibana.alert.rule.parameters.data_view_id' },
|
||||
detailsData
|
||||
)?.values,
|
||||
[detailsData]
|
||||
);
|
||||
const ruleDataViewId = useMemo(
|
||||
(): string | undefined =>
|
||||
Array.isArray(ruleDataViewIdRaw) ? ruleDataViewIdRaw[0] : undefined,
|
||||
[ruleDataViewIdRaw]
|
||||
);
|
||||
export const FlyoutFooterComponent = ({
|
||||
detailsData,
|
||||
detailsEcsData,
|
||||
handleOnEventClosed,
|
||||
isHostIsolationPanelOpen,
|
||||
isReadOnly,
|
||||
loadingEventDetails,
|
||||
onAddIsolationStatusClick,
|
||||
scopeId,
|
||||
refetchFlyoutData,
|
||||
}: FlyoutFooterProps) => {
|
||||
const alertId = detailsEcsData?.kibana?.alert ? detailsEcsData?._id : null;
|
||||
const ruleIndexRaw = useMemo(
|
||||
() =>
|
||||
find({ category: 'signal', field: 'signal.rule.index' }, detailsData)?.values ??
|
||||
find({ category: 'kibana', field: 'kibana.alert.rule.parameters.index' }, detailsData)
|
||||
?.values,
|
||||
[detailsData]
|
||||
);
|
||||
const ruleIndex = useMemo(
|
||||
(): string[] | undefined => (Array.isArray(ruleIndexRaw) ? ruleIndexRaw : undefined),
|
||||
[ruleIndexRaw]
|
||||
);
|
||||
const ruleDataViewIdRaw = useMemo(
|
||||
() =>
|
||||
find({ category: 'signal', field: 'signal.rule.data_view_id' }, detailsData)?.values ??
|
||||
find({ category: 'kibana', field: 'kibana.alert.rule.parameters.data_view_id' }, detailsData)
|
||||
?.values,
|
||||
[detailsData]
|
||||
);
|
||||
const ruleDataViewId = useMemo(
|
||||
(): string | undefined => (Array.isArray(ruleDataViewIdRaw) ? ruleDataViewIdRaw[0] : undefined),
|
||||
[ruleDataViewIdRaw]
|
||||
);
|
||||
|
||||
const addExceptionModalWrapperData = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ category: 'signal', field: 'signal.rule.id', name: 'ruleId' },
|
||||
{ category: 'signal', field: 'signal.rule.rule_id', name: 'ruleRuleId' },
|
||||
{ category: 'signal', field: 'signal.rule.name', name: 'ruleName' },
|
||||
{ category: 'signal', field: 'kibana.alert.workflow_status', name: 'alertStatus' },
|
||||
{ category: '_id', field: '_id', name: 'eventId' },
|
||||
].reduce<AddExceptionModalWrapperData>(
|
||||
(acc, curr) => ({
|
||||
...acc,
|
||||
[curr.name]: getFieldValue({ category: curr.category, field: curr.field }, detailsData),
|
||||
}),
|
||||
{} as AddExceptionModalWrapperData
|
||||
),
|
||||
[detailsData]
|
||||
);
|
||||
const addExceptionModalWrapperData = useMemo(
|
||||
() =>
|
||||
[
|
||||
{ category: 'signal', field: 'signal.rule.id', name: 'ruleId' },
|
||||
{ category: 'signal', field: 'signal.rule.rule_id', name: 'ruleRuleId' },
|
||||
{ category: 'signal', field: 'signal.rule.name', name: 'ruleName' },
|
||||
{ category: 'signal', field: 'kibana.alert.workflow_status', name: 'alertStatus' },
|
||||
{ category: '_id', field: '_id', name: 'eventId' },
|
||||
].reduce<AddExceptionModalWrapperData>(
|
||||
(acc, curr) => ({
|
||||
...acc,
|
||||
[curr.name]: getFieldValue({ category: curr.category, field: curr.field }, detailsData),
|
||||
}),
|
||||
{} as AddExceptionModalWrapperData
|
||||
),
|
||||
[detailsData]
|
||||
);
|
||||
|
||||
const refetchQuery = (newQueries: inputsModel.GlobalQuery[]) => {
|
||||
newQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)());
|
||||
};
|
||||
const { refetch: refetchAll } = useRefetchByScope({ scopeId });
|
||||
|
||||
const refetchAll = useCallback(() => {
|
||||
if (isActiveTimeline(scopeId)) {
|
||||
refetchQuery([timelineQuery]);
|
||||
} else {
|
||||
refetchQuery(globalQuery);
|
||||
}
|
||||
}, [scopeId, timelineQuery, globalQuery]);
|
||||
const {
|
||||
exceptionFlyoutType,
|
||||
openAddExceptionFlyout,
|
||||
onAddExceptionTypeClick,
|
||||
onAddExceptionCancel,
|
||||
onAddExceptionConfirm,
|
||||
} = useExceptionFlyout({
|
||||
refetch: refetchAll,
|
||||
isActiveTimelines: isActiveTimeline(scopeId),
|
||||
});
|
||||
const { closeAddEventFilterModal, isAddEventFilterModalOpen, onAddEventFilterClick } =
|
||||
useEventFilterModal();
|
||||
|
||||
const {
|
||||
exceptionFlyoutType,
|
||||
openAddExceptionFlyout,
|
||||
onAddExceptionTypeClick,
|
||||
onAddExceptionCancel,
|
||||
onAddExceptionConfirm,
|
||||
} = useExceptionFlyout({
|
||||
refetch: refetchAll,
|
||||
isActiveTimelines: isActiveTimeline(scopeId),
|
||||
});
|
||||
const { closeAddEventFilterModal, isAddEventFilterModalOpen, onAddEventFilterClick } =
|
||||
useEventFilterModal();
|
||||
const [isOsqueryFlyoutOpenWithAgentId, setOsqueryFlyoutOpenWithAgentId] = useState<null | string>(
|
||||
null
|
||||
);
|
||||
|
||||
const [isOsqueryFlyoutOpenWithAgentId, setOsqueryFlyoutOpenWithAgentId] = useState<
|
||||
null | string
|
||||
>(null);
|
||||
const closeOsqueryFlyout = useCallback(() => {
|
||||
setOsqueryFlyoutOpenWithAgentId(null);
|
||||
}, [setOsqueryFlyoutOpenWithAgentId]);
|
||||
|
||||
const closeOsqueryFlyout = useCallback(() => {
|
||||
setOsqueryFlyoutOpenWithAgentId(null);
|
||||
}, [setOsqueryFlyoutOpenWithAgentId]);
|
||||
if (isReadOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isReadOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlyoutFooter
|
||||
className="side-panel-flyout-footer"
|
||||
data-test-subj="side-panel-flyout-footer"
|
||||
>
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
{detailsEcsData && (
|
||||
<TakeActionDropdown
|
||||
detailsData={detailsData}
|
||||
ecsData={detailsEcsData}
|
||||
handleOnEventClosed={handleOnEventClosed}
|
||||
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
||||
loadingEventDetails={loadingEventDetails}
|
||||
onAddEventFilterClick={onAddEventFilterClick}
|
||||
onAddExceptionTypeClick={onAddExceptionTypeClick}
|
||||
onAddIsolationStatusClick={onAddIsolationStatusClick}
|
||||
refetchFlyoutData={refetchFlyoutData}
|
||||
refetch={refetchAll}
|
||||
scopeId={scopeId}
|
||||
onOsqueryClick={setOsqueryFlyoutOpenWithAgentId}
|
||||
/>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
{/* This is still wrong to do render flyout/modal inside of the flyout
|
||||
return (
|
||||
<>
|
||||
<EuiFlyoutFooter
|
||||
className="side-panel-flyout-footer"
|
||||
data-test-subj="side-panel-flyout-footer"
|
||||
>
|
||||
<EuiFlexGroup justifyContent="flexEnd">
|
||||
<EuiFlexItem grow={false}>
|
||||
{detailsEcsData && (
|
||||
<TakeActionDropdown
|
||||
detailsData={detailsData}
|
||||
ecsData={detailsEcsData}
|
||||
handleOnEventClosed={handleOnEventClosed}
|
||||
isHostIsolationPanelOpen={isHostIsolationPanelOpen}
|
||||
loadingEventDetails={loadingEventDetails}
|
||||
onAddEventFilterClick={onAddEventFilterClick}
|
||||
onAddExceptionTypeClick={onAddExceptionTypeClick}
|
||||
onAddIsolationStatusClick={onAddIsolationStatusClick}
|
||||
refetchFlyoutData={refetchFlyoutData}
|
||||
refetch={refetchAll}
|
||||
scopeId={scopeId}
|
||||
onOsqueryClick={setOsqueryFlyoutOpenWithAgentId}
|
||||
/>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutFooter>
|
||||
{/* This is still wrong to do render flyout/modal inside of the flyout
|
||||
We need to completely refactor the EventDetails component to be correct
|
||||
*/}
|
||||
{openAddExceptionFlyout &&
|
||||
addExceptionModalWrapperData.ruleId != null &&
|
||||
addExceptionModalWrapperData.ruleRuleId != null &&
|
||||
addExceptionModalWrapperData.eventId != null && (
|
||||
<AddExceptionFlyoutWrapper
|
||||
{...addExceptionModalWrapperData}
|
||||
ruleIndices={ruleIndex}
|
||||
ruleDataViewId={ruleDataViewId}
|
||||
exceptionListType={exceptionFlyoutType}
|
||||
onCancel={onAddExceptionCancel}
|
||||
onConfirm={onAddExceptionConfirm}
|
||||
/>
|
||||
)}
|
||||
{isAddEventFilterModalOpen && detailsEcsData != null && (
|
||||
<EventFiltersFlyout data={detailsEcsData} onCancel={closeAddEventFilterModal} />
|
||||
)}
|
||||
{isOsqueryFlyoutOpenWithAgentId && detailsEcsData != null && (
|
||||
<OsqueryFlyout
|
||||
agentId={isOsqueryFlyoutOpenWithAgentId}
|
||||
defaultValues={alertId ? { alertIds: [alertId] } : undefined}
|
||||
onClose={closeOsqueryFlyout}
|
||||
ecsData={detailsEcsData}
|
||||
{openAddExceptionFlyout &&
|
||||
addExceptionModalWrapperData.ruleId != null &&
|
||||
addExceptionModalWrapperData.ruleRuleId != null &&
|
||||
addExceptionModalWrapperData.eventId != null && (
|
||||
<AddExceptionFlyoutWrapper
|
||||
{...addExceptionModalWrapperData}
|
||||
ruleIndices={ruleIndex}
|
||||
ruleDataViewId={ruleDataViewId}
|
||||
exceptionListType={exceptionFlyoutType}
|
||||
onCancel={onAddExceptionCancel}
|
||||
onConfirm={onAddExceptionConfirm}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
const getGlobalQueries = inputsSelectors.globalQuery();
|
||||
const getTimelineQuery = inputsSelectors.timelineQueryByIdSelector();
|
||||
const mapStateToProps = (state: State, { scopeId }: FlyoutFooterProps) => {
|
||||
return {
|
||||
globalQuery: getGlobalQueries(state),
|
||||
timelineQuery: getTimelineQuery(state, scopeId),
|
||||
};
|
||||
};
|
||||
return mapStateToProps;
|
||||
{isAddEventFilterModalOpen && detailsEcsData != null && (
|
||||
<EventFiltersFlyout data={detailsEcsData} onCancel={closeAddEventFilterModal} />
|
||||
)}
|
||||
{isOsqueryFlyoutOpenWithAgentId && detailsEcsData != null && (
|
||||
<OsqueryFlyout
|
||||
agentId={isOsqueryFlyoutOpenWithAgentId}
|
||||
defaultValues={alertId ? { alertIds: [alertId] } : undefined}
|
||||
onClose={closeOsqueryFlyout}
|
||||
ecsData={detailsEcsData}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const connector = connect(makeMapStateToProps);
|
||||
|
||||
type PropsFromRedux = ConnectedProps<typeof connector>;
|
||||
|
||||
export const FlyoutFooter = connector(React.memo(FlyoutFooterComponent));
|
||||
export const FlyoutFooter = React.memo(FlyoutFooterComponent);
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import { EuiFlyoutHeader } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
|
||||
import type { GetFieldsData } from '../../../../../common/hooks/use_get_fields_data';
|
||||
import { ExpandableEventTitle } from '../expandable_event';
|
||||
import { BackToAlertDetailsLink } from './back_to_alert_details_link';
|
||||
|
||||
|
@ -22,6 +23,9 @@ interface FlyoutHeaderComponentProps {
|
|||
ruleName: string;
|
||||
showAlertDetails: () => void;
|
||||
timestamp: string;
|
||||
scopeId: string;
|
||||
refetchFlyoutData: () => Promise<void>;
|
||||
getFieldsData: GetFieldsData;
|
||||
}
|
||||
|
||||
const FlyoutHeaderContentComponent = ({
|
||||
|
@ -35,6 +39,9 @@ const FlyoutHeaderContentComponent = ({
|
|||
ruleName,
|
||||
showAlertDetails,
|
||||
timestamp,
|
||||
scopeId,
|
||||
refetchFlyoutData,
|
||||
getFieldsData,
|
||||
}: FlyoutHeaderComponentProps) => {
|
||||
return (
|
||||
<>
|
||||
|
@ -49,6 +56,9 @@ const FlyoutHeaderContentComponent = ({
|
|||
promptContextId={promptContextId}
|
||||
ruleName={ruleName}
|
||||
timestamp={timestamp}
|
||||
scopeId={scopeId}
|
||||
refetchFlyoutData={refetchFlyoutData}
|
||||
getFieldsData={getFieldsData}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
@ -67,6 +77,9 @@ const FlyoutHeaderComponent = ({
|
|||
ruleName,
|
||||
showAlertDetails,
|
||||
timestamp,
|
||||
scopeId,
|
||||
refetchFlyoutData,
|
||||
getFieldsData,
|
||||
}: FlyoutHeaderComponentProps) => {
|
||||
return (
|
||||
<EuiFlyoutHeader hasBorder={isHostIsolationPanelOpen}>
|
||||
|
@ -81,6 +94,9 @@ const FlyoutHeaderComponent = ({
|
|||
ruleName={ruleName}
|
||||
showAlertDetails={showAlertDetails}
|
||||
timestamp={timestamp}
|
||||
scopeId={scopeId}
|
||||
refetchFlyoutData={refetchFlyoutData}
|
||||
getFieldsData={getFieldsData}
|
||||
/>
|
||||
</EuiFlyoutHeader>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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 { useCallback } from 'react';
|
||||
import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector';
|
||||
import { isActiveTimeline } from '../../../../../helpers';
|
||||
import type { inputsModel } from '../../../../../common/store';
|
||||
import { inputsSelectors } from '../../../../../common/store';
|
||||
|
||||
export interface UseRefetchScopeQueryParams {
|
||||
/**
|
||||
* Scope ID
|
||||
*/
|
||||
scopeId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to refetch data within specified scope
|
||||
*/
|
||||
export const useRefetchByScope = ({ scopeId }: UseRefetchScopeQueryParams) => {
|
||||
const getGlobalQueries = inputsSelectors.globalQuery();
|
||||
const getTimelineQuery = inputsSelectors.timelineQueryByIdSelector();
|
||||
const { globalQuery, timelineQuery } = useDeepEqualSelector((state) => ({
|
||||
globalQuery: getGlobalQueries(state),
|
||||
timelineQuery: getTimelineQuery(state, scopeId),
|
||||
}));
|
||||
|
||||
const refetchQuery = (newQueries: inputsModel.GlobalQuery[]) => {
|
||||
newQueries.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)());
|
||||
};
|
||||
|
||||
const refetchAll = useCallback(() => {
|
||||
if (isActiveTimeline(scopeId)) {
|
||||
refetchQuery([timelineQuery]);
|
||||
} else {
|
||||
refetchQuery(globalQuery);
|
||||
}
|
||||
}, [scopeId, timelineQuery, globalQuery]);
|
||||
|
||||
return { refetch: refetchAll };
|
||||
};
|
|
@ -22,6 +22,7 @@ import {
|
|||
DEFAULT_PREVIEW_INDEX,
|
||||
ASSISTANT_FEATURE_ID,
|
||||
} from '../../../../../common/constants';
|
||||
import { useUpsellingMessage } from '../../../../common/hooks/use_upselling';
|
||||
|
||||
const ecsData: Ecs = {
|
||||
_id: '1',
|
||||
|
@ -69,6 +70,18 @@ jest.mock(
|
|||
}
|
||||
);
|
||||
|
||||
jest.mock('../../../../common/components/user_profiles/use_bulk_get_user_profiles', () => {
|
||||
return {
|
||||
useBulkGetUserProfiles: jest.fn().mockReturnValue({ isLoading: false, data: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../../common/components/user_profiles/use_suggest_users', () => {
|
||||
return {
|
||||
useSuggestUsers: jest.fn().mockReturnValue({ isLoading: false, data: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../../common/hooks/use_experimental_features', () => ({
|
||||
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
@ -112,6 +125,7 @@ jest.mock('../../../../explore/containers/risk_score', () => {
|
|||
}),
|
||||
};
|
||||
});
|
||||
jest.mock('../../../../common/hooks/use_upselling');
|
||||
|
||||
const defaultProps = {
|
||||
scopeId: TimelineId.test,
|
||||
|
@ -167,6 +181,7 @@ describe('event details panel component', () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
(useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
@ -13,6 +13,7 @@ import styled from 'styled-components';
|
|||
import deepEqual from 'fast-deep-equal';
|
||||
import type { EntityType } from '@kbn/timelines-plugin/common';
|
||||
|
||||
import { useGetFieldsData } from '../../../../common/hooks/use_get_fields_data';
|
||||
import { useAssistantAvailability } from '../../../../assistant/use_assistant_availability';
|
||||
import { getRawData } from '../../../../assistant/helpers';
|
||||
import type { BrowserFields } from '../../../../common/containers/source';
|
||||
|
@ -94,6 +95,7 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
|
|||
skip: !expandedEvent.eventId,
|
||||
}
|
||||
);
|
||||
const getFieldsData = useGetFieldsData(rawEventData?.fields);
|
||||
|
||||
const {
|
||||
isolateAction,
|
||||
|
@ -137,6 +139,9 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
|
|||
showAlertDetails={showAlertDetails}
|
||||
timestamp={timestamp}
|
||||
promptContextId={promptContextId}
|
||||
scopeId={scopeId}
|
||||
refetchFlyoutData={refetchFlyoutData}
|
||||
getFieldsData={getFieldsData}
|
||||
/>
|
||||
) : (
|
||||
<ExpandableEventTitle
|
||||
|
@ -148,6 +153,9 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
|
|||
timestamp={timestamp}
|
||||
handleOnEventClosed={handleOnEventClosed}
|
||||
promptContextId={promptContextId}
|
||||
scopeId={scopeId}
|
||||
refetchFlyoutData={refetchFlyoutData}
|
||||
getFieldsData={getFieldsData}
|
||||
/>
|
||||
),
|
||||
[
|
||||
|
@ -161,8 +169,11 @@ const EventDetailsPanelComponent: React.FC<EventDetailsPanelProps> = ({
|
|||
ruleName,
|
||||
showAlertDetails,
|
||||
timestamp,
|
||||
handleOnEventClosed,
|
||||
promptContextId,
|
||||
handleOnEventClosed,
|
||||
scopeId,
|
||||
refetchFlyoutData,
|
||||
getFieldsData,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -30,6 +30,18 @@ jest.mock('../../../common/containers/use_search_strategy', () => ({
|
|||
useSearchStrategy: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../common/components/user_profiles/use_bulk_get_user_profiles', () => {
|
||||
return {
|
||||
useBulkGetUserProfiles: jest.fn().mockReturnValue({ isLoading: false, data: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../common/components/user_profiles/use_suggest_users', () => {
|
||||
return {
|
||||
useSuggestUsers: jest.fn().mockReturnValue({ isLoading: false, data: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../../assistant/use_assistant_availability');
|
||||
const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/test', search: '?' });
|
||||
jest.mock('react-router-dom', () => {
|
||||
|
|
|
@ -11,9 +11,14 @@ import type { Filter } from '@kbn/es-query';
|
|||
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
|
||||
import type { ColumnHeaderOptions, RowRenderer } from '../../../../../../common/types';
|
||||
import type { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline';
|
||||
import type { RenderCellValueContext } from '../../../../../detections/configurations/security_solution_detections/fetch_page_context';
|
||||
|
||||
export interface ColumnRenderer {
|
||||
isInstance: (columnName: string, data: TimelineNonEcsData[]) => boolean;
|
||||
isInstance: (
|
||||
columnName: string,
|
||||
data: TimelineNonEcsData[],
|
||||
context?: RenderCellValueContext
|
||||
) => boolean;
|
||||
renderColumn: ({
|
||||
className,
|
||||
columnName,
|
||||
|
@ -28,6 +33,7 @@ export interface ColumnRenderer {
|
|||
truncate,
|
||||
values,
|
||||
key,
|
||||
context,
|
||||
}: {
|
||||
asPlainText?: boolean;
|
||||
className?: string;
|
||||
|
@ -44,5 +50,6 @@ export interface ColumnRenderer {
|
|||
truncate?: boolean;
|
||||
values: string[] | null | undefined;
|
||||
key?: string;
|
||||
context?: RenderCellValueContext;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ export const REFERENCE_URL_FIELD_NAME = 'reference.url';
|
|||
export const EVENT_URL_FIELD_NAME = 'event.url';
|
||||
export const SIGNAL_RULE_NAME_FIELD_NAME = 'kibana.alert.rule.name';
|
||||
export const SIGNAL_STATUS_FIELD_NAME = 'kibana.alert.workflow_status';
|
||||
export const SIGNAL_ASSIGNEE_IDS_FIELD_NAME = 'kibana.alert.workflow_assignee_ids';
|
||||
export const AGENT_STATUS_FIELD_NAME = 'agent.status';
|
||||
export const QUARANTINED_PATH_FIELD_NAME = 'quarantined.path';
|
||||
export const REASON_FIELD_NAME = 'kibana.alert.reason';
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { RenderCellValueContext } from '../../../../../detections/configurations/security_solution_detections/fetch_page_context';
|
||||
import type { TimelineNonEcsData } from '../../../../../../common/search_strategy/timeline';
|
||||
import type { ColumnRenderer } from './column_renderer';
|
||||
|
||||
|
@ -15,10 +16,11 @@ const unhandledColumnRenderer = (): never => {
|
|||
export const getColumnRenderer = (
|
||||
columnName: string,
|
||||
columnRenderers: ColumnRenderer[],
|
||||
data: TimelineNonEcsData[]
|
||||
data: TimelineNonEcsData[],
|
||||
context?: RenderCellValueContext
|
||||
): ColumnRenderer => {
|
||||
const renderer = columnRenderers.find((columnRenderer) =>
|
||||
columnRenderer.isInstance(columnName, data)
|
||||
columnRenderer.isInstance(columnName, data, context)
|
||||
);
|
||||
return renderer != null ? renderer : unhandledColumnRenderer();
|
||||
};
|
||||
|
|
|
@ -18,6 +18,7 @@ import { systemRowRenderers } from './system/generic_row_renderer';
|
|||
import { threatMatchRowRenderer } from './cti/threat_match_row_renderer';
|
||||
import { reasonColumnRenderer } from './reason_column_renderer';
|
||||
import { eventSummaryColumnRenderer } from './event_summary_column_renderer';
|
||||
import { userProfileColumnRenderer } from './user_profile_renderer';
|
||||
|
||||
// The row renderers are order dependent and will return the first renderer
|
||||
// which returns true from its isInstance call. The bottom renderers which
|
||||
|
@ -38,6 +39,7 @@ export const defaultRowRenderers: RowRenderer[] = [
|
|||
export const columnRenderers: ColumnRenderer[] = [
|
||||
reasonColumnRenderer,
|
||||
eventSummaryColumnRenderer,
|
||||
userProfileColumnRenderer,
|
||||
plainColumnRenderer,
|
||||
emptyColumnRenderer,
|
||||
unknownColumnRenderer,
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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 { EuiLoadingSpinner } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
|
||||
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
|
||||
import { UsersAvatarsPanel } from '../../../../../common/components/user_profiles/users_avatars_panel';
|
||||
import type { ColumnHeaderOptions, RowRenderer } from '../../../../../../common/types';
|
||||
import type { ColumnRenderer } from './column_renderer';
|
||||
import { profileUidColumns } from '../../../../../detections/configurations/security_solution_detections/fetch_page_context';
|
||||
import type { RenderCellValueContext } from '../../../../../detections/configurations/security_solution_detections/fetch_page_context';
|
||||
|
||||
export const userProfileColumnRenderer: ColumnRenderer = {
|
||||
isInstance: (columnName, _, context) => profileUidColumns.includes(columnName) && !!context,
|
||||
renderColumn: ({
|
||||
columnName,
|
||||
ecsData,
|
||||
eventId,
|
||||
field,
|
||||
isDetails,
|
||||
isDraggable = true,
|
||||
linkValues,
|
||||
rowRenderers = [],
|
||||
scopeId,
|
||||
truncate,
|
||||
values,
|
||||
context,
|
||||
}: {
|
||||
columnName: string;
|
||||
ecsData?: Ecs;
|
||||
eventId: string;
|
||||
field: ColumnHeaderOptions;
|
||||
isDetails?: boolean;
|
||||
isDraggable?: boolean;
|
||||
linkValues?: string[] | null | undefined;
|
||||
rowRenderers?: RowRenderer[];
|
||||
scopeId: string;
|
||||
truncate?: boolean;
|
||||
values: string[] | undefined | null;
|
||||
context?: RenderCellValueContext;
|
||||
}) => {
|
||||
// Show spinner if loading profiles or if there are no fetched profiles yet
|
||||
// Do not show the loading spinner if context is not provided at all
|
||||
if (context?.isLoading) {
|
||||
return <EuiLoadingSpinner size="s" />;
|
||||
}
|
||||
|
||||
const userProfiles =
|
||||
values?.map((uid) => context?.profiles?.find((user) => uid === user.uid)) ?? [];
|
||||
|
||||
return <UsersAvatarsPanel userProfiles={userProfiles} maxVisibleAvatars={4} />;
|
||||
},
|
||||
};
|
|
@ -76,7 +76,7 @@ describe('DefaultCellRenderer', () => {
|
|||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getColumnRenderer).toBeCalledWith(header.id, columnRenderers, data);
|
||||
expect(getColumnRenderer).toBeCalledWith(header.id, columnRenderers, data, undefined);
|
||||
});
|
||||
|
||||
test('if in tgrid expanded value, it invokes `renderColumn` with the expected arguments', () => {
|
||||
|
|
|
@ -33,6 +33,7 @@ export const DefaultCellRenderer: React.FC<CellValueElementProps> = ({
|
|||
scopeId,
|
||||
truncate,
|
||||
asPlainText,
|
||||
context,
|
||||
}) => {
|
||||
const asPlainTextDefault = useMemo(() => {
|
||||
return (
|
||||
|
@ -49,7 +50,7 @@ export const DefaultCellRenderer: React.FC<CellValueElementProps> = ({
|
|||
: 'eui-displayInlineBlock eui-textTruncate';
|
||||
return (
|
||||
<StyledContent className={styledContentClassName} $isDetails={isDetails}>
|
||||
{getColumnRenderer(header.id, columnRenderers, data).renderColumn({
|
||||
{getColumnRenderer(header.id, columnRenderers, data, context).renderColumn({
|
||||
asPlainText: asPlainText ?? asPlainTextDefault, // we want to render value with links as plain text but keep other formatters like badge. Except rule name for non preview tables
|
||||
columnName: header.id,
|
||||
ecsData,
|
||||
|
@ -62,6 +63,7 @@ export const DefaultCellRenderer: React.FC<CellValueElementProps> = ({
|
|||
scopeId,
|
||||
truncate,
|
||||
values,
|
||||
context,
|
||||
})}
|
||||
</StyledContent>
|
||||
);
|
||||
|
|
|
@ -506,6 +506,11 @@ export const getSignalsMigrationStatusRequest = () =>
|
|||
query: getSignalsMigrationStatusSchemaMock(),
|
||||
});
|
||||
|
||||
export const getMockUserProfiles = () => [
|
||||
{ uid: 'default-test-assignee-id-1', enabled: true, user: { username: 'user1' }, data: {} },
|
||||
{ uid: 'default-test-assignee-id-2', enabled: true, user: { username: 'user2' }, data: {} },
|
||||
];
|
||||
|
||||
/**
|
||||
* @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function
|
||||
*/
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue