[Security Solution] [Trigger Actions] Alert Table Refactoring (#149128)

## Summary

This PR replaces the existing `Alert Table` used in Security solution &
cases with that of `triggers-actions-ui`.

Ideally, this PR does make any changes to the functionality of the
product and from user perspective. Nothing should Change.


‼️ Note for @elastic/security-threat-hunting-explore : This PR makes no
changes to the table used in Host/Users page.

## Things to test and changes

- @elastic/actionable-observability 
- The changes in observability plugin are done to accommodate the
changes in the API of `triggers-actions-ui` alert table.
- Requesting you to do desk-testing this PR once by using Alert Table as
you do on the daily basis.

- @elastic/response-ops  
- changes have been done in API as `security-solution` needed some
parameters/values to achieve the parity in the functionality as compared
to the current alert Table.

---------

Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jatin Kathuria 2023-02-22 18:04:21 +01:00 committed by GitHub
parent 3c262bbfab
commit fe04a4c346
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
126 changed files with 4505 additions and 1009 deletions

View file

@ -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: {

View file

@ -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 ?? [],

View file

@ -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 };

View file

@ -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({});
};

View file

@ -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;

View file

@ -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<typeof TableIdLiteralRt>;
export type ViewSelectionTypes = keyof typeof VIEW_SELECTION;
export type ViewSelection = typeof VIEW_SELECTION[ViewSelectionTypes];

View file

@ -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();
});

View file

@ -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) => {

View file

@ -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');
});
});

View file

@ -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', () => {

View file

@ -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');
});

View file

@ -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)

View file

@ -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');

View file

@ -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);

View file

@ -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);

View file

@ -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,
@ -148,7 +148,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) => {
@ -192,7 +192,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) => {

View file

@ -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,
@ -492,7 +492,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);

View file

@ -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,
@ -161,7 +161,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');

View file

@ -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,
@ -143,7 +143,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);
});
});

View file

@ -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');

View file

@ -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

View file

@ -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');
});
});
});

View file

@ -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

View file

@ -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);
});
});
});
});

View file

@ -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"]';

View file

@ -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}"]`;
};

View file

@ -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"]';

View file

@ -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"]';

View file

@ -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}"]`;
};

View file

@ -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,
@ -49,6 +48,7 @@ import {
CELL_EXPAND_VALUE,
CELL_EXPANSION_POPOVER,
USER_DETAILS_LINK,
ALERT_FLYOUT,
} from '../screens/alerts_details';
import { FIELD_INPUT } from '../screens/exceptions';
import {
@ -64,6 +64,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();
@ -149,12 +150,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();
@ -290,10 +291,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');
}
};
@ -334,17 +340,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 });
};
@ -391,3 +390,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();
};

View file

@ -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 = () => {

View file

@ -16,14 +16,3 @@ export const createField = (fieldName: string): Cypress.Chainable<JQuery<HTMLEle
cy.get(RUNTIME_FIELD_INPUT).type(fieldName);
return cy.get(SAVE_FIELD_BUTTON).click();
};
export const assertFieldDisplayed = (fieldName: string, view: 'alerts' | 'timeline' = 'timeline') =>
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');

View file

@ -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)

View file

@ -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,
});
};

View file

@ -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');
};

View file

@ -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<DataProvider[]>((dataProviders, value, index) => {
let id: string = '';

View file

@ -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<TableState, 'tableById'>;
groups?: Pick<GroupMap, 'groupById'>;
exploreDataTables?: {
network: Pick<TableState, 'tableById'>;
hosts: Pick<TableState, 'tableById'>;

View file

@ -40,7 +40,7 @@ describe('RowAction', () => {
const defaultProps = {
columnHeaders: defaultHeaders,
controlColumn: getDefaultControlColumn(5)[0],
data: [sampleData],
data: sampleData,
disabled: false,
index: 1,
isEventViewer: false,

View file

@ -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<TimelineItem> = 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}
/>
)}
</>

View file

@ -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,
})
);
};

View file

@ -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[],

View file

@ -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<EventsQueryTabBodyComponentProps> =
from: startDate,
to: endDate,
scopeId: SourcererScopeName.default,
});
}) as CustomBulkAction;
const bulkActions = useMemo<BulkActionsProp | boolean>(() => {
return {

View file

@ -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,

View file

@ -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';

View file

@ -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}
>
<UpdatedFlexItem grow={false} $show={!loading}>
<InspectButton title={justTitle} queryId={tableId} />
</UpdatedFlexItem>
{showInspect ? (
<UpdatedFlexItem grow={false} $show={!loading}>
<InspectButton title={justTitle} queryId={tableId} />
</UpdatedFlexItem>
) : null}
<UpdatedFlexItem grow={false} $show={!loading}>
{additionalFilters}
</UpdatedFlexItem>

View file

@ -11,6 +11,7 @@ export interface StatefulEventContextType {
timelineID: string;
enableHostDetailsFlyout: boolean;
enableIpDetailsFlyout: boolean;
onRuleChange?: () => void;
}
export const StatefulEventContext = createContext<StatefulEventContextType | null>(null);

View file

@ -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 }>`

View file

@ -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 {

View file

@ -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 &

View file

@ -62,6 +62,8 @@ const ActionsComponent: React.FC<ActionProps> = ({
showNotes,
timelineId,
toggleShowNotes,
refetch,
setEventsLoading,
}) => {
const dispatch = useDispatch();
const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled');
@ -296,6 +298,7 @@ const ActionsComponent: React.FC<ActionProps> = ({
scopeId={timelineId}
disabled={isContextMenuDisabled}
onRuleChange={onRuleChange}
refetch={refetch}
/>
{isDisabled === false ? (
<div>

View file

@ -141,7 +141,7 @@ const SessionsViewComponent: React.FC<SessionsComponentsProps> = ({
return {
alertStatusActions: false,
customBulkActions: [addBulkToTimelineAction],
};
} as BulkActionsProp;
}, [addBulkToTimelineAction]);
const unit = (c: number) =>

View file

@ -31,7 +31,7 @@ export const getGlobalQueries = (
): GlobalQuery[] => {
const inputsRange = state.inputs[id];
return !isEmpty(inputsRange.linkTo)
? inputsRange.linkTo.reduce<GlobalQuery[]>((acc, linkToId) => {
? inputsRange.linkTo.reduce<GlobalQuery[]>((acc, linkToId: InputsModelId) => {
if (linkToId === InputsModelId.socTrends) {
return acc;
}

View file

@ -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';

View file

@ -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(
() => (
<GroupsSelector
groupSelected={selectedGroup}
data-test-subj="alerts-table-group-selector"
onGroupChange={(groupSelection: string) => {
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;
};

View file

@ -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;
};

View file

@ -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<UseDataTableFilters> = jest.fn(() => ({
showBuildingBlockAlerts: false,
showOnlyThreatIndicatorAlerts: false,
setShowBuildingBlockAlerts: jest.fn(),
setShowOnlyThreatIndicatorAlerts: jest.fn(),
}));

View file

@ -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,
};
};

View file

@ -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 };

View file

@ -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,
},
},
},

View file

@ -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 = {

View file

@ -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');

View file

@ -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) => ({

View file

@ -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 =

View file

@ -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<AlertPageFilterType, boolean>;
}
export type SubsetDataTableModel = Readonly<
@ -89,5 +96,7 @@ export type SubsetDataTableModel = Readonly<
| 'initialized'
| 'selectAll'
| 'totalCount'
| 'viewMode'
| 'additionalFilters'
>
>;

View file

@ -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();

View file

@ -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 = <State>(): Epic<
Action,
@ -27,5 +28,6 @@ export const createRootEpic = <State>(): Epic<
createTimelineFavoriteEpic<State>(),
createTimelineNoteEpic<State>(),
createTimelinePinnedEventEpic<State>(),
createDataTableLocalStorageEpic<State>()
createDataTableLocalStorageEpic<State>(),
createGroupingLocalStorageEpic<State>()
);

View file

@ -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');

View file

@ -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 = {};

View file

@ -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: [],
};

View file

@ -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 = <T>(value: T | null): value is T => value !== null;
const groupingActionTypes = [
updateActiveGroup.type,
updateGroupActivePage.type,
updateGroupItemsPerPage.type,
updateGroupOptions.type,
initGrouping.type,
];
export const createGroupingLocalStorageEpic =
<State>(): Epic<Action, Action, State, TimelineEpicDependencies<State>> =>
(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()
);
};

View file

@ -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<CombinedState<GroupState>, AnyAction>;
}

View file

@ -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],
},
},
}));

View file

@ -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);

View file

@ -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;
}

View file

@ -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));

View file

@ -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,
});

View file

@ -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,
};

View file

@ -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.
*/

View file

@ -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',

View file

@ -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<DetectionEngineComponentProps> = ({
const dispatch = useDispatch();
const containerElement = useRef<HTMLDivElement | null>(null);
const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []);
const graphEventId = useShallowEqualSelector(
(state) => (getTable(state, TableId.alertsOnRuleDetailsPage) ?? tableDefaults).graphEventId
);
@ -192,7 +197,7 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
(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<DetectionEngineComponentProps> = ({
isAuthenticated,
hasEncryptionKey,
canUserCRUD,
hasIndexWrite,
hasIndexRead,
hasIndexMaintenance,
signalIndexName,
hasIndexWrite,
hasIndexMaintenance,
},
] = useUserData();
const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } =
@ -235,6 +240,7 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
loading: ruleLoading,
isExistingRule,
} = useRuleWithFallback(ruleId);
const { pollForSignalIndex } = useSignalHelpers();
const [rule, setRule] = useState<Rule | null>(null);
const isLoading = ruleLoading && rule == null;
@ -301,11 +307,14 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
};
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<Status>(FILTER_OPEN);
const [dataViewOptions, setDataViewOptions] = useState<{ [x: string]: DataViewListItem }>({});
const { isSavedQueryLoading, savedQueryBar } = useGetSavedQuery(rule?.saved_id, {
@ -503,7 +512,7 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
// 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<DetectionEngineComponentProps> = ({
[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<DetectionEngineComponentProps> = ({
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<DetectionEngineComponentProps> = ({
[containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable]
);
const renderGroupedAlertTable = useCallback(
(groupingFilters: Filter[]) => {
return (
<AlertsTableComponent
configId={ALERTS_TABLE_REGISTRY_CONFIG_IDS.RULE_DETAILS}
flyoutSize="m"
inputFilters={[...alertMergedFilters, ...groupingFilters]}
tableId={TableId.alertsOnRuleDetailsPage}
onRuleChange={refreshRule}
/>
);
},
[alertMergedFilters, refreshRule]
);
const {
isBulkDuplicateConfirmationVisible,
showBulkDuplicateConfirmation,
@ -838,24 +839,18 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
<EuiSpacer />
</Display>
{ruleId != null && (
<AlertsTable
filterGroup={filterGroup}
<GroupedAlertsTable
tableId={TableId.alertsOnRuleDetailsPage}
defaultFilters={alertsTableDefaultFilters}
defaultFilters={alertMergedFilters}
hasIndexWrite={hasIndexWrite ?? false}
hasIndexMaintenance={hasIndexMaintenance ?? false}
from={from}
loading={loading}
showBuildingBlockAlerts={showBuildingBlockAlerts}
showOnlyThreatIndicatorAlerts={showOnlyThreatIndicatorAlerts}
onShowBuildingBlockAlertsChanged={onShowBuildingBlockAlertsChangedCallback}
onShowOnlyThreatIndicatorAlertsChanged={
onShowOnlyThreatIndicatorAlertsCallback
}
onRuleChange={refreshRule}
to={to}
signalIndexName={signalIndexName}
runtimeMappings={runtimeMappings}
currentAlertStatusFilterValue={filterGroup}
renderChildComponent={renderGroupedAlertTable}
/>
)}
</>

View file

@ -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<AlertsTableComponentProps> = ({
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(
() => (
<InspectButton queryId={uniqueQueryId} inspectIndex={0} title={i18n.INSPECT_GROUPING_TITLE} />
),
[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
) : (
<>
<GroupingContainer
selectedGroup={selectedGroup}
groupsSelector={groupsSelector}
inspectButton={inspect}
takeActionItems={getTakeActionItems}
data={alertsGroupsData?.aggregations ?? {}}
renderChildComponent={renderChildComponent}
unit={defaultUnit}
pagination={pagination}
groupPanelRenderer={(fieldBucket: RawBucket) =>
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<typeof connector>;
export const GroupedAlertsTable = connector(React.memo(GroupedAlertsTableComponent));

View file

@ -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;
}

View file

@ -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[]) => (
<AlertsTableComponent
configId={'testing'}
flyoutSize="m"
inputFilters={[...[], ...groupingFilters]}
tableId={TableId.alertsOnAlertsPage}
isLoading={false}
/>
);
describe('GroupedAlertsTable', () => {
(useSourcererDataView as jest.Mock).mockReturnValue({
...sourcererDataView,
selectedPatterns: ['myFakebeat-*'],
@ -164,28 +186,26 @@ describe('AlertsTableComponent', () => {
it('renders correctly', () => {
const wrapper = shallow(
<TestProviders>
<AlertsTableComponent
<TestProviders store={store}>
<GroupedAlertsTableComponent
defaultFilters={[]}
tableId={TableId.test}
hasIndexWrite
hasIndexMaintenance
from={'2020-07-07T08:20:18.966Z'}
loading
to={'2020-07-08T08:20:18.966Z'}
globalQuery={{
query: 'query',
language: 'language',
}}
from={from}
to={to}
globalQuery={
{
query: 'query',
language: 'language',
} as Query
}
globalFilters={[]}
loadingEventIds={[]}
isSelectAllChecked={false}
showBuildingBlockAlerts={false}
onShowBuildingBlockAlertsChanged={jest.fn()}
showOnlyThreatIndicatorAlerts={false}
onShowOnlyThreatIndicatorAlertsChanged={jest.fn()}
dispatch={jest.fn()}
runtimeMappings={{}}
signalIndexName={'test'}
hasIndexWrite
hasIndexMaintenance
loading={false}
renderChildComponent={renderChildComponent}
/>
</TestProviders>
);
@ -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(
<TestProviders store={store}>
<AlertsTableComponent
<GroupedAlertsTableComponent
tableId={TableId.test}
hasIndexWrite
hasIndexMaintenance
@ -208,22 +231,17 @@ describe('AlertsTableComponent', () => {
language: 'language',
}}
globalFilters={[]}
loadingEventIds={[]}
isSelectAllChecked={false}
showBuildingBlockAlerts={false}
onShowBuildingBlockAlertsChanged={jest.fn()}
showOnlyThreatIndicatorAlerts={false}
onShowOnlyThreatIndicatorAlertsChanged={jest.fn()}
dispatch={jest.fn()}
runtimeMappings={{}}
signalIndexName={'test'}
renderChildComponent={() => <></>}
/>
</TestProviders>
);
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();
});
});
});

View file

@ -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 { 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 { inputsSelectors } from '../../../common/store';
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 { buildTimeRangeFilter } from './helpers';
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 { 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 { eventsDefaultModel } from '../../../common/components/events_viewer/default_model';
import { GraphOverlay } from '../../../timelines/components/graph_overlay';
import {
getAlertsGroupingQuery,
getDefaultGroupingOptions,
getSelectedGroupBadgeMetrics,
getSelectedGroupButtonContent,
getSelectedGroupCustomMetrics,
useGroupTakeActionsItems,
} from './grouping_settings';
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 { 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';
/** 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 OwnProps {
defaultFilters?: Filter[];
from: string;
hasIndexMaintenance: boolean;
hasIndexWrite: boolean;
loading: 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;
interface GridContainerProps {
hideLastPage: boolean;
}
type AlertsTableComponentProps = OwnProps & PropsFromRedux;
export const FullWidthFlexGroupTable = styled(EuiFlexGroup)<{ $visible: boolean }>`
overflow: hidden;
margin: 0;
display: ${({ $visible }) => ($visible ? 'flex' : 'none')};
`;
export const AlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
defaultFilters,
from,
globalFilters,
globalQuery,
hasIndexMaintenance,
hasIndexWrite,
isSelectAllChecked,
loading,
loadingEventIds,
const EuiDataGridContainer = styled.div<GridContainerProps>`
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;
}
export const AlertsTableComponent: FC<DetectionEngineAlertTableProps> = ({
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<string>(
storage.get(`${ALERTS_TABLE_GROUPS_SELECTION_KEY}-${tableId}`) ?? NONE_GROUP_KEY
// 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 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 {
browserFields,
indexPattern: indexPatterns,
selectedPatterns,
} = useSourcererDataView(SourcererScopeName.detections);
const kibana = useKibana();
const license = useLicense();
const isEnterprisePlus = useLicense().isEnterprise();
const ACTION_BUTTON_COUNT = isEnterprisePlus ? 5 : 4;
dataTable: {
graphEventId, // If truthy, the graph viewer (Resolver) is showing
sessionViewConfig,
viewMode: tableView = eventsDefaultModel.viewMode,
} = eventsDefaultModel,
} = useShallowEqualSelector((state: State) => eventsViewerSelector(state, tableId));
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 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) {
const finalBoolQuery: AlertsTableStateProps['query'] = useMemo(() => {
if (!combinedQuery || combinedQuery.kqlError || !combinedQuery.filterQuery) {
return { bool: {} };
}
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 rowHeightsOptions: EuiDataGridRowHeightsOptions | undefined = useMemo(() => {
if (isEventRenderedView) {
return {
defaultHeight: 'auto',
};
}
return undefined;
}, [isEventRenderedView]);
const dataTableStorage = getDataTablesInStorageByIds(storage, [TableId.alertsOnAlertsPage]);
const columnsFormStorage = dataTableStorage?.[TableId.alertsOnAlertsPage]?.columns ?? [];
const alertColumns = columnsFormStorage.length ? columnsFormStorage : getColumns(license);
const evenRenderedColumns = useMemo(
() => getColumnHeaders(alertColumns, browserFields, true),
[alertColumns, browserFields]
);
const finalColumns = useMemo(
() => (isEventRenderedView ? evenRenderedColumns : alertColumns),
[evenRenderedColumns, alertColumns, isEventRenderedView]
);
const finalBrowserFields = useMemo(
() => (isEventRenderedView ? {} : browserFields),
[isEventRenderedView, browserFields]
);
const onAlertTableUpdate: AlertsTableStateProps['onUpdate'] = useCallback(
({ isLoading: isAlertTableLoading, totalCount, refresh }) => {
dispatch(
dataTableActions.setDataTableSelectAll({
updateIsLoading({
id: tableId,
selectAll: false,
isLoading: isAlertTableLoading,
})
);
}
}, [dispatch, isSelectAllChecked, tableId]);
const additionalFiltersComponent = useMemo(
() => (
<AdditionalFiltersAction
areEventsLoading={loadingEventIds.length > 0}
onShowBuildingBlockAlertsChanged={onShowBuildingBlockAlertsChanged}
showBuildingBlockAlerts={showBuildingBlockAlerts}
onShowOnlyThreatIndicatorAlertsChanged={onShowOnlyThreatIndicatorAlertsChanged}
showOnlyThreatIndicatorAlerts={showOnlyThreatIndicatorAlerts}
/>
),
dispatch(
updateTotalCount({
id: tableId,
totalCount,
})
);
alertTableRefreshHandlerRef.current = refresh;
// setting Query
setQuery({
id: tableId,
loading: isAlertTableLoading,
refetch: refresh,
inspect: null,
});
},
[dispatch, tableId, alertTableRefreshHandlerRef, setQuery]
);
const alertStateProps: AlertsTableStateProps = useMemo(
() => ({
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,
},
}),
[
loadingEventIds.length,
onShowBuildingBlockAlertsChanged,
onShowOnlyThreatIndicatorAlertsChanged,
showBuildingBlockAlerts,
showOnlyThreatIndicatorAlerts,
finalBoolQuery,
configId,
triggersActionsUi.alertsTableConfigurationRegistry,
flyoutSize,
gridStyle,
rowHeightsOptions,
finalColumns,
finalBrowserFields,
onAlertTableUpdate,
isEventRenderedView,
tableView,
]
);
const defaultFiltersMemo = useMemo(() => {
let alertStatusFilter: Filter[] = [];
if (filterGroup) {
alertStatusFilter = buildAlertStatusFilter(filterGroup);
}
if (isEmpty(defaultFilters)) {
return alertStatusFilter;
} else if (defaultFilters != null && !isEmpty(defaultFilters)) {
return [...defaultFilters, ...alertStatusFilter];
}
}, [defaultFilters, filterGroup]);
const { filterManager } = kibana.services.data.query;
const tGridEnabled = useIsExperimentalFeatureEnabled('tGridEnabled');
useEffect(() => {
if (isDataTableInitialized) return;
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,
title: i18n.SESSIONS_TITLE,
defaultColumns: finalColumns.map((c) => ({
...c,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
})),
})
);
}, [dispatch, filterManager, tGridEnabled, tableId, license]);
}, [dispatch, tableId, finalColumns, isDataTableInitialized]);
const leadingControlColumns = useMemo(
() => getDefaultControlColumn(ACTION_BUTTON_COUNT),
[ACTION_BUTTON_COUNT]
const AlertTable = useMemo(
() => triggersActionsUi.getAlertsStateTable(alertStateProps),
[alertStateProps, triggersActionsUi]
);
const addToCaseBulkActions = useBulkAddToCaseActions();
const addBulkToTimelineAction = useAddBulkToTimelineAction({
localFilters: defaultFiltersMemo ?? [],
tableId,
from,
to,
scopeId: SourcererScopeName.detections,
const { Navigation } = useSessionViewNavigation({
scopeId: tableId,
});
const bulkActions = useMemo(
() => ({
customBulkActions: [...addToCaseBulkActions, addBulkToTimelineAction],
}),
[addToCaseBulkActions, addBulkToTimelineAction]
);
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]);
const [groupsActivePage, setGroupsActivePage] = useState<number>(0);
const [groupsItemsPerPage, setGroupsItemsPerPage] = useState<number>(25);
const pagination = useMemo(
() => ({
pageIndex: groupsActivePage,
pageSize: groupsItemsPerPage,
onChangeItemsPerPage: (itemsPerPageNumber: number) =>
setGroupsItemsPerPage(itemsPerPageNumber),
onChangePage: (pageNumber: number) => setGroupsActivePage(pageNumber),
}),
[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,
]
);
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),
const { DetailsPanel, SessionView } = useSessionView({
entityType: 'alerts',
scopeId: tableId,
});
useEffect(() => {
if (!isNoneGroup(selectedGroup)) {
setAlertsQuery(queryGroups);
}
}, [queryGroups, selectedGroup, setAlertsQuery]);
const graphOverlay = useMemo(() => {
const shouldShowOverlay =
(graphEventId != null && graphEventId.length > 0) || sessionViewConfig != null;
return shouldShowOverlay ? (
<GraphOverlay scopeId={tableId} SessionView={SessionView} Navigation={Navigation} />
) : null;
}, [graphEventId, tableId, sessionViewConfig, SessionView, Navigation]);
useInspectButton({
deleteQuery,
loading: isLoadingGroups,
response,
setQuery,
refetch,
request,
uniqueQueryId,
});
const inspect = useMemo(
() => (
<InspectButton queryId={uniqueQueryId} inspectIndex={0} title={i18n.INSPECT_GROUPING_TITLE} />
),
[uniqueQueryId]
);
const defaultGroupingOptions = getDefaultGroupingOptions(tableId);
const [options, setOptions] = useState(
defaultGroupingOptions.find((o) => o.key === selectedGroup)
? defaultGroupingOptions
: [
...defaultGroupingOptions,
...(!isNoneGroup(selectedGroup)
? [
{
key: selectedGroup,
label: selectedGroup,
},
]
: []),
]
);
const groupsSelector = useMemo(
() => (
<GroupsSelector
groupSelected={selectedGroup}
data-test-subj="alerts-table-group-selector"
onGroupChange={(groupSelection: string) => {
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 takeActionItems = useGroupTakeActionsItems({
indexName: indexPatterns.title,
currentStatus: filterGroup as AlertWorkflowStatus,
showAlertStatusActions: hasIndexWrite && hasIndexMaintenance,
});
const getTakeActionItems = useCallback(
(groupFilters: Filter[]) =>
takeActionItems(
getGlobalQuery([...(defaultFiltersMemo ?? []), ...groupFilters])?.filterQuery
),
[defaultFiltersMemo, getGlobalQuery, takeActionItems]
);
if (loading || isLoadingGroups || isEmpty(selectedPatterns)) {
if (isLoading) {
return null;
}
const dataTable = (
<StatefulEventsViewer
additionalFilters={additionalFiltersComponent}
currentFilter={filterGroup as AlertWorkflowStatus}
defaultModel={getAlertsDefaultModel(license)}
end={to}
bulkActions={bulkActions}
hasCrudPermissions={hasIndexWrite && hasIndexMaintenance}
tableId={tableId}
leadingControlColumns={leadingControlColumns}
onRuleChange={onRuleChange}
pageFilters={defaultFiltersMemo}
renderCellValue={RenderCellValue}
rowRenderers={defaultRowRenderers}
sourcererScope={SourcererScopeName.detections}
start={from}
additionalRightMenuOptions={isNoneGroup(selectedGroup) ? [groupsSelector] : []}
/>
);
return (
<>
{isNoneGroup(selectedGroup) ? (
dataTable
) : (
<>
<GroupingContainer
selectedGroup={selectedGroup}
groupsSelector={groupsSelector}
inspectButton={inspect}
takeActionItems={getTakeActionItems}
data={alertsGroupsData?.aggregations ?? {}}
renderChildComponent={(groupFilter) => (
<StatefulEventsViewer
additionalFilters={additionalFiltersComponent}
currentFilter={filterGroup as AlertWorkflowStatus}
defaultModel={getAlertsDefaultModel(license)}
end={to}
bulkActions={bulkActions}
hasCrudPermissions={hasIndexWrite && hasIndexMaintenance}
tableId={tableId}
leadingControlColumns={leadingControlColumns}
onRuleChange={onRuleChange}
pageFilters={[...(defaultFiltersMemo ?? []), ...groupFilter]}
renderCellValue={RenderCellValue}
rowRenderers={defaultRowRenderers}
sourcererScope={SourcererScopeName.detections}
start={from}
additionalRightMenuOptions={isNoneGroup(selectedGroup) ? [groupsSelector] : []}
/>
)}
unit={defaultUnit}
pagination={pagination}
groupPanelRenderer={(fieldBucket: RawBucket) =>
getSelectedGroupButtonContent(selectedGroup, fieldBucket)
}
badgeMetricStats={(fieldBucket: RawBucket) =>
getSelectedGroupBadgeMetrics(selectedGroup, fieldBucket)
}
customMetricStats={(fieldBucket: RawBucket) =>
getSelectedGroupCustomMetrics(selectedGroup, fieldBucket)
}
/>
</>
)}
</>
<div>
{graphOverlay}
<FullWidthFlexGroupTable $visible={!graphEventId && graphOverlay == null} gutterSize="none">
<StatefulEventContext.Provider value={activeStatefulEventContext}>
<EuiDataGridContainer hideLastPage={false}>{AlertTable}</EuiDataGridContainer>
</StatefulEventContext.Provider>
</FullWidthFlexGroupTable>
{DetailsPanel}
</div>
);
};
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<typeof connector>;
export const AlertsTable = connector(React.memo(AlertsTableComponent));

View file

@ -53,6 +53,7 @@ interface AlertContextMenuProps {
ecsRowData: Ecs;
onRuleChange?: () => void;
scopeId: string;
refetch: (() => void) | undefined;
}
const AlertContextMenuComponent: React.FC<AlertContextMenuProps & PropsFromRedux> = ({
@ -65,6 +66,7 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps & PropsFromRedux
scopeId,
globalQuery,
timelineQuery,
refetch,
}) => {
const [isPopoverOpen, setPopover] = useState(false);
const [isOsqueryFlyoutOpen, setOsqueryFlyoutOpen] = useState(false);
@ -144,8 +146,9 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps & PropsFromRedux
}
} else {
refetchQuery(globalQuery);
if (refetch) refetch();
}
}, [scopeId, globalQuery, timelineQuery, routeProps]);
}, [scopeId, globalQuery, timelineQuery, routeProps, refetch]);
const ruleIndex =
ecsRowData['kibana.alert.rule.parameters']?.index ?? ecsRowData?.signal?.rule?.index;

View file

@ -10,7 +10,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import type { Filter } from '@kbn/es-query';
import { getEsQueryConfig } from '@kbn/data-plugin/public';
import type { TableId } from '../../../../../common/types';
import type { BulkActionsConfig } from '@kbn/triggers-actions-ui-plugin/public/types';
import type { CustomBulkAction } from '../../../../../common/types';
import { TableId } from '../../../../../common/types';
import { combineQueries } from '../../../../common/lib/kuery';
import { useKibana } from '../../../../common/lib/kibana';
import { BULK_ADD_TO_TIMELINE_LIMIT } from '../../../../../common/constants';
@ -181,47 +183,49 @@ export const useAddBulkToTimelineAction = ({
[dispatch, createTimeline, selectedEventIds, tableId]
);
const onResponseHandler = useCallback(
(localResponse: TimelineArgs) => {
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;
/*
* 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 (selectAll) {
dispatch(
setEventsLoading({
id: tableId,
isLoading: true,
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(() => {

View file

@ -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,

View file

@ -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 });
},
},

View file

@ -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',
}),
};

View file

@ -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,
},
];

View file

@ -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<EuiDataGridCellValueElementProps & CellVa
);
};
export const useRenderCellValue = ({
setFlyoutAlert,
export const getRenderCellValueHook = ({
scopeId,
tableId,
}: {
setFlyoutAlert?: (data: never) => 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 };
}
const useRenderCellValue: GetRenderCellValue = () => {
const { browserFields } = useSourcererDataView(scopeId);
const browserFieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]);
const getTable = useMemo(() => dataTableSelectors.getTableByIdSelector(), []);
const license = useLicense();
return (
<RenderCellValue
browserFields={browserFields}
columnId={columnId}
data={data}
ecsData={ecsData}
eventId={eventId}
header={myHeader}
isDetails={isDetails}
isDraggable={isDraggable}
isExpandable={isExpandable}
isExpanded={isExpanded}
linkValues={linkValues}
rowIndex={rowIndex}
colIndex={colIndex}
rowRenderers={rowRenderers}
setCellProps={setCellProps}
scopeId={TimelineId.casePage}
truncate={truncate}
/>
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 (
<RenderCellValue
browserFields={browserFields}
columnId={columnId}
data={finalData}
ecsData={ecsData}
eventId={eventId}
header={myHeader}
isDetails={isDetails}
isDraggable={isDraggable}
isExpandable={isExpandable}
isExpanded={isExpanded}
linkValues={linkValues ?? localLinkValues}
rowIndex={rowIndex}
colIndex={colIndex}
rowRenderers={rowRenderers ?? defaultRowRenderers}
setCellProps={setCellProps}
scopeId={tableId}
truncate={truncate}
asPlainText={false}
/>
);
},
[browserFieldsByName, browserFields, columnHeaders]
);
return result;
};
return useRenderCellValue;
};

View file

@ -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

View file

@ -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',
}
);

View file

@ -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 (
<RowAction
columnId={`actions-${rowIndex}`}
columnHeaders={columnHeaders}
controlColumn={leadingControlColumns[0]}
data={timelineItem}
disabled={false}
index={rowIndex}
isDetails={cveProps.isDetails}
isExpanded={cveProps.isExpanded}
isEventViewer={false}
isExpandable={cveProps.isExpandable}
loadingEventIds={loadingEventIds}
onRowSelected={() => {}}
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,
};
};

View file

@ -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<string, unknown> = 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)
);
};

View file

@ -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];
};

View file

@ -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<UseDataGridColumnsSecurityCellActionsProps>(() => {
const fields =
viewMode === VIEW_SELECTION.eventRenderedView
? []
: columns.map((col) => {
const fieldMeta: Partial<BrowserField> | 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;
};

View file

@ -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(
() => (
<AdditionalFiltersAction
areEventsLoading={false}
onShowBuildingBlockAlertsChanged={setShowBuildingBlockAlerts}
showBuildingBlockAlerts={showBuildingBlockAlerts}
onShowOnlyThreatIndicatorAlertsChanged={setShowOnlyThreatIndicatorAlerts}
showOnlyThreatIndicatorAlerts={showOnlyThreatIndicatorAlerts}
/>
),
[
showBuildingBlockAlerts,
setShowBuildingBlockAlerts,
showOnlyThreatIndicatorAlerts,
setShowOnlyThreatIndicatorAlerts,
]
);
const rightTopMenu = useMemo(
() => (
<RightTopMenu
position="relative"
tableView={tableView}
loading={false}
tableId={tableId}
title={'Some Title'}
onViewChange={handleChangeTableView}
hasRightOffset={false}
additionalFilters={additionalFiltersComponent}
showInspect={false}
additionalMenuOptions={isNoneGroup(selectedGroup) ? [groupsSelector] : []}
/>
),
[tableView, handleChangeTableView, additionalFiltersComponent, groupsSelector, selectedGroup]
);
return {
right: rightTopMenu,
};
};
return usePersistentControls;
};

View file

@ -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;
};

View file

@ -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,
};
}

Some files were not shown because too many files have changed in this diff Show more