diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx index ebc8d360cb2b6..54e5a0bc2febb 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.test.tsx @@ -45,7 +45,7 @@ describe('Case View Page activity tab', () => { await waitFor(async () => { expect(getAlertsStateTableMock).toHaveBeenCalledWith({ alertsTableConfigurationRegistry: expect.anything(), - configurationId: 'securitySolution', + configurationId: 'securitySolution-case', featureIds: ['siem', 'observability'], id: 'case-details-alerts-securitySolution', query: { diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx index ab2bf2831e21f..6baa6be3fea9e 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_alerts.tsx @@ -39,9 +39,12 @@ export const CaseViewAlerts = ({ caseData }: CaseViewAlertsProps) => { const { isLoading: isLoadingAlertFeatureIds, data: alertFeatureIds } = useGetFeatureIds(alertRegistrationContexts); + const configId = + caseData.owner === SECURITY_SOLUTION_OWNER ? `${caseData.owner}-case` : caseData.owner; + const alertStateProps = { alertsTableConfigurationRegistry: triggersActionsUi.alertsTableConfigurationRegistry, - configurationId: caseData.owner, + configurationId: configId, id: `case-details-alerts-${caseData.owner}`, flyoutSize: (alertFeatureIds?.includes('siem') ? 'm' : 's') as EuiFlyoutSize, featureIds: alertFeatureIds ?? [], diff --git a/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx b/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx index d6ac49b736a5a..e4ff40a936c4e 100644 --- a/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx +++ b/x-pack/plugins/observability/public/config/register_alerts_table_configuration.tsx @@ -8,8 +8,9 @@ import type { GetRenderCellValue } from '@kbn/triggers-actions-ui-plugin/public'; import { TIMESTAMP } from '@kbn/rule-data-utils'; import { SortOrder } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { AlertsTableConfigurationRegistry } from '@kbn/triggers-actions-ui-plugin/public/types'; import { casesFeatureId, observabilityFeatureId } from '../../common'; -import { useBulkAddToCaseActions } from '../hooks/use_alert_bulk_case_actions'; +import { useBulkAddToCaseTriggerActions } from '../hooks/use_alert_bulk_case_actions'; import { TopAlert, useToGetInternalFlyout } from '../pages/alerts'; import { getRenderCellValue } from '../pages/alerts/components/render_cell_value'; import { addDisplayNames } from '../pages/alerts/containers/alerts_table/add_display_names'; @@ -21,7 +22,7 @@ import type { ConfigSchema } from '../plugin'; const getO11yAlertsTableConfiguration = ( observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry, config: ConfigSchema -) => ({ +): AlertsTableConfigurationRegistry => ({ id: observabilityFeatureId, casesFeatureId, columns: alertO11yColumns.map(addDisplayNames), @@ -36,7 +37,7 @@ const getO11yAlertsTableConfiguration = ( }, ], useActionsColumn: getRowActions(observabilityRuleTypeRegistry, config), - useBulkActions: useBulkAddToCaseActions, + useBulkActions: useBulkAddToCaseTriggerActions, useInternalFlyout: () => { const { header, body, footer } = useToGetInternalFlyout(observabilityRuleTypeRegistry); return { header, body, footer }; diff --git a/x-pack/plugins/observability/public/hooks/use_alert_bulk_case_actions.ts b/x-pack/plugins/observability/public/hooks/use_alert_bulk_case_actions.ts index 6e4b915eccfe7..df740f6453a14 100644 --- a/x-pack/plugins/observability/public/hooks/use_alert_bulk_case_actions.ts +++ b/x-pack/plugins/observability/public/hooks/use_alert_bulk_case_actions.ts @@ -70,3 +70,12 @@ export const useBulkAddToCaseActions = ({ onClose, onSuccess }: UseAddToCaseActi selectCaseModal, ]); }; + +/* + * Wrapper hook to support trigger actions + * registry props for the alert table + * + * */ +export const useBulkAddToCaseTriggerActions = () => { + return useBulkAddToCaseActions({}); +}; diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index b2e623eebddb6..55b802ae06d8d 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -12,7 +12,6 @@ * https://mariusschulz.com/blog/literal-type-widening-in-typescript * Please follow this convention when adding to this file */ - export const APP_ID = 'securitySolution' as const; export const APP_UI_ID = 'securitySolutionUI' as const; export const CASES_FEATURE_ID = 'securitySolutionCases' as const; @@ -499,3 +498,17 @@ export const DEFAULT_DETECTION_PAGE_FILTERS = [ fieldName: 'host.name', }, ]; + +/** This local storage key stores the `Grid / Event rendered view` selection */ +export const ALERTS_TABLE_VIEW_SELECTION_KEY = 'securitySolution.alerts.table.view-selection'; + +export const VIEW_SELECTION = { + gridView: 'gridView', + eventRenderedView: 'eventRenderedView', +} as const; + +export const ALERTS_TABLE_REGISTRY_CONFIG_IDS = { + ALERTS_PAGE: `${APP_ID}-alerts-page`, + RULE_DETAILS: `${APP_ID}-rule-details`, + CASE: `${APP_ID}-case`, +} as const; diff --git a/x-pack/plugins/security_solution/common/types/data_table/index.ts b/x-pack/plugins/security_solution/common/types/data_table/index.ts index d96f8e8f4c268..130f7aaf1765a 100644 --- a/x-pack/plugins/security_solution/common/types/data_table/index.ts +++ b/x-pack/plugins/security_solution/common/types/data_table/index.ts @@ -6,6 +6,7 @@ */ import * as runtimeTypes from 'io-ts'; +import type { VIEW_SELECTION } from '../../constants'; export enum Direction { asc = 'asc', @@ -33,6 +34,7 @@ export enum TableId { alternateTest = 'alternateTest', rulePreview = 'rule-preview', kubernetesPageSessions = 'kubernetes-page-sessions', + alertsOnCasePage = 'alerts-case-page', } const TableIdLiteralRt = runtimeTypes.union([ @@ -46,4 +48,9 @@ const TableIdLiteralRt = runtimeTypes.union([ runtimeTypes.literal(TableId.rulePreview), runtimeTypes.literal(TableId.kubernetesPageSessions), ]); + export type TableIdLiteral = runtimeTypes.TypeOf; + +export type ViewSelectionTypes = keyof typeof VIEW_SELECTION; + +export type ViewSelection = typeof VIEW_SELECTION[ViewSelectionTypes]; diff --git a/x-pack/plugins/security_solution/cypress/ccs_e2e/detection_alerts/alerts_details.cy.ts b/x-pack/plugins/security_solution/cypress/ccs_e2e/detection_alerts/alerts_details.cy.ts index 3211d9db2b343..3141423038f6a 100644 --- a/x-pack/plugins/security_solution/cypress/ccs_e2e/detection_alerts/alerts_details.cy.ts +++ b/x-pack/plugins/security_solution/cypress/ccs_e2e/detection_alerts/alerts_details.cy.ts @@ -7,7 +7,7 @@ import { JSON_TEXT } from '../../screens/alerts_details'; -import { expandFirstAlert, waitForAlertsPanelToBeLoaded } from '../../tasks/alerts'; +import { expandFirstAlert, waitForAlerts } from '../../tasks/alerts'; import { openJsonView } from '../../tasks/alerts_details'; import { createCustomRuleEnabled } from '../../tasks/api_calls/rules'; import { cleanKibana } from '../../tasks/common'; @@ -25,7 +25,7 @@ describe('Alert details with unmapped fields', () => { esArchiverCCSLoad('unmapped_fields'); createCustomRuleEnabled(getUnmappedCCSRule()); visitWithoutDateRange(ALERTS_URL); - waitForAlertsPanelToBeLoaded(); + waitForAlerts(); expandFirstAlert(); }); diff --git a/x-pack/plugins/security_solution/cypress/ccs_e2e/detection_rules/event_correlation_rule.cy.ts b/x-pack/plugins/security_solution/cypress/ccs_e2e/detection_rules/event_correlation_rule.cy.ts index 2e1db0b18df68..d8cd2d2b10e6c 100644 --- a/x-pack/plugins/security_solution/cypress/ccs_e2e/detection_rules/event_correlation_rule.cy.ts +++ b/x-pack/plugins/security_solution/cypress/ccs_e2e/detection_rules/event_correlation_rule.cy.ts @@ -8,7 +8,7 @@ import { esArchiverCCSLoad } from '../../tasks/es_archiver'; import { getCCSEqlRule } from '../../objects/rule'; -import { ALERT_DATA_GRID, NUMBER_OF_ALERTS } from '../../screens/alerts'; +import { ALERTS_COUNT, ALERT_DATA_GRID } from '../../screens/alerts'; import { filterByCustomRules, @@ -41,7 +41,7 @@ describe('Detection rules', function () { waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); - cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); + cy.get(ALERTS_COUNT).should('have.text', expectedNumberOfAlerts); cy.get(ALERT_DATA_GRID) .invoke('text') .then((text) => { diff --git a/x-pack/plugins/security_solution/cypress/e2e/data_sources/create_runtime_field.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/data_sources/create_runtime_field.cy.ts index ea92bdb2748b2..e377c7482f3d3 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/data_sources/create_runtime_field.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/data_sources/create_runtime_field.cy.ts @@ -16,34 +16,39 @@ import { createCustomRuleEnabled } from '../../tasks/api_calls/rules'; import { getNewRule } from '../../objects/rule'; import { refreshPage } from '../../tasks/security_header'; import { waitForAlertsToPopulate } from '../../tasks/create_new_rule'; -import { openEventsViewerFieldsBrowser } from '../../tasks/hosts/events'; -import { assertFieldDisplayed, createField } from '../../tasks/create_runtime_field'; +import { createField } from '../../tasks/create_runtime_field'; +import { openAlertsFieldBrowser } from '../../tasks/alerts'; +import { deleteRuntimeField } from '../../tasks/sourcerer'; +import { GET_DATA_GRID_HEADER } from '../../screens/common/data_grid'; +import { GET_TIMELINE_HEADER } from '../../screens/timeline'; + +const alertRunTimeField = 'field.name.alert.page'; +const timelineRuntimeField = 'field.name.timeline'; describe('Create DataView runtime field', () => { before(() => { + deleteRuntimeField('security-solution-default', alertRunTimeField); + deleteRuntimeField('security-solution-default', timelineRuntimeField); login(); }); it('adds field to alert table', () => { - const fieldName = 'field.name.alert.page'; visit(ALERTS_URL); createCustomRuleEnabled(getNewRule()); refreshPage(); waitForAlertsToPopulate(); - openEventsViewerFieldsBrowser(); - - createField(fieldName); - assertFieldDisplayed(fieldName, 'alerts'); + openAlertsFieldBrowser(); + createField(alertRunTimeField); + cy.get(GET_DATA_GRID_HEADER(alertRunTimeField)).should('exist'); }); it('adds field to timeline', () => { - const fieldName = 'field.name.timeline'; visit(HOSTS_URL); openTimelineUsingToggle(); populateTimeline(); openTimelineFieldsBrowser(); - createField(fieldName); - assertFieldDisplayed(fieldName); + createField(timelineRuntimeField); + cy.get(GET_TIMELINE_HEADER(timelineRuntimeField)).should('exist'); }); }); diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alert_details/navigation.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alert_details/navigation.cy.ts index 32a582cbbeac7..94d2bc8256a4f 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_alert_details/navigation.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alert_details/navigation.cy.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { expandFirstAlert, waitForAlertsPanelToBeLoaded } from '../../tasks/alerts'; +import { expandFirstAlert, waitForAlerts } from '../../tasks/alerts'; import { createCustomRuleEnabled } from '../../tasks/api_calls/rules'; import { cleanKibana } from '../../tasks/common'; import { login, visit } from '../../tasks/login'; @@ -35,7 +35,7 @@ describe('Alert Details Page Navigation', () => { describe('context menu', () => { beforeEach(() => { visit(ALERTS_URL); - waitForAlertsPanelToBeLoaded(); + waitForAlerts(); }); it('should navigate to the details page from the alert context menu', () => { @@ -55,7 +55,7 @@ describe('Alert Details Page Navigation', () => { describe('flyout', () => { beforeEach(() => { visit(ALERTS_URL); - waitForAlertsPanelToBeLoaded(); + waitForAlerts(); }); it('should navigate to the details page from the alert flyout', () => { diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_cell_actions.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_cell_actions.cy.ts index b322a87929d52..be4a939910928 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_cell_actions.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/alerts_cell_actions.cy.ts @@ -51,7 +51,7 @@ describe('Alerts cell actions', () => { .first() .invoke('text') .then((severityVal) => { - scrollAlertTableColumnIntoView(ALERT_TABLE_FILE_NAME_HEADER); + scrollAlertTableColumnIntoView(ALERT_TABLE_SEVERITY_VALUES); filterForAlertProperty(ALERT_TABLE_SEVERITY_VALUES, 0); cy.get(FILTER_BADGE) .first() @@ -75,7 +75,7 @@ describe('Alerts cell actions', () => { .first() .invoke('text') .then((severityVal) => { - scrollAlertTableColumnIntoView(ALERT_TABLE_FILE_NAME_HEADER); + scrollAlertTableColumnIntoView(ALERT_TABLE_SEVERITY_VALUES); addAlertPropertyToTimeline(ALERT_TABLE_SEVERITY_VALUES, 0); openActiveTimeline(); cy.get(PROVIDER_BADGE) @@ -101,7 +101,7 @@ describe('Alerts cell actions', () => { .first() .invoke('text') .then(() => { - scrollAlertTableColumnIntoView(ALERT_TABLE_FILE_NAME_HEADER); + scrollAlertTableColumnIntoView(ALERT_TABLE_SEVERITY_VALUES); showTopNAlertProperty(ALERT_TABLE_SEVERITY_VALUES, 0); cy.get(SHOW_TOP_N_HEADER).first().should('have.text', `Top kibana.alert.severity`); }); @@ -114,7 +114,7 @@ describe('Alerts cell actions', () => { .first() .invoke('text') .then(() => { - scrollAlertTableColumnIntoView(ALERT_TABLE_FILE_NAME_HEADER); + scrollAlertTableColumnIntoView(ALERT_TABLE_SEVERITY_VALUES); cy.window().then((win) => { cy.stub(win, 'prompt').returns('DISABLED WINDOW PROMPT'); }); diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/detection_page_filters.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/detection_page_filters.cy.ts index 3cb1999e9b504..86e7393fd7938 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/detection_page_filters.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/detection_page_filters.cy.ts @@ -21,7 +21,6 @@ import { APP_ID, DEFAULT_DETECTION_PAGE_FILTERS } from '../../../common/constant import { formatPageFilterSearchParam } from '../../../common/utils/format_page_filter_search_param'; import { markAcknowledgedFirstAlert, - refreshAlertPageFilter, resetFilters, selectCountTable, waitForAlerts, @@ -152,7 +151,7 @@ describe.skip('Detections : Page Filters', () => { .then((noOfAlerts) => { const originalAlertCount = noOfAlerts.split(' ')[0]; markAcknowledgedFirstAlert(); - refreshAlertPageFilter(); + waitForAlerts(); cy.get(OPTION_LIST_VALUES).eq(0).click(); cy.get(OPTION_SELECTABLE(0, 'acknowledged')).should('be.visible'); cy.get(ALERTS_COUNT) diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/enrichments.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/enrichments.cy.ts index 580782981e169..de610f3fa1808 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/enrichments.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/enrichments.cy.ts @@ -7,12 +7,12 @@ import { getNewRule } from '../../objects/rule'; import { - NUMBER_OF_ALERTS, HOST_RISK_HEADER_COLIMN, USER_RISK_HEADER_COLIMN, HOST_RISK_COLUMN, USER_RISK_COLUMN, ACTION_COLUMN, + ALERTS_COUNT, } from '../../screens/alerts'; import { ENRICHED_DATA_ROW } from '../../screens/alerts_details'; import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; @@ -56,7 +56,7 @@ describe('Enrichment', () => { }); it('Should has enrichment fields', function () { - cy.get(NUMBER_OF_ALERTS) + cy.get(ALERTS_COUNT) .invoke('text') .should('match', /^[1-9].+$/); // Any number of alerts cy.get(HOST_RISK_HEADER_COLIMN).contains('host.risk.calculated_level'); diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_rules/custom_query_rule.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_rules/custom_query_rule.cy.ts index ae1bb250fc605..b0fa7e6ff3d8d 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_rules/custom_query_rule.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_rules/custom_query_rule.cy.ts @@ -14,7 +14,7 @@ import { getNewOverrideRule, } from '../../objects/rule'; import { getTimeline } from '../../objects/timeline'; -import { ALERT_GRID_CELL, NUMBER_OF_ALERTS } from '../../screens/alerts'; +import { ALERTS_COUNT, ALERT_GRID_CELL } from '../../screens/alerts'; import { CUSTOM_RULES_BTN, @@ -229,7 +229,7 @@ describe('Custom query rules', () => { waitForAlertsToPopulate(); cy.log('Asserting that alerts have been generated after the creation'); - cy.get(NUMBER_OF_ALERTS) + cy.get(ALERTS_COUNT) .invoke('text') .should('match', /^[1-9].+$/); // Any number of alerts cy.get(ALERT_GRID_CELL).contains(ruleFields.ruleName); diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_rules/custom_query_rule_data_view.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_rules/custom_query_rule_data_view.cy.ts index 04e08d5de572a..d0259f45d23cf 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_rules/custom_query_rule_data_view.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_rules/custom_query_rule_data_view.cy.ts @@ -9,7 +9,7 @@ import { formatMitreAttackDescription } from '../../helpers/rules'; import type { Mitre } from '../../objects/rule'; import { getDataViewRule } from '../../objects/rule'; import type { CompleteTimeline } from '../../objects/timeline'; -import { ALERT_GRID_CELL, NUMBER_OF_ALERTS } from '../../screens/alerts'; +import { ALERTS_COUNT, ALERT_GRID_CELL } from '../../screens/alerts'; import { CUSTOM_RULES_BTN, @@ -160,7 +160,7 @@ describe('Custom query rules', () => { waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); - cy.get(NUMBER_OF_ALERTS) + cy.get(ALERTS_COUNT) .invoke('text') .should('match', /^[1-9].+$/); cy.get(ALERT_GRID_CELL).contains(this.rule.name); diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_rules/event_correlation_rule.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_rules/event_correlation_rule.cy.ts index 79146eebc55df..ae2be01d66257 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_rules/event_correlation_rule.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_rules/event_correlation_rule.cy.ts @@ -9,7 +9,7 @@ import { formatMitreAttackDescription } from '../../helpers/rules'; import type { Mitre } from '../../objects/rule'; import { getEqlRule, getEqlSequenceRule, getIndexPatterns } from '../../objects/rule'; -import { ALERT_DATA_GRID, NUMBER_OF_ALERTS } from '../../screens/alerts'; +import { ALERTS_COUNT, ALERT_DATA_GRID } from '../../screens/alerts'; import { CUSTOM_RULES_BTN, RISK_SCORE, @@ -147,7 +147,7 @@ describe('EQL rules', () => { waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); - cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); + cy.get(ALERTS_COUNT).should('have.text', expectedNumberOfAlerts); cy.get(ALERT_DATA_GRID) .invoke('text') .then((text) => { @@ -191,7 +191,7 @@ describe('EQL rules', () => { waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); - cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfSequenceAlerts); + cy.get(ALERTS_COUNT).should('have.text', expectedNumberOfSequenceAlerts); cy.get(ALERT_DATA_GRID) .invoke('text') .then((text) => { diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_rules/indicator_match_rule.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_rules/indicator_match_rule.cy.ts index 2114dd3b1fe63..b12fe66a6873a 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_rules/indicator_match_rule.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_rules/indicator_match_rule.cy.ts @@ -17,7 +17,7 @@ import { ALERT_RULE_NAME, ALERT_RISK_SCORE, ALERT_SEVERITY, - NUMBER_OF_ALERTS, + ALERTS_COUNT, } from '../../screens/alerts'; import { CUSTOM_RULES_BTN, @@ -491,7 +491,7 @@ describe('indicator match', () => { waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); - cy.get(NUMBER_OF_ALERTS).should('have.text', expectedNumberOfAlerts); + cy.get(ALERTS_COUNT).should('have.text', expectedNumberOfAlerts); cy.get(ALERT_RULE_NAME).first().should('have.text', rule.name); cy.get(ALERT_SEVERITY).first().should('have.text', rule.severity?.toLowerCase()); cy.get(ALERT_RISK_SCORE).first().should('have.text', rule.riskScore); diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_rules/override.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_rules/override.cy.ts index 69ec2e9b75f9b..d4d6eb9fe0c08 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_rules/override.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_rules/override.cy.ts @@ -10,7 +10,7 @@ import type { Mitre, OverrideRule } from '../../objects/rule'; import { getNewOverrideRule, getSeveritiesOverride } from '../../objects/rule'; import type { CompleteTimeline } from '../../objects/timeline'; -import { NUMBER_OF_ALERTS, ALERT_GRID_CELL } from '../../screens/alerts'; +import { ALERT_GRID_CELL, ALERTS_COUNT } from '../../screens/alerts'; import { CUSTOM_RULES_BTN, @@ -160,7 +160,7 @@ describe('Detection rules, override', () => { waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); - cy.get(NUMBER_OF_ALERTS) + cy.get(ALERTS_COUNT) .invoke('text') .should('match', /^[1-9].+$/); // Any number of alerts cy.get(ALERT_GRID_CELL).contains('auditbeat'); diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_rules/threshold_rule.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_rules/threshold_rule.cy.ts index df3f0031289e3..ecf66438db3ab 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_rules/threshold_rule.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_rules/threshold_rule.cy.ts @@ -9,7 +9,7 @@ import { formatMitreAttackDescription } from '../../helpers/rules'; import type { Mitre } from '../../objects/rule'; import { getNewThresholdRule } from '../../objects/rule'; -import { ALERT_GRID_CELL, NUMBER_OF_ALERTS } from '../../screens/alerts'; +import { ALERTS_COUNT, ALERT_GRID_CELL } from '../../screens/alerts'; import { CUSTOM_RULES_BTN, @@ -142,7 +142,7 @@ describe('Detection rules, threshold', () => { waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); - cy.get(NUMBER_OF_ALERTS).should(($count) => expect(+$count.text().split(' ')[0]).to.be.lt(100)); + cy.get(ALERTS_COUNT).should(($count) => expect(+$count.text().split(' ')[0]).to.be.lt(100)); cy.get(ALERT_GRID_CELL).contains(rule.name); }); }); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/add_edit_flyout/flyout_validation.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/add_edit_flyout/flyout_validation.cy.ts index dda1115979e29..d96106430951a 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/add_edit_flyout/flyout_validation.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/add_edit_flyout/flyout_validation.cy.ts @@ -46,6 +46,7 @@ import { CONFIRM_BTN, VALUES_INPUT, EXCEPTION_FLYOUT_TITLE, + FIELD_INPUT_PARENT, } from '../../../screens/exceptions'; import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../urls/navigation'; @@ -157,8 +158,9 @@ describe('Exceptions flyout', () => { // delete second item, invalid values 'a' and 'c' should remain cy.get(ENTRY_DELETE_BTN).eq(1).click(); - cy.get(FIELD_INPUT).eq(0).should('have.text', 'agent.name'); - cy.get(FIELD_INPUT).eq(1).should('have.text', 'c'); + cy.get(LOADING_SPINNER).should('not.exist'); + cy.get(FIELD_INPUT_PARENT).eq(0).should('have.text', 'agent.name'); + cy.get(FIELD_INPUT_PARENT).eq(1).should('have.text', 'c'); closeExceptionBuilderFlyout(); }); @@ -187,32 +189,32 @@ describe('Exceptions flyout', () => { cy.get(ENTRY_DELETE_BTN).eq(3).click(); cy.get(EXCEPTION_ITEM_CONTAINER) .eq(0) - .find(FIELD_INPUT) + .find(FIELD_INPUT_PARENT) .eq(0) .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER) .eq(0) - .find(FIELD_INPUT) + .find(FIELD_INPUT_PARENT) .eq(1) .should('have.text', 'user.id.keyword'); cy.get(EXCEPTION_ITEM_CONTAINER) .eq(1) - .find(FIELD_INPUT) + .find(FIELD_INPUT_PARENT) .eq(0) .should('have.text', 'user.first'); - cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(1).should('have.text', 'e'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT_PARENT).eq(1).should('have.text', 'e'); // delete remaining entries in exception item 2 cy.get(ENTRY_DELETE_BTN).eq(2).click(); cy.get(ENTRY_DELETE_BTN).eq(2).click(); cy.get(EXCEPTION_ITEM_CONTAINER) .eq(0) - .find(FIELD_INPUT) + .find(FIELD_INPUT_PARENT) .eq(0) .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER) .eq(0) - .find(FIELD_INPUT) + .find(FIELD_INPUT_PARENT) .eq(1) .should('have.text', 'user.id.keyword'); cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).should('not.exist'); @@ -245,20 +247,28 @@ describe('Exceptions flyout', () => { cy.get(ENTRY_DELETE_BTN).eq(4).click(); cy.get(EXCEPTION_ITEM_CONTAINER) .eq(0) - .find(FIELD_INPUT) + .find(FIELD_INPUT_PARENT) .eq(0) .should('have.text', 'agent.name'); - cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT_PARENT).eq(1).should('have.text', 'b'); cy.get(EXCEPTION_ITEM_CONTAINER) .eq(1) - .find(FIELD_INPUT) + .find(FIELD_INPUT_PARENT) .eq(0) .should('have.text', 'agent.name'); - cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(1).should('have.text', 'user'); - cy.get(EXCEPTION_ITEM_CONTAINER).eq(1).find(FIELD_INPUT).eq(2).should('have.text', 'last'); cy.get(EXCEPTION_ITEM_CONTAINER) .eq(1) - .find(FIELD_INPUT) + .find(FIELD_INPUT_PARENT) + .eq(1) + .should('have.text', 'user'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT_PARENT) + .eq(2) + .should('have.text', 'last'); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(1) + .find(FIELD_INPUT_PARENT) .eq(3) .should('have.text', '@timestamp'); @@ -266,18 +276,18 @@ describe('Exceptions flyout', () => { cy.get(ENTRY_DELETE_BTN).eq(4).click(); cy.get(EXCEPTION_ITEM_CONTAINER) .eq(0) - .find(FIELD_INPUT) + .find(FIELD_INPUT_PARENT) .eq(0) .should('have.text', 'agent.name'); - cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(1).should('have.text', 'b'); + cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT_PARENT).eq(1).should('have.text', 'b'); cy.get(EXCEPTION_ITEM_CONTAINER) .eq(1) - .find(FIELD_INPUT) + .find(FIELD_INPUT_PARENT) .eq(0) .should('have.text', 'agent.name'); cy.get(EXCEPTION_ITEM_CONTAINER) .eq(1) - .find(FIELD_INPUT) + .find(FIELD_INPUT_PARENT) .eq(1) .should('have.text', '@timestamp'); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts index 9e5ee8b393282..8232f14b5259f 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_endpoint_exception.cy.ts @@ -43,9 +43,9 @@ import { CLOSE_SINGLE_ALERT_CHECKBOX, EXCEPTION_ITEM_CONTAINER, VALUES_INPUT, - FIELD_INPUT, EXCEPTION_CARD_ITEM_NAME, EXCEPTION_CARD_ITEM_CONDITIONS, + FIELD_INPUT_PARENT, } from '../../../screens/exceptions'; import { createEndpointExceptionList } from '../../../tasks/api_calls/exceptions'; @@ -143,7 +143,11 @@ describe('Add endpoint exception from rule details', () => { editExceptionFlyoutItemName(NEW_ITEM_NAME); // check that the existing item's field is being populated - cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(0).should('have.text', ITEM_FIELD); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT_PARENT) + .eq(0) + .should('have.text', ITEM_FIELD); cy.get(VALUES_INPUT).should('have.text', 'foo'); // edit conditions diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts index 590e4c996a21e..1439e5ed88210 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception.cy.ts @@ -8,7 +8,7 @@ import { getException, getExceptionList } from '../../../objects/exception'; import { getNewRule } from '../../../objects/rule'; -import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../screens/alerts'; +import { ALERTS_COUNT, EMPTY_ALERT_TABLE } from '../../../screens/alerts'; import { createCustomRule, createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; import { @@ -52,10 +52,10 @@ import { CONFIRM_BTN, ADD_TO_SHARED_LIST_RADIO_INPUT, EXCEPTION_ITEM_CONTAINER, - FIELD_INPUT, VALUES_MATCH_ANY_INPUT, EXCEPTION_CARD_ITEM_NAME, EXCEPTION_CARD_ITEM_CONDITIONS, + FIELD_INPUT_PARENT, } from '../../../screens/exceptions'; import { createExceptionList, @@ -145,7 +145,7 @@ describe('Add/edit exception from rule details', () => { // check that the existing item's field is being populated cy.get(EXCEPTION_ITEM_CONTAINER) .eq(0) - .find(FIELD_INPUT) + .find(FIELD_INPUT_PARENT) .eq(0) .should('have.text', ITEM_FIELD); cy.get(VALUES_MATCH_ANY_INPUT).should('have.text', 'foo'); @@ -317,7 +317,7 @@ describe('Add/edit exception from rule details', () => { // Closed alert should appear in table goToClosedAlertsOnRuleDetailsPage(); cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); + cy.get(ALERTS_COUNT).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); // Remove the exception and load an event that would have matched that exception // to show that said exception now starts to show up again @@ -332,12 +332,13 @@ describe('Add/edit exception from rule details', () => { // now that there are no more exceptions, the docs should match and populate alerts goToAlertsTab(); + waitForAlertsToPopulate(); goToOpenedAlertsOnRuleDetailsPage(); waitForTheRuleToBeExecuted(); waitForAlertsToPopulate(); cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); + cy.get(ALERTS_COUNT).should('have.text', '2 alerts'); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts index 3dc652545a72e..b939f11219eab 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/exceptions/rule_details_flow/add_edit_exception_data_view.cy.ts @@ -7,7 +7,7 @@ import { LOADING_INDICATOR } from '../../../screens/security_header'; import { getNewRule } from '../../../objects/rule'; -import { ALERTS_COUNT, EMPTY_ALERT_TABLE, NUMBER_OF_ALERTS } from '../../../screens/alerts'; +import { ALERTS_COUNT, EMPTY_ALERT_TABLE } from '../../../screens/alerts'; import { createCustomRuleEnabled } from '../../../tasks/api_calls/rules'; import { goToRuleDetails } from '../../../tasks/alerts_detection_rules'; import { @@ -16,7 +16,9 @@ import { goToOpenedAlertsOnRuleDetailsPage, } from '../../../tasks/alerts'; import { - addExceptionConditions, + addExceptionEntryFieldValue, + addExceptionEntryFieldValueValue, + addExceptionEntryOperatorValue, addExceptionFlyoutItemName, editException, editExceptionFlyoutItemName, @@ -47,8 +49,8 @@ import { EXCEPTION_CARD_ITEM_NAME, EXCEPTION_CARD_ITEM_CONDITIONS, EXCEPTION_ITEM_CONTAINER, - FIELD_INPUT, VALUES_INPUT, + FIELD_INPUT_PARENT, } from '../../../screens/exceptions'; import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; @@ -94,11 +96,11 @@ describe('Add exception using data views from rule details', () => { it('Creates an exception item from alert actions overflow menu', () => { cy.get(LOADING_INDICATOR).should('not.exist'); addExceptionFromFirstAlert(); - addExceptionConditions({ - field: 'agent.name', - operator: 'is', - values: ['foo'], - }); + + addExceptionEntryFieldValue('agent.name', 0); + addExceptionEntryOperatorValue('is', 0); + addExceptionEntryFieldValueValue('foo', 0); + addExceptionFlyoutItemName(ITEM_NAME); selectBulkCloseAlerts(); submitNewExceptionItem(); @@ -110,7 +112,7 @@ describe('Add exception using data views from rule details', () => { // Closed alert should appear in table goToClosedAlertsOnRuleDetailsPage(); cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); + cy.get(ALERTS_COUNT).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); // Remove the exception and load an event that would have matched that exception // to show that said exception now starts to show up again @@ -130,7 +132,7 @@ describe('Add exception using data views from rule details', () => { waitForAlertsToPopulate(); cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); + cy.get(ALERTS_COUNT).should('have.text', '2 alerts'); }); it('Creates an exception item', () => { @@ -160,7 +162,7 @@ describe('Add exception using data views from rule details', () => { // Closed alert should appear in table goToClosedAlertsOnRuleDetailsPage(); cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); + cy.get(ALERTS_COUNT).should('have.text', `${NUMBER_OF_AUDITBEAT_EXCEPTIONS_ALERTS}`); // Remove the exception and load an event that would have matched that exception // to show that said exception now starts to show up again @@ -180,7 +182,7 @@ describe('Add exception using data views from rule details', () => { waitForAlertsToPopulate(); cy.get(ALERTS_COUNT).should('exist'); - cy.get(NUMBER_OF_ALERTS).should('have.text', '2 alerts'); + cy.get(ALERTS_COUNT).should('have.text', '2 alerts'); }); it('Edits an exception item', () => { @@ -212,7 +214,11 @@ describe('Add exception using data views from rule details', () => { editExceptionFlyoutItemName(NEW_ITEM_NAME); // check that the existing item's field is being populated - cy.get(EXCEPTION_ITEM_CONTAINER).eq(0).find(FIELD_INPUT).eq(0).should('have.text', ITEM_FIELD); + cy.get(EXCEPTION_ITEM_CONTAINER) + .eq(0) + .find(FIELD_INPUT_PARENT) + .eq(0) + .should('have.text', ITEM_FIELD); cy.get(VALUES_INPUT).should('have.text', 'foo'); // edit conditions diff --git a/x-pack/plugins/security_solution/cypress/e2e/timelines/bulk_add_to_timeline.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/timelines/bulk_add_to_timeline.cy.ts index d79569097a137..22a156b57737e 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/timelines/bulk_add_to_timeline.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/timelines/bulk_add_to_timeline.cy.ts @@ -8,6 +8,7 @@ import { getNewRule } from '../../objects/rule'; import { SELECTED_ALERTS } from '../../screens/alerts'; import { SERVER_SIDE_EVENT_COUNT } from '../../screens/timeline'; +import { selectAllAlerts, selectFirstPageAlerts } from '../../tasks/alerts'; import { createCustomRuleEnabled } from '../../tasks/api_calls/rules'; import { cleanKibana } from '../../tasks/common'; import { @@ -22,28 +23,6 @@ import { openEvents, openSessions } from '../../tasks/hosts/main'; import { login, visit } from '../../tasks/login'; import { ALERTS_URL, HOSTS_URL } from '../../urls/navigation'; -const assertFirstPageEventsAddToTimeline = () => { - selectFirstPageEvents(); - cy.get(SELECTED_ALERTS).then((sub) => { - const alertCountText = sub.text(); - const alertCount = alertCountText.split(' ')[1]; - bulkInvestigateSelectedEventsInTimeline(); - cy.get('body').should('contain.text', `${alertCount} event IDs`); - cy.get(SERVER_SIDE_EVENT_COUNT).should('contain.text', alertCount); - }); -}; - -const assertAllEventsAddToTimeline = () => { - selectAllEvents(); - cy.get(SELECTED_ALERTS).then((sub) => { - const alertCountText = sub.text(); // Selected 3,654 alerts - const alertCount = alertCountText.split(' ')[1]; - bulkInvestigateSelectedEventsInTimeline(); - cy.get('body').should('contain.text', `${alertCount} event IDs`); - cy.get(SERVER_SIDE_EVENT_COUNT).should('contain.text', alertCount); - }); -}; - describe('Bulk Investigate in Timeline', () => { before(() => { cleanKibana(); @@ -66,11 +45,25 @@ describe('Bulk Investigate in Timeline', () => { }); it('Adding multiple alerts to the timeline should be successful', () => { - assertFirstPageEventsAddToTimeline(); + selectFirstPageAlerts(); + cy.get(SELECTED_ALERTS).then((sub) => { + const alertCountText = sub.text(); + const alertCount = alertCountText.split(' ')[1]; + bulkInvestigateSelectedEventsInTimeline(); + cy.get('body').should('contain.text', `${alertCount} event IDs`); + cy.get(SERVER_SIDE_EVENT_COUNT).should('contain.text', alertCount); + }); }); it('When selected all alerts are selected should be successfull', () => { - assertAllEventsAddToTimeline(); + selectAllAlerts(); + cy.get(SELECTED_ALERTS).then((sub) => { + const alertCountText = sub.text(); // Selected 3,654 alerts + const alertCount = alertCountText.split(' ')[1]; + bulkInvestigateSelectedEventsInTimeline(); + cy.get('body').should('contain.text', `${alertCount} event IDs`); + cy.get(SERVER_SIDE_EVENT_COUNT).should('contain.text', alertCount); + }); }); }); @@ -81,12 +74,26 @@ describe('Bulk Investigate in Timeline', () => { waitsForEventsToBeLoaded(); }); - it('Adding multiple alerts to the timeline should be successful', () => { - assertFirstPageEventsAddToTimeline(); + it('Adding multiple events to the timeline should be successful', () => { + selectFirstPageEvents(); + cy.get(SELECTED_ALERTS).then((sub) => { + const alertCountText = sub.text(); + const alertCount = alertCountText.split(' ')[1]; + bulkInvestigateSelectedEventsInTimeline(); + cy.get('body').should('contain.text', `${alertCount} event IDs`); + cy.get(SERVER_SIDE_EVENT_COUNT).should('contain.text', alertCount); + }); }); it('When selected all alerts are selected should be successfull', () => { - assertAllEventsAddToTimeline(); + selectAllEvents(); + cy.get(SELECTED_ALERTS).then((sub) => { + const alertCountText = sub.text(); // Selected 3,654 alerts + const alertCount = alertCountText.split(' ')[1]; + bulkInvestigateSelectedEventsInTimeline(); + cy.get('body').should('contain.text', `${alertCount} event IDs`); + cy.get(SERVER_SIDE_EVENT_COUNT).should('contain.text', alertCount); + }); }); }); @@ -97,12 +104,26 @@ describe('Bulk Investigate in Timeline', () => { waitsForEventsToBeLoaded(); }); - it('Adding multiple alerts to the timeline should be successful', () => { - assertFirstPageEventsAddToTimeline(); + it('Adding multiple events to the timeline should be successful', () => { + selectFirstPageEvents(); + cy.get(SELECTED_ALERTS).then((sub) => { + const alertCountText = sub.text(); + const alertCount = alertCountText.split(' ')[1]; + bulkInvestigateSelectedEventsInTimeline(); + cy.get('body').should('contain.text', `${alertCount} event IDs`); + cy.get(SERVER_SIDE_EVENT_COUNT).should('contain.text', alertCount); + }); }); - it('When selected all alerts are selected should be successfull', () => { - assertAllEventsAddToTimeline(); + it('When selected all events are selected should be successfull', () => { + selectAllEvents(); + cy.get(SELECTED_ALERTS).then((sub) => { + const alertCountText = sub.text(); // Selected 3,654 alerts + const alertCount = alertCountText.split(' ')[1]; + bulkInvestigateSelectedEventsInTimeline(); + cy.get('body').should('contain.text', `${alertCount} event IDs`); + cy.get(SERVER_SIDE_EVENT_COUNT).should('contain.text', alertCount); + }); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/alerts.ts b/x-pack/plugins/security_solution/cypress/screens/alerts.ts index eb2cb28d80844..ae444ea941fb2 100644 --- a/x-pack/plugins/security_solution/cypress/screens/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/screens/alerts.ts @@ -12,7 +12,7 @@ export const ADD_ENDPOINT_EXCEPTION_BTN = '[data-test-subj="add-endpoint-excepti export const ALERT_COUNT_TABLE_FIRST_ROW_COUNT = '[data-test-subj="alertsCountTable"] tr:nth-child(1) td:nth-child(2) .euiTableCellContent__text'; -export const ALERT_CHECKBOX = '[data-test-subj~="select-event"].euiCheckbox__input'; +export const ALERT_CHECKBOX = '[data-test-subj="bulk-actions-row-cell"].euiCheckbox__input'; export const ALERT_GRID_CELL = '[data-test-subj="dataGridRowCell"]'; @@ -26,21 +26,20 @@ export const ALERT_DATA_GRID = '[data-test-subj="euiDataGridBody"]'; export const ALERTS = '[data-test-subj="events-viewer-panel"][data-test-subj="event"]'; -export const ALERTS_COUNT = - '[data-test-subj="events-viewer-panel"] [data-test-subj="server-side-event-count"]'; +export const ALERTS_COUNT = '[data-test-subj="toolbar-alerts-count"]'; export const ALERTS_TREND_SIGNAL_RULE_NAME_PANEL = '[data-test-subj="render-content-kibana.alert.rule.name"]'; export const CLOSE_ALERT_BTN = '[data-test-subj="close-alert-status"]'; -export const CLOSE_SELECTED_ALERTS_BTN = '[data-test-subj="close-alert-status"]'; +export const CLOSE_SELECTED_ALERTS_BTN = '[data-test-subj="closed-alert-status"]'; export const CLOSED_ALERTS_FILTER_BTN = '[data-test-subj="closedAlerts"]'; export const DESTINATION_IP = '[data-test-subj^=formatted-field][data-test-subj$=destination\\.ip]'; -export const EMPTY_ALERT_TABLE = '[data-test-subj="tGridEmptyState"]'; +export const EMPTY_ALERT_TABLE = '[data-test-subj="alertsStateTableEmptyState"]'; export const EXPAND_ALERT_BTN = '[data-test-subj="expand-event"]'; @@ -68,9 +67,6 @@ export const ALERTS_HISTOGRAM_PANEL_LOADER = '[data-test-subj="loadingPanelAlert export const ALERTS_CONTAINER_LOADING_BAR = '[data-test-subj="events-container-loading-true"]'; -export const NUMBER_OF_ALERTS = - '[data-test-subj="events-viewer-panel"] [data-test-subj="server-side-event-count"]'; - export const OPEN_ALERT_BTN = '[data-test-subj="open-alert-status"]'; export const OPENED_ALERTS_FILTER_BTN = '[data-test-subj="openAlerts"]'; @@ -126,11 +122,15 @@ export const USER_RISK_HEADER_COLIMN = export const USER_RISK_COLUMN = '[data-gridcell-column-id="user.risk.calculated_level"]'; -export const ACTION_COLUMN = '[data-gridcell-column-id="default-timeline-control-column"]'; +export const ACTION_COLUMN = '[data-gridcell-column-id="expandColumn"]'; export const DATAGRID_CHANGES_IN_PROGRESS = '[data-test-subj="body-data-grid"] .euiProgress'; -export const EVENT_CONTAINER_TABLE_LOADING = '[data-test-subj="events-container-loading-true"]'; +export const EVENT_CONTAINER_TABLE_LOADING = '[data-test-subj="internalAlertsPageLoading"]'; + +export const SELECT_ALL_VISIBLE_ALERTS = '[data-test-subj="bulk-actions-header"]'; + +export const SELECT_ALL_ALERTS = '[data-test-subj="selectAllAlertsButton"]'; export const EVENT_CONTAINER_TABLE_NOT_LOADING = '[data-test-subj="events-container-loading-false"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/common/data_grid.ts b/x-pack/plugins/security_solution/cypress/screens/common/data_grid.ts new file mode 100644 index 0000000000000..d386628a193d7 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/common/data_grid.ts @@ -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 GET_DATA_GRID_HEADER = (fieldName: string) => { + return `[data-test-subj="dataGridHeaderCell-${fieldName}"]`; +}; diff --git a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts index 37e9bb01f8d7f..5e681aad86f6c 100644 --- a/x-pack/plugins/security_solution/cypress/screens/exceptions.ts +++ b/x-pack/plugins/security_solution/cypress/screens/exceptions.ts @@ -12,6 +12,9 @@ export const CLOSE_SINGLE_ALERT_CHECKBOX = '[data-test-subj="closeAlertOnAddExce export const CONFIRM_BTN = '[data-test-subj="addExceptionConfirmButton"]'; export const FIELD_INPUT = + '[data-test-subj="fieldAutocompleteComboBox"] [data-test-subj="comboBoxInput"] input'; + +export const FIELD_INPUT_PARENT = '[data-test-subj="fieldAutocompleteComboBox"] [data-test-subj="comboBoxInput"]'; export const LOADING_SPINNER = '[data-test-subj="loading-spinner"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts index 222ef1f684193..3fe364a0b06c3 100644 --- a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts @@ -70,7 +70,7 @@ export const NEW_TERMS_FIELDS_DETAILS = 'Fields'; export const NEW_TERMS_HISTORY_WINDOW_DETAILS = 'History Window Size'; export const FIELDS_BROWSER_BTN = - '[data-test-subj="events-viewer-panel"] [data-test-subj="show-field-browser"]'; + '[data-test-subj="alertsTable"] [data-test-subj="show-field-browser"]'; export const REFRESH_BUTTON = '[data-test-subj="refreshButton"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index 2752afad6323a..1fbe6e7fcaf60 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -129,6 +129,8 @@ export const QUERY_TAB_BUTTON = '[data-test-subj="timelineTabs-query"]'; export const SERVER_SIDE_EVENT_COUNT = '[data-test-subj="server-side-event-count"]'; +export const ALERTS_TABLE_COUNT = `[data-test-subj="toolbar-alerts-count"]`; + export const SOURCE_IP_KPI = '[data-test-subj="siem-timeline-source-ip-kpi"]'; export const STAR_ICON = '[data-test-subj="timeline-favorite-empty-star"]'; @@ -317,3 +319,7 @@ export const HOVER_ACTIONS = { COPY: '[data-test-subj="clipboard"]', SHOW_TOP: 'show-top-field', }; + +export const GET_TIMELINE_HEADER = (fieldName: string) => { + return `[data-test-subj="timeline"] [data-test-subj="header-text-${fieldName}"]`; +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts index a17d4bb004671..68df5862aced6 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/alerts.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/alerts.ts @@ -12,7 +12,6 @@ import { CLOSE_SELECTED_ALERTS_BTN, EXPAND_ALERT_BTN, GROUP_BY_TOP_INPUT, - LOADING_ALERTS_PANEL, MANAGE_ALERT_DETECTION_RULES_BTN, MARK_ALERT_ACKNOWLEDGED_BTN, OPEN_ALERT_BTN, @@ -25,12 +24,12 @@ import { TAKE_ACTION_BTN, TAKE_ACTION_MENU, ADD_ENDPOINT_EXCEPTION_BTN, - ALERTS_HISTOGRAM_PANEL_LOADER, - ALERTS_CONTAINER_LOADING_BAR, DATAGRID_CHANGES_IN_PROGRESS, - EVENT_CONTAINER_TABLE_NOT_LOADING, CLOSED_ALERTS_FILTER_BTN, OPENED_ALERTS_FILTER_BTN, + EVENT_CONTAINER_TABLE_LOADING, + SELECT_ALL_ALERTS, + SELECT_ALL_VISIBLE_ALERTS, ACKNOWLEDGED_ALERTS_FILTER_BTN, CELL_ADD_TO_TIMELINE_BUTTON, CELL_FILTER_IN_BUTTON, @@ -48,6 +47,7 @@ import { CELL_EXPAND_VALUE, CELL_EXPANSION_POPOVER, USER_DETAILS_LINK, + ALERT_FLYOUT, } from '../screens/alerts_details'; import { FIELD_INPUT } from '../screens/exceptions'; import { @@ -63,6 +63,7 @@ import { } from '../screens/common/filter_group'; import { LOADING_SPINNER } from '../screens/common/page'; import { ALERTS_URL } from '../urls/navigation'; +import { FIELDS_BROWSER_BTN } from '../screens/rule_details'; export const addExceptionFromFirstAlert = () => { expandFirstAlertActions(); @@ -148,12 +149,12 @@ export const expandFirstAlertActions = () => { }; export const expandFirstAlert = () => { - cy.get(EXPAND_ALERT_BTN).should('exist'); - - cy.get(EXPAND_ALERT_BTN) - .first() - .should('exist') - .pipe(($el) => $el.trigger('click')); + cy.root() + .pipe(($el) => { + $el.find(EXPAND_ALERT_BTN).trigger('click'); + return $el.find(ALERT_FLYOUT); + }) + .should('be.visible'); }; export const closeAlertFlyout = () => cy.get(CLOSE_FLYOUT).click(); @@ -285,10 +286,15 @@ export const markAcknowledgedFirstAlert = () => { cy.get(MARK_ALERT_ACKNOWLEDGED_BTN).click(); }; +export const openAlertsFieldBrowser = () => { + cy.get(FIELDS_BROWSER_BTN).click(); +}; + export const selectNumberOfAlerts = (numberOfAlerts: number) => { - waitForAlerts(); for (let i = 0; i < numberOfAlerts; i++) { - cy.get(ALERT_CHECKBOX).eq(i).click({ force: true }); + waitForAlerts(); + cy.get(ALERT_CHECKBOX).eq(i).as('checkbox').click({ force: true }); + cy.get('@checkbox').should('have.attr', 'checked'); } }; @@ -329,17 +335,10 @@ export const waitForAlerts = () => { * */ cy.get(REFRESH_BUTTON).should('not.have.attr', 'aria-label', 'Needs updating'); cy.get(DATAGRID_CHANGES_IN_PROGRESS).should('not.be.true'); - cy.get(EVENT_CONTAINER_TABLE_NOT_LOADING).should('be.visible'); + cy.get(EVENT_CONTAINER_TABLE_LOADING).should('not.exist'); cy.get(LOADING_INDICATOR).should('not.exist'); }; -export const waitForAlertsPanelToBeLoaded = () => { - cy.get(LOADING_ALERTS_PANEL).should('exist'); - cy.get(LOADING_ALERTS_PANEL).should('not.exist'); - cy.get(ALERTS_CONTAINER_LOADING_BAR).should('not.exist'); - cy.get(ALERTS_HISTOGRAM_PANEL_LOADER).should('not.exist'); -}; - export const expandAlertTableCellValue = (columnSelector: string, row = 1) => { cy.get(columnSelector).eq(1).focus().find(CELL_EXPAND_VALUE).click({ force: true }); }; @@ -386,3 +385,12 @@ export const resetFilters = () => { * * */ }; + +export const selectFirstPageAlerts = () => { + cy.get(SELECT_ALL_VISIBLE_ALERTS).first().scrollIntoView().click({ force: true }); +}; + +export const selectAllAlerts = () => { + selectFirstPageAlerts(); + cy.get(SELECT_ALL_ALERTS).click(); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index 07a104102625f..29f8670459649 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -11,6 +11,7 @@ import type { ThreatSubtechnique, ThreatTechnique, } from '@kbn/securitysolution-io-ts-alerting-types'; +import { parseInt } from 'lodash'; import type { CustomRule, MachineLearningRule, @@ -114,12 +115,14 @@ import { } from '../screens/common/rule_actions'; import { fillIndexConnectorForm, fillEmailConnectorForm } from './common/rule_actions'; import { TOAST_ERROR } from '../screens/shared'; -import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline'; +import { ALERTS_TABLE_COUNT } from '../screens/timeline'; import { TIMELINE } from '../screens/timelines'; -import { refreshPage } from './security_header'; import { EUI_FILTER_SELECT_ITEM, COMBO_BOX_INPUT } from '../screens/common/controls'; import { ruleFields } from '../data/detection_engine'; import { BACK_TO_RULES_TABLE } from '../screens/rule_details'; +import { waitForAlerts } from './alerts'; +import { refreshPage } from './security_header'; +import { EMPTY_ALERT_TABLE } from '../screens/alerts'; export const createAndEnableRule = () => { cy.get(CREATE_AND_ENABLE_BTN).click({ force: true }); @@ -670,17 +673,22 @@ export const selectNewTermsRuleType = () => { export const waitForAlertsToPopulate = async (alertCountThreshold = 1) => { cy.waitUntil( () => { + cy.log('Waiting for alerts to appear'); refreshPage(); - return cy - .get(SERVER_SIDE_EVENT_COUNT) - .invoke('text') - .then((countText) => { - const alertCount = parseInt(countText, 10) || 0; - return alertCount >= alertCountThreshold; - }); + return cy.root().then(($el) => { + const emptyTableState = $el.find(EMPTY_ALERT_TABLE); + if (emptyTableState.length > 0) { + cy.log('Table is empty', emptyTableState.length); + return false; + } + const countEl = $el.find(ALERTS_TABLE_COUNT); + const alertCount = parseInt(countEl.text(), 10) || 0; + return alertCount >= alertCountThreshold; + }); }, { interval: 500, timeout: 12000 } ); + waitForAlerts(); }; export const waitForTheRuleToBeExecuted = () => { diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_runtime_field.ts b/x-pack/plugins/security_solution/cypress/tasks/create_runtime_field.ts index 1ec4bd7cdf52f..662db2eece70d 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_runtime_field.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_runtime_field.ts @@ -16,14 +16,3 @@ export const createField = (fieldName: string): Cypress.Chainable - view === 'alerts' - ? cy - .get( - `[data-test-subj="events-viewer-panel"] [data-test-subj="dataGridHeaderCell-${fieldName}"]` - ) - .should('exist') - : cy - .get(`[data-test-subj="timeline"] [data-test-subj="header-text-${fieldName}"]`) - .should('exist'); diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts index 1a7540325a2b4..d30cbaf06e17b 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts @@ -123,6 +123,7 @@ export const removeException = () => { export const waitForTheRuleToBeExecuted = () => { cy.waitUntil(() => { + cy.log('Wating for the rule to be executed'); cy.get(REFRESH_BUTTON).click({ force: true }); return cy .get(RULE_STATUS) diff --git a/x-pack/plugins/security_solution/cypress/tasks/sourcerer.ts b/x-pack/plugins/security_solution/cypress/tasks/sourcerer.ts index b21dd651d2cfc..266e3655a8342 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/sourcerer.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/sourcerer.ts @@ -151,3 +151,14 @@ export const waitForAlertsIndexToExist = () => { createCustomRuleEnabled(getNewRule(), '1', 100); refreshUntilAlertsIndexExists(); }; + +export const deleteRuntimeField = (dataView: string, fieldName: string) => { + const deleteRuntimeFieldPath = `/api/data_views/data_view/${dataView}/runtime_field/${fieldName}`; + + cy.request({ + url: deleteRuntimeFieldPath, + method: 'DELETE', + headers: { 'kbn-xsrf': 'cypress-creds' }, + failOnStatusCode: false, + }); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/timelines.ts b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts index d7f69638d0c55..27b0c97a23fa1 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timelines.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timelines.ts @@ -5,7 +5,6 @@ * 2.0. */ -import { LOADING_INDICATOR } from '../screens/security_header'; import { TIMELINE_CHECKBOX, BULK_ACTIONS, @@ -47,7 +46,5 @@ export const openTimeline = (id?: string) => { }; export const waitForTimelinesPanelToBeLoaded = () => { - cy.get(LOADING_INDICATOR).should('exist'); - cy.get(LOADING_INDICATOR).should('not.exist'); cy.get(TIMELINES_TABLE).should('exist'); }; diff --git a/x-pack/plugins/security_solution/public/actions/add_to_timeline/data_provider.ts b/x-pack/plugins/security_solution/public/actions/add_to_timeline/data_provider.ts index e7c6cfcbe46be..f81d199a2632f 100644 --- a/x-pack/plugins/security_solution/public/actions/add_to_timeline/data_provider.ts +++ b/x-pack/plugins/security_solution/public/actions/add_to_timeline/data_provider.ts @@ -77,7 +77,7 @@ export const createDataProviders = ({ }: CreateDataProviderParams) => { if (field == null) return null; - const arrayValues = Array.isArray(values) ? values : [values]; + const arrayValues = Array.isArray(values) ? (values.length > 0 ? values : [null]) : [values]; return arrayValues.reduce((dataProviders, value, index) => { let id: string = ''; diff --git a/x-pack/plugins/security_solution/public/app/types.ts b/x-pack/plugins/security_solution/public/app/types.ts index a1314b27a0ab7..75608a3519a70 100644 --- a/x-pack/plugins/security_solution/public/app/types.ts +++ b/x-pack/plugins/security_solution/public/app/types.ts @@ -35,6 +35,7 @@ import type { State, SubPluginsInitReducer } from '../common/store'; import type { Immutable } from '../../common/endpoint/types'; import type { AppAction } from '../common/store/actions'; import type { TableState } from '../common/store/data_table/types'; +import type { GroupMap } from '../common/store/grouping'; export { SecurityPageName } from '../../common/constants'; @@ -49,6 +50,7 @@ export type SecuritySubPluginRoutes = RouteProps[]; export interface SecuritySubPlugin { routes: SecuritySubPluginRoutes; storageDataTables?: Pick; + groups?: Pick; exploreDataTables?: { network: Pick; hosts: Pick; diff --git a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.test.tsx index 2e74209287d57..322f1237928d0 100644 --- a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.test.tsx @@ -40,7 +40,7 @@ describe('RowAction', () => { const defaultProps = { columnHeaders: defaultHeaders, controlColumn: getDefaultControlColumn(5)[0], - data: [sampleData], + data: sampleData, disabled: false, index: 1, isEventViewer: false, diff --git a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx index 09bb1baa3cbc0..c41eaba862c9c 100644 --- a/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/control_columns/row_action/index.tsx @@ -23,7 +23,7 @@ import { dataTableActions } from '../../../store/data_table'; type Props = EuiDataGridCellValueElementProps & { columnHeaders: ColumnHeaderOptions[]; controlColumn: ControlColumnProps; - data: TimelineItem[]; + data: TimelineItem; disabled: boolean; index: number; isEventViewer: boolean; @@ -38,6 +38,7 @@ type Props = EuiDataGridCellValueElementProps & { setEventsLoading: SetEventsLoading; setEventsDeleted: SetEventsDeleted; pageRowIndex: number; + refetch?: () => void; }; const RowActionComponent = ({ @@ -59,16 +60,9 @@ const RowActionComponent = ({ setEventsLoading, setEventsDeleted, width, + refetch, }: Props) => { - const { - data: timelineNonEcsData, - ecs: ecsData, - _id: eventId, - _index: indexName, - } = useMemo(() => { - const rowData: Partial = data[pageRowIndex]; - return rowData ?? {}; - }, [data, pageRowIndex]); + const { data: timelineNonEcsData, ecs: ecsData, _id: eventId, _index: indexName } = data ?? {}; const dispatch = useDispatch(); @@ -137,6 +131,7 @@ const RowActionComponent = ({ width={width} setEventsLoading={setEventsLoading} setEventsDeleted={setEventsDeleted} + refetch={refetch} /> )} diff --git a/x-pack/plugins/security_solution/public/common/components/control_columns/transform_control_columns.tsx b/x-pack/plugins/security_solution/public/common/components/control_columns/transform_control_columns.tsx index 651310755b183..931a857eeb3fd 100644 --- a/x-pack/plugins/security_solution/public/common/components/control_columns/transform_control_columns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/control_columns/transform_control_columns.tsx @@ -72,8 +72,8 @@ export const transformControlColumns = ({ theme, setEventsLoading, setEventsDeleted, -}: TransformColumnsProps): EuiDataGridControlColumn[] => - controlColumns.map( +}: TransformColumnsProps): EuiDataGridControlColumn[] => { + return controlColumns.map( ({ id: columnId, headerCellRender = EmptyHeaderCellRender, rowCellRender, width }, i) => ({ id: `${columnId}`, headerCellRender: () => { @@ -122,7 +122,7 @@ export const transformControlColumns = ({ columnId={columnId ?? ''} columnHeaders={columnHeaders} controlColumn={controlColumns[i]} - data={data} + data={data[pageRowIndex]} disabled={false} index={i} isDetails={isDetails} @@ -149,3 +149,4 @@ export const transformControlColumns = ({ width, }) ); +}; diff --git a/x-pack/plugins/security_solution/public/common/components/data_table/column_headers/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/data_table/column_headers/helpers.tsx index ad8a3e8e7c2af..a39a0fc3664c8 100644 --- a/x-pack/plugins/security_solution/public/common/components/data_table/column_headers/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/data_table/column_headers/helpers.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import { i18n } from '@kbn/i18n'; import type { EuiDataGridColumnActions } from '@elastic/eui'; import { keyBy } from 'lodash/fp'; import React from 'react'; +import { eventRenderedViewColumns } from '../../../../detections/configurations/security_solution_detections/columns'; import type { BrowserField, BrowserFields, @@ -152,45 +152,6 @@ export const getSchema = (type: string | undefined): BUILT_IN_SCHEMA | undefined } }; -const eventRenderedViewColumns: ColumnHeaderOptions[] = [ - { - columnHeaderType: defaultColumnHeaderType, - id: '@timestamp', - displayAsText: i18n.translate( - 'xpack.securitySolution.EventRenderedView.timestampTitle.column', - { - defaultMessage: 'Timestamp', - } - ), - initialWidth: DEFAULT_TABLE_DATE_COLUMN_MIN_WIDTH + 50, - actions: false, - isExpandable: false, - isResizable: false, - }, - { - columnHeaderType: defaultColumnHeaderType, - displayAsText: i18n.translate('xpack.securitySolution.EventRenderedView.ruleTitle.column', { - defaultMessage: 'Rule', - }), - id: 'kibana.alert.rule.name', - initialWidth: DEFAULT_TABLE_COLUMN_MIN_WIDTH + 50, - linkField: 'kibana.alert.rule.uuid', - actions: false, - isExpandable: false, - isResizable: false, - }, - { - columnHeaderType: defaultColumnHeaderType, - id: 'eventSummary', - displayAsText: i18n.translate('xpack.securitySolution.EventRenderedView.eventSummary.column', { - defaultMessage: 'Event Summary', - }), - actions: false, - isExpandable: false, - isResizable: false, - }, -]; - /** Enriches the column headers with field details from the specified browserFields */ export const getColumnHeaders = ( headers: ColumnHeaderOptions[], diff --git a/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx index af8b54f6f5c90..2e22cd032f8f4 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx @@ -10,7 +10,7 @@ import { useDispatch } from 'react-redux'; import { EuiCheckbox } from '@elastic/eui'; import type { Filter } from '@kbn/es-query'; -import type { TableId } from '../../../../common/types'; +import type { CustomBulkAction, TableId } from '../../../../common/types'; import { dataTableActions } from '../../store/data_table'; import { RowRendererId } from '../../../../common/types/timeline'; import { StatefulEventsViewer } from '../events_viewer'; @@ -156,7 +156,7 @@ const EventsQueryTabBodyComponent: React.FC = from: startDate, to: endDate, scopeId: SourcererScopeName.default, - }); + }) as CustomBulkAction; const bulkActions = useMemo(() => { return { diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/helpers.tsx index 93176993c2dd7..dcc561b1aeed0 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/helpers.tsx @@ -5,12 +5,12 @@ * 2.0. */ +import type { ViewSelection } from '../../../../common/types'; import { TableId } from '../../../../common/types'; import type { CombineQueries } from '../../lib/kuery'; import { buildTimeRangeFilter, combineQueries } from '../../lib/kuery'; import { EVENTS_TABLE_CLASS_NAME } from './styles'; -import type { ViewSelection } from './summary_view_select'; export const getCombinedFilterQuery = ({ from, diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 9504ac8728b80..30ba9db8c033e 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -17,6 +17,7 @@ import { isEmpty } from 'lodash'; import { getEsQueryConfig } from '@kbn/data-plugin/common'; import type { EuiTheme } from '@kbn/kibana-react-plugin/common'; import type { EuiDataGridRowHeightsOptions } from '@elastic/eui'; +import { ALERTS_TABLE_VIEW_SELECTION_KEY } from '../../../../common/constants'; import type { Sort } from '../../../timelines/components/timeline/body/sort'; import type { ControlColumnProps, @@ -25,6 +26,7 @@ import type { SetEventsDeleted, SetEventsLoading, TableId, + ViewSelection, } from '../../../../common/types'; import { dataTableActions } from '../../store/data_table'; import { InputsModelId } from '../../store/inputs/constants'; @@ -62,8 +64,6 @@ import type { SetQuery } from '../../containers/use_global_time/types'; import { defaultHeaders } from '../../store/data_table/defaults'; import { checkBoxControlColumn, transformControlColumns } from '../control_columns'; import { getEventIdToDataMapping } from '../data_table/helpers'; -import { ALERTS_TABLE_VIEW_SELECTION_KEY } from './summary_view_select'; -import type { ViewSelection } from './summary_view_select'; import { RightTopMenu } from './right_top_menu'; import { useAlertBulkActions } from './use_alert_bulk_actions'; import type { BulkActionsProp } from '../toolbar/bulk_actions/types'; diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/right_top_menu.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/right_top_menu.tsx index 8294cc78d77ce..4e08b0cb94b62 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/right_top_menu.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/right_top_menu.tsx @@ -6,12 +6,13 @@ */ import React, { useMemo } from 'react'; +import type { CSSProperties } from 'styled-components'; import styled from 'styled-components'; +import type { ViewSelection } from '../../../../common/types'; import { TableId } from '../../../../common/types'; import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; import { InspectButton } from '../inspect'; import { UpdatedFlexGroup, UpdatedFlexItem } from './styles'; -import type { ViewSelection } from './summary_view_select'; import { SummaryViewSelector } from './summary_view_select'; const TitleText = styled.span` @@ -26,6 +27,8 @@ interface Props { onViewChange: (viewSelection: ViewSelection) => void; additionalFilters?: React.ReactNode; hasRightOffset?: boolean; + showInspect?: boolean; + position?: CSSProperties['position']; additionalMenuOptions?: React.ReactNode[]; } @@ -37,6 +40,8 @@ export const RightTopMenu = ({ onViewChange, additionalFilters, hasRightOffset, + showInspect = true, + position = 'absolute', additionalMenuOptions = [], }: Props) => { const alignItems = tableView === 'gridView' ? 'baseline' : 'center'; @@ -63,12 +68,17 @@ export const RightTopMenu = ({ alignItems={alignItems} data-test-subj="events-viewer-updated" gutterSize="m" + component="span" justifyContent="flexEnd" + direction="row" $hasRightOffset={hasRightOffset} + position={position} > - - - + {showInspect ? ( + + + + ) : null} {additionalFilters} diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/stateful_event_context.ts b/x-pack/plugins/security_solution/public/common/components/events_viewer/stateful_event_context.ts index 0d723c01127e4..4389c76f8a52e 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/stateful_event_context.ts +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/stateful_event_context.ts @@ -11,6 +11,7 @@ export interface StatefulEventContextType { timelineID: string; enableHostDetailsFlyout: boolean; enableIpDetailsFlyout: boolean; + onRuleChange?: () => void; } export const StatefulEventContext = createContext(null); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/styles.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/styles.tsx index a5a944006c10c..e7eecc27c2255 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/styles.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/styles.tsx @@ -6,6 +6,7 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import type { CSSProperties } from 'styled-components'; import styled from 'styled-components'; export const SELECTOR_TIMELINE_GLOBAL_CONTAINER = 'securitySolutionTimeline__container'; export const EVENTS_TABLE_CLASS_NAME = 'siemEventsTable'; @@ -36,15 +37,27 @@ export const FullWidthFlexGroup = styled(EuiFlexGroup)<{ $visible?: boolean }>` export const UpdatedFlexGroup = styled(EuiFlexGroup)<{ $hasRightOffset?: boolean; + position: CSSProperties['position']; }>` - ${({ $hasRightOffset, theme }) => - $hasRightOffset + ${({ $hasRightOffset, theme, position }) => + position === 'relative' + ? `margin-right: ${theme.eui.euiSizeXS}; margin-left: ` + : $hasRightOffset && position === 'absolute' ? `margin-right: ${theme.eui.euiSizeXL};` - : `margin-right: ${theme.eui.euiSizeL};`} - position: absolute; + : `margin-right: ${theme.eui.euiSizeXS};`} + ${({ position }) => { + return position === 'absolute' + ? `position: absolute` + : `display: flex; justify-content:center; align-items:center`; + }}; + display: inline-flex; z-index: ${({ theme }) => theme.eui.euiZLevel1 - 3}; - ${({ $hasRightOffset, theme }) => - $hasRightOffset ? `right: ${theme.eui.euiSizeXL};` : `right: ${theme.eui.euiSizeL};`} + ${({ $hasRightOffset, theme, position }) => + position === 'relative' + ? `right: 0;` + : $hasRightOffset && position === 'absolute' + ? `right: ${theme.eui.euiSizeXL};` + : `right: ${theme.eui.euiSizeL};`} `; export const UpdatedFlexItem = styled(EuiFlexItem)<{ $show: boolean }>` diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/summary_view_select/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/summary_view_select/index.tsx index cdd391900ba86..6a383ada1ac85 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/summary_view_select/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/summary_view_select/index.tsx @@ -11,14 +11,11 @@ import { Storage } from '@kbn/kibana-utils-plugin/public'; import { i18n } from '@kbn/i18n'; import React, { useCallback, useMemo, useState } from 'react'; import styled from 'styled-components'; - -/** This local storage key stores the `Grid / Event rendered view` selection */ -export const ALERTS_TABLE_VIEW_SELECTION_KEY = 'securitySolution.alerts.table.view-selection'; +import type { ViewSelection } from '../../../../../common/types'; +import { ALERTS_TABLE_VIEW_SELECTION_KEY } from '../../../../../common/constants'; const storage = new Storage(localStorage); -export type ViewSelection = 'gridView' | 'eventRenderedView'; - const ContainerEuiSelectable = styled.div` width: 300px; .euiSelectableListItem__text { diff --git a/x-pack/plugins/security_solution/public/common/components/grouping/container/index.tsx b/x-pack/plugins/security_solution/public/common/components/grouping/container/index.tsx index d23e220e3c567..beb1ee796827a 100644 --- a/x-pack/plugins/security_solution/public/common/components/grouping/container/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/grouping/container/index.tsx @@ -20,7 +20,7 @@ import { GroupingStyledContainer, GroupsUnitCount } from '../styles'; import { GROUPS_UNIT } from '../translations'; import type { GroupingTableAggregation, RawBucket } from '../types'; -interface GroupingContainerProps { +export interface GroupingContainerProps { badgeMetricStats?: (fieldBucket: RawBucket) => BadgeMetric[]; customMetricStats?: (fieldBucket: RawBucket) => CustomMetric[]; data: GroupingTableAggregation & diff --git a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx index 4ca8f2d0fae28..1d7006bdd3756 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_actions/actions.tsx @@ -62,6 +62,8 @@ const ActionsComponent: React.FC = ({ showNotes, timelineId, toggleShowNotes, + refetch, + setEventsLoading, }) => { const dispatch = useDispatch(); const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); @@ -296,6 +298,7 @@ const ActionsComponent: React.FC = ({ scopeId={timelineId} disabled={isContextMenuDisabled} onRuleChange={onRuleChange} + refetch={refetch} /> {isDisabled === false ? (
diff --git a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.tsx index ae87d6bbdef95..8d66e0ad37104 100644 --- a/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/sessions_viewer/index.tsx @@ -141,7 +141,7 @@ const SessionsViewComponent: React.FC = ({ return { alertStatusActions: false, customBulkActions: [addBulkToTimelineAction], - }; + } as BulkActionsProp; }, [addBulkToTimelineAction]); const unit = (c: number) => diff --git a/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.ts b/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.ts index e0047c4200c81..e0e20c203df6b 100644 --- a/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.ts +++ b/x-pack/plugins/security_solution/public/common/components/super_date_picker/selectors.ts @@ -31,7 +31,7 @@ export const getGlobalQueries = ( ): GlobalQuery[] => { const inputsRange = state.inputs[id]; return !isEmpty(inputsRange.linkTo) - ? inputsRange.linkTo.reduce((acc, linkToId) => { + ? inputsRange.linkTo.reduce((acc, linkToId: InputsModelId) => { if (linkToId === InputsModelId.socTrends) { return acc; } diff --git a/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx b/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx index 8e1836d733a88..11886504eaa20 100644 --- a/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx +++ b/x-pack/plugins/security_solution/public/common/components/utility_bar/styles.tsx @@ -129,7 +129,6 @@ export const BarAction = styled.div.attrs({ })` ${({ theme }) => css` font-size: ${theme.eui.euiFontSizeXS}; - line-height: ${theme.eui.euiLineHeight}; `} `; BarAction.displayName = 'BarAction'; diff --git a/x-pack/plugins/security_solution/public/common/containers/grouping/hooks/use_get_group_selector.tsx b/x-pack/plugins/security_solution/public/common/containers/grouping/hooks/use_get_group_selector.tsx new file mode 100644 index 0000000000000..aac4305f8518c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/grouping/hooks/use_get_group_selector.tsx @@ -0,0 +1,119 @@ +/* + * 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 { FieldSpec } from '@kbn/data-views-plugin/common'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import type { TableId } from '../../../../../common/types'; +import { getDefaultGroupingOptions } from '../../../../detections/components/alerts_table/grouping_settings'; +import type { State } from '../../../store'; +import { defaultGroup } from '../../../store/grouping/defaults'; +import type { GroupOption } from '../../../store/grouping'; +import { groupActions, groupSelectors } from '../../../store/grouping'; +import { GroupsSelector, isNoneGroup } from '../../../components/grouping'; + +export interface UseGetGroupSelectorArgs { + fields: FieldSpec[]; + groupingId: string; + tableId: TableId; +} + +export const useGetGroupingSelector = ({ + fields, + groupingId, + tableId, +}: UseGetGroupSelectorArgs) => { + const dispatch = useDispatch(); + + const getGroupByIdSelector = groupSelectors.getGroupByIdSelector(); + + const { activeGroup: selectedGroup, options } = + useSelector((state: State) => getGroupByIdSelector(state, groupingId)) ?? defaultGroup; + + const setGroupsActivePage = useCallback( + (activePage: number) => { + dispatch(groupActions.updateGroupActivePage({ id: groupingId, activePage })); + }, + [dispatch, groupingId] + ); + + const setSelectedGroup = useCallback( + (activeGroup: string) => { + dispatch(groupActions.updateActiveGroup({ id: groupingId, activeGroup })); + }, + [dispatch, groupingId] + ); + + const setOptions = useCallback( + (newOptions: GroupOption[]) => { + dispatch(groupActions.updateGroupOptions({ id: groupingId, newOptionList: newOptions })); + }, + [dispatch, groupingId] + ); + const defaultGroupingOptions = getDefaultGroupingOptions(tableId); + + useEffect(() => { + if (options.length > 0) return; + setOptions( + defaultGroupingOptions.find((o) => o.key === selectedGroup) + ? defaultGroupingOptions + : [ + ...defaultGroupingOptions, + ...(!isNoneGroup(selectedGroup) + ? [ + { + key: selectedGroup, + label: selectedGroup, + }, + ] + : []), + ] + ); + }, [defaultGroupingOptions, selectedGroup, setOptions, options]); + + const groupsSelector = useMemo( + () => ( + { + if (groupSelection === selectedGroup) { + return; + } + setGroupsActivePage(0); + setSelectedGroup(groupSelection); + + if (!isNoneGroup(groupSelection) && !options.find((o) => o.key === groupSelection)) { + setOptions([ + ...defaultGroupingOptions, + { + label: groupSelection, + key: groupSelection, + }, + ]); + } else { + setOptions(defaultGroupingOptions); + } + }} + fields={fields} + options={options} + title={'Group Alerts Selector'} + /> + ), + [ + defaultGroupingOptions, + fields, + options, + selectedGroup, + setGroupsActivePage, + setOptions, + setSelectedGroup, + ] + ); + + return groupsSelector; +}; diff --git a/x-pack/plugins/security_solution/public/common/containers/grouping/hooks/use_grouping_pagination.ts b/x-pack/plugins/security_solution/public/common/containers/grouping/hooks/use_grouping_pagination.ts new file mode 100644 index 0000000000000..c55246e39d509 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/grouping/hooks/use_grouping_pagination.ts @@ -0,0 +1,53 @@ +/* + * 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 { useDispatch, useSelector } from 'react-redux'; +import { useCallback, useMemo } from 'react'; +import { groupActions, groupSelectors } from '../../../store/grouping'; +import type { State } from '../../../store'; +import { defaultGroup } from '../../../store/grouping/defaults'; + +export interface UseGroupingPaginationArgs { + groupingId: string; +} + +export const useGroupingPagination = ({ groupingId }: UseGroupingPaginationArgs) => { + const dispatch = useDispatch(); + + const getGroupByIdSelector = groupSelectors.getGroupByIdSelector(); + + const { activePage, itemsPerPage } = + useSelector((state: State) => getGroupByIdSelector(state, groupingId)) ?? defaultGroup; + + const setGroupsActivePage = useCallback( + (newActivePage: number) => { + dispatch(groupActions.updateGroupActivePage({ id: groupingId, activePage: newActivePage })); + }, + [dispatch, groupingId] + ); + + const setGroupsItemsPerPage = useCallback( + (newItemsPerPage: number) => { + dispatch( + groupActions.updateGroupItemsPerPage({ id: groupingId, itemsPerPage: newItemsPerPage }) + ); + }, + [dispatch, groupingId] + ); + + const pagination = useMemo( + () => ({ + pageIndex: activePage, + pageSize: itemsPerPage, + onChangeItemsPerPage: setGroupsItemsPerPage, + onChangePage: setGroupsActivePage, + }), + [activePage, itemsPerPage, setGroupsActivePage, setGroupsItemsPerPage] + ); + + return pagination; +}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/__mocks__/use_data_table_filters.ts b/x-pack/plugins/security_solution/public/common/hooks/__mocks__/use_data_table_filters.ts new file mode 100644 index 0000000000000..990d8394d6224 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/__mocks__/use_data_table_filters.ts @@ -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 { UseDataTableFilters } from '../use_data_table_filters'; + +export const useDataTableFilters: jest.Mocked = jest.fn(() => ({ + showBuildingBlockAlerts: false, + showOnlyThreatIndicatorAlerts: false, + setShowBuildingBlockAlerts: jest.fn(), + setShowOnlyThreatIndicatorAlerts: jest.fn(), +})); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_data_table_filters.ts b/x-pack/plugins/security_solution/public/common/hooks/use_data_table_filters.ts new file mode 100644 index 0000000000000..b27cf49560f2a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_data_table_filters.ts @@ -0,0 +1,67 @@ +/* + * 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, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import type { TableId } from '../../../common/types'; +import { dataTableSelectors } from '../store/data_table'; +import { + updateShowBuildingBlockAlertsFilter, + updateShowThreatIndicatorAlertsFilter, +} from '../store/data_table/actions'; +import { tableDefaults } from '../store/data_table/defaults'; +import { useShallowEqualSelector } from './use_selector'; + +export type UseDataTableFilters = (tableId: TableId) => { + showBuildingBlockAlerts: boolean; + setShowBuildingBlockAlerts: (value: boolean) => void; + showOnlyThreatIndicatorAlerts: boolean; + setShowOnlyThreatIndicatorAlerts: (value: boolean) => void; +}; + +export const useDataTableFilters: UseDataTableFilters = (tableId: TableId) => { + const dispatch = useDispatch(); + + const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []); + + const { showOnlyThreatIndicatorAlerts, showBuildingBlockAlerts } = useShallowEqualSelector( + (state) => + (getTable(state, tableId) ?? tableDefaults).additionalFilters ?? + tableDefaults.additionalFilters + ); + + const setShowBuildingBlockAlerts = useCallback( + (value: boolean) => { + dispatch( + updateShowBuildingBlockAlertsFilter({ + id: tableId, + showBuildingBlockAlerts: value, + }) + ); + }, + [dispatch, tableId] + ); + + const setShowOnlyThreatIndicatorAlerts = useCallback( + (value: boolean) => { + dispatch( + updateShowThreatIndicatorAlertsFilter({ + id: tableId, + showOnlyThreatIndicatorAlerts: value, + }) + ); + }, + [dispatch, tableId] + ); + + return { + showBuildingBlockAlerts, + setShowBuildingBlockAlerts, + showOnlyThreatIndicatorAlerts, + setShowOnlyThreatIndicatorAlerts, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/triggers_actions_ui/register_alerts_table_configuration.tsx b/x-pack/plugins/security_solution/public/common/lib/triggers_actions_ui/register_alerts_table_configuration.tsx index 56a0659c637a5..5e43ca54a4898 100644 --- a/x-pack/plugins/security_solution/public/common/lib/triggers_actions_ui/register_alerts_table_configuration.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/triggers_actions_ui/register_alerts_table_configuration.tsx @@ -6,39 +6,112 @@ */ import type { Storage } from '@kbn/kibana-utils-plugin/public'; -import type { - AlertsTableConfigurationRegistryContract, - GetRenderCellValue, -} from '@kbn/triggers-actions-ui-plugin/public'; +import type { AlertsTableConfigurationRegistryContract } from '@kbn/triggers-actions-ui-plugin/public'; -import { APP_ID, CASES_FEATURE_ID } from '../../../../common/constants'; +import type { AlertsTableConfigurationRegistry } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { getUseTriggersActionsFieldBrowserOptions } from '../../../detections/hooks/trigger_actions_alert_table/use_trigger_actions_browser_fields_options'; +import { getUseCellActionsHook } from '../../../detections/hooks/trigger_actions_alert_table/use_cell_actions'; +import { getBulkActionHook } from '../../../detections/hooks/trigger_actions_alert_table/use_bulk_actions'; +import { getUseActionColumnHook } from '../../../detections/hooks/trigger_actions_alert_table/use_actions_column'; +import { getPersistentControlsHook } from '../../../detections/hooks/trigger_actions_alert_table/use_persistent_controls'; +import { + ALERTS_TABLE_REGISTRY_CONFIG_IDS, + APP_ID, + CASES_FEATURE_ID, +} from '../../../../common/constants'; import { getDataTablesInStorageByIds } from '../../../timelines/containers/local_storage'; import { TableId } from '../../../../common/types'; import { getColumns } from '../../../detections/configurations/security_solution_detections'; -import { useRenderCellValue } from '../../../detections/configurations/security_solution_detections/render_cell_value'; +import { getRenderCellValueHook } from '../../../detections/configurations/security_solution_detections/render_cell_value'; import { useToGetInternalFlyout } from '../../../timelines/components/side_panel/event_details/flyout'; +import { SourcererScopeName } from '../../store/sourcerer/model'; const registerAlertsTableConfiguration = ( registry: AlertsTableConfigurationRegistryContract, storage: Storage ) => { - if (registry.has(APP_ID)) { - return; - } const dataTableStorage = getDataTablesInStorageByIds(storage, [TableId.alertsOnAlertsPage]); const columnsFormStorage = dataTableStorage?.[TableId.alertsOnAlertsPage]?.columns ?? []; const alertColumns = columnsFormStorage.length ? columnsFormStorage : getColumns(); - registry.register({ - id: APP_ID, + const useInternalFlyout = () => { + const { header, body, footer } = useToGetInternalFlyout(); + return { header, body, footer }; + }; + + const renderCellValueHookAlertPage = getRenderCellValueHook({ + scopeId: SourcererScopeName.detections, + tableId: TableId.alertsOnAlertsPage, + }); + + const renderCellValueHookCasePage = getRenderCellValueHook({ + scopeId: SourcererScopeName.detections, + tableId: TableId.alertsOnCasePage, + }); + + const sort: AlertsTableConfigurationRegistry['sort'] = [ + { + '@timestamp': { + order: 'desc', + }, + }, + ]; + + // register Alert Table on Alert Page + registerIfNotAlready(registry, { + id: ALERTS_TABLE_REGISTRY_CONFIG_IDS.ALERTS_PAGE, + app_id: APP_ID, casesFeatureId: CASES_FEATURE_ID, columns: alertColumns, - getRenderCellValue: useRenderCellValue as GetRenderCellValue, - useInternalFlyout: () => { - const { header, body, footer } = useToGetInternalFlyout(); - return { header, body, footer }; - }, + getRenderCellValue: renderCellValueHookAlertPage, + useActionsColumn: getUseActionColumnHook(TableId.alertsOnAlertsPage), + useInternalFlyout, + useBulkActions: getBulkActionHook(TableId.alertsOnAlertsPage), + useCellActions: getUseCellActionsHook(TableId.alertsOnAlertsPage), + usePersistentControls: getPersistentControlsHook(TableId.alertsOnAlertsPage), + sort, + useFieldBrowserOptions: getUseTriggersActionsFieldBrowserOptions(SourcererScopeName.detections), + showInspectButton: true, + }); + + // register Alert Table on RuleDetails Page + registerIfNotAlready(registry, { + id: ALERTS_TABLE_REGISTRY_CONFIG_IDS.RULE_DETAILS, + app_id: APP_ID, + casesFeatureId: CASES_FEATURE_ID, + columns: alertColumns, + getRenderCellValue: renderCellValueHookAlertPage, + useActionsColumn: getUseActionColumnHook(TableId.alertsOnRuleDetailsPage), + useInternalFlyout, + useBulkActions: getBulkActionHook(TableId.alertsOnRuleDetailsPage), + useCellActions: getUseCellActionsHook(TableId.alertsOnRuleDetailsPage), + usePersistentControls: getPersistentControlsHook(TableId.alertsOnRuleDetailsPage), + sort, + useFieldBrowserOptions: getUseTriggersActionsFieldBrowserOptions(SourcererScopeName.detections), + showInspectButton: true, + }); + + registerIfNotAlready(registry, { + id: ALERTS_TABLE_REGISTRY_CONFIG_IDS.CASE, + app_id: APP_ID, + casesFeatureId: CASES_FEATURE_ID, + columns: alertColumns, + getRenderCellValue: renderCellValueHookCasePage, + useInternalFlyout, + useBulkActions: getBulkActionHook(TableId.alertsOnCasePage), + useCellActions: getUseCellActionsHook(TableId.alertsOnCasePage), + sort, + showInspectButton: true, }); }; +const registerIfNotAlready = ( + registry: AlertsTableConfigurationRegistryContract, + registryArgs: AlertsTableConfigurationRegistry +) => { + if (!registry.has(registryArgs.id)) { + registry.register(registryArgs); + } +}; + export { registerAlertsTableConfiguration }; diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index f243f21055865..e02dab98bac02 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -28,6 +28,7 @@ import { DEFAULT_INDEX_PATTERN, DEFAULT_DATA_VIEW_ID, DEFAULT_SIGNALS_INDEX, + VIEW_SELECTION, } from '../../../common/constants'; import { networkModel } from '../../explore/network/store'; import { @@ -44,6 +45,7 @@ import { getScopePatternListSelection } from '../store/sourcerer/helpers'; import { mockBrowserFields, mockIndexFields, mockRuntimeMappings } from '../containers/source/mock'; import { usersModel } from '../../explore/users/store'; import { UsersFields } from '../../../common/search_strategy/security_solution/users/common'; +import { defaultGroup } from '../store/grouping/defaults'; export const mockSourcererState = { ...initialSourcererState, @@ -405,6 +407,18 @@ export const mockGlobalState: State = { isLoading: false, queryFields: [], totalCount: 0, + viewMode: VIEW_SELECTION.gridView, + additionalFilters: { + showBuildingBlockAlerts: false, + showOnlyThreatIndicatorAlerts: false, + }, + }, + }, + }, + groups: { + groupById: { + testing: { + ...defaultGroup, }, }, }, diff --git a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts index 6032df5a04756..fcf34596e7ca9 100644 --- a/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts +++ b/x-pack/plugins/security_solution/public/common/mock/timeline_results.ts @@ -7,6 +7,7 @@ import { FilterStateStore } from '@kbn/es-query'; +import { VIEW_SELECTION } from '../../../common/constants'; import type { TimelineResult } from '../../../common/types/timeline'; import { TimelineId, @@ -2075,6 +2076,11 @@ export const mockDataTableModel: DataTableModel = { showCheckboxes: false, selectAll: false, totalCount: 0, + viewMode: VIEW_SELECTION.gridView, + additionalFilters: { + showOnlyThreatIndicatorAlerts: false, + showBuildingBlockAlerts: false, + }, }; export const mockGetOneTimelineResult: TimelineResult = { diff --git a/x-pack/plugins/security_solution/public/common/store/data_table/actions.ts b/x-pack/plugins/security_solution/public/common/store/data_table/actions.ts index 9850aa5fc3eea..6ed2b5252e309 100644 --- a/x-pack/plugins/security_solution/public/common/store/data_table/actions.ts +++ b/x-pack/plugins/security_solution/public/common/store/data_table/actions.ts @@ -9,7 +9,7 @@ import actionCreatorFactory from 'typescript-fsa'; import type { SessionViewConfig } from '../../../../common/types/session_view'; import type { ExpandedDetailType } from '../../../../common/types/detail_panel'; import type { TimelineNonEcsData } from '../../../../common/search_strategy'; -import type { ColumnHeaderOptions, SortColumnTable } from '../../../../common/types'; +import type { ColumnHeaderOptions, SortColumnTable, ViewSelection } from '../../../../common/types'; import type { InitialyzeDataTableSettings, DataTablePersistInput } from './types'; const actionCreator = actionCreatorFactory('x-pack/security_solution/data-table'); @@ -126,3 +126,18 @@ export const setTableUpdatedAt = actionCreator<{ id: string; updated: number }>( export const updateTotalCount = actionCreator<{ id: string; totalCount: number }>( 'UPDATE_TOTAL_COUNT' ); + +export const changeViewMode = actionCreator<{ + id: string; + viewMode: ViewSelection; +}>('CHANGE_ALERT_TABLE_VIEW_MODE'); + +export const updateShowBuildingBlockAlertsFilter = actionCreator<{ + id: string; + showBuildingBlockAlerts: boolean; +}>('UPDATE_BUILDING_BLOCK_ALERTS_FILTER'); + +export const updateShowThreatIndicatorAlertsFilter = actionCreator<{ + id: string; + showOnlyThreatIndicatorAlerts: boolean; +}>('UPDATE_SHOW_THREAT_INDICATOR_ALERTS_FILTER'); diff --git a/x-pack/plugins/security_solution/public/common/store/data_table/defaults.ts b/x-pack/plugins/security_solution/public/common/store/data_table/defaults.ts index 4e50a74c31b56..7b7a07f6ba3c6 100644 --- a/x-pack/plugins/security_solution/public/common/store/data_table/defaults.ts +++ b/x-pack/plugins/security_solution/public/common/store/data_table/defaults.ts @@ -14,6 +14,7 @@ import * as i18n from './translations'; export const defaultColumnHeaderType: ColumnHeaderType = 'not-filtered'; +import { VIEW_SELECTION } from '../../../../common/constants'; export const defaultHeaders: ColumnHeaderOptions[] = [ { columnHeaderType: defaultColumnHeaderType, @@ -88,6 +89,11 @@ export const tableDefaults: SubsetDataTableModel = { queryFields: [], title: '', totalCount: 0, + viewMode: VIEW_SELECTION.gridView, + additionalFilters: { + showBuildingBlockAlerts: false, + showOnlyThreatIndicatorAlerts: false, + }, }; export const getDataTableManageDefaults = (id: string) => ({ diff --git a/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts b/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts index 65123067f4cc4..52ecd7d69c866 100644 --- a/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts +++ b/x-pack/plugins/security_solution/public/common/store/data_table/epic_local_storage.ts @@ -10,6 +10,7 @@ import { map, filter, ignoreElements, tap, withLatestFrom, delay } from 'rxjs/op import type { Epic } from 'redux-observable'; import { get } from 'lodash/fp'; +import { updateTotalCount } from '../../../timelines/store/timeline/actions'; import type { TableIdLiteral } from '../../../../common/types'; import { addTableInStorage } from '../../../timelines/containers/local_storage'; @@ -22,6 +23,9 @@ import { updateColumnWidth, updateItemsPerPage, updateSort, + changeViewMode, + updateShowBuildingBlockAlertsFilter, + updateIsLoading, } from './actions'; import type { TimelineEpicDependencies } from '../../../timelines/store/timeline/types'; @@ -36,6 +40,10 @@ const tableActionTypes = [ updateColumnWidth.type, updateItemsPerPage.type, updateSort.type, + changeViewMode.type, + updateShowBuildingBlockAlertsFilter.type, + updateTotalCount.type, + updateIsLoading.type, ]; export const createDataTableLocalStorageEpic = diff --git a/x-pack/plugins/security_solution/public/common/store/data_table/model.ts b/x-pack/plugins/security_solution/public/common/store/data_table/model.ts index 201971cb07e8b..d9f1f6f30a159 100644 --- a/x-pack/plugins/security_solution/public/common/store/data_table/model.ts +++ b/x-pack/plugins/security_solution/public/common/store/data_table/model.ts @@ -10,7 +10,7 @@ import type { Filter } from '@kbn/es-query'; import type { ExpandedDetail } from '../../../../common/types/detail_panel'; import type { SessionViewConfig } from '../../../../common/types/session_view'; import type { TimelineNonEcsData } from '../../../../common/search_strategy'; -import type { ColumnHeaderOptions, SortColumnTable } from '../../../../common/types'; +import type { ColumnHeaderOptions, SortColumnTable, ViewSelection } from '../../../../common/types'; export interface DataTableModelSettings { defaultColumns: Array< @@ -27,6 +27,9 @@ export interface DataTableModelSettings { title: string; unit?: (n: number) => string | React.ReactNode; } + +export type AlertPageFilterType = 'showOnlyThreatIndicatorAlerts' | 'showBuildingBlockAlerts'; + export interface DataTableModel extends DataTableModelSettings { /** The columns displayed in the data table */ columns: Array< @@ -62,6 +65,10 @@ export interface DataTableModel extends DataTableModelSettings { updated?: number; /** Total number of fetched events/alerts */ totalCount: number; + /* viewMode of the table */ + viewMode: ViewSelection; + /* custom filters applicable to */ + additionalFilters: Record; } export type SubsetDataTableModel = Readonly< @@ -89,5 +96,7 @@ export type SubsetDataTableModel = Readonly< | 'initialized' | 'selectAll' | 'totalCount' + | 'viewMode' + | 'additionalFilters' > >; diff --git a/x-pack/plugins/security_solution/public/common/store/data_table/reducer.ts b/x-pack/plugins/security_solution/public/common/store/data_table/reducer.ts index 77f3bcf5b2c9a..9ed7bbe38a726 100644 --- a/x-pack/plugins/security_solution/public/common/store/data_table/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/data_table/reducer.ts @@ -31,6 +31,9 @@ import { updateSessionViewConfig, setTableUpdatedAt, updateTotalCount, + changeViewMode, + updateShowBuildingBlockAlertsFilter, + updateShowThreatIndicatorAlertsFilter, } from './actions'; import { @@ -83,19 +86,21 @@ export const dataTableReducer = reducerWithInitialState(initialDataTableState) dataTableSettingsProps, }), })) - .case(toggleDetailPanel, (state, action) => ({ - ...state, - tableById: { - ...state.tableById, - [action.id]: { - ...state.tableById[action.id], - expandedDetail: { - ...state.tableById[action.id].expandedDetail, - ...updateTableDetailsPanel(action), + .case(toggleDetailPanel, (state, action) => { + return { + ...state, + tableById: { + ...state.tableById, + [action.id]: { + ...state.tableById[action.id], + expandedDetail: { + ...state.tableById[action.id].expandedDetail, + ...updateTableDetailsPanel(action), + }, }, }, - }, - })) + }; + }) .case(applyDeltaToColumnWidth, (state, { id, columnId, delta }) => ({ ...state, tableById: applyDeltaToTableColumnWidth({ @@ -269,4 +274,40 @@ export const dataTableReducer = reducerWithInitialState(initialDataTableState) }, }, })) + .case(changeViewMode, (state, { id, viewMode }) => ({ + ...state, + tableById: { + ...state.tableById, + [id]: { + ...state.tableById[id], + viewMode, + }, + }, + })) + .case(updateShowBuildingBlockAlertsFilter, (state, { id, showBuildingBlockAlerts }) => ({ + ...state, + tableById: { + ...state.tableById, + [id]: { + ...state.tableById[id], + additionalFilters: { + ...state.tableById[id].additionalFilters, + showBuildingBlockAlerts, + }, + }, + }, + })) + .case(updateShowThreatIndicatorAlertsFilter, (state, { id, showOnlyThreatIndicatorAlerts }) => ({ + ...state, + tableById: { + ...state.tableById, + [id]: { + ...state.tableById[id], + additionalFilters: { + ...state.tableById[id].additionalFilters, + showOnlyThreatIndicatorAlerts, + }, + }, + }, + })) .build(); diff --git a/x-pack/plugins/security_solution/public/common/store/epic.ts b/x-pack/plugins/security_solution/public/common/store/epic.ts index e79a85f429dc4..971449f330b12 100644 --- a/x-pack/plugins/security_solution/public/common/store/epic.ts +++ b/x-pack/plugins/security_solution/public/common/store/epic.ts @@ -15,6 +15,7 @@ import { createTimelineNoteEpic } from '../../timelines/store/timeline/epic_note import { createTimelinePinnedEventEpic } from '../../timelines/store/timeline/epic_pinned_event'; import type { TimelineEpicDependencies } from '../../timelines/store/timeline/types'; import { createDataTableLocalStorageEpic } from './data_table/epic_local_storage'; +import { createGroupingLocalStorageEpic } from './grouping/epic_local_storage_epic'; export const createRootEpic = (): Epic< Action, @@ -27,5 +28,6 @@ export const createRootEpic = (): Epic< createTimelineFavoriteEpic(), createTimelineNoteEpic(), createTimelinePinnedEventEpic(), - createDataTableLocalStorageEpic() + createDataTableLocalStorageEpic(), + createGroupingLocalStorageEpic() ); diff --git a/x-pack/plugins/security_solution/public/common/store/grouping/actions.ts b/x-pack/plugins/security_solution/public/common/store/grouping/actions.ts new file mode 100644 index 0000000000000..3c81245269996 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/grouping/actions.ts @@ -0,0 +1,35 @@ +/* + * 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 actionCreatorFactory from 'typescript-fsa'; +import type { GroupOption } from './types'; + +const actionCreator = actionCreatorFactory('x-pack/security_solution/groups'); + +export const updateActiveGroup = actionCreator<{ + id: string; + activeGroup: string; +}>('UPDATE_ACTIVE_GROUP'); + +export const updateGroupActivePage = actionCreator<{ + id: string; + activePage: number; +}>('UPDATE_GROUP_ACTIVE_PAGE'); + +export const updateGroupItemsPerPage = actionCreator<{ + id: string; + itemsPerPage: number; +}>('UPDATE_GROUP_ITEMS_PER_PAGE'); + +export const updateGroupOptions = actionCreator<{ + id: string; + newOptionList: GroupOption[]; +}>('UPDATE_GROUP_OPTIONS'); + +export const initGrouping = actionCreator<{ + id: string; +}>('INIT_GROUPING'); diff --git a/x-pack/plugins/security_solution/public/common/store/grouping/constants.ts b/x-pack/plugins/security_solution/public/common/store/grouping/constants.ts new file mode 100644 index 0000000000000..f1b24ed4d176f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/grouping/constants.ts @@ -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. + */ + +import type { GroupsById } from './types'; + +export const EMPTY_GROUP_BY_ID: GroupsById = {}; diff --git a/x-pack/plugins/security_solution/public/common/store/grouping/defaults.ts b/x-pack/plugins/security_solution/public/common/store/grouping/defaults.ts new file mode 100644 index 0000000000000..7f1c492f98e0e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/grouping/defaults.ts @@ -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 { GroupModel } from './types'; + +export const defaultGroup: GroupModel = { + activePage: 0, + itemsPerPage: 25, + activeGroup: 'none', + options: [], +}; diff --git a/x-pack/plugins/security_solution/public/common/store/grouping/epic_local_storage_epic.ts b/x-pack/plugins/security_solution/public/common/store/grouping/epic_local_storage_epic.ts new file mode 100644 index 0000000000000..06ceb7e3683a3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/grouping/epic_local_storage_epic.ts @@ -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 type { Action } from 'redux'; +import { map, filter, ignoreElements, tap, withLatestFrom, delay } from 'rxjs/operators'; +import type { Epic } from 'redux-observable'; +import { get } from 'lodash/fp'; + +import { + updateGroupOptions, + updateActiveGroup, + updateGroupItemsPerPage, + updateGroupActivePage, + initGrouping, +} from './actions'; +import type { TimelineEpicDependencies } from '../../../timelines/store/timeline/types'; +import { addGroupsToStorage } from '../../../timelines/containers/local_storage/groups'; + +export const isNotNull = (value: T | null): value is T => value !== null; + +const groupingActionTypes = [ + updateActiveGroup.type, + updateGroupActivePage.type, + updateGroupItemsPerPage.type, + updateGroupOptions.type, + initGrouping.type, +]; + +export const createGroupingLocalStorageEpic = + (): Epic> => + (action$, state$, { groupByIdSelector, storage }) => { + const group$ = state$.pipe(map(groupByIdSelector), filter(isNotNull)); + return action$.pipe( + delay(500), + withLatestFrom(group$), + tap(([action, groupById]) => { + if (groupingActionTypes.includes(action.type)) { + if (storage) { + const groupId: string = get('payload.id', action); + addGroupsToStorage(storage, groupId, groupById[groupId]); + } + } + }), + ignoreElements() + ); + }; diff --git a/x-pack/plugins/security_solution/public/common/store/grouping/index.ts b/x-pack/plugins/security_solution/public/common/store/grouping/index.ts new file mode 100644 index 0000000000000..60367c517ea16 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/grouping/index.ts @@ -0,0 +1,19 @@ +/* + * 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 { AnyAction, CombinedState, Reducer } from 'redux'; +import * as groupActions from './actions'; +import * as groupSelectors from './selectors'; +import type { GroupState } from './types'; + +export * from './types'; + +export { groupActions, groupSelectors }; + +export interface GroupsReducer { + groups: Reducer, AnyAction>; +} diff --git a/x-pack/plugins/security_solution/public/common/store/grouping/reducer.ts b/x-pack/plugins/security_solution/public/common/store/grouping/reducer.ts new file mode 100644 index 0000000000000..33ad322ed3f8c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/grouping/reducer.ts @@ -0,0 +1,75 @@ +/* + * 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 { reducerWithInitialState } from 'typescript-fsa-reducers'; +import { + initGrouping, + updateActiveGroup, + updateGroupActivePage, + updateGroupItemsPerPage, + updateGroupOptions, +} from './actions'; +import { EMPTY_GROUP_BY_ID } from './constants'; +import { defaultGroup } from './defaults'; +import type { GroupMap } from './types'; + +const initialGroupState: GroupMap = { + groupById: EMPTY_GROUP_BY_ID, +}; + +export const groupsReducer = reducerWithInitialState(initialGroupState) + .case(updateActiveGroup, (state, { id, activeGroup }) => ({ + ...state, + groupById: { + ...state.groupById, + [id]: { + ...state.groupById[id], + activeGroup, + }, + }, + })) + .case(updateGroupActivePage, (state, { id, activePage }) => ({ + ...state, + groupById: { + ...state.groupById, + [id]: { + ...state.groupById[id], + activePage, + }, + }, + })) + + .case(updateGroupItemsPerPage, (state, { id, itemsPerPage }) => ({ + ...state, + groupById: { + ...state.groupById, + [id]: { + ...state.groupById[id], + itemsPerPage, + }, + }, + })) + .case(updateGroupOptions, (state, { id, newOptionList }) => ({ + ...state, + groupById: { + ...state.groupById, + [id]: { + ...state.groupById[id], + options: newOptionList, + }, + }, + })) + .case(initGrouping, (state, { id }) => ({ + ...state, + groupById: { + ...state.groupById, + [id]: { + ...defaultGroup, + ...state.groupById[id], + }, + }, + })); diff --git a/x-pack/plugins/security_solution/public/common/store/grouping/selectors.ts b/x-pack/plugins/security_solution/public/common/store/grouping/selectors.ts new file mode 100644 index 0000000000000..02ad1e476253c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/grouping/selectors.ts @@ -0,0 +1,21 @@ +/* + * 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 { createSelector } from 'reselect'; +import type { GroupModel, GroupsById, GroupState } from './types'; + +const selectGroupByEntityId = (state: GroupState): GroupsById => state.groups.groupById; + +export const groupByIdSelector = createSelector( + selectGroupByEntityId, + (groupsByEntityId) => groupsByEntityId +); + +export const selectGroup = (state: GroupState, entityId: string): GroupModel => + state.groups.groupById[entityId]; + +export const getGroupByIdSelector = () => createSelector(selectGroup, (group) => group); diff --git a/x-pack/plugins/security_solution/public/common/store/grouping/types.ts b/x-pack/plugins/security_solution/public/common/store/grouping/types.ts new file mode 100644 index 0000000000000..7d5c91f208353 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/store/grouping/types.ts @@ -0,0 +1,30 @@ +/* + * 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 GroupOption { + key: string; + label: string; +} + +export interface GroupModel { + activeGroup: string; + options: GroupOption[]; + activePage: number; + itemsPerPage: number; +} + +export interface GroupsById { + [id: string]: GroupModel; +} + +export interface GroupMap { + groupById: GroupsById; +} + +export interface GroupState { + groups: GroupMap; +} diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.test.ts b/x-pack/plugins/security_solution/public/common/store/reducer.test.ts index 63ec63d672df5..ab4f12956599b 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.test.ts @@ -38,9 +38,16 @@ describe('createInitialState', () => { kibanaDataViews: [mockSourcererState.defaultDataView], signalIndexName: 'siem-signals-default', }; - const initState = createInitialState(mockPluginState, defaultState, { - dataTable: { tableById: {} }, - }); + const initState = createInitialState( + mockPluginState, + defaultState, + { + dataTable: { tableById: {} }, + }, + { + groups: { groupById: {} }, + } + ); beforeEach(() => { (useDeepEqualSelector as jest.Mock).mockImplementation((cb) => cb(initState)); }); @@ -73,6 +80,11 @@ describe('createInitialState', () => { dataTable: { tableById: {}, }, + }, + { + groups: { + groupById: {}, + }, } ); (useDeepEqualSelector as jest.Mock).mockImplementation((cb) => cb(state)); diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.ts b/x-pack/plugins/security_solution/public/common/store/reducer.ts index ae75e32ce5958..767bed3df7954 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.ts @@ -29,6 +29,8 @@ import { getScopePatternListSelection } from './sourcerer/helpers'; import { globalUrlParamReducer, initialGlobalUrlParam } from './global_url_param'; import type { DataTableState } from './data_table/types'; import { dataTableReducer } from './data_table/reducer'; +import { groupsReducer } from './grouping/reducer'; +import type { GroupState } from './grouping/types'; export type SubPluginsInitReducer = HostsPluginReducer & UsersPluginReducer & @@ -54,7 +56,8 @@ export const createInitialState = ( signalIndexName: SourcererModel['signalIndexName']; enableExperimental: ExperimentalFeatures; }, - dataTableState: DataTableState + dataTableState: DataTableState, + groupsState: GroupState ): State => { const initialPatterns = { [SourcererScopeName.default]: getScopePatternListSelection( @@ -108,6 +111,7 @@ export const createInitialState = ( }, globalUrlParam: initialGlobalUrlParam, dataTable: dataTableState.dataTable, + groups: groupsState.groups, }; return preloadedState; @@ -126,5 +130,6 @@ export const createReducer: ( sourcerer: sourcererReducer, globalUrlParam: globalUrlParamReducer, dataTable: dataTableReducer, + groups: groupsReducer, ...pluginsReducer, }); diff --git a/x-pack/plugins/security_solution/public/common/store/store.ts b/x-pack/plugins/security_solution/public/common/store/store.ts index 6307fb556251d..cae84f2954d2d 100644 --- a/x-pack/plugins/security_solution/public/common/store/store.ts +++ b/x-pack/plugins/security_solution/public/common/store/store.ts @@ -47,6 +47,8 @@ import { initDataView } from './sourcerer/model'; import type { AppObservableLibs, StartedSubPlugins, StartPlugins } from '../../types'; import type { ExperimentalFeatures } from '../../../common/experimental_features'; import { createSourcererDataView } from '../containers/sourcerer/create_sourcerer_data_view'; +import type { GroupState } from './grouping/types'; +import { groupSelectors } from './grouping'; type ComposeType = typeof compose; declare global { @@ -127,6 +129,15 @@ export const createStoreFactory = async ( }, }; + const groupsInitialState: GroupState = { + groups: { + groupById: { + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + ...subPlugins.alerts.groups!.groupById, + }, + }, + }; + const timelineReducer = reduceReducers( timelineInitialState.timeline, startPlugins.timelines?.getTimelineReducer() ?? {}, @@ -145,7 +156,8 @@ export const createStoreFactory = async ( signalIndexName: signal.name, enableExperimental, }, - dataTableInitialState + dataTableInitialState, + groupsInitialState ); const rootReducer = { @@ -178,6 +190,7 @@ export const createStore = ( timelineByIdSelector: timelineSelectors.timelineByIdSelector, timelineTimeRangeSelector: inputsSelectors.timelineTimeRangeSelector, tableByIdSelector: dataTableSelectors.tableByIdSelector, + groupByIdSelector: groupSelectors.groupByIdSelector, storage, }; diff --git a/x-pack/plugins/security_solution/public/common/store/types.ts b/x-pack/plugins/security_solution/public/common/store/types.ts index 4229d4d6e3ca1..4cfe92b7425f1 100644 --- a/x-pack/plugins/security_solution/public/common/store/types.ts +++ b/x-pack/plugins/security_solution/public/common/store/types.ts @@ -22,6 +22,7 @@ import type { ManagementPluginState } from '../../management'; import type { UsersPluginState } from '../../explore/users/store'; import type { GlobalUrlParam } from './global_url_param'; import type { DataTableState } from './data_table/types'; +import type { GroupState } from './grouping/types'; export type State = HostsPluginState & UsersPluginState & @@ -34,8 +35,8 @@ export type State = HostsPluginState & inputs: InputsState; sourcerer: SourcererState; globalUrlParam: GlobalUrlParam; - } & DataTableState; - + } & DataTableState & + GroupState; /** * The Redux store type for the Security app. */ diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.test.tsx index ee0b719169d5f..28ed5a658558d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.test.tsx @@ -64,6 +64,9 @@ jest.mock('../../../../common/containers/sourcerer', () => { .mockReturnValue({ indexPattern: ['fakeindex'], loading: false }), }; }); + +jest.mock('../../../../common/hooks/use_data_table_filters'); + jest.mock('../../../../common/containers/use_global_time', () => ({ useGlobalTime: jest.fn().mockReturnValue({ from: '2020-07-07T08:20:18.966Z', diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx index 4ebd2d2aa6f49..5a71b73d719ff 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_details_ui/pages/rule_details/index.tsx @@ -17,6 +17,7 @@ import { EuiToolTip, EuiWindowEvent, } from '@elastic/eui'; +import type { Filter } from '@kbn/es-query'; import { i18n as i18nTranslate } from '@kbn/i18n'; import { Route } from '@kbn/shared-ux-router'; @@ -32,6 +33,9 @@ import type { Dispatch } from 'redux'; import { isTab } from '@kbn/timelines-plugin/public'; import type { DataViewListItem } from '@kbn/data-views-plugin/common'; +import { AlertsTableComponent } from '../../../../detections/components/alerts_table'; +import { GroupedAlertsTable } from '../../../../detections/components/alerts_table/grouped_alerts'; +import { useDataTableFilters } from '../../../../common/hooks/use_data_table_filters'; import { FILTER_OPEN, TableId } from '../../../../../common/types'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { TabNavigationWithBreadcrumbs } from '../../../../common/components/navigation/tab_navigation_with_breadcrumbs'; @@ -57,7 +61,6 @@ import { useListsConfig } from '../../../../detections/containers/detection_engi import { SpyRoute } from '../../../../common/utils/route/spy_routes'; import { StepAboutRuleToggleDetails } from '../../../../detections/components/rules/step_about_rule_details'; import { AlertsHistogramPanel } from '../../../../detections/components/alerts_kpis/alerts_histogram_panel'; -import { AlertsTable } from '../../../../detections/components/alerts_table'; import { useUserData } from '../../../../detections/components/user_info'; import { StepDefineRule } from '../../../../detections/components/rules/step_define_rule'; import { StepScheduleRule } from '../../../../detections/components/rules/step_schedule_rule'; @@ -82,6 +85,7 @@ import { hasMlAdminPermissions } from '../../../../../common/machine_learning/ha import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license'; import { SecurityPageName } from '../../../../app/types'; import { + ALERTS_TABLE_REGISTRY_CONFIG_IDS, APP_UI_ID, DEFAULT_INDEX_KEY, DEFAULT_THREAT_INDEX_KEY, @@ -185,6 +189,7 @@ const RuleDetailsPageComponent: React.FC = ({ const dispatch = useDispatch(); const containerElement = useRef(null); const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []); + const graphEventId = useShallowEqualSelector( (state) => (getTable(state, TableId.alertsOnRuleDetailsPage) ?? tableDefaults).graphEventId ); @@ -192,7 +197,7 @@ const RuleDetailsPageComponent: React.FC = ({ (state) => (getTable(state, TableId.alertsOnRuleDetailsPage) ?? tableDefaults).updated ); const isAlertsLoading = useShallowEqualSelector( - (state) => (getTable(state, TableId.alertsOnAlertsPage) ?? tableDefaults).isLoading + (state) => (getTable(state, TableId.alertsOnRuleDetailsPage) ?? tableDefaults).isLoading ); const getGlobalFiltersQuerySelector = useMemo( () => inputsSelectors.globalFiltersQuerySelector(), @@ -210,10 +215,10 @@ const RuleDetailsPageComponent: React.FC = ({ isAuthenticated, hasEncryptionKey, canUserCRUD, - hasIndexWrite, hasIndexRead, - hasIndexMaintenance, signalIndexName, + hasIndexWrite, + hasIndexMaintenance, }, ] = useUserData(); const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } = @@ -235,6 +240,7 @@ const RuleDetailsPageComponent: React.FC = ({ loading: ruleLoading, isExistingRule, } = useRuleWithFallback(ruleId); + const { pollForSignalIndex } = useSignalHelpers(); const [rule, setRule] = useState(null); const isLoading = ruleLoading && rule == null; @@ -301,11 +307,14 @@ const RuleDetailsPageComponent: React.FC = ({ }; fetchDataViewTitle(); }, [data.dataViews, defineRuleData?.dataViewId]); - const [showBuildingBlockAlerts, setShowBuildingBlockAlerts] = useState(false); - const [showOnlyThreatIndicatorAlerts, setShowOnlyThreatIndicatorAlerts] = useState(false); + + const { showBuildingBlockAlerts, setShowBuildingBlockAlerts, showOnlyThreatIndicatorAlerts } = + useDataTableFilters(TableId.alertsOnRuleDetailsPage); + const mlCapabilities = useMlCapabilities(); const { globalFullScreen } = useGlobalFullScreen(); const [filterGroup, setFilterGroup] = useState(FILTER_OPEN); + const [dataViewOptions, setDataViewOptions] = useState<{ [x: string]: DataViewListItem }>({}); const { isSavedQueryLoading, savedQueryBar } = useGetSavedQuery(rule?.saved_id, { @@ -503,7 +512,7 @@ const RuleDetailsPageComponent: React.FC = ({ // Set showBuildingBlockAlerts if rule is a Building Block Rule otherwise we won't show alerts useEffect(() => { setShowBuildingBlockAlerts(rule?.building_block_type != null); - }, [rule]); + }, [rule, setShowBuildingBlockAlerts]); const alertDefaultFilters = useMemo( () => [ @@ -515,15 +524,6 @@ const RuleDetailsPageComponent: React.FC = ({ [rule, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, filterGroup] ); - const alertsTableDefaultFilters = useMemo( - () => [ - ...buildAlertsFilter(rule?.rule_id ?? ''), - ...buildShowBuildingBlockFilter(showBuildingBlockAlerts), - ...buildThreatMatchFilter(showOnlyThreatIndicatorAlerts), - ], - [rule, showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts] - ); - const alertMergedFilters = useMemo( () => [...alertDefaultFilters, ...filters], [alertDefaultFilters, filters] @@ -588,20 +588,6 @@ const RuleDetailsPageComponent: React.FC = ({ setRule((currentRule) => (currentRule ? { ...currentRule, enabled } : currentRule)); }, []); - const onShowBuildingBlockAlertsChangedCallback = useCallback( - (newShowBuildingBlockAlerts: boolean) => { - setShowBuildingBlockAlerts(newShowBuildingBlockAlerts); - }, - [setShowBuildingBlockAlerts] - ); - - const onShowOnlyThreatIndicatorAlertsCallback = useCallback( - (newShowOnlyThreatIndicatorAlerts: boolean) => { - setShowOnlyThreatIndicatorAlerts(newShowOnlyThreatIndicatorAlerts); - }, - [setShowOnlyThreatIndicatorAlerts] - ); - const onSkipFocusBeforeEventsTable = useCallback(() => { focusUtilityBarAction(containerElement.current); }, [containerElement]); @@ -624,6 +610,21 @@ const RuleDetailsPageComponent: React.FC = ({ [containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable] ); + const renderGroupedAlertTable = useCallback( + (groupingFilters: Filter[]) => { + return ( + + ); + }, + [alertMergedFilters, refreshRule] + ); + const { isBulkDuplicateConfirmationVisible, showBulkDuplicateConfirmation, @@ -838,24 +839,18 @@ const RuleDetailsPageComponent: React.FC = ({ {ruleId != null && ( - )} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouped_alerts.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouped_alerts.tsx new file mode 100644 index 0000000000000..2e4576576dc60 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouped_alerts.tsx @@ -0,0 +1,294 @@ +/* + * 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 { isEmpty } from 'lodash/fp'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; +import type { ConnectedProps } from 'react-redux'; +import { connect, useDispatch, useSelector } from 'react-redux'; +import { v4 as uuidv4 } from 'uuid'; +import type { Filter } from '@kbn/es-query'; +import { buildEsQuery } from '@kbn/es-query'; +import { getEsQueryConfig } from '@kbn/data-plugin/common'; +import type { ReactNode } from 'react-markdown'; +import { useGetGroupingSelector } from '../../../common/containers/grouping/hooks/use_get_group_selector'; +import type { Status } from '../../../../common/detection_engine/schemas/common'; +import { defaultGroup } from '../../../common/store/grouping/defaults'; +import { groupSelectors } from '../../../common/store/grouping'; +import { InspectButton } from '../../../common/components/inspect'; +import { defaultUnit } from '../../../common/components/toolbar/unit'; +import type { + GroupingFieldTotalAggregation, + GroupingTableAggregation, + RawBucket, +} from '../../../common/components/grouping'; +import { GroupingContainer, isNoneGroup } from '../../../common/components/grouping'; +import { useGlobalTime } from '../../../common/containers/use_global_time'; +import { combineQueries } from '../../../common/lib/kuery'; +import type { TableIdLiteral } from '../../../../common/types'; +import { useSourcererDataView } from '../../../common/containers/sourcerer'; +import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; +import { useKibana } from '../../../common/lib/kibana'; +import type { inputsModel, State } from '../../../common/store'; +import { inputsSelectors } from '../../../common/store'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { useInspectButton } from '../alerts_kpis/common/hooks'; + +import { buildTimeRangeFilter } from './helpers'; +import * as i18n from './translations'; +import { useQueryAlerts } from '../../containers/detection_engine/alerts/use_query'; +import { ALERTS_QUERY_NAMES } from '../../containers/detection_engine/alerts/constants'; +import { + getAlertsGroupingQuery, + getSelectedGroupBadgeMetrics, + getSelectedGroupButtonContent, + getSelectedGroupCustomMetrics, + useGroupTakeActionsItems, +} from './grouping_settings'; +import { initGrouping } from '../../../common/store/grouping/actions'; +import { useGroupingPagination } from '../../../common/containers/grouping/hooks/use_grouping_pagination'; + +/** This local storage key stores the `Grid / Event rendered view` selection */ +export const ALERTS_TABLE_GROUPS_SELECTION_KEY = 'securitySolution.alerts.table.group-selection'; + +const ALERTS_GROUPING_ID = 'alerts-grouping'; + +interface OwnProps { + defaultFilters?: Filter[]; + from: string; + hasIndexMaintenance: boolean; + hasIndexWrite: boolean; + loading: boolean; + tableId: TableIdLiteral; + to: string; + runtimeMappings: MappingRuntimeFields; + signalIndexName: string | null; + currentAlertStatusFilterValue?: Status; + renderChildComponent: (groupingFilters: Filter[]) => ReactNode; +} + +type AlertsTableComponentProps = OwnProps & PropsFromRedux; + +export const GroupedAlertsTableComponent: React.FC = ({ + defaultFilters = [], + from, + globalFilters, + globalQuery, + hasIndexMaintenance, + hasIndexWrite, + loading, + tableId, + to, + runtimeMappings, + signalIndexName, + currentAlertStatusFilterValue, + renderChildComponent, +}) => { + const dispatch = useDispatch(); + const groupingId = tableId; + + const getGroupbyIdSelector = groupSelectors.getGroupByIdSelector(); + + const { activeGroup: selectedGroup } = + useSelector((state: State) => getGroupbyIdSelector(state, groupingId)) ?? defaultGroup; + + const { + browserFields, + indexPattern: indexPatterns, + selectedPatterns, + } = useSourcererDataView(SourcererScopeName.detections); + const kibana = useKibana(); + + const getGlobalQuery = useCallback( + (customFilters: Filter[]) => { + if (browserFields != null && indexPatterns != null) { + return combineQueries({ + config: getEsQueryConfig(kibana.services.uiSettings), + dataProviders: [], + indexPattern: indexPatterns, + browserFields, + filters: [ + ...(defaultFilters ?? []), + ...globalFilters, + ...customFilters, + ...buildTimeRangeFilter(from, to), + ], + kqlQuery: globalQuery, + kqlMode: globalQuery.language, + }); + } + return null; + }, + [browserFields, defaultFilters, globalFilters, globalQuery, indexPatterns, kibana, to, from] + ); + + useInvalidFilterQuery({ + id: tableId, + filterQuery: getGlobalQuery([])?.filterQuery, + kqlError: getGlobalQuery([])?.kqlError, + query: globalQuery, + startDate: from, + endDate: to, + }); + + useEffect(() => { + dispatch(initGrouping({ id: tableId })); + }, [dispatch, tableId]); + + const { deleteQuery, setQuery } = useGlobalTime(false); + // create a unique, but stable (across re-renders) query id + const uniqueQueryId = useMemo(() => `${ALERTS_GROUPING_ID}-${uuidv4()}`, []); + + const additionalFilters = useMemo(() => { + try { + return [ + buildEsQuery(undefined, globalQuery != null ? [globalQuery] : [], [ + ...(globalFilters?.filter((f) => f.meta.disabled === false) ?? []), + ...(defaultFilters ?? []), + ]), + ]; + } catch (e) { + return []; + } + }, [defaultFilters, globalFilters, globalQuery]); + + const pagination = useGroupingPagination({ + groupingId, + }); + + const queryGroups = useMemo( + () => + getAlertsGroupingQuery({ + additionalFilters, + selectedGroup, + from, + runtimeMappings, + to, + pageSize: pagination.pageSize, + pageIndex: pagination.pageIndex, + }), + [ + additionalFilters, + selectedGroup, + from, + runtimeMappings, + to, + pagination.pageSize, + pagination.pageIndex, + ] + ); + + const { + data: alertsGroupsData, + loading: isLoadingGroups, + refetch, + request, + response, + setQuery: setAlertsQuery, + } = useQueryAlerts<{}, GroupingTableAggregation & GroupingFieldTotalAggregation>({ + query: queryGroups, + indexName: signalIndexName, + queryName: ALERTS_QUERY_NAMES.ALERTS_GROUPING, + skip: isNoneGroup(selectedGroup), + }); + + useEffect(() => { + if (!isNoneGroup(selectedGroup)) { + setAlertsQuery(queryGroups); + } + }, [queryGroups, selectedGroup, setAlertsQuery]); + + useInspectButton({ + deleteQuery, + loading: isLoadingGroups, + response, + setQuery, + refetch, + request, + uniqueQueryId, + }); + + const inspect = useMemo( + () => ( + + ), + [uniqueQueryId] + ); + + const groupsSelector = useGetGroupingSelector({ + tableId, + groupingId, + fields: indexPatterns.fields, + }); + + const takeActionItems = useGroupTakeActionsItems({ + indexName: indexPatterns.title, + currentStatus: currentAlertStatusFilterValue, + showAlertStatusActions: hasIndexWrite && hasIndexMaintenance, + }); + + const getTakeActionItems = useCallback( + (groupFilters: Filter[]) => + takeActionItems(getGlobalQuery([...(defaultFilters ?? []), ...groupFilters])?.filterQuery), + [defaultFilters, getGlobalQuery, takeActionItems] + ); + + if (loading || isLoadingGroups || isEmpty(selectedPatterns)) { + return null; + } + + const dataTable = renderChildComponent([]); + + return ( + <> + {isNoneGroup(selectedGroup) ? ( + dataTable + ) : ( + <> + + getSelectedGroupButtonContent(selectedGroup, fieldBucket) + } + badgeMetricStats={(fieldBucket: RawBucket) => + getSelectedGroupBadgeMetrics(selectedGroup, fieldBucket) + } + customMetricStats={(fieldBucket: RawBucket) => + getSelectedGroupCustomMetrics(selectedGroup, fieldBucket) + } + /> + + )} + + ); +}; + +const makeMapStateToProps = () => { + const getGlobalInputs = inputsSelectors.globalSelector(); + const mapStateToProps = (state: State) => { + const globalInputs: inputsModel.InputsRange = getGlobalInputs(state); + const { query, filters } = globalInputs; + return { + globalQuery: query, + globalFilters: filters, + }; + }; + return mapStateToProps; +}; + +const connector = connect(makeMapStateToProps); + +type PropsFromRedux = ConnectedProps; + +export const GroupedAlertsTable = connector(React.memo(GroupedAlertsTableComponent)); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/group_take_action_items.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/group_take_action_items.tsx index 5829ba18cd431..d1e4d61e30dd5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/group_take_action_items.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings/group_take_action_items.tsx @@ -7,6 +7,7 @@ import React, { useMemo, useCallback } from 'react'; import { EuiContextMenuItem } from '@elastic/eui'; +import type { Status } from '../../../../../common/detection_engine/schemas/common'; import type { inputsModel } from '../../../../common/store'; import { inputsSelectors } from '../../../../common/store'; import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; @@ -28,7 +29,7 @@ import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import * as i18n from '../translations'; export interface TakeActionsProps { - currentStatus?: AlertWorkflowStatus; + currentStatus?: Status; indexName: string; showAlertStatusActions?: boolean; } diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx index 85ddea6277e20..f2d1c860bf390 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.test.tsx @@ -6,9 +6,9 @@ */ import React from 'react'; -import { mount, shallow } from 'enzyme'; -import { waitFor } from '@testing-library/react'; - +import { shallow } from 'enzyme'; +import { waitFor, render, fireEvent } from '@testing-library/react'; +import type { Filter, Query } from '@kbn/es-query'; import useResizeObserver from 'use-resize-observer/polyfilled'; import '../../../common/mock/match_media'; @@ -19,7 +19,7 @@ import { SUB_PLUGINS_REDUCER, TestProviders, } from '../../../common/mock'; -import { AlertsTableComponent } from '.'; +import { GroupedAlertsTableComponent } from './grouped_alerts'; import { TableId } from '../../../../common/types'; import { useSourcererDataView } from '../../../common/containers/sourcerer'; import type { UseFieldBrowserOptionsProps } from '../../../timelines/components/fields_browser'; @@ -28,6 +28,8 @@ import { mockTimelines } from '../../../common/mock/mock_timelines_plugin'; import { createFilterManagerMock } from '@kbn/data-plugin/public/query/filter_manager/filter_manager.mock'; import type { State } from '../../../common/store'; import { createStore } from '../../../common/store'; +import { AlertsTableComponent } from '.'; +import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock'; jest.mock('../../../common/containers/sourcerer'); jest.mock('../../../common/containers/use_global_time', () => ({ @@ -87,6 +89,8 @@ mockUseResizeObserver.mockImplementation(() => ({})); const mockFilterManager = createFilterManagerMock(); +const mockKibanaServices = createStartServicesMock(); + jest.mock('../../../common/lib/kibana', () => { const original = jest.requireActual('../../../common/lib/kibana'); @@ -95,6 +99,7 @@ jest.mock('../../../common/lib/kibana', () => { useUiSetting$: jest.fn().mockReturnValue([]), useKibana: () => ({ services: { + ...mockKibanaServices, application: { navigateToUrl: jest.fn(), capabilities: { @@ -124,6 +129,10 @@ jest.mock('../../../common/lib/kibana', () => { get: jest.fn(), set: jest.fn(), }, + triggerActionsUi: { + getAlertsStateTable: jest.fn(() => <>), + alertsTableConfigurationRegistry: {}, + }, }, }), useToasts: jest.fn().mockReturnValue({ @@ -154,9 +163,22 @@ const sourcererDataView = { indexPattern: { fields: [], }, + browserFields: {}, }; -describe('AlertsTableComponent', () => { +const from = '2020-07-07T08:20:18.966Z'; +const to = '2020-07-08T08:20:18.966Z'; +const renderChildComponent = (groupingFilters: Filter[]) => ( + +); + +describe('GroupedAlertsTable', () => { (useSourcererDataView as jest.Mock).mockReturnValue({ ...sourcererDataView, selectedPatterns: ['myFakebeat-*'], @@ -164,28 +186,26 @@ describe('AlertsTableComponent', () => { it('renders correctly', () => { const wrapper = shallow( - - + ); @@ -193,10 +213,13 @@ describe('AlertsTableComponent', () => { expect(wrapper.find('[title="Alerts"]')).toBeTruthy(); }); - it('it renders groupping fields options when the grouping field is selected', async () => { - const wrapper = mount( + // Not a valid test as of now.. because, table is used from trigger actions.. + // Need to find a better way to test grouping + // Need to make grouping_alerts independent of Alerts Table. + it.skip('it renders groupping fields options when the grouping field is selected', async () => { + const { getByTestId, getAllByTestId } = render( - { language: 'language', }} globalFilters={[]} - loadingEventIds={[]} - isSelectAllChecked={false} - showBuildingBlockAlerts={false} - onShowBuildingBlockAlertsChanged={jest.fn()} - showOnlyThreatIndicatorAlerts={false} - onShowOnlyThreatIndicatorAlertsChanged={jest.fn()} dispatch={jest.fn()} runtimeMappings={{}} signalIndexName={'test'} + renderChildComponent={() => <>} /> ); await waitFor(() => { - expect(wrapper.find('[data-test-subj="group-selector-dropdown"]').exists()).toBe(true); - wrapper.find('[data-test-subj="group-selector-dropdown"]').first().simulate('click'); - expect(wrapper.find('[data-test-subj="panel-kibana.alert.rule.name"]').exists()).toBe(true); + expect(getByTestId('[data-test-subj="group-selector-dropdown"]')).toBeVisible(); + fireEvent.click(getAllByTestId('group-selector-dropdown')[0]); + expect(getByTestId('[data-test-subj="panel-kibana.alert.rule.name"]')).toBeVisible(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index c191dcf20e98e..1b122df170bec 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -5,503 +5,325 @@ * 2.0. */ -import { isEmpty } from 'lodash/fp'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types'; -import type { ConnectedProps } from 'react-redux'; -import { connect, useDispatch } from 'react-redux'; -import { v4 as uuidv4 } from 'uuid'; +import type { EuiDataGridRowHeightsOptions, EuiDataGridStyle, EuiFlyoutSize } from '@elastic/eui'; +import { EuiFlexGroup } from '@elastic/eui'; import type { Filter } from '@kbn/es-query'; -import { buildEsQuery } from '@kbn/es-query'; -import { getEsQueryConfig } from '@kbn/data-plugin/common'; +import type { FC } from 'react'; +import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react'; import { Storage } from '@kbn/kibana-utils-plugin/public'; -import { InspectButton } from '../../../common/components/inspect'; -import { defaultUnit } from '../../../common/components/toolbar/unit'; -import type { - GroupingFieldTotalAggregation, - GroupingTableAggregation, - RawBucket, -} from '../../../common/components/grouping'; -import { - GroupingContainer, - GroupsSelector, - isNoneGroup, - NONE_GROUP_KEY, -} from '../../../common/components/grouping'; +import type { AlertsTableStateProps } from '@kbn/triggers-actions-ui-plugin/public/application/sections/alerts_table/alerts_table_state'; +import styled from 'styled-components'; +import { useDispatch, useSelector } from 'react-redux'; +import { getEsQueryConfig } from '@kbn/data-plugin/public'; import { useGlobalTime } from '../../../common/containers/use_global_time'; -import { combineQueries } from '../../../common/lib/kuery'; -import type { AlertWorkflowStatus } from '../../../common/types'; -import type { TableIdLiteral } from '../../../../common/types'; import { tableDefaults } from '../../../common/store/data_table/defaults'; +import { useLicense } from '../../../common/hooks/use_license'; +import { updateIsLoading, updateTotalCount } from '../../../common/store/data_table/actions'; +import { VIEW_SELECTION } from '../../../../common/constants'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; import { dataTableActions, dataTableSelectors } from '../../../common/store/data_table'; -import type { Status } from '../../../../common/detection_engine/schemas/common/schemas'; -import { StatefulEventsViewer } from '../../../common/components/events_viewer'; -import { useSourcererDataView } from '../../../common/containers/sourcerer'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; -import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; -import { useKibana } from '../../../common/lib/kibana'; -import type { inputsModel, State } from '../../../common/store'; +import { eventsDefaultModel } from '../../../common/components/events_viewer/default_model'; +import { GraphOverlay } from '../../../timelines/components/graph_overlay'; +import { + useSessionView, + useSessionViewNavigation, +} from '../../../timelines/components/timeline/session_tab_content/use_session_view'; import { inputsSelectors } from '../../../common/store'; +import { combineQueries } from '../../../common/lib/kuery'; +import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; +import { StatefulEventContext } from '../../../common/components/events_viewer/stateful_event_context'; +import { getDataTablesInStorageByIds } from '../../../timelines/containers/local_storage'; +import { useSourcererDataView } from '../../../common/containers/sourcerer'; import { SourcererScopeName } from '../../../common/store/sourcerer/model'; -import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; -import { getDefaultControlColumn } from '../../../timelines/components/timeline/body/control_columns'; -import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; -import { getColumns, RenderCellValue } from '../../configurations/security_solution_detections'; -import { useInspectButton } from '../alerts_kpis/common/hooks'; - -import { AdditionalFiltersAction } from './additional_filters_action'; -import { - getAlertsDefaultModel, - buildAlertStatusFilter, - requiredFieldsForActions, -} from './default_config'; +import { TableId } from '../../../../common/types'; +import { useKibana } from '../../../common/lib/kibana'; +import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { getColumns } from '../../configurations/security_solution_detections'; +import { getColumnHeaders } from '../../../common/components/data_table/column_headers/helpers'; import { buildTimeRangeFilter } from './helpers'; +import { eventsViewerSelector } from '../../../common/components/events_viewer/selectors'; +import type { State } from '../../../common/store'; import * as i18n from './translations'; -import { useLicense } from '../../../common/hooks/use_license'; -import { useBulkAddToCaseActions } from './timeline_actions/use_bulk_add_to_case_actions'; -import { useAddBulkToTimelineAction } from './timeline_actions/use_add_bulk_to_timeline'; -import { useQueryAlerts } from '../../containers/detection_engine/alerts/use_query'; -import { ALERTS_QUERY_NAMES } from '../../containers/detection_engine/alerts/constants'; -import { - getAlertsGroupingQuery, - getDefaultGroupingOptions, - getSelectedGroupBadgeMetrics, - getSelectedGroupButtonContent, - getSelectedGroupCustomMetrics, - useGroupTakeActionsItems, -} from './grouping_settings'; - -/** This local storage key stores the `Grid / Event rendered view` selection */ -export const ALERTS_TABLE_GROUPS_SELECTION_KEY = 'securitySolution.alerts.table.group-selection'; + const storage = new Storage(localStorage); -const ALERTS_GROUPING_ID = 'alerts-grouping'; +interface GridContainerProps { + hideLastPage: boolean; +} -interface OwnProps { - defaultFilters?: Filter[]; - from: string; - hasIndexMaintenance: boolean; - hasIndexWrite: boolean; - loading: boolean; +export const FullWidthFlexGroupTable = styled(EuiFlexGroup)<{ $visible: boolean }>` + overflow: hidden; + margin: 0; + display: ${({ $visible }) => ($visible ? 'flex' : 'none')}; +`; + +const EuiDataGridContainer = styled.div` + ul.euiPagination__list { + li.euiPagination__item:last-child { + ${({ hideLastPage }) => { + return `${hideLastPage ? 'display:none' : ''}`; + }}; + } + } + div .euiDataGridRowCell__contentByHeight { + height: auto; + align-self: center; + } + div .euiDataGridRowCell--lastColumn .euiDataGridRowCell__contentByHeight { + flex-grow: 0; + width: 100%; + } + div .siemEventsTable__trSupplement--summary { + display: block; + } + width: 100%; +`; +interface DetectionEngineAlertTableProps { + configId: string; + flyoutSize: EuiFlyoutSize; + inputFilters: Filter[]; + tableId: TableId; + sourcererScope?: SourcererScopeName; + isLoading?: boolean; onRuleChange?: () => void; - onShowBuildingBlockAlertsChanged: (showBuildingBlockAlerts: boolean) => void; - onShowOnlyThreatIndicatorAlertsChanged: (showOnlyThreatIndicatorAlerts: boolean) => void; - showBuildingBlockAlerts: boolean; - showOnlyThreatIndicatorAlerts: boolean; - tableId: TableIdLiteral; - to: string; - filterGroup?: Status; - runtimeMappings: MappingRuntimeFields; - signalIndexName: string | null; } - -type AlertsTableComponentProps = OwnProps & PropsFromRedux; - -export const AlertsTableComponent: React.FC = ({ - defaultFilters, - from, - globalFilters, - globalQuery, - hasIndexMaintenance, - hasIndexWrite, - isSelectAllChecked, - loading, - loadingEventIds, +export const AlertsTableComponent: FC = ({ + configId, + flyoutSize, + inputFilters, + tableId = TableId.alertsOnAlertsPage, + sourcererScope = SourcererScopeName.detections, + isLoading, onRuleChange, - onShowBuildingBlockAlertsChanged, - onShowOnlyThreatIndicatorAlertsChanged, - showBuildingBlockAlerts, - showOnlyThreatIndicatorAlerts, - tableId, - to, - filterGroup, - runtimeMappings, - signalIndexName, }) => { + const { triggersActionsUi, uiSettings } = useKibana().services; + + const { from, to, setQuery } = useGlobalTime(); + + const alertTableRefreshHandlerRef = useRef<(() => void) | null>(null); + const dispatch = useDispatch(); - const [selectedGroup, setSelectedGroup] = useState( - storage.get(`${ALERTS_TABLE_GROUPS_SELECTION_KEY}-${tableId}`) ?? NONE_GROUP_KEY - ); - const { - browserFields, - indexPattern: indexPatterns, - selectedPatterns, - } = useSourcererDataView(SourcererScopeName.detections); - const kibana = useKibana(); + // Store context in state rather than creating object in provider value={} to prevent re-renders caused by a new object being created + const [activeStatefulEventContext] = useState({ + timelineID: tableId, + tabType: 'query', + enableHostDetailsFlyout: true, + enableIpDetailsFlyout: true, + onRuleChange, + }); + const { browserFields, indexPattern: indexPatterns } = useSourcererDataView(sourcererScope); const license = useLicense(); - const isEnterprisePlus = useLicense().isEnterprise(); - const ACTION_BUTTON_COUNT = isEnterprisePlus ? 5 : 4; - - const getGlobalQuery = useCallback( - (customFilters: Filter[]) => { - if (browserFields != null && indexPatterns != null) { - return combineQueries({ - config: getEsQueryConfig(kibana.services.uiSettings), - dataProviders: [], - indexPattern: indexPatterns, - browserFields, - filters: [ - ...(defaultFilters ?? []), - ...globalFilters, - ...customFilters, - ...buildTimeRangeFilter(from, to), - ], - kqlQuery: globalQuery, - kqlMode: globalQuery.language, - }); - } - return null; - }, - [browserFields, defaultFilters, globalFilters, globalQuery, indexPatterns, kibana, to, from] + + const getGlobalInputs = inputsSelectors.globalSelector(); + const globalInputs = useSelector((state: State) => getGlobalInputs(state)); + const { query: globalQuery, filters: globalFilters } = globalInputs; + + const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []); + + const isDataTableInitialized = useShallowEqualSelector( + (state) => (getTable(state, tableId) ?? tableDefaults).initialized ); + const timeRangeFilter = useMemo(() => buildTimeRangeFilter(from, to), [from, to]); + + const allFilters = useMemo(() => { + return [...inputFilters, ...(globalFilters ?? []), ...(timeRangeFilter ?? [])]; + }, [inputFilters, globalFilters, timeRangeFilter]); + + const { + dataTable: { + graphEventId, // If truthy, the graph viewer (Resolver) is showing + sessionViewConfig, + viewMode: tableView = eventsDefaultModel.viewMode, + } = eventsDefaultModel, + } = useShallowEqualSelector((state: State) => eventsViewerSelector(state, tableId)); + + const combinedQuery = useMemo(() => { + if (browserFields != null && indexPatterns != null) { + return combineQueries({ + config: getEsQueryConfig(uiSettings), + dataProviders: [], + indexPattern: indexPatterns, + browserFields, + filters: [...allFilters], + kqlQuery: globalQuery, + kqlMode: globalQuery.language, + }); + } + return null; + }, [browserFields, globalQuery, indexPatterns, uiSettings, allFilters]); + useInvalidFilterQuery({ id: tableId, - filterQuery: getGlobalQuery([])?.filterQuery, - kqlError: getGlobalQuery([])?.kqlError, + filterQuery: combinedQuery?.filterQuery, + kqlError: combinedQuery?.kqlError, query: globalQuery, startDate: from, endDate: to, }); - // Catches state change isSelectAllChecked->false upon user selection change to reset utility bar - useEffect(() => { - if (isSelectAllChecked) { - dispatch( - dataTableActions.setDataTableSelectAll({ - id: tableId, - selectAll: false, - }) - ); + const finalBoolQuery: AlertsTableStateProps['query'] = useMemo(() => { + if (!combinedQuery || combinedQuery.kqlError || !combinedQuery.filterQuery) { + return { bool: {} }; } - }, [dispatch, isSelectAllChecked, tableId]); - - const additionalFiltersComponent = useMemo( - () => ( - 0} - onShowBuildingBlockAlertsChanged={onShowBuildingBlockAlertsChanged} - showBuildingBlockAlerts={showBuildingBlockAlerts} - onShowOnlyThreatIndicatorAlertsChanged={onShowOnlyThreatIndicatorAlertsChanged} - showOnlyThreatIndicatorAlerts={showOnlyThreatIndicatorAlerts} - /> - ), - [ - loadingEventIds.length, - onShowBuildingBlockAlertsChanged, - onShowOnlyThreatIndicatorAlertsChanged, - showBuildingBlockAlerts, - showOnlyThreatIndicatorAlerts, - ] + return { bool: { filter: JSON.parse(combinedQuery.filterQuery) } }; + }, [combinedQuery]); + + const isEventRenderedView = tableView === VIEW_SELECTION.eventRenderedView; + + const gridStyle = useMemo( + () => + ({ + border: 'none', + fontSize: 's', + header: 'underline', + stripes: isEventRenderedView, + } as EuiDataGridStyle), + [isEventRenderedView] ); - const defaultFiltersMemo = useMemo(() => { - let alertStatusFilter: Filter[] = []; - if (filterGroup) { - alertStatusFilter = buildAlertStatusFilter(filterGroup); + const rowHeightsOptions: EuiDataGridRowHeightsOptions | undefined = useMemo(() => { + if (isEventRenderedView) { + return { + defaultHeight: 'auto', + }; } - if (isEmpty(defaultFilters)) { - return alertStatusFilter; - } else if (defaultFilters != null && !isEmpty(defaultFilters)) { - return [...defaultFilters, ...alertStatusFilter]; - } - }, [defaultFilters, filterGroup]); + return undefined; + }, [isEventRenderedView]); - const { filterManager } = kibana.services.data.query; + const dataTableStorage = getDataTablesInStorageByIds(storage, [TableId.alertsOnAlertsPage]); + const columnsFormStorage = dataTableStorage?.[TableId.alertsOnAlertsPage]?.columns ?? []; + const alertColumns = columnsFormStorage.length ? columnsFormStorage : getColumns(license); - const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled'); + const evenRenderedColumns = useMemo( + () => getColumnHeaders(alertColumns, browserFields, true), + [alertColumns, browserFields] + ); - useEffect(() => { - dispatch( - dataTableActions.initializeDataTableSettings({ - defaultColumns: getColumns(license).map((c) => - !tGridEnabled && c.initialWidth == null - ? { - ...c, - initialWidth: DEFAULT_COLUMN_MIN_WIDTH, - } - : c - ), - id: tableId, - loadingText: i18n.LOADING_ALERTS, - queryFields: requiredFieldsForActions, - title: i18n.ALERTS_DOCUMENT_TYPE, - showCheckboxes: true, - }) - ); - }, [dispatch, filterManager, tGridEnabled, tableId, license]); + const finalColumns = useMemo( + () => (isEventRenderedView ? evenRenderedColumns : alertColumns), + [evenRenderedColumns, alertColumns, isEventRenderedView] + ); - const leadingControlColumns = useMemo( - () => getDefaultControlColumn(ACTION_BUTTON_COUNT), - [ACTION_BUTTON_COUNT] + const finalBrowserFields = useMemo( + () => (isEventRenderedView ? {} : browserFields), + [isEventRenderedView, browserFields] ); - const addToCaseBulkActions = useBulkAddToCaseActions(); - const addBulkToTimelineAction = useAddBulkToTimelineAction({ - localFilters: defaultFiltersMemo ?? [], - tableId, - from, - to, - scopeId: SourcererScopeName.detections, - }); + const onAlertTableUpdate: AlertsTableStateProps['onUpdate'] = useCallback( + ({ isLoading: isAlertTableLoading, totalCount, refresh }) => { + dispatch( + updateIsLoading({ + id: tableId, + isLoading: isAlertTableLoading, + }) + ); - const bulkActions = useMemo( - () => ({ - customBulkActions: [...addToCaseBulkActions, addBulkToTimelineAction], - }), - [addToCaseBulkActions, addBulkToTimelineAction] - ); + dispatch( + updateTotalCount({ + id: tableId, + totalCount, + }) + ); - const { deleteQuery, setQuery } = useGlobalTime(false); - // create a unique, but stable (across re-renders) query id - const uniqueQueryId = useMemo(() => `${ALERTS_GROUPING_ID}-${uuidv4()}`, []); - - const additionalFilters = useMemo(() => { - try { - return [ - buildEsQuery(undefined, globalQuery != null ? [globalQuery] : [], [ - ...(globalFilters?.filter((f) => f.meta.disabled === false) ?? []), - ...(defaultFiltersMemo ?? []), - ]), - ]; - } catch (e) { - return []; - } - }, [defaultFiltersMemo, globalFilters, globalQuery]); + alertTableRefreshHandlerRef.current = refresh; - const [groupsActivePage, setGroupsActivePage] = useState(0); - const [groupsItemsPerPage, setGroupsItemsPerPage] = useState(25); + // setting Query + setQuery({ + id: tableId, + loading: isAlertTableLoading, + refetch: refresh, + inspect: null, + }); + }, + [dispatch, tableId, alertTableRefreshHandlerRef, setQuery] + ); - const pagination = useMemo( + const alertStateProps: AlertsTableStateProps = useMemo( () => ({ - pageIndex: groupsActivePage, - pageSize: groupsItemsPerPage, - onChangeItemsPerPage: (itemsPerPageNumber: number) => - setGroupsItemsPerPage(itemsPerPageNumber), - onChangePage: (pageNumber: number) => setGroupsActivePage(pageNumber), + alertsTableConfigurationRegistry: triggersActionsUi.alertsTableConfigurationRegistry, + configurationId: configId, + // stores saperate configuration based on the view of the table + id: `detection-engine-alert-table-${configId}-${tableView}`, + flyoutSize, + featureIds: ['siem'], + query: finalBoolQuery, + showExpandToDetails: false, + gridStyle, + rowHeightsOptions, + columns: finalColumns, + browserFields: finalBrowserFields, + onUpdate: onAlertTableUpdate, + toolbarVisibility: { + showColumnSelector: !isEventRenderedView, + showSortSelector: !isEventRenderedView, + }, }), - [groupsActivePage, groupsItemsPerPage] - ); - - const queryGroups = useMemo( - () => - getAlertsGroupingQuery({ - additionalFilters, - selectedGroup, - from, - runtimeMappings, - to, - pageSize: pagination.pageSize, - pageIndex: pagination.pageIndex, - }), [ - additionalFilters, - selectedGroup, - from, - runtimeMappings, - to, - pagination.pageSize, - pagination.pageIndex, + finalBoolQuery, + configId, + triggersActionsUi.alertsTableConfigurationRegistry, + flyoutSize, + gridStyle, + rowHeightsOptions, + finalColumns, + finalBrowserFields, + onAlertTableUpdate, + isEventRenderedView, + tableView, ] ); - const { - data: alertsGroupsData, - loading: isLoadingGroups, - refetch, - request, - response, - setQuery: setAlertsQuery, - } = useQueryAlerts<{}, GroupingTableAggregation & GroupingFieldTotalAggregation>({ - query: queryGroups, - indexName: signalIndexName, - queryName: ALERTS_QUERY_NAMES.ALERTS_GROUPING, - skip: isNoneGroup(selectedGroup), - }); - useEffect(() => { - if (!isNoneGroup(selectedGroup)) { - setAlertsQuery(queryGroups); - } - }, [queryGroups, selectedGroup, setAlertsQuery]); - - useInspectButton({ - deleteQuery, - loading: isLoadingGroups, - response, - setQuery, - refetch, - request, - uniqueQueryId, - }); - - const inspect = useMemo( - () => ( - - ), - [uniqueQueryId] - ); + if (isDataTableInitialized) return; + dispatch( + dataTableActions.initializeDataTableSettings({ + id: tableId, + title: i18n.SESSIONS_TITLE, + defaultColumns: finalColumns.map((c) => ({ + ...c, + initialWidth: DEFAULT_COLUMN_MIN_WIDTH, + })), + }) + ); + }, [dispatch, tableId, finalColumns, isDataTableInitialized]); - const defaultGroupingOptions = getDefaultGroupingOptions(tableId); - const [options, setOptions] = useState( - defaultGroupingOptions.find((o) => o.key === selectedGroup) - ? defaultGroupingOptions - : [ - ...defaultGroupingOptions, - ...(!isNoneGroup(selectedGroup) - ? [ - { - key: selectedGroup, - label: selectedGroup, - }, - ] - : []), - ] + const AlertTable = useMemo( + () => triggersActionsUi.getAlertsStateTable(alertStateProps), + [alertStateProps, triggersActionsUi] ); - const groupsSelector = useMemo( - () => ( - { - if (groupSelection === selectedGroup) { - return; - } - storage.set(`${ALERTS_TABLE_GROUPS_SELECTION_KEY}-${tableId}`, groupSelection); - setGroupsActivePage(0); - setSelectedGroup(groupSelection); - - if (!isNoneGroup(groupSelection) && !options.find((o) => o.key === groupSelection)) { - setOptions([ - ...defaultGroupingOptions, - { - label: groupSelection, - key: groupSelection, - }, - ]); - } else { - setOptions(defaultGroupingOptions); - } - }} - fields={indexPatterns.fields} - options={options} - title={i18n.GROUP_ALERTS_SELECTOR} - /> - ), - [defaultGroupingOptions, indexPatterns.fields, options, selectedGroup, tableId] - ); + const { Navigation } = useSessionViewNavigation({ + scopeId: tableId, + }); - const takeActionItems = useGroupTakeActionsItems({ - indexName: indexPatterns.title, - currentStatus: filterGroup as AlertWorkflowStatus, - showAlertStatusActions: hasIndexWrite && hasIndexMaintenance, + const { DetailsPanel, SessionView } = useSessionView({ + entityType: 'alerts', + scopeId: tableId, }); - const getTakeActionItems = useCallback( - (groupFilters: Filter[]) => - takeActionItems( - getGlobalQuery([...(defaultFiltersMemo ?? []), ...groupFilters])?.filterQuery - ), - [defaultFiltersMemo, getGlobalQuery, takeActionItems] - ); + const graphOverlay = useMemo(() => { + const shouldShowOverlay = + (graphEventId != null && graphEventId.length > 0) || sessionViewConfig != null; + return shouldShowOverlay ? ( + + ) : null; + }, [graphEventId, tableId, sessionViewConfig, SessionView, Navigation]); - if (loading || isLoadingGroups || isEmpty(selectedPatterns)) { + if (isLoading) { return null; } - const dataTable = ( - - ); - return ( - <> - {isNoneGroup(selectedGroup) ? ( - dataTable - ) : ( - <> - ( - - )} - unit={defaultUnit} - pagination={pagination} - groupPanelRenderer={(fieldBucket: RawBucket) => - getSelectedGroupButtonContent(selectedGroup, fieldBucket) - } - badgeMetricStats={(fieldBucket: RawBucket) => - getSelectedGroupBadgeMetrics(selectedGroup, fieldBucket) - } - customMetricStats={(fieldBucket: RawBucket) => - getSelectedGroupCustomMetrics(selectedGroup, fieldBucket) - } - /> - - )} - +
+ {graphOverlay} + + + {AlertTable} + + + {DetailsPanel} +
); }; - -const makeMapStateToProps = () => { - const getDataTable = dataTableSelectors.getTableByIdSelector(); - const getGlobalInputs = inputsSelectors.globalSelector(); - const mapStateToProps = (state: State, ownProps: OwnProps) => { - const { tableId } = ownProps; - const table = getDataTable(state, tableId) ?? tableDefaults; - const { isSelectAllChecked, loadingEventIds } = table; - - const globalInputs: inputsModel.InputsRange = getGlobalInputs(state); - const { query, filters } = globalInputs; - return { - globalQuery: query, - globalFilters: filters, - isSelectAllChecked, - loadingEventIds, - }; - }; - return mapStateToProps; -}; - -const connector = connect(makeMapStateToProps); - -type PropsFromRedux = ConnectedProps; - -export const AlertsTable = connector(React.memo(AlertsTableComponent)); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index c74f7fad2f30a..44c20b3cee0d6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -53,6 +53,7 @@ interface AlertContextMenuProps { ecsRowData: Ecs; onRuleChange?: () => void; scopeId: string; + refetch: (() => void) | undefined; } const AlertContextMenuComponent: React.FC = ({ @@ -65,6 +66,7 @@ const AlertContextMenuComponent: React.FC { const [isPopoverOpen, setPopover] = useState(false); const [isOsqueryFlyoutOpen, setOsqueryFlyoutOpen] = useState(false); @@ -144,8 +146,9 @@ const AlertContextMenuComponent: React.FC { - sendBulkEventsToTimelineHandler(localResponse.events); - dispatch( - setEventsLoading({ - id: tableId, - isLoading: false, - eventIds: Object.keys(selectedEventIds), - }) - ); - }, - [dispatch, sendBulkEventsToTimelineHandler, tableId, selectedEventIds] - ); - - const onActionClick = useCallback( - (items: TimelineItem[] | undefined) => { + const onActionClick: BulkActionsConfig['onClick'] | CustomBulkAction['onClick'] = useCallback( + (items: TimelineItem[] | undefined, isAllSelected: boolean, setLoading, clearSelection) => { if (!items) return; - - if (selectAll) { - dispatch( - setEventsLoading({ - id: tableId, - isLoading: true, - eventIds: Object.keys(selectedEventIds), - }) - ); + /* + * Trigger actions table passed isAllSelected param + * + * and selectAll is used when using DataTable + * */ + const onResponseHandler = (localResponse: TimelineArgs) => { + sendBulkEventsToTimelineHandler(localResponse.events); + if (tableId === TableId.alertsOnAlertsPage) { + setLoading(false); + clearSelection(); + } else { + dispatch( + setEventsLoading({ + id: tableId, + isLoading: false, + eventIds: Object.keys(selectedEventIds), + }) + ); + } + }; + + if (isAllSelected || selectAll) { + if (tableId === TableId.alertsOnAlertsPage) { + setLoading(true); + } else { + dispatch( + setEventsLoading({ + id: tableId, + isLoading: true, + eventIds: Object.keys(selectedEventIds), + }) + ); + } searchhandler(onResponseHandler); return; } - sendBulkEventsToTimelineHandler(items); + clearSelection(); }, - [ - dispatch, - selectedEventIds, - tableId, - searchhandler, - selectAll, - onResponseHandler, - sendBulkEventsToTimelineHandler, - ] + [dispatch, selectedEventIds, tableId, searchhandler, selectAll, sendBulkEventsToTimelineHandler] ); const investigateInTimelineTitle = useMemo(() => { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx index 880a7b690508e..840f9635f4cb7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_alerts_actions.tsx @@ -42,7 +42,7 @@ export const useAlertsActions = ({ }, [closePopover, refetch]); const scopedActions = getScopedActions(scopeId); - const setEventsLoading = useCallback( + const localSetEventsLoading = useCallback( ({ eventIds, isLoading }: SetEventsLoadingProps) => { if (scopedActions) { dispatch(scopedActions.setEventsLoading({ id: scopeId, eventIds, isLoading })); @@ -64,7 +64,7 @@ export const useAlertsActions = ({ eventIds: [eventId], currentStatus: alertStatus as AlertWorkflowStatus, indexName, - setEventsLoading, + setEventsLoading: localSetEventsLoading, setEventsDeleted, onUpdateSuccess: onStatusUpdate, onUpdateFailure: onStatusUpdate, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_bulk_add_to_case_actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_bulk_add_to_case_actions.tsx index ba9e737729a43..d77a87ccf25c8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_bulk_add_to_case_actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/use_bulk_add_to_case_actions.tsx @@ -41,7 +41,6 @@ export const useBulkAddToCaseActions = ({ onClose, onSuccess }: UseAddToCaseActi disabledLabel: ADD_TO_CASE_DISABLED, onClick: (items?: TimelineItem[]) => { const caseAttachments = items ? casesUi.helpers.groupAlertsByRule(items) : []; - addToNewCase.open({ attachments: caseAttachments }); }, }, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts index ec5b23f0851d6..650dbacbf0a0f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/translations.ts @@ -295,6 +295,10 @@ export const INVESTIGATE_BULK_IN_TIMELINE = i18n.translate( } ); +export const SESSIONS_TITLE = i18n.translate('xpack.securitySolution.sessionsView.sessionsTitle', { + defaultMessage: 'Sessions', +}); + export const TAKE_ACTION = i18n.translate( 'xpack.securitySolution.detectionEngine.groups.additionalActions.takeAction', { @@ -418,3 +422,15 @@ export const INSPECT_GROUPING_TITLE = i18n.translate( defaultMessage: 'Grouping query', } ); + +export const EVENT_RENDERED_VIEW_COLUMNS = { + timestamp: i18n.translate('xpack.securitySolution.EventRenderedView.timestampTitle.column', { + defaultMessage: 'Timestamp', + }), + rule: i18n.translate('xpack.securitySolution.EventRenderedView.ruleTitle.column', { + defaultMessage: 'Rule', + }), + eventSummary: i18n.translate('xpack.securitySolution.EventRenderedView.eventSummary.column', { + defaultMessage: 'Event Summary', + }), +}; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts index 616578b38bcf6..29c8cb4ec0962 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/columns.ts @@ -16,6 +16,10 @@ import { } from '../../../timelines/components/timeline/body/constants'; import * as i18n from '../../components/alerts_table/translations'; +import { + DEFAULT_TABLE_COLUMN_MIN_WIDTH, + DEFAULT_TABLE_DATE_COLUMN_MIN_WIDTH, +} from './translations'; const getBaseColumns = ( license?: LicenseService @@ -120,3 +124,33 @@ export const getRulePreviewColumns = ( }, ...getBaseColumns(license), ]; + +export const eventRenderedViewColumns: ColumnHeaderOptions[] = [ + { + columnHeaderType: defaultColumnHeaderType, + id: '@timestamp', + displayAsText: i18n.EVENT_RENDERED_VIEW_COLUMNS.timestamp, + initialWidth: DEFAULT_TABLE_DATE_COLUMN_MIN_WIDTH + 50, + actions: false, + isExpandable: false, + isResizable: false, + }, + { + columnHeaderType: defaultColumnHeaderType, + displayAsText: i18n.EVENT_RENDERED_VIEW_COLUMNS.rule, + id: 'kibana.alert.rule.name', + initialWidth: DEFAULT_TABLE_COLUMN_MIN_WIDTH + 50, + linkField: 'kibana.alert.rule.uuid', + actions: false, + isExpandable: false, + isResizable: false, + }, + { + columnHeaderType: defaultColumnHeaderType, + id: 'eventSummary', + displayAsText: i18n.EVENT_RENDERED_VIEW_COLUMNS.eventSummary, + actions: false, + isExpandable: false, + isResizable: false, + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx index 476e15fce02a3..108325735bbc4 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/render_cell_value.tsx @@ -7,8 +7,16 @@ import type { EuiDataGridCellValueElementProps } from '@elastic/eui'; import { EuiIcon, EuiToolTip, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { find } from 'lodash/fp'; -import React, { useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; +import type { GetRenderCellValue } from '@kbn/triggers-actions-ui-plugin/public'; +import { find, getOr } from 'lodash/fp'; +import type { TimelineNonEcsData } from '@kbn/timelines-plugin/common'; +import { useLicense } from '../../../common/hooks/use_license'; +import { dataTableSelectors } from '../../../common/store/data_table'; +import type { TableId } from '../../../../common/types'; +import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { defaultRowRenderers } from '../../../timelines/components/timeline/body/renderers'; +import type { SourcererScopeName } from '../../../common/store/sourcerer/model'; import { GuidedOnboardingTourStep } from '../../../common/components/guided_onboarding_tour/tour_step'; import { isDetectionsAlertsTable } from '../../../common/components/top_n/helpers'; import { @@ -16,14 +24,16 @@ import { SecurityStepId, } from '../../../common/components/guided_onboarding_tour/tour_config'; import { SIGNAL_RULE_NAME_FIELD_NAME } from '../../../timelines/components/timeline/body/renderers/constants'; -import { TimelineId } from '../../../../common/types'; import { useSourcererDataView } from '../../../common/containers/sourcerer'; -import { SourcererScopeName } from '../../../common/store/sourcerer/model'; import type { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering'; import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer'; import { SUPPRESSED_ALERT_TOOLTIP } from './translations'; +import { tableDefaults } from '../../../common/store/data_table/defaults'; +import { VIEW_SELECTION } from '../../../../common/constants'; +import { getAllFieldsByName } from '../../../common/containers/source'; +import { eventRenderedViewColumns, getColumns } from './columns'; /** * This implementation of `EuiDataGrid`'s `renderCellValue` @@ -79,59 +89,98 @@ export const RenderCellValue: React.FC void; + scopeId: SourcererScopeName; + tableId: TableId; }) => { - const { browserFields } = useSourcererDataView(SourcererScopeName.detections); - return ({ - columnId, - colIndex, - data, - ecsData, - eventId, - header, - isDetails = false, - isDraggable = false, - isExpandable, - isExpanded, - linkValues, - rowIndex, - rowRenderers, - setCellProps, - truncate = true, - }: CellValueElementProps) => { - const splitColumnId = columnId.split('.'); - let myHeader = header ?? { id: columnId }; - if (splitColumnId.length > 1 && browserFields[splitColumnId[0]]) { - const attr = (browserFields[splitColumnId[0]].fields ?? {})[columnId] ?? {}; - myHeader = { ...myHeader, ...attr }; - } else if (splitColumnId.length === 1) { - const attr = (browserFields.base.fields ?? {})[columnId] ?? {}; - myHeader = { ...myHeader, ...attr }; - } - - return ( - + const useRenderCellValue: GetRenderCellValue = () => { + const { browserFields } = useSourcererDataView(scopeId); + const browserFieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]); + const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []); + const license = useLicense(); + + const viewMode = + useShallowEqualSelector((state) => (getTable(state, tableId) ?? tableDefaults).viewMode) ?? + tableDefaults.viewMode; + + const columnHeaders = + viewMode === VIEW_SELECTION.gridView ? getColumns(license) : eventRenderedViewColumns; + + const result = useCallback( + ({ + columnId, + colIndex, + data, + ecsData, + eventId, + header, + isDetails = false, + isDraggable = false, + isExpandable, + isExpanded, + rowIndex, + rowRenderers, + setCellProps, + linkValues, + truncate = true, + }) => { + const myHeader = header ?? { id: columnId, ...browserFieldsByName[columnId] }; + /** + * There is difference between how `triggers actions` fetched data v/s + * how security solution fetches data via timelineSearchStrategy + * + * _id and _index fields are array in timelineSearchStrategy but not in + * ruleStrategy + * + * + */ + + const finalData = (data as TimelineNonEcsData[]).map((field) => { + let localField = field; + if (['_id', '_index'].includes(field.field)) { + const newValue = field.value ?? ''; + localField = { + field: field.field, + value: Array.isArray(newValue) ? newValue : [newValue], + }; + } + return localField; + }); + + const colHeader = columnHeaders.find((col) => col.id === columnId); + + const localLinkValues = getOr([], colHeader?.linkField ?? '', ecsData); + + return ( + + ); + }, + [browserFieldsByName, browserFields, columnHeaders] ); + return result; }; + + return useRenderCellValue; }; diff --git a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/translations.ts b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/translations.ts index 4f34bd2dc03ce..64fd913a8d8c3 100644 --- a/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/configurations/security_solution_detections/translations.ts @@ -12,3 +12,9 @@ export const SUPPRESSED_ALERT_TOOLTIP = (numAlertsSuppressed: number) => defaultMessage: 'Alert has {numAlertsSuppressed} suppressed alerts', values: { numAlertsSuppressed }, }); + +/** The default minimum width of a column (when a width for the column type is not specified) */ +export const DEFAULT_TABLE_COLUMN_MIN_WIDTH = 180; // px + +/** The default minimum width of a column of type `date` */ +export const DEFAULT_TABLE_DATE_COLUMN_MIN_WIDTH = 190; // px diff --git a/x-pack/plugins/security_solution/public/detections/hooks/translations.ts b/x-pack/plugins/security_solution/public/detections/hooks/translations.ts new file mode 100644 index 0000000000000..6fc3c49e65fc0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/hooks/translations.ts @@ -0,0 +1,113 @@ +/* + * 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 SELECTED_ALERTS = (selectedAlertsFormatted: string, selectedAlerts: number) => + i18n.translate('xpack.securitySolution.toolbar.bulkActions.selectedAlertsTitle', { + values: { selectedAlertsFormatted, selectedAlerts }, + defaultMessage: + 'Selected {selectedAlertsFormatted} {selectedAlerts, plural, =1 {alert} other {alerts}}', + }); + +export const SELECT_ALL_ALERTS = (totalAlertsFormatted: string, totalAlerts: number) => + i18n.translate('xpack.securitySolution.toolbar.bulkActions.selectAllAlertsTitle', { + values: { totalAlertsFormatted, totalAlerts }, + defaultMessage: + 'Select all {totalAlertsFormatted} {totalAlerts, plural, =1 {alert} other {alerts}}', + }); + +export const CLEAR_SELECTION = i18n.translate( + 'xpack.securitySolution.toolbar.bulkActions.clearSelectionTitle', + { + defaultMessage: 'Clear selection', + } +); + +export const UPDATE_ALERT_STATUS_FAILED = (conflicts: number) => + i18n.translate('xpack.securitySolution.bulkActions.updateAlertStatusFailed', { + values: { conflicts }, + defaultMessage: + 'Failed to update { conflicts } {conflicts, plural, =1 {alert} other {alerts}}.', + }); + +export const UPDATE_ALERT_STATUS_FAILED_DETAILED = (updated: number, conflicts: number) => + i18n.translate('xpack.securitySolution.bulkActions.updateAlertStatusFailedDetailed', { + values: { updated, conflicts }, + defaultMessage: `{ updated } {updated, plural, =1 {alert was} other {alerts were}} updated successfully, but { conflicts } failed to update + because { conflicts, plural, =1 {it was} other {they were}} already being modified.`, + }); + +export const CLOSED_ALERT_SUCCESS_TOAST = (totalAlerts: number) => + i18n.translate('xpack.securitySolution.bulkActions.closedAlertSuccessToastMessage', { + values: { totalAlerts }, + defaultMessage: + 'Successfully closed {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.', + }); + +export const OPENED_ALERT_SUCCESS_TOAST = (totalAlerts: number) => + i18n.translate('xpack.securitySolution.bulkActions.openedAlertSuccessToastMessage', { + values: { totalAlerts }, + defaultMessage: + 'Successfully opened {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.', + }); + +export const ACKNOWLEDGED_ALERT_SUCCESS_TOAST = (totalAlerts: number) => + i18n.translate('xpack.securitySolution.bulkActions.acknowledgedAlertSuccessToastMessage', { + values: { totalAlerts }, + defaultMessage: + 'Successfully marked {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}} as acknowledged.', + }); + +export const CLOSED_ALERT_FAILED_TOAST = i18n.translate( + 'xpack.securitySolution.bulkActions.closedAlertFailedToastMessage', + { + defaultMessage: 'Failed to close alert(s).', + } +); + +export const OPENED_ALERT_FAILED_TOAST = i18n.translate( + 'xpack.securitySolution.bulkActions.openedAlertFailedToastMessage', + { + defaultMessage: 'Failed to open alert(s)', + } +); + +export const ACKNOWLEDGED_ALERT_FAILED_TOAST = i18n.translate( + 'xpack.securitySolution.bulkActions.acknowledgedAlertFailedToastMessage', + { + defaultMessage: 'Failed to mark alert(s) as acknowledged', + } +); + +export const BULK_ACTION_FAILED_SINGLE_ALERT = i18n.translate( + 'xpack.securitySolution.bulkActions.updateAlertStatusFailedSingleAlert', + { + defaultMessage: 'Failed to update alert because it was already being modified.', + } +); + +export const BULK_ACTION_OPEN_SELECTED = i18n.translate( + 'xpack.securitySolution.bulkActions.openSelectedTitle', + { + defaultMessage: 'Mark as open', + } +); + +export const BULK_ACTION_ACKNOWLEDGED_SELECTED = i18n.translate( + 'xpack.securitySolution.bulkActions.acknowledgedSelectedTitle', + { + defaultMessage: 'Mark as acknowledged', + } +); + +export const BULK_ACTION_CLOSE_SELECTED = i18n.translate( + 'xpack.securitySolution.bulkActions.closeSelectedTitle', + { + defaultMessage: 'Mark as closed', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx new file mode 100644 index 0000000000000..0455a9dc9ee53 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_actions_column.tsx @@ -0,0 +1,114 @@ +/* + * 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 { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import React, { useCallback, useContext, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import type { AlertsTableConfigurationRegistry } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { StatefulEventContext } from '../../../common/components/events_viewer/stateful_event_context'; +import { eventsViewerSelector } from '../../../common/components/events_viewer/selectors'; +import { getDefaultControlColumn } from '../../../timelines/components/timeline/body/control_columns'; +import { useLicense } from '../../../common/hooks/use_license'; +import type { TimelineItem } from '../../../../common/search_strategy'; +import { getAlertsDefaultModel } from '../../components/alerts_table/default_config'; +import type { TableId } from '../../../../common/types'; +import type { State } from '../../../common/store'; +import { RowAction } from '../../../common/components/control_columns/row_action'; + +export const getUseActionColumnHook = + (tableId: TableId): AlertsTableConfigurationRegistry['useActionsColumn'] => + () => { + const license = useLicense(); + const isEnterprisePlus = license.isEnterprise(); + const ACTION_BUTTON_COUNT = isEnterprisePlus ? 5 : 4; + + const eventContext = useContext(StatefulEventContext); + + const leadingControlColumns = useMemo( + () => [...getDefaultControlColumn(ACTION_BUTTON_COUNT)], + [ACTION_BUTTON_COUNT] + ); + + const { + dataTable: { + columns, + showCheckboxes, + selectedEventIds, + loadingEventIds, + } = getAlertsDefaultModel(license), + } = useSelector((state: State) => eventsViewerSelector(state, tableId)); + + const columnHeaders = columns; + + const renderCustomActionsRow = useCallback( + ({ + rowIndex, + cveProps, + setIsActionLoading, + refresh: alertsTableRefresh, + clearSelection, + ecsAlert: alert, + nonEcsData, + }) => { + const timelineItem: TimelineItem = { + _id: (alert as Ecs)._id, + _index: (alert as Ecs)._index, + ecs: alert as Ecs, + data: nonEcsData, + }; + + return ( + {}} + rowIndex={cveProps.rowIndex} + colIndex={cveProps.colIndex} + pageRowIndex={rowIndex} + selectedEventIds={selectedEventIds} + setCellProps={cveProps.setCellProps} + showCheckboxes={showCheckboxes} + onRuleChange={eventContext?.onRuleChange} + tabType={'query'} + tableId={tableId} + width={0} + setEventsLoading={({ isLoading }) => { + if (!isLoading) { + clearSelection(); + return; + } + if (setIsActionLoading) setIsActionLoading(isLoading); + }} + setEventsDeleted={() => {}} + refetch={alertsTableRefresh} + /> + ); + }, + [ + columnHeaders, + loadingEventIds, + showCheckboxes, + leadingControlColumns, + selectedEventIds, + eventContext, + ] + ); + + return { + renderCustomActionsRow, + width: 124, + }; + }; diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.tsx new file mode 100644 index 0000000000000..d5896584ae099 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_alert_actions.tsx @@ -0,0 +1,186 @@ +/* + * 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 { BulkActionsConfig } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { useCallback } from 'react'; +import type { Filter } from '@kbn/es-query'; +import { buildEsQuery } from '@kbn/es-query'; +import type { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { APM_USER_INTERACTIONS } from '../../../common/lib/apm/constants'; +import { useUpdateAlertsStatus } from '../../../common/components/toolbar/bulk_actions/use_update_alerts'; +import { useSourcererDataView } from '../../../common/containers/sourcerer'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; +import { useStartTransaction } from '../../../common/lib/apm/use_start_transaction'; +import type { AlertWorkflowStatus } from '../../../common/types'; +import type { TableId } from '../../../../common/types'; +import { FILTER_CLOSED, FILTER_OPEN, FILTER_ACKNOWLEDGED } from '../../../../common/types'; +import * as i18n from '../translations'; +import { getUpdateAlertsQuery } from '../../components/alerts_table/actions'; +import { buildTimeRangeFilter } from '../../components/alerts_table/helpers'; + +interface UseBulkAlertActionItemsArgs { + /* Table ID for which this hook is being used */ + tableId: TableId; + /* start time being passed to the Events Table */ + from: string; + /* End Time of the table being passed to the Events Table */ + to: string; + /* Sourcerer Scope Id*/ + scopeId: SourcererScopeName; + /* filter of the Alerts Query*/ + filters: Filter[]; + refetch?: () => void; +} + +export const useBulkAlertActionItems = ({ + scopeId, + filters, + from, + to, + refetch: refetchProp, +}: UseBulkAlertActionItemsArgs) => { + const { startTransaction } = useStartTransaction(); + + const { updateAlertStatus } = useUpdateAlertsStatus(); + const { addSuccess, addError, addWarning } = useAppToasts(); + + const onAlertStatusUpdateSuccess = useCallback( + (updated: number, conflicts: number, newStatus: AlertWorkflowStatus) => { + if (conflicts > 0) { + // Partial failure + addWarning({ + title: i18n.UPDATE_ALERT_STATUS_FAILED(conflicts), + text: i18n.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), + }); + } else { + let title: string; + switch (newStatus) { + case 'closed': + title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated); + break; + case 'open': + title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated); + break; + case 'acknowledged': + title = i18n.ACKNOWLEDGED_ALERT_SUCCESS_TOAST(updated); + } + addSuccess({ title }); + } + }, + [addSuccess, addWarning] + ); + + const onAlertStatusUpdateFailure = useCallback( + (newStatus: AlertWorkflowStatus, error: Error) => { + let title: string; + switch (newStatus) { + case 'closed': + title = i18n.CLOSED_ALERT_FAILED_TOAST; + break; + case 'open': + title = i18n.OPENED_ALERT_FAILED_TOAST; + break; + case 'acknowledged': + title = i18n.ACKNOWLEDGED_ALERT_FAILED_TOAST; + } + addError(error.message, { title }); + }, + [addError] + ); + + const { selectedPatterns } = useSourcererDataView(scopeId); + + const getOnAction = useCallback( + (status: AlertWorkflowStatus) => { + const onActionClick: BulkActionsConfig['onClick'] = async ( + items, + isSelectAllChecked, + setAlertLoading, + clearSelection, + refresh + ) => { + const ids = items.map((item) => item._id); + let query: Record = getUpdateAlertsQuery(ids).query; + + if (isSelectAllChecked) { + const timeFilter = buildTimeRangeFilter(from, to); + query = buildEsQuery(undefined, [], [...timeFilter, ...filters], undefined); + } + if (query) { + startTransaction({ name: APM_USER_INTERACTIONS.BULK_QUERY_STATUS_UPDATE }); + } else if (items.length > 1) { + startTransaction({ name: APM_USER_INTERACTIONS.BULK_STATUS_UPDATE }); + } else { + startTransaction({ name: APM_USER_INTERACTIONS.STATUS_UPDATE }); + } + + try { + setAlertLoading(true); + const response = await updateAlertStatus({ + index: selectedPatterns.join(','), + status, + query, + }); + + setAlertLoading(false); + if (refetchProp) refetchProp(); + refresh(); + clearSelection(); + + if (response.version_conflicts && items.length === 1) { + throw new Error(i18n.BULK_ACTION_FAILED_SINGLE_ALERT); + } + + onAlertStatusUpdateSuccess( + response.updated ?? 0, + response.version_conflicts ?? 0, + status + ); + } catch (error) { + onAlertStatusUpdateFailure(status, error); + } + }; + + return onActionClick; + }, + [ + onAlertStatusUpdateFailure, + onAlertStatusUpdateSuccess, + updateAlertStatus, + selectedPatterns, + startTransaction, + filters, + from, + to, + refetchProp, + ] + ); + + const getUpdateAlertStatusAction = useCallback( + (status: AlertWorkflowStatus) => { + const label = + status === FILTER_OPEN + ? i18n.BULK_ACTION_OPEN_SELECTED + : status === FILTER_CLOSED + ? i18n.BULK_ACTION_CLOSE_SELECTED + : i18n.BULK_ACTION_ACKNOWLEDGED_SELECTED; + + return { + label, + key: `${status}-alert-status`, + 'data-test-subj': `${status}-alert-status`, + disableOnQuery: false, + onClick: getOnAction(status), + }; + }, + [getOnAction] + ); + + return [FILTER_OPEN, FILTER_CLOSED, FILTER_ACKNOWLEDGED].map((status) => + getUpdateAlertStatusAction(status as AlertWorkflowStatus) + ); +}; diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx new file mode 100644 index 0000000000000..48072fbe0fa86 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_bulk_actions.tsx @@ -0,0 +1,95 @@ +/* + * 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 { AlertsTableConfigurationRegistry } from '@kbn/triggers-actions-ui-plugin/public/types'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import type { SerializableRecord } from '@kbn/utility-types'; +import { isEqual } from 'lodash'; +import type { Filter } from '@kbn/es-query'; +import { useCallback } from 'react'; +import type { inputsModel, State } from '../../../common/store'; +import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { inputsSelectors } from '../../../common/store'; +import type { TableId } from '../../../../common/types'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { useGlobalTime } from '../../../common/containers/use_global_time'; +import { useAddBulkToTimelineAction } from '../../components/alerts_table/timeline_actions/use_add_bulk_to_timeline'; +import { useBulkAlertActionItems } from './use_alert_actions'; +import { useBulkAddToCaseActions } from '../../components/alerts_table/timeline_actions/use_bulk_add_to_case_actions'; + +// check to see if the query is a known "empty" shape +export function isKnownEmptyQuery(query: QueryDslQueryContainer) { + const queries = [ + // the default query used by the job wizards + { bool: { must: [{ match_all: {} }] } }, + // the default query used created by lens created jobs + { bool: { filter: [], must: [{ match_all: {} }], must_not: [] } }, + // variations on the two previous queries + { bool: { filter: [], must: [{ match_all: {} }] } }, + { bool: { must: [{ match_all: {} }], must_not: [] } }, + // the query generated by QA Framework created jobs + { match_all: {} }, + ]; + if (queries.some((q) => isEqual(q, query))) { + return true; + } + + return false; +} + +function getFiltersForDSLQuery(datafeedQuery: QueryDslQueryContainer): Filter[] { + if (isKnownEmptyQuery(datafeedQuery)) { + return []; + } + + return [ + { + meta: { + negate: false, + disabled: false, + type: 'custom', + value: JSON.stringify(datafeedQuery), + }, + query: datafeedQuery as SerializableRecord, + }, + ]; +} + +export const getBulkActionHook = + (tableId: TableId): AlertsTableConfigurationRegistry['useBulkActions'] => + (query) => { + const { from, to } = useGlobalTime(); + const filters = getFiltersForDSLQuery(query); + const getGlobalQueries = inputsSelectors.globalQuery(); + + const globalQuery = useShallowEqualSelector((state: State) => getGlobalQueries(state)); + + const refetchGlobalQuery = useCallback(() => { + globalQuery.forEach((q) => q.refetch && (q.refetch as inputsModel.Refetch)()); + }, [globalQuery]); + + const timelineAction = useAddBulkToTimelineAction({ + localFilters: filters, + from, + to, + scopeId: SourcererScopeName.detections, + tableId, + }); + + const alertActions = useBulkAlertActionItems({ + scopeId: SourcererScopeName.detections, + filters, + from, + to, + tableId, + refetch: refetchGlobalQuery, + }); + + const caseActions = useBulkAddToCaseActions(); + + return [...alertActions, ...caseActions, timelineAction]; + }; diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_cell_actions.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_cell_actions.tsx new file mode 100644 index 0000000000000..277deee3e6c80 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_cell_actions.tsx @@ -0,0 +1,109 @@ +/* + * 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 { BrowserField, TimelineNonEcsData } from '@kbn/timelines-plugin/common'; +import type { AlertsTableConfigurationRegistry } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { useCallback, useMemo } from 'react'; +import { getAllFieldsByName } from '../../../common/containers/source'; +import type { UseDataGridColumnsSecurityCellActionsProps } from '../../../common/components/cell_actions'; +import { useDataGridColumnsSecurityCellActions } from '../../../common/components/cell_actions'; +import { SecurityCellActionsTrigger } from '../../../actions/constants'; +import { tableDefaults } from '../../../common/store/data_table/defaults'; +import { VIEW_SELECTION } from '../../../../common/constants'; +import { useSourcererDataView } from '../../../common/containers/sourcerer'; +import type { TableId } from '../../../../common/types'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { dataTableSelectors } from '../../../common/store/data_table'; + +export const getUseCellActionsHook = (tableId: TableId) => { + const useCellActions: AlertsTableConfigurationRegistry['useCellActions'] = ({ + columns, + data, + dataGridRef, + }) => { + const { browserFields } = useSourcererDataView(SourcererScopeName.detections); + /** + * There is difference between how `triggers actions` fetched data v/s + * how security solution fetches data via timelineSearchStrategy + * + * _id and _index fields are array in timelineSearchStrategy but not in + * ruleStrategy + * + * + */ + + const browserFieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]); + const finalData = useMemo( + () => + (data as TimelineNonEcsData[][]).map((row) => + row.map((field) => { + let localField = field; + if (['_id', '_index'].includes(field.field)) { + const newValue = field.value ?? ''; + localField = { + field: field.field, + value: Array.isArray(newValue) ? newValue : [newValue], + }; + } + return localField; + }) + ), + [data] + ); + + const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []); + + const viewMode = + useShallowEqualSelector((state) => (getTable(state, tableId) ?? tableDefaults).viewMode) ?? + tableDefaults.viewMode; + + const cellActionProps = useMemo(() => { + const fields = + viewMode === VIEW_SELECTION.eventRenderedView + ? [] + : columns.map((col) => { + const fieldMeta: Partial | undefined = browserFieldsByName[col.id]; + return { + name: col.id, + type: fieldMeta?.type ?? 'keyword', + values: (finalData as TimelineNonEcsData[][]).map( + (row) => row.find((rowData) => rowData.field === col.id)?.value ?? [] + ), + aggregatable: fieldMeta?.aggregatable ?? false, + }; + }); + + return { + triggerId: SecurityCellActionsTrigger.DEFAULT, + fields, + metadata: { + // cell actions scope + scopeId: tableId, + }, + dataGridRef, + }; + }, [viewMode, browserFieldsByName, columns, finalData, dataGridRef]); + + const cellActions = useDataGridColumnsSecurityCellActions(cellActionProps); + + const getCellActions = useCallback( + (_columnId: string, columnIndex: number) => { + if (cellActions.length === 0) return []; + return cellActions[columnIndex]; + }, + [cellActions] + ); + + return { + getCellActions, + visibleCellActions: 3, + }; + }; + + return useCellActions; +}; diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_persistent_controls.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_persistent_controls.tsx new file mode 100644 index 0000000000000..c43a5195a29be --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_persistent_controls.tsx @@ -0,0 +1,109 @@ +/* + * 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, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useGetGroupingSelector } from '../../../common/containers/grouping/hooks/use_get_group_selector'; +import { defaultGroup } from '../../../common/store/grouping/defaults'; +import { isNoneGroup } from '../../../common/components/grouping'; +import type { State } from '../../../common/store'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +import { useSourcererDataView } from '../../../common/containers/sourcerer'; +import { useDataTableFilters } from '../../../common/hooks/use_data_table_filters'; +import { dataTableSelectors } from '../../../common/store/data_table'; +import { changeViewMode } from '../../../common/store/data_table/actions'; +import type { ViewSelection, TableId } from '../../../../common/types'; +import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { RightTopMenu } from '../../../common/components/events_viewer/right_top_menu'; +import { AdditionalFiltersAction } from '../../components/alerts_table/additional_filters_action'; +import { tableDefaults } from '../../../common/store/data_table/defaults'; +import { groupSelectors } from '../../../common/store/grouping'; + +export const getPersistentControlsHook = (tableId: TableId) => { + const usePersistentControls = () => { + const dispatch = useDispatch(); + const getGroupbyIdSelector = groupSelectors.getGroupByIdSelector(); + + const { activeGroup: selectedGroup } = + useSelector((state: State) => getGroupbyIdSelector(state, tableId)) ?? defaultGroup; + + const { indexPattern: indexPatterns } = useSourcererDataView(SourcererScopeName.detections); + + const groupsSelector = useGetGroupingSelector({ + fields: indexPatterns.fields, + groupingId: tableId, + tableId, + }); + + const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []); + + const tableView = useShallowEqualSelector( + (state) => (getTable(state, tableId) ?? tableDefaults).viewMode ?? tableDefaults.viewMode + ); + + const handleChangeTableView = useCallback( + (selectedView: ViewSelection) => { + dispatch( + changeViewMode({ + id: tableId, + viewMode: selectedView, + }) + ); + }, + [dispatch] + ); + + const { + showBuildingBlockAlerts, + setShowBuildingBlockAlerts, + showOnlyThreatIndicatorAlerts, + setShowOnlyThreatIndicatorAlerts, + } = useDataTableFilters(tableId); + + const additionalFiltersComponent = useMemo( + () => ( + + ), + [ + showBuildingBlockAlerts, + setShowBuildingBlockAlerts, + showOnlyThreatIndicatorAlerts, + setShowOnlyThreatIndicatorAlerts, + ] + ); + + const rightTopMenu = useMemo( + () => ( + + ), + [tableView, handleChangeTableView, additionalFiltersComponent, groupsSelector, selectedGroup] + ); + + return { + right: rightTopMenu, + }; + }; + + return usePersistentControls; +}; diff --git a/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_trigger_actions_browser_fields_options.tsx b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_trigger_actions_browser_fields_options.tsx new file mode 100644 index 0000000000000..443dde3fd6ee8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/hooks/trigger_actions_alert_table/use_trigger_actions_browser_fields_options.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AlertsTableConfigurationRegistry } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { useFieldBrowserOptions } from '../../../timelines/components/fields_browser'; +import type { SourcererScopeName } from '../../../common/store/sourcerer/model'; + +export const getUseTriggersActionsFieldBrowserOptions = (scopeId: SourcererScopeName) => { + const useTriggersActionsFieldBrowserOptions: AlertsTableConfigurationRegistry['useFieldBrowserOptions'] = + ({ onToggleColumn }) => { + const options = useFieldBrowserOptions({ + sourcererScope: scopeId, + removeColumn: onToggleColumn, + upsertColumn: (column) => { + onToggleColumn(column.id); + }, + }); + + return { + createFieldButton: options.createFieldButton, + }; + }; + + return useTriggersActionsFieldBrowserOptions; +}; diff --git a/x-pack/plugins/security_solution/public/detections/index.ts b/x-pack/plugins/security_solution/public/detections/index.ts index f5315e03074c8..4a6bb4e4c56f9 100644 --- a/x-pack/plugins/security_solution/public/detections/index.ts +++ b/x-pack/plugins/security_solution/public/detections/index.ts @@ -11,6 +11,7 @@ import { TableId } from '../../common/types'; import { getDataTablesInStorageByIds } from '../timelines/containers/local_storage'; import { routes } from './routes'; import type { SecuritySubPlugin } from '../app/types'; +import { getAllGroupsInStorage } from '../timelines/containers/local_storage/groups'; export const DETECTIONS_TABLE_IDS: TableIdLiteral[] = [ TableId.alertsOnRuleDetailsPage, @@ -25,6 +26,9 @@ export class Detections { storageDataTables: { tableById: getDataTablesInStorageByIds(storage, DETECTIONS_TABLE_IDS), }, + groups: { + groupById: getAllGroupsInStorage(storage), + }, routes, }; } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index f5a044a0b287d..9ccf3c6bb193b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -113,6 +113,13 @@ jest.mock('../../../common/lib/kibana', () => { get: jest.fn(), set: jest.fn(), }, + triggersActionsUi: { + alertsTableConfigurationRegistry: {}, + getAlertsStateTable: () => <>, + }, + sessionView: { + getSessionView: jest.fn().mockReturnValue(
), + }, }, }), useToasts: jest.fn().mockReturnValue({ @@ -124,6 +131,17 @@ jest.mock('../../../common/lib/kibana', () => { }; }); +jest.mock('../../../timelines/components/side_panel/hooks/use_detail_panel', () => { + return { + useDetailPanel: () => ({ + openEventDetailsPanel: jest.fn(), + handleOnDetailsPanelClosed: () => {}, + DetailsPanel: () =>
, + shouldShowDetailsPanel: false, + }), + }; +}); + const state: State = { ...mockGlobalState, }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index 6b51c12607e38..0cb2cf9d3807e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -10,11 +10,11 @@ // TODO: Refactor code - component can be broken apart import { EuiFlexGroup, - EuiFlexItem, - EuiHorizontalRule, EuiLoadingSpinner, EuiSpacer, EuiWindowEvent, + EuiHorizontalRule, + EuiFlexItem, } from '@elastic/eui'; import styled from 'styled-components'; import { noop } from 'lodash/fp'; @@ -25,6 +25,8 @@ import type { Dispatch } from 'redux'; import { isTab } from '@kbn/timelines-plugin/public'; import type { Filter } from '@kbn/es-query'; import type { DocLinks } from '@kbn/doc-links'; +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'; import { FILTER_OPEN, TableId } from '../../../../common/types'; import { tableDefaults } from '../../../common/store/data_table/defaults'; @@ -40,7 +42,6 @@ import { SiemSearchBar } from '../../../common/components/search_bar'; import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper'; import { inputsSelectors } from '../../../common/store/inputs'; import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; -import { AlertsTable } from '../../components/alerts_table'; import { NoApiIntegrationKeyCallOut } from '../../components/callouts/no_api_integration_callout'; import { useUserData } from '../../components/user_info'; import { DetectionEngineNoIndex } from './detection_engine_no_index'; @@ -77,6 +78,8 @@ import { DetectionPageFilterSet } from '../../components/detection_page_filters' import type { FilterGroupHandler } from '../../../common/components/filter_group/types'; import type { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { AlertsTableFilterGroup } from '../../components/alerts_table/alerts_filter_group'; +import { GroupedAlertsTable } from '../../components/alerts_table/grouped_alerts'; +import { AlertsTableComponent } from '../../components/alerts_table'; /** * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. */ @@ -119,10 +122,10 @@ const DetectionEnginePageComponent: React.FC = ({ isAuthenticated: isUserAuthenticated, hasEncryptionKey, signalIndexName, - hasIndexWrite = false, - hasIndexMaintenance = false, canUserREAD, hasIndexRead, + hasIndexWrite, + hasIndexMaintenance, }, ] = useUserData(); const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } = @@ -151,8 +154,11 @@ const DetectionEnginePageComponent: React.FC = ({ } = useSourcererDataView(SourcererScopeName.detections); const { formatUrl } = useFormatUrl(SecurityPageName.rules); - const [showBuildingBlockAlerts, setShowBuildingBlockAlerts] = useState(false); - const [showOnlyThreatIndicatorAlerts, setShowOnlyThreatIndicatorAlerts] = useState(false); + + const { showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts } = useDataTableFilters( + TableId.alertsOnAlertsPage + ); + const loading = userInfoLoading || listsConfigLoading; const { application: { navigateToUrl }, @@ -237,20 +243,6 @@ const DetectionEnginePageComponent: React.FC = ({ [showBuildingBlockAlerts, showOnlyThreatIndicatorAlerts, alertPageFilters] ); - const onShowBuildingBlockAlertsChangedCallback = useCallback( - (newShowBuildingBlockAlerts: boolean) => { - setShowBuildingBlockAlerts(newShowBuildingBlockAlerts); - }, - [setShowBuildingBlockAlerts] - ); - - const onShowOnlyThreatIndicatorAlertsCallback = useCallback( - (newShowOnlyThreatIndicatorAlerts: boolean) => { - setShowOnlyThreatIndicatorAlerts(newShowOnlyThreatIndicatorAlerts); - }, - [setShowOnlyThreatIndicatorAlerts] - ); - const { signalIndexNeedsInit, pollForSignalIndex } = useSignalHelpers(); const onSkipFocusBeforeEventsTable = useCallback(() => { @@ -301,6 +293,7 @@ const DetectionEnginePageComponent: React.FC = ({ () => loading || areDetectionPageFiltersLoading, [loading, areDetectionPageFiltersLoading] ); + const isChartPanelLoading = useMemo( () => isLoadingIndexPattern || areDetectionPageFiltersLoading, [isLoadingIndexPattern, areDetectionPageFiltersLoading] @@ -365,6 +358,21 @@ const DetectionEnginePageComponent: React.FC = ({ ] ); + const renderGroupedAlertTable = useCallback( + (groupingFilters: Filter[]) => { + return ( + + ); + }, + [alertsTableDefaultFilters, isAlertTableLoading] + ); + if (loading) { return ( @@ -444,23 +452,20 @@ const DetectionEnginePageComponent: React.FC = ({ signalIndexName={signalIndexName} updateDateRangeCallback={updateDateRangeCallback} /> - - diff --git a/x-pack/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/index.tsx b/x-pack/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/index.tsx index 8be7e9d0ac251..f46b009643f81 100644 --- a/x-pack/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/index.tsx +++ b/x-pack/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/index.tsx @@ -231,7 +231,7 @@ export const getHostNames = (hosts: HostEcs[]): string[] => { export const getUncommonColumnsCurated = (pageType: HostsType): UncommonProcessTableColumns => { const columns: UncommonProcessTableColumns = getUncommonColumns(); if (pageType === HostsType.details) { - return [i18n.HOSTS, i18n.NUMBER_OF_HOSTS].reduce((acc, name) => { + return [i18n.HOSTS, i18n.NUMBER_OF_HOSTS].reduce((acc, name) => { acc.splice( acc.findIndex((column) => column.name === name), 1 diff --git a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/groups.ts b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/groups.ts new file mode 100644 index 0000000000000..66a65284ab3ee --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/groups.ts @@ -0,0 +1,30 @@ +/* + * 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 { Storage } from '@kbn/kibana-utils-plugin/public'; +import type { GroupModel } from '../../../common/store/grouping'; +const LOCAL_STORAGE_GROUPING_KEY = 'groups'; + +const EMPTY_GROUP = {} as { + [K: string]: GroupModel; +}; + +export const getAllGroupsInStorage = (storage: Storage) => { + const allGroups = storage.get(LOCAL_STORAGE_GROUPING_KEY); + if (!allGroups) { + return EMPTY_GROUP; + } + return allGroups; +}; + +export const addGroupsToStorage = (storage: Storage, groupingId: string, group: GroupModel) => { + const groups = getAllGroupsInStorage(storage); + storage.set(LOCAL_STORAGE_GROUPING_KEY, { + ...groups, + [groupingId]: group, + }); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts index cab24ae94b88b..b4383385ce256 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.test.ts @@ -14,12 +14,15 @@ import { getDataTablesInStorageByIds, getAllDataTablesInStorage, addTableInStorage, + migrateAlertTableStateToTriggerActionsState, } from '.'; import { mockDataTableModel, createSecuritySolutionStorageMock } from '../../../common/mock'; import { useKibana } from '../../../common/lib/kibana'; import type { DataTableModel } from '../../../common/store/data_table/model'; import { TableId } from '../../../../common/types'; +import { VIEW_SELECTION } from '../../../../common/constants'; +import type { DataTableState } from '../../../common/store/data_table/types'; jest.mock('../../../common/lib/kibana'); @@ -639,6 +642,11 @@ describe('SiemLocalStorage', () => { initialized: true, updated: 1665943295913, totalCount: 0, + viewMode: VIEW_SELECTION.gridView, + additionalFilters: { + showBuildingBlockAlerts: false, + showOnlyThreatIndicatorAlerts: false, + }, }; const dataTables = getDataTablesInStorageByIds(storage, [TableId.alertsOnAlertsPage]); expect(dataTables).toStrictEqual({ @@ -780,4 +788,702 @@ describe('SiemLocalStorage', () => { }); }); }); + + describe('Trigger Actions Alert Table Migration', () => { + const legacyDataTableState: DataTableState['dataTable']['tableById'] = { + 'alerts-page': { + queryFields: [], + isLoading: false, + defaultColumns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Rule', + id: 'kibana.alert.rule.name', + initialWidth: 180, + linkField: 'kibana.alert.rule.uuid', + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Severity', + id: 'kibana.alert.severity', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Risk Score', + id: 'kibana.alert.risk_score', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Reason', + id: 'kibana.alert.reason', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'process.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'file.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + initialWidth: 180, + }, + ], + dataViewId: null, + deletedEventIds: [], + expandedDetail: { + query: { + params: { hostName: 'Host-riizqhdnoy' }, + panelView: 'hostDetail', + }, + }, + filters: [], + indexNames: [], + isSelectAllChecked: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + loadingEventIds: [], + selectedEventIds: {}, + showCheckboxes: false, + sort: [ + { + columnId: '@timestamp', + columnType: 'date', + esTypes: ['date'], + sortDirection: 'desc', + }, + ], + selectAll: false, + graphEventId: '', + sessionViewConfig: null, + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Rule', + id: 'kibana.alert.rule.name', + initialWidth: 180, + linkField: 'kibana.alert.rule.uuid', + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Severity', + id: 'kibana.alert.severity', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Risk Score', + id: 'kibana.alert.risk_score', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Reason', + id: 'kibana.alert.reason', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'process.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'file.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + initialWidth: 180, + }, + ], + title: 'Sessions', + totalCount: 419, + viewMode: 'gridView', + additionalFilters: { + showBuildingBlockAlerts: false, + showOnlyThreatIndicatorAlerts: false, + }, + id: 'alerts-page', + initialized: true, + }, + 'hosts-page-events': { + isLoading: false, + queryFields: [], + defaultColumns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 190, + esTypes: ['date'], + type: 'date', + }, + { columnHeaderType: 'not-filtered', id: 'message' }, + { columnHeaderType: 'not-filtered', id: 'host.name' }, + { columnHeaderType: 'not-filtered', id: 'event.module' }, + { columnHeaderType: 'not-filtered', id: 'agent.type' }, + { columnHeaderType: 'not-filtered', id: 'event.dataset' }, + { columnHeaderType: 'not-filtered', id: 'event.action' }, + { columnHeaderType: 'not-filtered', id: 'user.name' }, + { columnHeaderType: 'not-filtered', id: 'source.ip' }, + { columnHeaderType: 'not-filtered', id: 'destination.ip' }, + ], + dataViewId: 'security-solution-default', + deletedEventIds: [], + expandedDetail: {}, + filters: [], + indexNames: ['logs-*'], + isSelectAllChecked: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + loadingEventIds: [], + selectedEventIds: {}, + showCheckboxes: true, + sort: [ + { + columnId: '@timestamp', + columnType: 'date', + esTypes: ['date'], + sortDirection: 'desc', + }, + ], + selectAll: false, + graphEventId: '', + sessionViewConfig: null, + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 190, + esTypes: ['date'], + type: 'date', + }, + { columnHeaderType: 'not-filtered', id: 'message' }, + { columnHeaderType: 'not-filtered', id: 'host.name' }, + { columnHeaderType: 'not-filtered', id: 'event.module' }, + { columnHeaderType: 'not-filtered', id: 'agent.type' }, + { columnHeaderType: 'not-filtered', id: 'event.dataset' }, + { columnHeaderType: 'not-filtered', id: 'event.action' }, + { columnHeaderType: 'not-filtered', id: 'user.name' }, + { columnHeaderType: 'not-filtered', id: 'source.ip' }, + { columnHeaderType: 'not-filtered', id: 'destination.ip' }, + ], + title: '', + totalCount: 486, + viewMode: 'gridView', + additionalFilters: { + showBuildingBlockAlerts: false, + showOnlyThreatIndicatorAlerts: false, + }, + id: 'hosts-page-events', + initialized: true, + updated: 1676474453149, + }, + 'alerts-rules-details-page': { + isLoading: false, + queryFields: [], + defaultColumns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Rule', + id: 'kibana.alert.rule.name', + initialWidth: 180, + linkField: 'kibana.alert.rule.uuid', + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Severity', + id: 'kibana.alert.severity', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Risk Score', + id: 'kibana.alert.risk_score', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Reason', + id: 'kibana.alert.reason', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'process.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'file.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + initialWidth: 180, + }, + ], + dataViewId: null, + deletedEventIds: [], + expandedDetail: {}, + filters: [], + indexNames: [], + isSelectAllChecked: false, + itemsPerPage: 25, + itemsPerPageOptions: [10, 25, 50, 100], + loadingEventIds: [], + selectedEventIds: {}, + showCheckboxes: false, + sort: [ + { + columnId: '@timestamp', + columnType: 'date', + esTypes: ['date'], + sortDirection: 'desc', + }, + + { + columnId: 'kibana.alert.rule.name', + columnType: 'string', + esTypes: ['keyword'], + sortDirection: 'desc', + }, + ], + selectAll: false, + graphEventId: '', + sessionViewConfig: null, + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Rule', + id: 'kibana.alert.rule.name', + initialWidth: 180, + linkField: 'kibana.alert.rule.uuid', + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Severity', + id: 'kibana.alert.severity', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Risk Score', + id: 'kibana.alert.risk_score', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Reason', + id: 'kibana.alert.reason', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'process.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'file.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + initialWidth: 180, + }, + ], + title: 'Sessions', + totalCount: 403, + viewMode: 'gridView', + additionalFilters: { + showBuildingBlockAlerts: false, + showOnlyThreatIndicatorAlerts: false, + }, + id: 'alerts-rules-details-page', + initialized: true, + }, + }; + + const expectedMigratedResult: Array>> = [ + { + 'detection-engine-alert-table-securitySolution-alerts-page-gridView': { + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Rule', + id: 'kibana.alert.rule.name', + initialWidth: 180, + linkField: 'kibana.alert.rule.uuid', + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Severity', + id: 'kibana.alert.severity', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Risk Score', + id: 'kibana.alert.risk_score', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Reason', + id: 'kibana.alert.reason', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'process.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'file.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + initialWidth: 180, + }, + ], + sort: [{ '@timestamp': { order: 'desc' } }], + visibleColumns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Rule', + id: 'kibana.alert.rule.name', + initialWidth: 180, + linkField: 'kibana.alert.rule.uuid', + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Severity', + id: 'kibana.alert.severity', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Risk Score', + id: 'kibana.alert.risk_score', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Reason', + id: 'kibana.alert.reason', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'process.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'file.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + initialWidth: 180, + }, + ], + }, + }, + { + 'detection-engine-alert-table-securitySolution-rule-details-gridView': { + columns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Rule', + id: 'kibana.alert.rule.name', + initialWidth: 180, + linkField: 'kibana.alert.rule.uuid', + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Severity', + id: 'kibana.alert.severity', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Risk Score', + id: 'kibana.alert.risk_score', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Reason', + id: 'kibana.alert.reason', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'process.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'file.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + initialWidth: 180, + }, + ], + sort: [ + { '@timestamp': { order: 'desc' } }, + { 'kibana.alert.rule.name': { order: 'desc' } }, + ], + visibleColumns: [ + { + columnHeaderType: 'not-filtered', + id: '@timestamp', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Rule', + id: 'kibana.alert.rule.name', + initialWidth: 180, + linkField: 'kibana.alert.rule.uuid', + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Severity', + id: 'kibana.alert.severity', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Risk Score', + id: 'kibana.alert.risk_score', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + displayAsText: 'Reason', + id: 'kibana.alert.reason', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'host.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'user.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'process.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'file.name', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'source.ip', + initialWidth: 180, + }, + { + columnHeaderType: 'not-filtered', + id: 'destination.ip', + initialWidth: 180, + }, + ], + }, + }, + ]; + beforeEach(() => { + storage.clear(); + }); + + it('User Table preference already exists in local storage - GridView', () => { + migrateAlertTableStateToTriggerActionsState(storage, legacyDataTableState); + for (const item of expectedMigratedResult) { + for (const key of Object.keys(item)) { + expect(item[key]).toMatchObject(storage.get(key)); + } + } + }); + it('Trigger Actions state already exists for Alerts Table', () => { + const existingKey = 'detection-engine-alert-table-securitySolution-alerts-page-gridView'; + storage.set(existingKey, 'Some value'); + + migrateAlertTableStateToTriggerActionsState(storage, legacyDataTableState); + for (const item of expectedMigratedResult) { + for (const key of Object.keys(item)) { + if (key === existingKey) { + expect(storage.get(key)).toEqual('Some value'); + } else { + expect(storage.get(key)).toMatchObject(item[key]); + } + } + } + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx index d4bd979b26637..a234fd46dc5f2 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/local_storage/index.tsx @@ -7,6 +7,9 @@ import { isEmpty } from 'lodash/fp'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; +import { TableId } from '../../../../common/types/data_table'; +import type { DataTableState } from '../../../common/store/data_table/types'; +import { ALERTS_TABLE_REGISTRY_CONFIG_IDS, VIEW_SELECTION } from '../../../../common/constants'; import type { ColumnHeaderOptions, TableIdLiteral } from '../../../../common/types'; import type { DataTablesStorage } from './types'; import { useKibana } from '../../../common/lib/kibana'; @@ -54,6 +57,11 @@ export const migrateLegacyTimelinesToSecurityDataTable = (legacyTimelineTables: deletedEventIds: timelineModel.deletedEventIds, expandedDetail: timelineModel.expandedDetail, totalCount: timelineModel.totalCount || 0, + viewMode: VIEW_SELECTION.gridView, + additionalFilters: { + showBuildingBlockAlerts: false, + showOnlyThreatIndicatorAlerts: false, + }, ...(Array.isArray(timelineModel.columns) ? { columns: timelineModel.columns @@ -66,6 +74,41 @@ export const migrateLegacyTimelinesToSecurityDataTable = (legacyTimelineTables: }, {} as { [K in TableIdLiteral]: DataTableModel }); }; +export const migrateAlertTableStateToTriggerActionsState = ( + storage: Storage, + legacyDataTableState: DataTableState['dataTable']['tableById'] +) => { + const triggerActionsStateKey: Record = { + [TableId.alertsOnAlertsPage]: `detection-engine-alert-table-${ALERTS_TABLE_REGISTRY_CONFIG_IDS.ALERTS_PAGE}-gridView`, + [TableId.alertsOnRuleDetailsPage]: `detection-engine-alert-table-${ALERTS_TABLE_REGISTRY_CONFIG_IDS.RULE_DETAILS}-gridView`, + }; + + const triggersActionsState = Object.keys(legacyDataTableState) + .filter((tableKey) => { + return tableKey in triggerActionsStateKey && !storage.get(triggerActionsStateKey[tableKey]); + }) + .map((tableKey) => { + const newKey = triggerActionsStateKey[ + tableKey as keyof typeof triggerActionsStateKey + ] as string; + return { + [newKey]: { + columns: legacyDataTableState[tableKey].columns, + sort: legacyDataTableState[tableKey].sort.map((sortCandidate) => ({ + [sortCandidate.columnId]: { order: sortCandidate.sortDirection }, + })), + visibleColumns: legacyDataTableState[tableKey].columns, + }, + }; + }); + + triggersActionsState.forEach((stateObj) => + Object.keys(stateObj).forEach((key) => { + storage.set(key, stateObj[key]); + }) + ); +}; + /** * Migrates the value of the column's `width` property to `initialWidth` * when `width` is valid, and `initialWidth` is invalid @@ -108,6 +151,8 @@ export const getDataTablesInStorageByIds = (storage: Storage, tableIds: TableIdL } } + migrateAlertTableStateToTriggerActionsState(storage, allDataTables); + return tableIds.reduce((acc, tableId) => { const tableModel = allDataTables[tableId]; if (!tableModel) { diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts index 3fb42e99a032f..9da516308cdfd 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts @@ -11,6 +11,7 @@ import type { Observable } from 'rxjs'; import type { Storage } from '@kbn/kibana-utils-plugin/public'; import type { CoreStart } from '@kbn/core/public'; import type { FilterManager } from '@kbn/data-plugin/public'; +import type { GroupsById } from '../../../common/store/grouping'; import type { ColumnHeaderOptions, RowRendererId, @@ -63,6 +64,7 @@ export interface TimelineEpicDependencies { selectAllTimelineQuery: () => (state: State, id: string) => inputsModel.GlobalQuery; selectNotesByIdSelector: (state: State) => NotesById; tableByIdSelector: (state: State) => TableById; + groupByIdSelector: (state: State) => GroupsById; kibana$: Observable; storage: Storage; } diff --git a/x-pack/plugins/timelines/server/plugin.ts b/x-pack/plugins/timelines/server/plugin.ts index e7af669344d12..9e25464ba7154 100644 --- a/x-pack/plugins/timelines/server/plugin.ts +++ b/x-pack/plugins/timelines/server/plugin.ts @@ -33,6 +33,7 @@ export class TimelinesPlugin const TimelineSearchStrategy = timelineSearchStrategyProvider( depsStart.data, depsStart.alerting, + this.logger, this.security ); const TimelineEqlSearchStrategy = timelineEqlSearchStrategyProvider(depsStart.data); diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/index.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/index.ts index d450daadf4689..b9e1b32bc10f4 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/index.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/index.ts @@ -22,6 +22,7 @@ import { import { ENHANCED_ES_SEARCH_STRATEGY, ISearchOptions } from '@kbn/data-plugin/common'; import { AuditLogger, SecurityPluginSetup } from '@kbn/security-plugin/server'; import { AlertAuditAction, alertAuditEvent } from '@kbn/rule-registry-plugin/server'; +import { Logger } from '@kbn/logging'; import { TimelineFactoryQueryTypes, TimelineStrategyResponseType, @@ -35,6 +36,7 @@ import { isAggCardinalityAggregate } from './factory/helpers/is_agg_cardinality_ export const timelineSearchStrategyProvider = ( data: PluginStart, alerting: AlertingPluginStartContract, + logger: Logger, security?: SecurityPluginSetup ): ISearchStrategy, TimelineStrategyResponseType> => { const esAsInternal = data.search.searchAsInternalUser; @@ -70,7 +72,7 @@ export const timelineSearchStrategyProvider = { @@ -93,6 +95,7 @@ const timelineSearchStrategy = ({ options: ISearchOptions; deps: SearchStrategyDependencies; queryFactory: TimelineFactory; + logger: Logger; }) => { const dsl = queryFactory.buildDsl(request); return es.search({ ...request, params: dsl }, options, deps).pipe( diff --git a/x-pack/plugins/timelines/tsconfig.json b/x-pack/plugins/timelines/tsconfig.json index 288701db08c55..0612384baf8a7 100644 --- a/x-pack/plugins/timelines/tsconfig.json +++ b/x-pack/plugins/timelines/tsconfig.json @@ -32,6 +32,7 @@ "@kbn/i18n", "@kbn/security-plugin", "@kbn/safer-lodash-set", + "@kbn/logging", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx index 479e0b138e867..e93db4793cf14 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.test.tsx @@ -6,7 +6,7 @@ */ import React, { useMemo, useReducer } from 'react'; -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { fireEvent, render, screen, within, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import { @@ -17,10 +17,20 @@ import { ALERT_CASE_IDS, } from '@kbn/rule-data-utils'; import { AlertsTable } from './alerts_table'; -import type { Alerts, AlertsTableProps, BulkActionsState, RowSelectionState } from '../../../types'; -import { EuiButtonIcon, EuiFlexItem } from '@elastic/eui'; +import { + AlertsField, + AlertsTableConfigurationRegistry, + AlertsTableProps, + BulkActionsState, + FetchAlertData, + RowSelectionState, + UseCellActions, + Alerts, +} from '../../../types'; +import { EuiButton, EuiButtonIcon, EuiDataGridColumnCellAction, EuiFlexItem } from '@elastic/eui'; import { BulkActionsContext } from './bulk_actions/context'; import { bulkActionsReducer } from './bulk_actions/reducer'; +import { BrowserFields } from '@kbn/rule-registry-plugin/common'; import { getCasesMockMap } from './cases/index.mock'; import { createAppMockRenderer } from '../test_utils'; @@ -77,6 +87,94 @@ const alerts = [ }, ] as unknown as Alerts; +const oldAlertsData = [ + [ + { + field: AlertsField.name, + value: ['one'], + }, + { + field: AlertsField.reason, + value: ['two'], + }, + ], + [ + { + field: AlertsField.name, + value: ['three'], + }, + { + field: AlertsField.reason, + value: ['four'], + }, + ], +] as FetchAlertData['oldAlertsData']; + +const ecsAlertsData = [ + [ + { + '@timestamp': ['2023-01-28T10:48:49.559Z'], + _id: 'SomeId', + _index: 'SomeIndex', + kibana: { + alert: { + rule: { + name: ['one'], + }, + reason: ['two'], + }, + }, + }, + ], + [ + { + '@timestamp': ['2023-01-27T10:48:49.559Z'], + _id: 'SomeId2', + _index: 'SomeIndex', + kibana: { + alert: { + rule: { + name: ['three'], + }, + reason: ['four'], + }, + }, + }, + ], +] as FetchAlertData['ecsAlertsData']; + +const cellActionOnClickMockedFn = jest.fn(); + +const TEST_ID = { + CELL_ACTIONS_POPOVER: 'euiDataGridExpansionPopover', + CELL_ACTIONS_EXPAND: 'euiDataGridCellExpandButton', + FIELD_BROWSER: 'fields-browser-container', + FIELD_BROWSER_BTN: 'show-field-browser', + FIELD_BROWSER_CUSTOM_CREATE_BTN: 'field-browser-custom-create-btn', +}; + +const mockedUseCellActions: UseCellActions = () => { + const mockedGetCellActions = (columnId: string): EuiDataGridColumnCellAction[] => { + const fakeCellAction: EuiDataGridColumnCellAction = ({ rowIndex, Component }) => { + const label = 'Fake Cell First Action'; + return ( + cellActionOnClickMockedFn(columnId, rowIndex)} + data-test-subj={'fake-cell-first-action'} + iconType="refresh" + aria-label={label} + /> + ); + }; + return [fakeCellAction]; + }; + return { + getCellActions: mockedGetCellActions, + visibleCellActions: 2, + disabledCellActions: [], + }; +}; + // FAILING: https://github.com/elastic/kibana/issues/151688 describe.skip('AlertsTable', () => { const fetchAlertsData = { @@ -86,18 +184,19 @@ describe.skip('AlertsTable', () => { isInitializing: false, isLoading: false, getInspectQuery: jest.fn().mockImplementation(() => ({ request: {}, response: {} })), - onColumnsChange: jest.fn(), onPageChange: jest.fn(), onSortChange: jest.fn(), refresh: jest.fn(), sort: [], + ecsAlertsData, + oldAlertsData, }; const useFetchAlertsData = () => { return fetchAlertsData; }; - const alertsTableConfiguration = { + const alertsTableConfiguration: AlertsTableConfigurationRegistry = { id: '', casesFeatureId: '', columns, @@ -120,15 +219,40 @@ describe.skip('AlertsTable', () => { onClick: () => {}, }, ], + useFieldBrowserOptions: () => { + return { + createFieldButton: () => ( + + ), + }; + }, + }; + + const browserFields: BrowserFields = { + kibana: { + fields: { + [AlertsField.uuid]: { + category: 'kibana', + name: AlertsField.uuid, + }, + [AlertsField.name]: { + category: 'kibana', + name: AlertsField.name, + }, + [AlertsField.reason]: { + category: 'kibana', + name: AlertsField.reason, + }, + }, + }, }; const casesMap = getCasesMockMap(); - const tableProps = { + const tableProps: AlertsTableProps = { alertsTableConfiguration, casesData: { cases: casesMap, isLoading: false }, columns, - bulkActions: [], deletedEventIds: [], disabledCellActions: [], pageSize: 1, @@ -136,7 +260,6 @@ describe.skip('AlertsTable', () => { leadingControlColumns: [], showExpandToDetails: true, trailingControlColumns: [], - alerts, useFetchAlertsData, visibleColumns: columns.map((c) => c.id), 'data-test-subj': 'testTable', @@ -145,14 +268,15 @@ describe.skip('AlertsTable', () => { onResetColumns: () => {}, onColumnsChange: () => {}, onChangeVisibleColumns: () => {}, - browserFields: {}, + browserFields, + query: {}, }; const defaultBulkActionsState = { rowSelection: new Map(), isAllSelected: false, areAllVisibleRowsSelected: false, - rowCount: 2, + rowCount: 4, }; const AlertsTableWithProviders: React.FunctionComponent< @@ -373,6 +497,7 @@ describe.skip('AlertsTable', () => { ), + width: 124, }; }, }, @@ -420,6 +545,69 @@ describe.skip('AlertsTable', () => { }); }); + describe('cell Actions', () => { + let customTableProps: AlertsTableProps; + + beforeEach(() => { + customTableProps = { + ...tableProps, + pageSize: 2, + alertsTableConfiguration: { + ...alertsTableConfiguration, + useCellActions: mockedUseCellActions, + }, + }; + }); + + it('Should render cell actions on hover', async () => { + render(); + + const reasonFirstRow = (await screen.findAllByTestId('dataGridRowCell'))[3]; + + fireEvent.mouseOver(reasonFirstRow); + + await waitFor(() => { + expect(screen.getByTestId('fake-cell-first-action')).toBeInTheDocument(); + }); + }); + it('cell Actions can be expanded', async () => { + render(); + const reasonFirstRow = (await screen.findAllByTestId('dataGridRowCell'))[3]; + + fireEvent.mouseOver(reasonFirstRow); + + expect(await screen.findByTestId(TEST_ID.CELL_ACTIONS_EXPAND)).toBeVisible(); + + fireEvent.click(await screen.findByTestId(TEST_ID.CELL_ACTIONS_EXPAND)); + + expect(await screen.findByTestId(TEST_ID.CELL_ACTIONS_POPOVER)).toBeVisible(); + expect(await screen.findAllByLabelText(/fake cell first action/i)).toHaveLength(2); + }); + }); + + describe('Alert Registry use field Browser Hook', () => { + it('field Browser Options hook is working correctly', async () => { + render( + + ); + + const fieldBrowserBtn = screen.getByTestId(TEST_ID.FIELD_BROWSER_BTN); + expect(fieldBrowserBtn).toBeVisible(); + + fireEvent.click(fieldBrowserBtn); + + expect(await screen.findByTestId(TEST_ID.FIELD_BROWSER)).toBeVisible(); + + expect(await screen.findByTestId(TEST_ID.FIELD_BROWSER_CUSTOM_CREATE_BTN)).toBeVisible(); + }); + }); + describe('cases column', () => { const props = { ...tableProps, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx index baf451ea5e19e..0c07083df17ba 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table.tsx @@ -6,7 +6,7 @@ */ import { ALERT_UUID } from '@kbn/rule-data-utils'; -import React, { useState, Suspense, lazy, useCallback, useMemo, useEffect } from 'react'; +import React, { useState, Suspense, lazy, useCallback, useMemo, useEffect, useRef } from 'react'; import { EuiDataGrid, EuiDataGridCellValueElementProps, @@ -16,9 +16,10 @@ import { EuiButtonIcon, EuiDataGridStyle, EuiLoadingContent, + EuiDataGridRefProps, } from '@elastic/eui'; import { useSorting, usePagination, useBulkActions, useActionsColumn } from './hooks'; -import { AlertsTableProps } from '../../../types'; +import { AlertsTableProps, FetchAlertData } from '../../../types'; import { ALERTS_TABLE_CONTROL_COLUMNS_ACTIONS_LABEL, ALERTS_TABLE_CONTROL_COLUMNS_VIEW_DETAILS_LABEL, @@ -44,6 +45,7 @@ const basicRenderCellValue = ({ columnId, }: { data: Array<{ field: string; value: string[] }>; + ecsData?: FetchAlertData['ecsAlertsData'][number]; columnId: string; }) => { const value = data.find((d) => d.field === columnId)?.value ?? []; @@ -58,16 +60,20 @@ const isSystemCell = (columnId: string): columnId is SystemCellId => { }; const AlertsTable: React.FunctionComponent = (props: AlertsTableProps) => { - const [rowClasses, setRowClasses] = useState({}); + const dataGridRef = useRef(null); + const [_, setRowClasses] = useState({}); const alertsData = props.useFetchAlertsData(); const { activePage, alerts, + oldAlertsData, + ecsAlertsData, alertsCount, isLoading, onPageChange, onSortChange, sort: sortingFields, + refresh: alertsRefresh, getInspectQuery, } = alertsData; const { cases, isLoading: isLoadingCases } = props.casesData; @@ -85,11 +91,18 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab bulkActionsState, bulkActions, setIsBulkActionsLoading, + clearSelection, } = useBulkActions({ alerts, + query: props.query, useBulkActionsConfig: props.alertsTableConfiguration.useBulkActions, }); + const refresh = useCallback(() => { + alertsRefresh(); + clearSelection(); + }, [alertsRefresh, clearSelection]); + const { pagination, onChangePageSize, @@ -125,6 +138,12 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab [alerts, setFlyoutAlertIndex] ); + const fieldBrowserOptions = props.alertsTableConfiguration.useFieldBrowserOptions + ? props.alertsTableConfiguration?.useFieldBrowserOptions({ + onToggleColumn, + }) + : undefined; + const toolbarVisibility = useCallback(() => { const { rowSelection } = bulkActionsState; return getToolbarVisibility({ @@ -140,8 +159,12 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab browserFields, controls: props.controls, setIsBulkActionsLoading, + clearSelection, + refresh, + fieldBrowserOptions, getInspectQuery, showInspectButton, + toolbarVisiblityProp: props.toolbarVisibility, }); }, [ bulkActionsState, @@ -156,8 +179,12 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab browserFields, props.controls, setIsBulkActionsLoading, + clearSelection, + refresh, + fieldBrowserOptions, getInspectQuery, showInspectButton, + props.toolbarVisibility, ])(); const leadingControlColumns = useMemo(() => { @@ -202,12 +229,18 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab )} {renderCustomActionsRow && - alerts[visibleRowIndex] && + ecsAlertsData[visibleRowIndex] && renderCustomActionsRow({ alert: alerts[visibleRowIndex], + ecsAlert: ecsAlertsData[visibleRowIndex], + nonEcsData: oldAlertsData[visibleRowIndex], + rowIndex: visibleRowIndex, setFlyoutAlert: handleFlyoutAlert, id: props.id, + cveProps, setIsActionLoading: getSetIsActionLoadingCallback(visibleRowIndex), + refresh, + clearSelection, })} ); @@ -225,6 +258,8 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab }, [ actionsColumnWidth, alerts, + oldAlertsData, + ecsAlertsData, getBulkActionsLeadingControlColumn, handleFlyoutAlert, isBulkActionsColumnActive, @@ -234,6 +269,8 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab renderCustomActionsRow, setFlyoutAlertIndex, getSetIsActionLoadingCallback, + refresh, + clearSelection, ]); useEffect(() => { @@ -259,7 +296,10 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab const handleRenderCellValue = useCallback( (_props: EuiDataGridCellValueElementProps) => { // https://github.com/elastic/eui/issues/5811 - const alert = alerts[_props.rowIndex - pagination.pageSize * pagination.pageIndex]; + const idx = _props.rowIndex - pagination.pageSize * pagination.pageIndex; + const alert = alerts[idx]; + // ecsAlert is needed for security solution + const ecsAlert = ecsAlertsData[idx]; if (alert) { const data: Array<{ field: string; value: string[] }> = []; Object.entries(alert ?? {}).forEach(([key, value]) => { @@ -281,6 +321,7 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab return renderCellValue({ ..._props, data, + ecsData: ecsAlert, }); } else if (isLoading) { return ; @@ -289,6 +330,7 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab }, [ alerts, + ecsAlertsData, cases, isLoading, isLoadingCases, @@ -299,6 +341,32 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab ] ); + const { getCellActions, visibleCellActions, disabledCellActions } = props.alertsTableConfiguration + ?.useCellActions + ? props.alertsTableConfiguration?.useCellActions({ + columns: props.columns, + data: oldAlertsData, + ecsData: ecsAlertsData, + dataGridRef, + pageSize: pagination.pageSize, + }) + : { getCellActions: () => null, visibleCellActions: undefined, disabledCellActions: [] }; + + const columnsWithCellActions = useMemo(() => { + if (getCellActions) { + return props.columns.map((col, idx) => ({ + ...col, + ...(!(disabledCellActions ?? []).includes(col.id) + ? { + cellActions: getCellActions(col.id, idx) ?? [], + visibleCellActions, + } + : {}), + })); + } + return props.columns; + }, [getCellActions, disabledCellActions, props.columns, visibleCellActions]); + return (
@@ -320,13 +388,13 @@ const AlertsTable: React.FunctionComponent = (props: AlertsTab = (props: AlertsTab onChangeItemsPerPage: onChangePageSize, onChangePage: onChangePageIndex, }} + rowHeightsOptions={props.rowHeightsOptions} + ref={dataGridRef} /> )}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx index 9af3f57792e86..e54df0487add1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.test.tsx @@ -17,6 +17,7 @@ import { AlertsField, AlertsTableConfigurationRegistry, AlertsTableFlyoutBaseProps, + FetchAlertData, } from '../../../types'; import { PLUGIN_ID } from '../../../common/constants'; import { TypeRegistry } from '../../type_registry'; @@ -106,6 +107,62 @@ const alerts = [ }, ] as unknown as Alerts; +const oldAlertsData = [ + [ + { + field: AlertsField.name, + value: ['one'], + }, + { + field: AlertsField.reason, + value: ['two'], + }, + ], + [ + { + field: AlertsField.name, + value: ['three'], + }, + { + field: AlertsField.reason, + value: ['four'], + }, + ], +] as FetchAlertData['oldAlertsData']; + +const ecsAlertsData = [ + [ + { + '@timestamp': ['2023-01-28T10:48:49.559Z'], + _id: 'SomeId', + _index: 'SomeIndex', + kibana: { + alert: { + rule: { + name: ['one'], + }, + reason: ['two'], + }, + }, + }, + ], + [ + { + '@timestamp': ['2023-01-27T10:48:49.559Z'], + _id: 'SomeId2', + _index: 'SomeIndex', + kibana: { + alert: { + rule: { + name: ['three'], + }, + reason: ['four'], + }, + }, + }, + ], +] as FetchAlertData['ecsAlertsData']; + const FlyoutBody = ({ alert }: AlertsTableFlyoutBaseProps) => (
    {columns.map((column) => ( @@ -158,6 +215,8 @@ const fetchAlertsResponse = { getInspectQuery: jest.fn(), refetch: refetchMock, totalAlerts: alerts.length, + ecsAlertsData, + oldAlertsData, }; hookUseFetchAlerts.mockReturnValue([false, fetchAlertsResponse]); @@ -398,6 +457,7 @@ describe('AlertsTableState', () => { }, set: jest.fn(), })); + const { getByTestId, queryByTestId } = render(); expect(queryByTestId(`dataGridHeaderCell-${AlertsField.name}`)).toBe(null); @@ -503,4 +563,27 @@ describe('AlertsTableState', () => { }); }); }); + + describe('Client provided toolbar visiblity options', () => { + it('hide column order control', () => { + const customTableProps: AlertsTableStateProps = { + ...tableProps, + toolbarVisibility: { showColumnSelector: false }, + }; + + render(); + + expect(screen.queryByTestId('dataGridColumnSelectorButton')).not.toBeInTheDocument(); + }); + it('hide sort Selection', () => { + const customTableProps: AlertsTableStateProps = { + ...tableProps, + toolbarVisibility: { showSortSelector: false }, + }; + + render(); + + expect(screen.queryByTestId('dataGridColumnSortingButton')).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx index 3ce4f1395c3a8..8fecebacb3bc4 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/alerts_table_state.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState, useCallback, useRef, useMemo, useReducer } from 'react'; +import React, { useState, useCallback, useRef, useMemo, useReducer, useEffect } from 'react'; import { isEmpty } from 'lodash'; import { EuiDataGridColumn, @@ -13,9 +13,14 @@ import { EuiDataGridSorting, EuiEmptyPrompt, EuiFlyoutSize, + EuiDataGridProps, + EuiDataGridToolBarVisibilityOptions, } from '@elastic/eui'; import type { ValidFeatureId } from '@kbn/rule-data-utils'; -import type { RuleRegistrySearchRequestPagination } from '@kbn/rule-registry-plugin/common'; +import type { + BrowserFields, + RuleRegistrySearchRequestPagination, +} from '@kbn/rule-registry-plugin/common'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import type { @@ -35,6 +40,7 @@ import { BulkActionsReducerAction, BulkActionsState, RowSelectionState, + TableUpdateHandlerArgs, } from '../../../types'; import { ALERTS_TABLE_CONF_ERROR_MESSAGE, ALERTS_TABLE_CONF_ERROR_TITLE } from './translations'; import { TypeRegistry } from '../../type_registry'; @@ -56,7 +62,7 @@ interface CaseUi { }; } -export interface AlertsTableStateProps { +export type AlertsTableStateProps = { alertsTableConfigurationRegistry: TypeRegistry; configurationId: string; id: string; @@ -65,8 +71,11 @@ export interface AlertsTableStateProps { query: Pick; pageSize?: number; showExpandToDetails: boolean; + browserFields?: BrowserFields; + onUpdate?: (args: TableUpdateHandlerArgs) => void; showAlertStatusWithFlapping?: boolean; -} + toolbarVisibility?: EuiDataGridToolBarVisibilityOptions; +} & Partial; export interface AlertsTableStorage { columns: EuiDataGridColumn[]; @@ -92,7 +101,6 @@ const AlertsTableWithBulkActionsContextComponent: React.FunctionComponent<{ ); const AlertsTableWithBulkActionsContext = React.memo(AlertsTableWithBulkActionsContextComponent); -const EMPTY_FIELDS = [{ field: '*', include_unmapped: true }]; type AlertWithCaseIds = Alert & Required>; @@ -124,13 +132,25 @@ const AlertsTableStateWithQueryProvider = ({ query, pageSize, showExpandToDetails, + leadingControlColumns, + rowHeightsOptions, + renderCellValue, + columns: propColumns, + gridStyle, + browserFields: propBrowserFields, + onUpdate, showAlertStatusWithFlapping, + toolbarVisibility, }: AlertsTableStateProps) => { const { cases: casesService } = useKibana<{ cases: CaseUi }>().services; const hasAlertsTableConfiguration = alertsTableConfigurationRegistry?.has(configurationId) ?? false; + if (!hasAlertsTableConfiguration) + // eslint-disable-next-line no-console + console.warn(`Missing Alert Table configuration for configuration ID: ${configurationId}`); + const alertsTableConfiguration = hasAlertsTableConfiguration ? alertsTableConfigurationRegistry.get(configurationId) : EmptyConfiguration; @@ -140,14 +160,17 @@ const AlertsTableStateWithQueryProvider = ({ const persistentControls = alertsTableConfiguration?.usePersistentControls?.(); const showInspectButton = alertsTableConfiguration?.showInspectButton ?? false; + const columnConfigByClient = + propColumns && !isEmpty(propColumns) ? propColumns : alertsTableConfiguration?.columns ?? []; + const columnsLocal = localAlertsTableConfig && localAlertsTableConfig.columns && !isEmpty(localAlertsTableConfig?.columns) ? localAlertsTableConfig?.columns ?? [] - : alertsTableConfiguration?.columns ?? []; + : columnConfigByClient; - const storageAlertsTable = useRef({ + const getStorageConfig = () => ({ columns: columnsLocal, sort: localAlertsTableConfig && @@ -162,6 +185,9 @@ const AlertsTableStateWithQueryProvider = ({ ? localAlertsTableConfig?.visibleColumns ?? [] : columnsLocal.map((c) => c.id), }); + const storageAlertsTable = useRef(getStorageConfig()); + + storageAlertsTable.current = getStorageConfig(); const [sort, setSort] = useState(storageAlertsTable.current.sort); const [pagination, setPagination] = useState({ @@ -178,18 +204,22 @@ const AlertsTableStateWithQueryProvider = ({ onResetColumns, visibleColumns, onChangeVisibleColumns, + fields, } = useColumns({ featureIds, storageAlertsTable, storage, id, - defaultColumns: (alertsTableConfiguration && alertsTableConfiguration.columns) ?? [], + defaultColumns: columnConfigByClient, + initialBrowserFields: propBrowserFields, }); const [ isLoading, { alerts, + oldAlertsData, + ecsAlertsData, isInitializing, getInspectQuery, refetch: refresh, @@ -197,7 +227,7 @@ const AlertsTableStateWithQueryProvider = ({ updatedAt, }, ] = useFetchAlerts({ - fields: EMPTY_FIELDS, + fields, featureIds, query, pagination, @@ -205,7 +235,14 @@ const AlertsTableStateWithQueryProvider = ({ skip: false, }); + useEffect(() => { + if (onUpdate) { + onUpdate({ isLoading, totalCount: alertsCount, refresh }); + } + }, [isLoading, alertsCount, onUpdate, refresh]); + const caseIds = useMemo(() => getCaseIdsFromAlerts(alerts), [alerts]); + const { data: cases, isLoading: isLoadingCases } = useBulkGetCases(Array.from(caseIds.values())); const onPageChange = useCallback((_pagination: RuleRegistrySearchRequestPagination) => { @@ -252,13 +289,17 @@ const AlertsTableStateWithQueryProvider = ({ refresh, sort, updatedAt, + oldAlertsData, + ecsAlertsData, }; }, [ alerts, alertsCount, + ecsAlertsData, getInspectQuery, isInitializing, isLoading, + oldAlertsData, onPageChange, onSortChange, pagination.pageIndex, @@ -267,7 +308,7 @@ const AlertsTableStateWithQueryProvider = ({ updatedAt, ]); - const tableProps = useMemo( + const tableProps: AlertsTableProps = useMemo( () => ({ alertsTableConfiguration, casesData: { cases: cases ?? new Map(), isLoading: isLoadingCases }, @@ -279,7 +320,7 @@ const AlertsTableStateWithQueryProvider = ({ pageSize: pagination.pageSize, pageSizeOptions: [10, 20, 50, 100], id, - leadingControlColumns: [], + leadingControlColumns: leadingControlColumns ?? [], showExpandToDetails, showAlertStatusWithFlapping, trailingControlColumns: [], @@ -292,8 +333,13 @@ const AlertsTableStateWithQueryProvider = ({ onResetColumns, onColumnsChange, onChangeVisibleColumns, + query, + rowHeightsOptions, + renderCellValue, + gridStyle, controls: persistentControls, showInspectButton, + toolbarVisibility, }), [ alertsTableConfiguration, @@ -313,8 +359,14 @@ const AlertsTableStateWithQueryProvider = ({ onResetColumns, onColumnsChange, onChangeVisibleColumns, + leadingControlColumns, + query, + rowHeightsOptions, + renderCellValue, + gridStyle, persistentControls, showInspectButton, + toolbarVisibility, ] ); @@ -337,7 +389,7 @@ const AlertsTableStateWithQueryProvider = ({ )} {alertsCount !== 0 && CasesContext && casesService && ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx index 8bd7ea0f27a6b..27188fab622c9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/bulk_actions.test.tsx @@ -6,7 +6,7 @@ */ import React, { useMemo, useReducer } from 'react'; -import { render, screen, within, fireEvent } from '@testing-library/react'; +import { render, screen, within, fireEvent, waitFor } from '@testing-library/react'; import { waitForEuiPopoverOpen } from '@elastic/eui/lib/test/rtl'; import { BulkActionsContext } from './context'; @@ -15,7 +15,10 @@ import { Alerts, AlertsField, AlertsTableProps, + BulkActionsConfig, BulkActionsState, + FetchAlertData, + InspectQuery, RowSelectionState, } from '../../../../types'; import { bulkActionsReducer } from './reducer'; @@ -28,6 +31,8 @@ jest.mock('@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting', () => ({ useUiSetting$: jest.fn((value: string) => ['0,0']), })); +const refreshMockFn = jest.fn(); + const columns = [ { id: AlertsField.name, @@ -57,17 +62,18 @@ describe('AlertsTable.BulkActions', () => { }, ] as unknown as Alerts; - const alertsData = { + const alertsData: FetchAlertData = { activePage: 0, alerts, + ecsAlertsData: [], + oldAlertsData: [], alertsCount: alerts.length, isInitializing: false, isLoading: false, - getInspectQuery: () => ({ request: [], response: [] }), - onColumnsChange: () => {}, + getInspectQuery: () => ({ request: {}, response: {} } as InspectQuery), onPageChange: () => {}, onSortChange: () => {}, - refresh: () => {}, + refresh: refreshMockFn, sort: [], }; @@ -89,7 +95,7 @@ describe('AlertsTable.BulkActions', () => { const casesMap = getCasesMockMap(); - const tableProps = { + const tableProps: AlertsTableProps = { alertsTableConfiguration, casesData: { cases: casesMap, isLoading: false }, columns, @@ -100,7 +106,6 @@ describe('AlertsTable.BulkActions', () => { leadingControlColumns: [], showExpandToDetails: true, trailingControlColumns: [], - alerts, useFetchAlertsData: () => alertsData, visibleColumns: columns.map((c) => c.id), 'data-test-subj': 'testTable', @@ -110,6 +115,7 @@ describe('AlertsTable.BulkActions', () => { onColumnsChange: () => {}, onChangeVisibleColumns: () => {}, browserFields: {}, + query: {}, }; const tablePropsWithBulkActions = { @@ -117,15 +123,43 @@ describe('AlertsTable.BulkActions', () => { alertsTableConfiguration: { ...alertsTableConfiguration, - useBulkActions: () => [ - { - label: 'Fake Bulk Action', - key: 'fakeBulkAction', - 'data-test-subj': 'fake-bulk-action', - disableOnQuery: false, - onClick: () => {}, - }, - ], + useBulkActions: () => + [ + { + label: 'Fake Bulk Action', + key: 'fakeBulkAction', + 'data-test-subj': 'fake-bulk-action', + disableOnQuery: false, + onClick: () => {}, + }, + { + label: 'Fake Bulk Action with clear selection', + key: 'fakeBulkActionClear', + 'data-test-subj': 'fake-bulk-action-clear', + disableOnQuery: false, + onClick: (ids, isSelectAll, setIsBulkActionLoading, clearSelection, refresh) => { + clearSelection(); + }, + }, + { + label: 'Fake Bulk Action with loading and clear selection', + key: 'fakeBulkActionLoadingClear', + 'data-test-subj': 'fake-bulk-action-loading', + disableOnQuery: false, + onClick: (ids, isSelectAll, setIsBulkActionLoading, clearSelection, refresh) => { + setIsBulkActionLoading(true); + }, + }, + { + label: 'Fake Bulk Action with refresh Action', + key: 'fakeBulkActionRefresh', + 'data-test-subj': 'fake-bulk-action-refresh', + disableOnQuery: false, + onClick: (ids, isSelectAll, setIsBulkActionLoading, clearSelection, refresh) => { + refresh(); + }, + }, + ] as BulkActionsConfig[], }, }; @@ -434,6 +468,7 @@ describe('AlertsTable.BulkActions', () => { initialBulkActionsState={initialBulkActionsState} /> ); + fireEvent.click(await screen.findByTestId('selectedShowBulkActionsButton')); await waitForEuiPopoverOpen(); @@ -626,6 +661,114 @@ describe('AlertsTable.BulkActions', () => { expect(mockedFn.mock.calls[0][1]).toEqual(true); expect(mockedFn.mock.calls[0][2]).toBeDefined(); }); + + it('should first set all to loading, then clears the selection', async () => { + const props = { + ...tablePropsWithBulkActions, + + initialBulkActionsState: { + ...defaultBulkActionsState, + areAllVisibleRowsSelected: true, + rowSelection: new Map(), + }, + }; + render(); + + let bulkActionsCells = screen.getAllByTestId( + 'bulk-actions-row-cell' + ) as HTMLInputElement[]; + + fireEvent.click(screen.getByTestId('bulk-actions-header')); + + await waitFor(async () => { + bulkActionsCells = screen.getAllByTestId( + 'bulk-actions-row-cell' + ) as HTMLInputElement[]; + expect(bulkActionsCells[0].checked).toBeTruthy(); + expect(bulkActionsCells[1].checked).toBeTruthy(); + expect(screen.getByTestId('selectedShowBulkActionsButton')).toBeDefined(); + }); + + fireEvent.click(screen.getByTestId('selectedShowBulkActionsButton')); + await waitForEuiPopoverOpen(); + + fireEvent.click(screen.getByTestId('fake-bulk-action-loading')); + + await waitFor(() => { + expect(screen.queryAllByTestId('row-loader')).toHaveLength(2); + }); + }); + + it('should call refresh function of use fetch alerts when bulk action 3 is clicked', async () => { + const props = { + ...tablePropsWithBulkActions, + + initialBulkActionsState: { + ...defaultBulkActionsState, + areAllVisibleRowsSelected: false, + rowSelection: new Map(), + }, + }; + render(); + + let bulkActionsCells = screen.getAllByTestId( + 'bulk-actions-row-cell' + ) as HTMLInputElement[]; + + fireEvent.click(screen.getByTestId('bulk-actions-header')); + + await waitFor(async () => { + bulkActionsCells = screen.getAllByTestId( + 'bulk-actions-row-cell' + ) as HTMLInputElement[]; + expect(bulkActionsCells[0].checked).toBeTruthy(); + expect(bulkActionsCells[1].checked).toBeTruthy(); + expect(screen.getByTestId('selectedShowBulkActionsButton')).toBeDefined(); + }); + + fireEvent.click(screen.getByTestId('selectedShowBulkActionsButton')); + await waitForEuiPopoverOpen(); + + refreshMockFn.mockClear(); + expect(refreshMockFn.mock.calls.length).toBe(0); + fireEvent.click(screen.getByTestId('fake-bulk-action-refresh')); + expect(refreshMockFn.mock.calls.length).toBeGreaterThan(0); + }); + + it('should clear all selection on bulk action click', async () => { + const props = { + ...tablePropsWithBulkActions, + + initialBulkActionsState: { + ...defaultBulkActionsState, + areAllVisibleRowsSelected: true, + rowSelection: new Map([[0, { isLoading: true }]]), + }, + }; + render(); + + let bulkActionsCells = screen.getAllByTestId( + 'bulk-actions-row-cell' + ) as HTMLInputElement[]; + + fireEvent.click(screen.getByTestId('bulk-actions-header')); + + expect(screen.getByTestId('selectedShowBulkActionsButton')).toBeVisible(); + + fireEvent.click(screen.getByTestId('selectedShowBulkActionsButton')); + await waitForEuiPopoverOpen(); + + fireEvent.click(screen.getByTestId('fake-bulk-action-clear')); + + // clear Selection happens after 150ms + await waitFor(() => { + bulkActionsCells = screen.getAllByTestId( + 'bulk-actions-row-cell' + ) as HTMLInputElement[]; + expect(bulkActionsCells[0].checked).toBeFalsy(); + expect(bulkActionsCells[1].checked).toBeFalsy(); + }); + }); }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx index 4203f7e73c180..7e054087a15f5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/bulk_actions/components/toolbar.tsx @@ -19,6 +19,8 @@ interface BulkActionsProps { items: BulkActionsConfig[]; alerts: Alerts; setIsBulkActionsLoading: (loading: boolean) => void; + clearSelection: () => void; + refresh: () => void; } // Duplicated just for legacy reasons. Timelines plugin will be removed but @@ -61,8 +63,14 @@ const selectedIdsToTimelineItemMapper = ( const useBulkActionsToMenuItemMapper = ( items: BulkActionsConfig[], - alerts: Alerts, - setIsBulkActionsLoading: (loading: boolean) => void + // in case the action takes time, client can set the alerts to a loading + // state and back when done + setIsBulkActionsLoading: BulkActionsProps['setIsBulkActionsLoading'], + // Once the bulk action has been completed, it can set the selection to false. + clearSelection: BulkActionsProps['clearSelection'], + // In case bulk item action changes the alert data and need to refresh table page. + refresh: BulkActionsProps['refresh'], + alerts: Alerts ) => { const [{ isAllSelected, rowSelection }] = useContext(BulkActionsContext); @@ -77,14 +85,20 @@ const useBulkActionsToMenuItemMapper = ( disabled={isDisabled} onClick={() => { const selectedAlertIds = selectedIdsToTimelineItemMapper(alerts, rowSelection); - item.onClick(selectedAlertIds, isAllSelected, setIsBulkActionsLoading); + item.onClick( + selectedAlertIds, + isAllSelected, + setIsBulkActionsLoading, + clearSelection, + refresh + ); }} > {isDisabled && item.disabledLabel ? item.disabledLabel : item.label} ); }), - [alerts, isAllSelected, items, rowSelection, setIsBulkActionsLoading] + [alerts, isAllSelected, items, rowSelection, setIsBulkActionsLoading, clearSelection, refresh] ); return bulkActionsItems; @@ -95,12 +109,20 @@ const BulkActionsComponent: React.FC = ({ items, alerts, setIsBulkActionsLoading, + clearSelection, + refresh, }) => { const [{ rowSelection, isAllSelected }, updateSelectedRows] = useContext(BulkActionsContext); const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false); const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); const [showClearSelection, setShowClearSelectiong] = useState(false); - const bulkActionItems = useBulkActionsToMenuItemMapper(items, alerts, setIsBulkActionsLoading); + const bulkActionItems = useBulkActionsToMenuItemMapper( + items, + setIsBulkActionsLoading, + clearSelection, + refresh, + alerts + ); useEffect(() => { setShowClearSelectiong(isAllSelected); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.ts index 7758143f9e584..1c0509c88ef63 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_bulk_actions.ts @@ -5,6 +5,7 @@ * 2.0. */ import { useContext, useEffect } from 'react'; +import { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { Alerts, BulkActionsConfig, @@ -19,6 +20,7 @@ import { } from '../bulk_actions/get_leading_control_column'; interface BulkActionsProps { + query: Pick; alerts: Alerts; useBulkActionsConfig?: UseBulkActionsRegistry; } @@ -29,14 +31,16 @@ export interface UseBulkActions { bulkActionsState: BulkActionsState; bulkActions: BulkActionsConfig[]; setIsBulkActionsLoading: (isLoading: boolean) => void; + clearSelection: () => void; } export function useBulkActions({ alerts, + query, useBulkActionsConfig = () => [], }: BulkActionsProps): UseBulkActions { const [bulkActionsState, updateBulkActionsState] = useContext(BulkActionsContext); - const bulkActions = useBulkActionsConfig(); + const bulkActions = useBulkActionsConfig(query); const isBulkActionsColumnActive = bulkActions.length !== 0; @@ -48,11 +52,16 @@ export function useBulkActions({ updateBulkActionsState({ action: BulkActionsVerbs.updateAllLoadingState, isLoading }); }; + const clearSelection = () => { + updateBulkActionsState({ action: BulkActionsVerbs.clear }); + }; + return { isBulkActionsColumnActive, getBulkActionsLeadingControlColumn, bulkActionsState, bulkActions, setIsBulkActionsLoading, + clearSelection, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_columns/use_columns.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_columns/use_columns.ts index f10807824e8fc..9caea0dfd26b0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_columns/use_columns.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_columns/use_columns.ts @@ -8,8 +8,9 @@ import { EuiDataGridColumn } from '@elastic/eui'; import { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import { BrowserField, BrowserFields } from '@kbn/rule-registry-plugin/common'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { AlertConsumers } from '@kbn/rule-data-utils'; +import { isEqual } from 'lodash'; import { AlertsTableStorage } from '../../alerts_table_state'; import { toggleColumn } from './toggle_column'; import { useFetchBrowserFieldCapabilities } from '../use_fetch_browser_fields_capabilities'; @@ -20,8 +21,11 @@ interface UseColumnsArgs { storage: React.MutableRefObject; id: string; defaultColumns: EuiDataGridColumn[]; + initialBrowserFields?: BrowserFields; } +const EMPTY_FIELDS = [{ field: '*', include_unmapped: true }]; + const fieldTypeToDataGridColumnTypeMapper = (fieldType: string | undefined) => { if (fieldType === 'date') return 'datetime'; if (fieldType === 'number') return 'numeric'; @@ -140,20 +144,40 @@ export const useColumns = ({ storage, id, defaultColumns, + initialBrowserFields, }: UseColumnsArgs) => { const [isBrowserFieldDataLoading, browserFields] = useFetchBrowserFieldCapabilities({ featureIds, + initialBrowserFields, }); + const [columns, setColumns] = useState(storageAlertsTable.current.columns); const [isColumnsPopulated, setColumnsPopulated] = useState(false); + const defaultColumnsRef = useRef(defaultColumns); + + const didDefaultColumnChange = useMemo( + () => !isEqual(defaultColumns, defaultColumnsRef.current), + [defaultColumns] + ); + + useEffect(() => { + // if defaultColumns have changed, populate again + if (didDefaultColumnChange) { + defaultColumnsRef.current = defaultColumns; + setColumns(storageAlertsTable.current.columns); + return; + } + }, [didDefaultColumnChange, storageAlertsTable, defaultColumns]); + useEffect(() => { if (isBrowserFieldDataLoading !== false || isColumnsPopulated) return; const populatedColumns = populateColumns(columns, browserFields, defaultColumns); + setColumnsPopulated(true); setColumns(populatedColumns); - }, [browserFields, columns, defaultColumns, isBrowserFieldDataLoading, isColumnsPopulated]); + }, [browserFields, defaultColumns, isBrowserFieldDataLoading, isColumnsPopulated, columns]); const setColumnsAndSave = useCallback( (newColumns: EuiDataGridColumn[]) => { @@ -196,6 +220,19 @@ export const useColumns = ({ setColumnsAndSave(populatedDefaultColumns); }, [browserFields, defaultColumns, setColumnsAndSave]); + /* + * In some case such security, we need some special fields such as threat.enrichments which are + * not fetched when passing only EMPTY_FIELDS. Hence, we will fetch all the fields that user has added to the table. + * + * Additionaly, system such as o11y needs fields which are not even added in the table such as rule_type_id and hence we + * additionly pass EMPTY_FIELDS so that it brings all fields apart from special fields + * + * */ + const fieldsToFetch = useMemo( + () => [...columns.map((col) => ({ field: col.id, include_unmapped: true })), ...EMPTY_FIELDS], + [columns] + ); + return { columns, visibleColumns: getColumnIds(columns), @@ -205,5 +242,6 @@ export const useColumns = ({ onToggleColumn, onResetColumns, onChangeVisibleColumns: setColumnsByColumnIds, + fields: fieldsToFetch, }; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.test.tsx index af9b9409e406b..6fe979ed4f8dd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.test.tsx @@ -8,7 +8,7 @@ import sinon from 'sinon'; import { of } from 'rxjs'; import { act, renderHook } from '@testing-library/react-hooks'; -import { useFetchAlerts, FetchAlertsArgs } from './use_fetch_alerts'; +import { useFetchAlerts, FetchAlertsArgs, FetchAlertResp } from './use_fetch_alerts'; import { useKibana } from '../../../../common/lib/kibana'; import { IKibanaSearchResponse } from '@kbn/data-plugin/public'; @@ -75,11 +75,25 @@ const searchResponse = { const searchResponse$ = of(searchResponse); +const expectedResponse: FetchAlertResp = { + alerts: [], + getInspectQuery: expect.anything(), + refetch: expect.anything(), + isInitializing: true, + totalAlerts: -1, + updatedAt: 0, + oldAlertsData: [], + ecsAlertsData: [], +}; + describe('useFetchAlerts', () => { let clock: sinon.SinonFakeTimers; const args: FetchAlertsArgs = { featureIds: ['siem'], - fields: [{ field: '*', include_unmapped: true }], + fields: [ + { field: 'kibana.rule.type.id', include_unmapped: true }, + { field: '*', include_unmapped: true }, + ], query: { ids: { values: ['alert-id-1'] }, }, @@ -111,6 +125,7 @@ describe('useFetchAlerts', () => { expect(result.current).toEqual([ false, { + ...expectedResponse, alerts: [ { _index: '.internal.alerts-security.alerts-default-000001', @@ -146,6 +161,86 @@ describe('useFetchAlerts', () => { updatedAt: 1609502400000, getInspectQuery: expect.anything(), refetch: expect.anything(), + ecsAlertsData: [ + { + kibana: { + alert: { + severity: ['low'], + risk_score: [21], + rule: { name: ['test'] }, + reason: [ + 'registry event with process iexlorer.exe, by 5qcxz8o4j7 on Host-4dbzugdlqd created low alert test.', + ], + }, + }, + process: { name: ['iexlorer.exe'] }, + '@timestamp': ['2022-03-22T16:48:07.518Z'], + user: { name: ['5qcxz8o4j7'] }, + host: { name: ['Host-4dbzugdlqd'] }, + _id: '38dd308706a127696cc63b8f142e8e4d66f8f79bc7d491dd79a42ea4ead62dd1', + _index: '.internal.alerts-security.alerts-default-000001', + }, + { + kibana: { + alert: { + severity: ['low'], + risk_score: [21], + rule: { name: ['test'] }, + reason: [ + 'network event with process iexlorer.exe, by hdgsmwj08h on Host-4dbzugdlqd created low alert test.', + ], + }, + }, + process: { name: ['iexlorer.exe'] }, + '@timestamp': ['2022-03-22T16:17:50.769Z'], + user: { name: ['hdgsmwj08h'] }, + host: { name: ['Host-4dbzugdlqd'] }, + _id: '8361363c0db6f30ca2dfb4aeb4835e7d6ec57bc195b96d9ee5a4ead1bb9f8b86', + _index: '.internal.alerts-security.alerts-default-000001', + }, + ], + oldAlertsData: [ + [ + { field: 'kibana.alert.severity', value: ['low'] }, + { field: 'process.name', value: ['iexlorer.exe'] }, + { field: '@timestamp', value: ['2022-03-22T16:48:07.518Z'] }, + { field: 'kibana.alert.risk_score', value: [21] }, + { field: 'kibana.alert.rule.name', value: ['test'] }, + { field: 'user.name', value: ['5qcxz8o4j7'] }, + { + field: 'kibana.alert.reason', + value: [ + 'registry event with process iexlorer.exe, by 5qcxz8o4j7 on Host-4dbzugdlqd created low alert test.', + ], + }, + { field: 'host.name', value: ['Host-4dbzugdlqd'] }, + { + field: '_id', + value: '38dd308706a127696cc63b8f142e8e4d66f8f79bc7d491dd79a42ea4ead62dd1', + }, + { field: '_index', value: '.internal.alerts-security.alerts-default-000001' }, + ], + [ + { field: 'kibana.alert.severity', value: ['low'] }, + { field: 'process.name', value: ['iexlorer.exe'] }, + { field: '@timestamp', value: ['2022-03-22T16:17:50.769Z'] }, + { field: 'kibana.alert.risk_score', value: [21] }, + { field: 'kibana.alert.rule.name', value: ['test'] }, + { field: 'user.name', value: ['hdgsmwj08h'] }, + { + field: 'kibana.alert.reason', + value: [ + 'network event with process iexlorer.exe, by hdgsmwj08h on Host-4dbzugdlqd created low alert test.', + ], + }, + { field: 'host.name', value: ['Host-4dbzugdlqd'] }, + { + field: '_id', + value: '8361363c0db6f30ca2dfb4aeb4835e7d6ec57bc195b96d9ee5a4ead1bb9f8b86', + }, + { field: '_index', value: '.internal.alerts-security.alerts-default-000001' }, + ], + ], }, ]); }); @@ -156,7 +251,7 @@ describe('useFetchAlerts', () => { expect(dataSearchMock).toHaveBeenCalledWith( { featureIds: args.featureIds, - fields: undefined, + fields: [...args.fields], pagination: args.pagination, query: { ids: { @@ -176,6 +271,7 @@ describe('useFetchAlerts', () => { expect(result.current).toEqual([ false, { + ...expectedResponse, alerts: [], getInspectQuery: expect.anything(), refetch: expect.anything(), @@ -195,6 +291,7 @@ describe('useFetchAlerts', () => { expect(result.current).toEqual([ false, { + ...expectedResponse, alerts: [], getInspectQuery: expect.anything(), refetch: expect.anything(), @@ -215,6 +312,7 @@ describe('useFetchAlerts', () => { expect(result.current).toEqual([ true, { + ...expectedResponse, alerts: [], getInspectQuery: expect.anything(), refetch: expect.anything(), @@ -233,6 +331,7 @@ describe('useFetchAlerts', () => { expect(result.current).toEqual([ false, { + ...expectedResponse, alerts: [], getInspectQuery: expect.anything(), refetch: expect.anything(), @@ -253,6 +352,7 @@ describe('useFetchAlerts', () => { expect(result.current).toEqual([ false, { + ...expectedResponse, alerts: [], getInspectQuery: expect.anything(), refetch: expect.anything(), @@ -319,7 +419,7 @@ describe('useFetchAlerts', () => { expect(dataSearchMock).toHaveBeenCalledWith( { featureIds: args.featureIds, - fields: undefined, + fields: [...args.fields], pagination: { pageIndex: 5, pageSize: 10, @@ -341,7 +441,7 @@ describe('useFetchAlerts', () => { expect(dataSearchMock).toHaveBeenCalledWith( { featureIds: args.featureIds, - fields: undefined, + fields: [...args.fields], pagination: { pageIndex: 0, pageSize: 10, @@ -364,6 +464,7 @@ describe('useFetchAlerts', () => { expect(result.current).toEqual([ false, { + ...expectedResponse, alerts: [], getInspectQuery: expect.anything(), refetch: expect.anything(), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx index b3f5c046bef28..ab49469477ba6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_alerts.tsx @@ -6,6 +6,7 @@ */ import type { ValidFeatureId } from '@kbn/rule-data-utils'; +import { set } from '@kbn/safer-lodash-set'; import deepEqual from 'fast-deep-equal'; import { noop } from 'lodash'; import { useCallback, useEffect, useReducer, useRef, useMemo } from 'react'; @@ -43,6 +44,16 @@ type AlertRequest = Omit; type Refetch = () => void; export interface FetchAlertResp { + /** + * We need to have it because of lot code is expecting this format + * @deprecated + */ + oldAlertsData: Array>; + /** + * We need to have it because of lot code is expecting this format + * @deprecated + */ + ecsAlertsData: unknown[]; alerts: Alerts; isInitializing: boolean; getInspectQuery: GetInspectQuery; @@ -60,7 +71,13 @@ interface AlertStateReducer { type AlertActions = | { type: 'loading'; loading: boolean } - | { type: 'response'; alerts: Alerts; totalAlerts: number } + | { + type: 'response'; + alerts: Alerts; + totalAlerts: number; + oldAlertsData: Array>; + ecsAlertsData: unknown[]; + } | { type: 'resetPagination' } | { type: 'request'; request: Omit }; @@ -80,6 +97,8 @@ const initialAlertState: AlertStateReducer = { }, response: { alerts: [], + oldAlertsData: [], + ecsAlertsData: [], totalAlerts: -1, isInitializing: true, updatedAt: 0, @@ -98,6 +117,8 @@ function alertReducer(state: AlertStateReducer, action: AlertActions) { isInitializing: false, alerts: action.alerts, totalAlerts: action.totalAlerts, + oldAlertsData: action.oldAlertsData, + ecsAlertsData: action.ecsAlertsData, updatedAt: Date.now(), }, }; @@ -170,7 +191,7 @@ const useFetchAlerts = ({ if (data && data.search) { searchSubscription$.current = data.search .search( - { ...request, featureIds, fields: undefined, query }, + { ...request, featureIds, fields, query }, { strategy: 'privateRuleRegistryAlertsSearchStrategy', abortSignal: abortCtrl.current.signal, @@ -190,19 +211,44 @@ const useFetchAlerts = ({ } else if (rawResponse.hits.total && typeof rawResponse.hits.total === 'object') { totalAlerts = rawResponse.hits.total?.value ?? 0; } - dispatch({ - type: 'response', - alerts: rawResponse.hits.hits.reduce((acc, hit) => { - if (hit.fields) { - acc.push({ - ...hit.fields, - _id: hit._id, - _index: hit._index, - } as Alert); - } + const alerts = rawResponse.hits.hits.reduce((acc, hit) => { + if (hit.fields) { + acc.push({ + ...hit.fields, + _id: hit._id, + _index: hit._index, + } as Alert); + } + return acc; + }, []); + const { oldAlertsData, ecsAlertsData } = alerts.reduce<{ + oldAlertsData: Array>; + ecsAlertsData: unknown[]; + }>( + (acc, alert) => { + const itemOldData = Object.entries(alert).reduce< + Array<{ field: string; value: string[] }> + >((oldData, [key, value]) => { + oldData.push({ field: key, value: value as string[] }); + return oldData; + }, []); + const ecsData = Object.entries(alert).reduce((ecs, [key, value]) => { + set(ecs, key, value ?? []); + return ecs; + }, {}); + acc.oldAlertsData.push(itemOldData); + acc.ecsAlertsData.push(ecsData); return acc; - }, []), + }, + { oldAlertsData: [], ecsAlertsData: [] } + ); + + dispatch({ + type: 'response', + alerts, + oldAlertsData, + ecsAlertsData, totalAlerts, }); searchSubscription$.current.unsubscribe(); @@ -226,7 +272,7 @@ const useFetchAlerts = ({ asyncSearch(); refetch.current = asyncSearch; }, - [skip, data, featureIds, query] + [skip, data, featureIds, query, fields] ); useEffect(() => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_browser_fields_capabilities.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_browser_fields_capabilities.test.ts index 0c02506751228..07097d6282472 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_browser_fields_capabilities.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_browser_fields_capabilities.test.ts @@ -8,9 +8,30 @@ import { renderHook } from '@testing-library/react-hooks'; import { useFetchBrowserFieldCapabilities } from './use_fetch_browser_fields_capabilities'; import { useKibana } from '../../../../common/lib/kibana'; +import { BrowserFields } from '@kbn/rule-registry-plugin/common'; +import { AlertsField } from '../../../../types'; jest.mock('../../../../common/lib/kibana'); +const browserFields: BrowserFields = { + kibana: { + fields: { + [AlertsField.uuid]: { + category: 'kibana', + name: AlertsField.uuid, + }, + [AlertsField.name]: { + category: 'kibana', + name: AlertsField.name, + }, + [AlertsField.reason]: { + category: 'kibana', + name: AlertsField.reason, + }, + }, + }, +}; + describe('useFetchBrowserFieldCapabilities', () => { let httpMock: jest.Mock; @@ -45,4 +66,13 @@ describe('useFetchBrowserFieldCapabilities', () => { expect(httpMock).toHaveBeenCalledTimes(1); expect(result.current).toEqual([false, { fakeCategory: {} }]); }); + + it('should not fetch if browserFields have been provided', async () => { + const { result } = renderHook(() => + useFetchBrowserFieldCapabilities({ featureIds: ['apm'], initialBrowserFields: browserFields }) + ); + + expect(httpMock).toHaveBeenCalledTimes(0); + expect(result.current).toEqual([undefined, browserFields]); + }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_browser_fields_capabilities.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_browser_fields_capabilities.tsx index 5f04421f5bc81..a1391889fa815 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_browser_fields_capabilities.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/hooks/use_fetch_browser_fields_capabilities.tsx @@ -14,6 +14,7 @@ import { ERROR_FETCH_BROWSER_FIELDS } from './translations'; export interface FetchAlertsArgs { featureIds: ValidFeatureId[]; + initialBrowserFields?: BrowserFields; } export interface FetchAlertResp { @@ -26,6 +27,7 @@ const INVALID_FEATURE_ID = 'siem'; export const useFetchBrowserFieldCapabilities = ({ featureIds, + initialBrowserFields, }: FetchAlertsArgs): [boolean | undefined, BrowserFields] => { const { http, @@ -33,7 +35,9 @@ export const useFetchBrowserFieldCapabilities = ({ } = useKibana().services; const [isLoading, setIsLoading] = useState(undefined); - const [browserFields, setBrowserFields] = useState(() => ({})); + const [browserFields, setBrowserFields] = useState( + () => initialBrowserFields ?? {} + ); const getBrowserFieldInfo = useCallback(async () => { if (!http) return Promise.resolve({}); @@ -49,7 +53,16 @@ export const useFetchBrowserFieldCapabilities = ({ }, [featureIds, http, toasts]); useEffect(() => { - if (isLoading !== undefined || featureIds.includes(INVALID_FEATURE_ID)) return; + if (initialBrowserFields) { + // Event if initial browser fields is empty, assign it + // because client may be doing it to hide Fields Browser + setBrowserFields(initialBrowserFields); + return; + } + + if (isLoading !== undefined || featureIds.includes(INVALID_FEATURE_ID)) { + return; + } setIsLoading(true); @@ -61,7 +74,7 @@ export const useFetchBrowserFieldCapabilities = ({ }; callApi(); - }, [getBrowserFieldInfo, isLoading, featureIds]); + }, [getBrowserFieldInfo, isLoading, featureIds, initialBrowserFields]); return [isLoading, browserFields]; }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/toolbar/toolbar_visibility.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/toolbar/toolbar_visibility.tsx index a515d308cf4b6..65ac4f0eaf2dc 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/toolbar/toolbar_visibility.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_table/toolbar/toolbar_visibility.tsx @@ -15,6 +15,7 @@ import { AlertsCount } from './components/alerts_count/alerts_count'; import type { Alerts, BulkActionsConfig, GetInspectQuery, RowSelection } from '../../../../types'; import { LastUpdatedAt } from './components/last_updated_at'; import { FieldBrowser } from '../../field_browser'; +import { FieldBrowserOptions } from '../../field_browser/types'; import { InspectButton } from './components/inspect'; const BulkActionsToolbar = lazy(() => import('../bulk_actions/components/toolbar')); @@ -47,6 +48,7 @@ const getDefaultVisibility = ({ onResetColumns, browserFields, controls, + fieldBrowserOptions, getInspectQuery, showInspectButton, }: { @@ -57,6 +59,7 @@ const getDefaultVisibility = ({ onResetColumns: () => void; browserFields: BrowserFields; controls?: EuiDataGridToolBarAdditionalControlsOptions; + fieldBrowserOptions?: FieldBrowserOptions; getInspectQuery: GetInspectQuery; showInspectButton: boolean; }): EuiDataGridToolBarVisibilityOptions => { @@ -73,6 +76,7 @@ const getDefaultVisibility = ({ browserFields={browserFields} onResetColumns={onResetColumns} onToggleColumn={onToggleColumn} + options={fieldBrowserOptions} /> )} @@ -101,9 +105,13 @@ export const getToolbarVisibility = ({ onResetColumns, browserFields, setIsBulkActionsLoading, + clearSelection, controls, + refresh, + fieldBrowserOptions, getInspectQuery, showInspectButton, + toolbarVisiblityProp, }: { bulkActions: BulkActionsConfig[]; alertsCount: number; @@ -116,9 +124,13 @@ export const getToolbarVisibility = ({ onResetColumns: () => void; browserFields: any; setIsBulkActionsLoading: (isLoading: boolean) => void; + clearSelection: () => void; controls?: EuiDataGridToolBarAdditionalControlsOptions; + refresh: () => void; + fieldBrowserOptions?: FieldBrowserOptions; getInspectQuery: GetInspectQuery; showInspectButton: boolean; + toolbarVisiblityProp?: EuiDataGridToolBarVisibilityOptions; }): EuiDataGridToolBarVisibilityOptions => { const selectedRowsCount = rowSelection.size; const defaultVisibility = getDefaultVisibility({ @@ -129,13 +141,18 @@ export const getToolbarVisibility = ({ onResetColumns, browserFields, controls, + fieldBrowserOptions, getInspectQuery, showInspectButton, }); const isBulkActionsActive = selectedRowsCount === 0 || selectedRowsCount === undefined || bulkActions.length === 0; - if (isBulkActionsActive) return defaultVisibility; + if (isBulkActionsActive) + return { + ...defaultVisibility, + ...(toolbarVisiblityProp ?? {}), + }; const options = { showColumnSelector: false, @@ -152,12 +169,15 @@ export const getToolbarVisibility = ({ items={bulkActions} alerts={alerts} setIsBulkActionsLoading={setIsBulkActionsLoading} + clearSelection={clearSelection} + refresh={refresh} /> ), }, }, + ...(toolbarVisiblityProp ?? {}), }; return options; diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 2ed9c2c1c8994..d75125e056630 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -6,7 +6,7 @@ */ import type { Moment } from 'moment'; -import type { ComponentType, ReactNode } from 'react'; +import type { ComponentType, ReactNode, RefObject } from 'react'; import type { PublicMethodsOf } from '@kbn/utility-types'; import type { DocLinksStart } from '@kbn/core/public'; import type { ChartsPluginSetup } from '@kbn/charts-plugin/public'; @@ -17,7 +17,12 @@ import type { IconType, EuiFlyoutSize, RecursivePartial, + EuiDataGridCellValueElementProps, EuiDataGridToolBarAdditionalControlsOptions, + EuiDataGridProps, + EuiDataGridRefProps, + EuiDataGridColumnCellAction, + EuiDataGridToolBarVisibilityOptions, } from '@elastic/eui'; import { EuiDataGridColumn, EuiDataGridControlColumn, EuiDataGridSorting } from '@elastic/eui'; import { HttpSetup } from '@kbn/core/public'; @@ -52,7 +57,10 @@ import { import type { BulkOperationError } from '@kbn/alerting-plugin/server'; import { RuleRegistrySearchRequestPagination } from '@kbn/rule-registry-plugin/common'; import { EcsFieldsResponse } from '@kbn/rule-registry-plugin/common/search_strategy'; -import { SortCombinations } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { + QueryDslQueryContainer, + SortCombinations, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import React from 'react'; import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; import { TypeRegistry } from './application/type_registry'; @@ -473,9 +481,19 @@ export interface FetchAlertData { onSortChange: (sort: EuiDataGridSorting['columns']) => void; refresh: () => void; sort: SortCombinations[]; -} - -export interface AlertsTableProps { + /** + * We need to have it because of lot code is expecting this format + * @deprecated + */ + oldAlertsData: Array>; + /** + * We need to have it because of lot code is expecting this format + * @deprecated + */ + ecsAlertsData: unknown[]; +} + +export type AlertsTableProps = { alertsTableConfiguration: AlertsTableConfigurationRegistry; casesData: { cases: Map; isLoading: boolean }; columns: EuiDataGridColumn[]; @@ -499,9 +517,11 @@ export interface AlertsTableProps { onResetColumns: () => void; onColumnsChange: (columns: EuiDataGridColumn[], visibleColumns: string[]) => void; onChangeVisibleColumns: (newColumns: string[]) => void; + query: Pick; controls?: EuiDataGridToolBarAdditionalControlsOptions; showInspectButton?: boolean; -} + toolbarVisibility?: EuiDataGridToolBarVisibilityOptions; +} & Partial>; // TODO We need to create generic type between our plugin, right now we have different one because of the old alerts table export type GetRenderCellValue = ({ @@ -530,15 +550,40 @@ export interface BulkActionsConfig { onClick: ( selectedIds: TimelineItem[], isAllSelected: boolean, - setIsBulkActionsLoading: (isLoading: boolean) => void + setIsBulkActionsLoading: (isLoading: boolean) => void, + clearSelection: () => void, + refresh: () => void ) => void; } +export type UseBulkActionsRegistry = ( + query: Pick +) => BulkActionsConfig[]; + +export type UseCellActions = (props: { + columns: EuiDataGridColumn[]; + data: unknown[][]; + dataGridRef: RefObject; + ecsData: unknown[]; + pageSize: number; +}) => { + // getCellAction function for system to return cell actions per Id + getCellActions: (columnId: string, columnIndex: number) => EuiDataGridColumnCellAction[]; + visibleCellActions?: number; + disabledCellActions?: string[]; +}; + export interface RenderCustomActionsRowArgs { + ecsAlert: FetchAlertData['ecsAlertsData'][number]; + nonEcsData: FetchAlertData['oldAlertsData'][number]; + rowIndex: number; + cveProps: EuiDataGridCellValueElementProps; alert: Alert; setFlyoutAlert: (data: unknown) => void; id?: string; setIsActionLoading?: (isLoading: boolean) => void; + refresh: () => void; + clearSelection: () => void; } export type UseActionsColumnRegistry = () => { @@ -546,7 +591,11 @@ export type UseActionsColumnRegistry = () => { width?: number; }; -export type UseBulkActionsRegistry = () => BulkActionsConfig[]; +export interface UseFieldBrowserOptionsArgs { + onToggleColumn: (columnId: string) => void; +} + +export type UseFieldBrowserOptions = (args: UseFieldBrowserOptionsArgs) => FieldBrowserOptions; export interface AlertsTableConfigurationRegistry { id: string; @@ -561,10 +610,13 @@ export interface AlertsTableConfigurationRegistry { getRenderCellValue?: GetRenderCellValue; useActionsColumn?: UseActionsColumnRegistry; useBulkActions?: UseBulkActionsRegistry; + useCellActions?: UseCellActions; usePersistentControls?: () => { right?: ReactNode; }; + useFieldBrowserOptions?: UseFieldBrowserOptions; showInspectButton?: boolean; + app_id?: string; } export enum BulkActionsVerbs { @@ -673,6 +725,12 @@ export interface UpdateRulesToBulkEditProps { filter?: KueryNode | null; } +export interface TableUpdateHandlerArgs { + totalCount: number; + isLoading: boolean; + refresh: () => void; +} + export interface LazyLoadProps { hideLazyLoader?: boolean; } diff --git a/x-pack/plugins/triggers_actions_ui/tsconfig.json b/x-pack/plugins/triggers_actions_ui/tsconfig.json index a2bd70a08e6fa..929ad9f975eb4 100644 --- a/x-pack/plugins/triggers_actions_ui/tsconfig.json +++ b/x-pack/plugins/triggers_actions_ui/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../../../tsconfig.base.json", "compilerOptions": { - "outDir": "target/types", + "outDir": "target/types" }, "include": [ ".storybook/**/*", @@ -44,12 +44,13 @@ "@kbn/ui-theme", "@kbn/datemath", "@kbn/core-capabilities-common", + "@kbn/safer-lodash-set", "@kbn/shared-ux-router", "@kbn/alerts-ui-shared", "@kbn/safer-lodash-set", "@kbn/cases-components" ], "exclude": [ - "target/**/*", + "target/**/*" ] } diff --git a/x-pack/test/security_solution_ftr/page_objects/detections/index.ts b/x-pack/test/security_solution_ftr/page_objects/detections/index.ts index e633bd9b62dda..d754d9d0d5781 100644 --- a/x-pack/test/security_solution_ftr/page_objects/detections/index.ts +++ b/x-pack/test/security_solution_ftr/page_objects/detections/index.ts @@ -8,7 +8,7 @@ import { FtrService } from '../../../functional/ftr_provider_context'; import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper'; -const ALERT_TABLE_ROW_CSS_SELECTOR = '[data-test-subj="events-viewer-panel"] .euiDataGridRow'; +const ALERT_TABLE_ROW_CSS_SELECTOR = '[data-test-subj="alertsTable"] .euiDataGridRow'; export class DetectionsPageObject extends FtrService { private readonly find = this.ctx.getService('find');