[Security solution] Grouping UI package (#152385)

This commit is contained in:
Steph Milovic 2023-03-02 12:03:25 -07:00 committed by GitHub
parent 54d6321246
commit 2a1740d035
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 491 additions and 398 deletions

2
.github/CODEOWNERS vendored
View file

@ -533,6 +533,7 @@ packages/kbn-securitysolution-autocomplete @elastic/security-solution-platform
packages/kbn-securitysolution-ecs @elastic/security-threat-hunting-explore
packages/kbn-securitysolution-es-utils @elastic/security-solution-platform
packages/kbn-securitysolution-exception-list-components @elastic/security-solution-platform
packages/kbn-securitysolution-grouping @elastic/security-threat-hunting-explore
packages/kbn-securitysolution-hook-utils @elastic/security-solution-platform
packages/kbn-securitysolution-io-ts-alerting-types @elastic/security-solution-platform
packages/kbn-securitysolution-io-ts-list-types @elastic/security-solution-platform
@ -1007,7 +1008,6 @@ packages/kbn-yarn-lock-validator @elastic/kibana-operations
/x-pack/plugins/security_solution/public/common/components/guided_onboarding_tour @elastic/security-threat-hunting-explore
/x-pack/plugins/security_solution/public/common/components/charts @elastic/security-threat-hunting-explore
/x-pack/plugins/security_solution/public/common/components/grouping @elastic/security-threat-hunting-explore
/x-pack/plugins/security_solution/public/detections/components/alerts_table/grouping_settings @elastic/security-threat-hunting-explore
/x-pack/plugins/security_solution/public/common/components/header_page @elastic/security-threat-hunting-explore
/x-pack/plugins/security_solution/public/common/components/header_section @elastic/security-threat-hunting-explore

View file

@ -45,6 +45,7 @@
"files": "src/plugins/files",
"filesManagement": "src/plugins/files_management",
"flot": "packages/kbn-flot-charts/lib",
"grouping": "packages/kbn-securitysolution-grouping/src",
"guidedOnboarding": "src/plugins/guided_onboarding",
"guidedOnboardingPackage": "packages/kbn-guided-onboarding",
"home": "src/plugins/home",

View file

@ -535,6 +535,7 @@
"@kbn/securitysolution-ecs": "link:packages/kbn-securitysolution-ecs",
"@kbn/securitysolution-es-utils": "link:packages/kbn-securitysolution-es-utils",
"@kbn/securitysolution-exception-list-components": "link:packages/kbn-securitysolution-exception-list-components",
"@kbn/securitysolution-grouping": "link:packages/kbn-securitysolution-grouping",
"@kbn/securitysolution-hook-utils": "link:packages/kbn-securitysolution-hook-utils",
"@kbn/securitysolution-io-ts-alerting-types": "link:packages/kbn-securitysolution-io-ts-alerting-types",
"@kbn/securitysolution-io-ts-list-types": "link:packages/kbn-securitysolution-io-ts-list-types",

View file

@ -0,0 +1,3 @@
# @kbn/securitysolution-grouping
Grouping component and query. Currently only consumed by security solution alerts table. Package is a WIP. Refactoring to make generic https://github.com/elastic/kibana/issues/152491

View file

@ -0,0 +1,31 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import {
GroupSelectorProps,
Grouping,
GroupingProps,
GroupSelector,
RawBucket,
getGroupingQuery,
isNoneGroup,
} from './src';
import type { NamedAggregation, GroupingFieldTotalAggregation, GroupingAggregation } from './src';
export const getGrouping = (props: GroupingProps): React.ReactElement<GroupingProps> => (
<Grouping {...props} />
);
export const getGroupSelector = (
props: GroupSelectorProps
): React.ReactElement<GroupSelectorProps> => <GroupSelector {...props} />;
export { isNoneGroup, getGroupingQuery };
export type { GroupingAggregation, GroupingFieldTotalAggregation, NamedAggregation, RawBucket };

View file

@ -0,0 +1,26 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-securitysolution-grouping'],
coverageReporters: ['text', 'html'],
collectCoverageFrom: [
'<rootDir>/packages/kbn-securitysolution-grouping/**/*.{ts,tsx}',
'!<rootDir>/packages/kbn-securitysolution-grouping/**/*.test',
'!<rootDir>/packages/kbn-securitysolution-grouping/**/types/*',
'!<rootDir>/packages/kbn-securitysolution-grouping/**/*.type',
'!<rootDir>/packages/kbn-securitysolution-grouping/**/*.styles',
'!<rootDir>/packages/kbn-securitysolution-grouping/**/mocks/*',
'!<rootDir>/packages/kbn-securitysolution-grouping/**/*.config',
'!<rootDir>/packages/kbn-securitysolution-grouping/**/translations',
'!<rootDir>/packages/kbn-securitysolution-grouping/**/types/*',
],
setupFilesAfterEnv: ['<rootDir>/packages/kbn-securitysolution-grouping/setup_test.ts'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/securitysolution-grouping",
"owner": "@elastic/security-threat-hunting-explore"
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/securitysolution-grouping",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,9 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';

View file

@ -1,14 +1,14 @@
/*
* 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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { GroupStats } from './group_stats';
import { TestProviders } from '../../../mock';
const onTakeActionsOpen = jest.fn();
const testProps = {
@ -49,11 +49,7 @@ describe('Group stats', () => {
jest.clearAllMocks();
});
it('renders each stat item', () => {
const { getByTestId } = render(
<TestProviders>
<GroupStats {...testProps} />
</TestProviders>
);
const { getByTestId } = render(<GroupStats {...testProps} />);
expect(getByTestId('group-stats')).toBeInTheDocument();
testProps.badgeMetricStats.forEach(({ title: stat }) => {
expect(getByTestId(`metric-${stat}`)).toBeInTheDocument();
@ -63,11 +59,7 @@ describe('Group stats', () => {
});
});
it('when onTakeActionsOpen is defined, call onTakeActionsOpen on popover click', () => {
const { getByTestId, queryByTestId } = render(
<TestProviders>
<GroupStats {...testProps} />
</TestProviders>
);
const { getByTestId, queryByTestId } = render(<GroupStats {...testProps} />);
fireEvent.click(getByTestId('take-action-button'));
expect(onTakeActionsOpen).toHaveBeenCalled();
['takeActionItem-1', 'takeActionItem-2'].forEach((actionItem) => {
@ -75,11 +67,7 @@ describe('Group stats', () => {
});
});
it('when onTakeActionsOpen is undefined, render take actions dropdown on popover click', () => {
const { getByTestId } = render(
<TestProviders>
<GroupStats {...testProps} onTakeActionsOpen={undefined} />
</TestProviders>
);
const { getByTestId } = render(<GroupStats {...testProps} onTakeActionsOpen={undefined} />);
fireEvent.click(getByTestId('take-action-button'));
['takeActionItem-1', 'takeActionItem-2'].forEach((actionItem) => {
expect(getByTestId(actionItem)).toBeInTheDocument();

View file

@ -1,8 +1,9 @@
/*
* 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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
@ -16,7 +17,7 @@ import {
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import type { BadgeMetric, CustomMetric } from '.';
import { StatsContainer } from '../styles';
import { statsContainerCss } from '../styles';
import { TAKE_ACTION } from '../translations';
import type { RawBucket } from '../types';
@ -46,7 +47,7 @@ const GroupStatsComponent = ({
() =>
badgeMetricStats?.map((metric) => (
<EuiFlexItem grow={false} key={metric.title}>
<StatsContainer data-test-subj={`metric-${metric.title}`}>
<span css={statsContainerCss} data-test-subj={`metric-${metric.title}`}>
<>
{metric.title}
<EuiToolTip position="top" content={metric.value}>
@ -58,7 +59,7 @@ const GroupStatsComponent = ({
</EuiBadge>
</EuiToolTip>
</>
</StatsContainer>
</span>
</EuiFlexItem>
)),
[badgeMetricStats]
@ -68,10 +69,10 @@ const GroupStatsComponent = ({
() =>
customMetricStats?.map((customMetric) => (
<EuiFlexItem grow={false} key={customMetric.title}>
<StatsContainer data-test-subj={`customMetric-${customMetric.title}`}>
<span css={statsContainerCss} data-test-subj={`customMetric-${customMetric.title}`}>
{customMetric.title}
{customMetric.customStatRenderer}
</StatsContainer>
</span>
</EuiFlexItem>
)),
[customMetricStats]

View file

@ -1,8 +1,9 @@
/*
* 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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const createGroupFilter = (selectedGroup: string, query?: string) =>

View file

@ -1,8 +1,9 @@
/*
* 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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { fireEvent, render } from '@testing-library/react';

View file

@ -1,14 +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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import React, { useCallback, useMemo } from 'react';
import { firstNonNullValue } from '../../../../../common/endpoint/models/ecs_safety_helpers';
import { firstNonNullValue } from '../../helpers';
import type { RawBucket } from '../types';
import { createGroupFilter } from './helpers';

View file

@ -1,15 +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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiFlexGroup, EuiFlexItem, EuiImage, EuiPanel, EuiText, EuiTitle } from '@elastic/eui';
import React from 'react';
import type { CoreStart } from '@kbn/core/public';
import { FormattedMessage } from '@kbn/i18n-react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { noResultsIllustrationLight } from '@kbn/shared-svg';
const panelStyle = {
maxWidth: 500,
@ -23,8 +23,6 @@ const heights = {
export const EmptyGroupingComponent: React.FC<{ height?: keyof typeof heights }> = ({
height = 'tall',
}) => {
const { http } = useKibana<CoreStart>().services;
return (
<EuiPanel color="subdued" data-test-subj="empty-results-panel">
<EuiFlexGroup style={{ height: heights[height] }} alignItems="center" justifyContent="center">
@ -36,27 +34,21 @@ export const EmptyGroupingComponent: React.FC<{ height?: keyof typeof heights }>
<EuiTitle>
<h3>
<FormattedMessage
id="xpack.securitySolution.grouping.empty.title"
id="grouping.empty.title"
defaultMessage="No grouping results match your selected Group alerts field"
/>
</h3>
</EuiTitle>
<p>
<FormattedMessage
id="xpack.securitySolution.grouping.empty.description"
id="grouping.empty.description"
defaultMessage="Try searching over a longer period of time or modifying your Group alerts field"
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiImage
size="200"
alt=""
url={http.basePath.prepend(
'/plugins/timelines/assets/illustration_product_no_results_magnifying_glass.svg'
)}
/>
<EuiImage size="200" alt="" src={noResultsIllustrationLight} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>

View file

@ -1,8 +1,9 @@
/*
* 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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { EuiComboBoxOptionOption } from '@elastic/eui';
@ -50,22 +51,19 @@ export class CustomFieldPanel extends React.PureComponent<Props, State> {
<div data-test-subj="custom-field-panel" style={{ padding: 16 }}>
<EuiForm>
<EuiFormRow
label={i18n.translate('xpack.securitySolution.groupsSelector.customGroupByFieldLabel', {
label={i18n.translate('grouping.groupsSelector.customGroupByFieldLabel', {
defaultMessage: 'Field',
})}
helpText={i18n.translate(
'xpack.securitySolution.groupsSelector.customGroupByHelpText',
{
defaultMessage: 'This is the field used for the terms aggregation',
}
)}
helpText={i18n.translate('grouping.groupsSelector.customGroupByHelpText', {
defaultMessage: 'This is the field used for the terms aggregation',
})}
display="rowCompressed"
fullWidth
>
<EuiComboBox
data-test-subj="groupByCustomField"
placeholder={i18n.translate(
'xpack.securitySolution.groupsSelector.customGroupByDropdownPlacehoder',
'grouping.groupsSelector.customGroupByDropdownPlacehoder',
{
defaultMessage: 'Select one',
}
@ -86,7 +84,7 @@ export class CustomFieldPanel extends React.PureComponent<Props, State> {
fill
onClick={this.handleSubmit}
>
{i18n.translate('xpack.securitySolution.selector.grouping.label.add', {
{i18n.translate('grouping.selector.grouping.label.add', {
defaultMessage: 'Add',
})}
</EuiButton>

View file

@ -1,13 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { fireEvent, render } from '@testing-library/react';
import { TestProviders } from '../../../mock';
import { GroupsSelector } from '..';
import { GroupSelector } from '..';
import React from 'react';
const onGroupChange = jest.fn();
@ -69,19 +69,11 @@ describe('group selector', () => {
jest.clearAllMocks();
});
it('Sets the selected group from the groupSelected prop', () => {
const { getByTestId } = render(
<TestProviders>
<GroupsSelector {...testProps} />
</TestProviders>
);
const { getByTestId } = render(<GroupSelector {...testProps} />);
expect(getByTestId('group-selector-dropdown').textContent).toBe('Group alerts by: Rule name');
});
it('Presents correct option when group selector dropdown is clicked', () => {
const { getByTestId } = render(
<TestProviders>
<GroupsSelector {...testProps} />
</TestProviders>
);
const { getByTestId } = render(<GroupSelector {...testProps} />);
fireEvent.click(getByTestId('group-selector-dropdown'));
[
...testProps.options,
@ -92,11 +84,7 @@ describe('group selector', () => {
});
});
it('Presents fields dropdown when custom field option is selected', () => {
const { getByTestId } = render(
<TestProviders>
<GroupsSelector {...testProps} />
</TestProviders>
);
const { getByTestId } = render(<GroupSelector {...testProps} />);
fireEvent.click(getByTestId('group-selector-dropdown'));
fireEvent.click(getByTestId('panel-none'));
expect(onGroupChange).toHaveBeenCalled();

View file

@ -1,8 +1,9 @@
/*
* 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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type {
@ -16,7 +17,8 @@ import { CustomFieldPanel } from './custom_field_panel';
import * as i18n from '../translations';
import { StyledContextMenu, StyledEuiButtonEmpty } from '../styles';
interface GroupSelectorProps {
export interface GroupSelectorProps {
'data-test-subj'?: string;
fields: FieldSpec[];
groupSelected: string;
onGroupChange: (groupSelection: string) => void;
@ -24,7 +26,8 @@ interface GroupSelectorProps {
title?: string;
}
const GroupsSelectorComponent = ({
const GroupSelectorComponent = ({
'data-test-subj': dataTestSubj,
fields,
groupSelected = 'none',
onGroupChange,
@ -131,6 +134,7 @@ const GroupsSelectorComponent = ({
return (
<EuiPopover
data-test-subj={dataTestSubj ?? 'groupByPopover'}
button={button}
closePopover={closePopover}
isOpen={isPopoverOpen}
@ -145,4 +149,4 @@ const GroupsSelectorComponent = ({
);
};
export const GroupsSelector = React.memo(GroupsSelectorComponent);
export const GroupSelector = React.memo(GroupSelectorComponent);

View file

@ -1,15 +1,16 @@
/*
* 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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { fireEvent, render, within } from '@testing-library/react';
import React from 'react';
import { GroupingContainer } from '..';
import { TestProviders } from '../../../mock';
import { createGroupFilter } from '../accordion_panel/helpers';
import { I18nProvider } from '@kbn/i18n-react';
import { Grouping } from './grouping';
import { createGroupFilter } from './accordion_panel/helpers';
const renderChildComponent = jest.fn();
const takeActionItems = jest.fn();
@ -103,6 +104,7 @@ const testProps = {
pageSize: 25,
onChangeItemsPerPage: jest.fn(),
onChangePage: jest.fn(),
itemsPerPageOptions: [10, 25, 50, 100],
},
renderChildComponent,
selectedGroup: 'kibana.alert.rule.name',
@ -115,9 +117,9 @@ describe('grouping container', () => {
});
it('Renders group counts when groupsNumber > 0', () => {
const { getByTestId, getAllByTestId, queryByTestId } = render(
<TestProviders>
<GroupingContainer {...testProps} />
</TestProviders>
<I18nProvider>
<Grouping {...testProps} />
</I18nProvider>
);
expect(getByTestId('alert-count').textContent).toBe('2 alerts');
expect(getByTestId('groups-count').textContent).toBe('2 groups');
@ -140,9 +142,9 @@ describe('grouping container', () => {
},
};
const { getByTestId, queryByTestId } = render(
<TestProviders>
<GroupingContainer {...testProps} data={data} />
</TestProviders>
<I18nProvider>
<Grouping {...testProps} data={data} />
</I18nProvider>
);
expect(queryByTestId('alert-count')).not.toBeInTheDocument();
expect(queryByTestId('groups-count')).not.toBeInTheDocument();
@ -152,9 +154,9 @@ describe('grouping container', () => {
it('Opens one group at a time when each group is clicked', () => {
const { getAllByTestId } = render(
<TestProviders>
<GroupingContainer {...testProps} />
</TestProviders>
<I18nProvider>
<Grouping {...testProps} />
</I18nProvider>
);
const group1 = within(getAllByTestId('grouping-accordion')[0]).getAllByRole('button')[0];
const group2 = within(getAllByTestId('grouping-accordion')[1]).getAllByRole('button')[0];

View file

@ -1,8 +1,9 @@
/*
* 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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
@ -14,31 +15,20 @@ import {
} from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import React, { useMemo, useState } from 'react';
import { firstNonNullValue } from '../../../../../common/endpoint/models/ecs_safety_helpers';
import { createGroupFilter } from '../accordion_panel/helpers';
import { tableDefaults } from '../../../store/data_table/defaults';
import { defaultUnit } from '../../toolbar/unit';
import type { BadgeMetric, CustomMetric } from '../accordion_panel';
import { GroupPanel } from '../accordion_panel';
import { GroupStats } from '../accordion_panel/group_stats';
import { EmptyGroupingComponent } from '../empty_resuls_panel';
import { GroupingStyledContainer, GroupsUnitCount } from '../styles';
import { GROUPS_UNIT } from '../translations';
import type { GroupingTableAggregation, RawBucket } from '../types';
import { defaultUnit, firstNonNullValue } from '../helpers';
import { createGroupFilter } from './accordion_panel/helpers';
import type { BadgeMetric, CustomMetric } from './accordion_panel';
import { GroupPanel } from './accordion_panel';
import { GroupStats } from './accordion_panel/group_stats';
import { EmptyGroupingComponent } from './empty_resuls_panel';
import { groupingContainerCss, groupsUnitCountCss } from './styles';
import { GROUPS_UNIT } from './translations';
import type { GroupingAggregation, GroupingFieldTotalAggregation, RawBucket } from './types';
export interface GroupingContainerProps {
export interface GroupingProps {
badgeMetricStats?: (fieldBucket: RawBucket) => BadgeMetric[];
customMetricStats?: (fieldBucket: RawBucket) => CustomMetric[];
data: GroupingTableAggregation &
Record<
string,
{
value?: number | null;
buckets?: Array<{
doc_count?: number | null;
}>;
}
>;
data?: GroupingAggregation & GroupingFieldTotalAggregation;
groupPanelRenderer?: (fieldBucket: RawBucket) => JSX.Element | undefined;
groupsSelector?: JSX.Element;
inspectButton?: JSX.Element;
@ -48,6 +38,7 @@ export interface GroupingContainerProps {
pageSize: number;
onChangeItemsPerPage: (itemsPerPageNumber: number) => void;
onChangePage: (pageNumber: number) => void;
itemsPerPageOptions: number[];
};
renderChildComponent: (groupFilter: Filter[]) => React.ReactNode;
selectedGroup: string;
@ -55,7 +46,7 @@ export interface GroupingContainerProps {
unit?: (n: number) => string;
}
const GroupingContainerComponent = ({
const GroupingComponent = ({
badgeMetricStats,
customMetricStats,
data,
@ -68,7 +59,7 @@ const GroupingContainerComponent = ({
selectedGroup,
takeActionItems,
unit = defaultUnit,
}: GroupingContainerProps) => {
}: GroupingProps) => {
const [trigger, setTrigger] = useState<
Record<string, { state: 'open' | 'closed' | undefined; selectedBucket: RawBucket }>
>({});
@ -86,7 +77,7 @@ const GroupingContainerComponent = ({
const groupPanels = useMemo(
() =>
data.stackByMultipleFields0?.buckets?.map((groupBucket) => {
data?.stackByMultipleFields0?.buckets?.map((groupBucket) => {
const group = firstNonNullValue(groupBucket.key);
const groupKey = `group0-${group}`;
@ -128,7 +119,7 @@ const GroupingContainerComponent = ({
[
badgeMetricStats,
customMetricStats,
data.stackByMultipleFields0?.buckets,
data?.stackByMultipleFields0?.buckets,
groupPanelRenderer,
isLoading,
renderChildComponent,
@ -153,12 +144,18 @@ const GroupingContainerComponent = ({
{groupsNumber > 0 ? (
<EuiFlexGroup gutterSize="none">
<EuiFlexItem grow={false}>
<GroupsUnitCount data-test-subj="alert-count">{unitCountText}</GroupsUnitCount>
<span css={groupsUnitCountCss} data-test-subj="alert-count">
{unitCountText}
</span>
</EuiFlexItem>
<EuiFlexItem>
<GroupsUnitCount data-test-subj="groups-count" style={{ borderRight: 'none' }}>
<span
css={groupsUnitCountCss}
data-test-subj="groups-count"
style={{ borderRight: 'none' }}
>
{unitGroupsCountText}
</GroupsUnitCount>
</span>
</EuiFlexItem>
</EuiFlexGroup>
) : null}
@ -170,7 +167,7 @@ const GroupingContainerComponent = ({
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<GroupingStyledContainer className="eui-xScroll">
<div css={groupingContainerCss} className="eui-xScroll">
{groupsNumber > 0 ? (
<>
{groupPanels}
@ -179,7 +176,7 @@ const GroupingContainerComponent = ({
activePage={pagination.pageIndex}
data-test-subj="grouping-table-pagination"
itemsPerPage={pagination.pageSize}
itemsPerPageOptions={tableDefaults.itemsPerPageOptions}
itemsPerPageOptions={pagination.itemsPerPageOptions}
onChangeItemsPerPage={pagination.onChangeItemsPerPage}
onChangePage={pagination.onChangePage}
pageCount={pageCount}
@ -194,9 +191,9 @@ const GroupingContainerComponent = ({
<EmptyGroupingComponent />
</>
)}
</GroupingStyledContainer>
</div>
</>
);
};
export const GroupingContainer = React.memo(GroupingContainerComponent);
export const Grouping = React.memo(GroupingComponent);

View file

@ -1,16 +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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { NONE_GROUP_KEY } from './types';
export * from './container';
export * from './query';
export * from './query/types';
export * from './groups_selector';
export * from './group_selector';
export * from './types';
export * from './grouping';
export const isNoneGroup = (groupKey: string) => groupKey === NONE_GROUP_KEY;

View file

@ -1,26 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiButtonEmpty, EuiContextMenu } from '@elastic/eui';
import { euiStyled } from '@kbn/kibana-react-plugin/common';
import styled from 'styled-components';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
export const GroupsUnitCount = styled.span`
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold};
border-right: ${({ theme }) => theme.eui.euiBorderThin};
export const groupsUnitCountCss = css`
font-size: ${euiThemeVars.euiFontSizeXS};
font-weight: ${euiThemeVars.euiFontWeightSemiBold};
border-right: ${euiThemeVars.euiBorderThin};
margin-right: 16px;
padding-right: 16px;
`;
export const StatsContainer = styled.span`
font-size: ${({ theme }) => theme.eui.euiFontSizeXS};
font-weight: ${({ theme }) => theme.eui.euiFontWeightSemiBold};
border-right: ${({ theme }) => theme.eui.euiBorderThin};
export const statsContainerCss = css`
font-size: ${euiThemeVars.euiFontSizeXS};
font-weight: ${euiThemeVars.euiFontWeightSemiBold};
border-right: ${euiThemeVars.euiBorderThin};
margin-right: 16px;
padding-right: 16px;
.smallDot {
@ -33,26 +35,26 @@ export const StatsContainer = styled.span`
}
`;
export const GroupingStyledContainer = styled.div`
export const groupingContainerCss = css`
.euiAccordion__childWrapper .euiAccordion__padding--m {
margin-left: 8px;
margin-right: 8px;
border-left: ${({ theme }) => theme.eui.euiBorderThin};
border-right: ${({ theme }) => theme.eui.euiBorderThin};
border-bottom: ${({ theme }) => theme.eui.euiBorderThin};
border-left: ${euiThemeVars.euiBorderThin};
border-right: ${euiThemeVars.euiBorderThin};
border-bottom: ${euiThemeVars.euiBorderThin};
border-radius: 0 0 6px 6px;
}
.euiAccordion__triggerWrapper {
border-bottom: ${({ theme }) => theme.eui.euiBorderThin};
border-left: ${({ theme }) => theme.eui.euiBorderThin};
border-right: ${({ theme }) => theme.eui.euiBorderThin};
border-bottom: ${euiThemeVars.euiBorderThin};
border-left: ${euiThemeVars.euiBorderThin};
border-right: ${euiThemeVars.euiBorderThin};
border-radius: 6px;
min-height: 78px;
padding-left: 16px;
padding-right: 16px;
}
.groupingAccordionForm {
border-top: ${({ theme }) => theme.eui.euiBorderThin};
border-top: ${euiThemeVars.euiBorderThin};
border-bottom: none;
border-radius: 6px;
min-width: 1090px;
@ -75,7 +77,7 @@ export const StyledContextMenu = euiStyled(EuiContextMenu)`
text-overflow: ellipsis;
}
.euiContextMenuItem {
border-bottom: ${({ theme }) => theme.eui.euiBorderThin};
border-bottom: ${euiThemeVars.euiBorderThin};
}
.euiContextMenuItem:last-child {
border: none;

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
export const GROUPS_UNIT = (totalCount: number) =>
i18n.translate('grouping.total.unit', {
values: { totalCount },
defaultMessage: `{totalCount, plural, =1 {group} other {groups}}`,
});
export const TAKE_ACTION = i18n.translate('grouping.additionalActions.takeAction', {
defaultMessage: 'Take actions',
});
export const BETA = i18n.translate('grouping.betaLabel', {
defaultMessage: 'Beta',
});
export const BETA_TOOL_TIP = i18n.translate('grouping.betaToolTip', {
defaultMessage:
'Grouping may show only a subset of alerts while in beta. To see all alerts, use the list view by selecting "None"',
});
export const GROUP_BY = i18n.translate('grouping.alerts.label', {
defaultMessage: 'Group alerts by',
});
export const GROUP_BY_CUSTOM_FIELD = i18n.translate('grouping.customGroupByPanelTitle', {
defaultMessage: 'Group By Custom Field',
});
export const SELECT_FIELD = i18n.translate('grouping.groupByPanelTitle', {
defaultMessage: 'Select Field',
});
export const NONE = i18n.translate('grouping.noneGroupByOptionName', {
defaultMessage: 'None',
});
export const CUSTOM_FIELD = i18n.translate('grouping.customGroupByOptionName', {
defaultMessage: 'Custom field',
});
export const ALERTS_UNIT = (totalCount: number) =>
i18n.translate('grouping.eventsTab.unit', {
values: { totalCount },
defaultMessage: `{totalCount, plural, =1 {alert} other {alerts}}`,
});

View file

@ -1,13 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { GenericBuckets } from '../../../../common/search_strategy/common';
export const DEFAULT_GROUPING_QUERY_ID = 'defaultGroupingQuery';
// copied from common/search_strategy/common
export interface GenericBuckets {
key: string | string[];
key_as_string?: string; // contains, for example, formatted dates
doc_count: number;
}
export const NONE_GROUP_KEY = 'none';
@ -43,7 +47,7 @@ export type RawBucket = GenericBuckets & {
};
/** Defines the shape of the aggregation returned by Elasticsearch */
export interface GroupingTableAggregation {
export interface GroupingAggregation {
stackByMultipleFields0?: {
buckets?: RawBucket[];
};

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * from './query';
export * from './query/types';

View file

@ -1,12 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { GroupingQueryArgs } from '..';
import { getGroupingQuery, MAX_QUERY_SIZE } from '..';
import type { GroupingQueryArgs } from './types';
import { getGroupingQuery, MAX_QUERY_SIZE } from '.';
const testProps: GroupingQueryArgs = {
additionalFilters: [],

View file

@ -1,8 +1,9 @@
/*
* 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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { isEmpty } from 'lodash/fp';

View file

@ -1,8 +1,9 @@
/*
* 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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';

View file

@ -0,0 +1,32 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as i18n from './components/translations';
/**
* All mappings in Elasticsearch support arrays. They can also return null values or be missing. For example, a `keyword` mapping could return `null` or `[null]` or `[]` or `'hi'`, or `['hi', 'there']`. We need to handle these cases in order to avoid throwing an error.
* When dealing with an value that comes from ES, wrap the underlying type in `ECSField`. For example, if you have a `keyword` or `text` value coming from ES, cast it to `ECSField<string>`.
*/
export type ECSField<T> = T | null | undefined | Array<T | null>;
/**
* Return first non-null value. If the field contains an array, this will return the first value that isn't null. If the field isn't an array it'll be returned unless it's null.
*/
export function firstNonNullValue<T>(valueOrCollection: ECSField<T>): T | undefined {
if (valueOrCollection === null) {
return undefined;
} else if (Array.isArray(valueOrCollection)) {
for (const value of valueOrCollection) {
if (value !== null) {
return value;
}
}
} else {
return valueOrCollection;
}
}
export const defaultUnit = (n: number) => i18n.ALERTS_UNIT(n);

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * from './components';
export * from './containers';
export * from './helpers';

View file

@ -0,0 +1,28 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react",
"@emotion/react/types/css-prop"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/data-views-plugin",
"@kbn/es-query",
"@kbn/i18n",
"@kbn/i18n-react",
"@kbn/kibana-react-plugin",
"@kbn/shared-svg",
"@kbn/ui-theme"
]
}

View file

@ -1060,6 +1060,8 @@
"@kbn/securitysolution-es-utils/*": ["packages/kbn-securitysolution-es-utils/*"],
"@kbn/securitysolution-exception-list-components": ["packages/kbn-securitysolution-exception-list-components"],
"@kbn/securitysolution-exception-list-components/*": ["packages/kbn-securitysolution-exception-list-components/*"],
"@kbn/securitysolution-grouping": ["packages/kbn-securitysolution-grouping"],
"@kbn/securitysolution-grouping/*": ["packages/kbn-securitysolution-grouping/*"],
"@kbn/securitysolution-hook-utils": ["packages/kbn-securitysolution-hook-utils"],
"@kbn/securitysolution-hook-utils/*": ["packages/kbn-securitysolution-hook-utils/*"],
"@kbn/securitysolution-io-ts-alerting-types": ["packages/kbn-securitysolution-io-ts-alerting-types"],

View file

@ -1,59 +0,0 @@
/*
* 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 GROUPS_UNIT = (totalCount: number) =>
i18n.translate('xpack.securitySolution.grouping.total.unit', {
values: { totalCount },
defaultMessage: `{totalCount, plural, =1 {group} other {groups}}`,
});
export const TAKE_ACTION = i18n.translate(
'xpack.securitySolution.grouping.additionalActions.takeAction',
{
defaultMessage: 'Take actions',
}
);
export const BETA = i18n.translate('xpack.securitySolution.grouping.betaLabel', {
defaultMessage: 'Beta',
});
export const BETA_TOOL_TIP = i18n.translate('xpack.securitySolution.grouping.betaToolTip', {
defaultMessage:
'Grouping may show only a subset of alerts while in beta. To see all alerts, use the list view by selecting "None"',
});
export const GROUP_BY = i18n.translate('xpack.securitySolution.selector.grouping.label', {
defaultMessage: 'Group alerts by',
});
export const GROUP_BY_CUSTOM_FIELD = i18n.translate(
'xpack.securitySolution.groupsSelector.customGroupByPanelTitle',
{
defaultMessage: 'Group By Custom Field',
}
);
export const SELECT_FIELD = i18n.translate(
'xpack.securitySolution.groupsSelector.groupByPanelTitle',
{
defaultMessage: 'Select Field',
}
);
export const NONE = i18n.translate('xpack.securitySolution.groupsSelector.noneGroupByOptionName', {
defaultMessage: 'None',
});
export const CUSTOM_FIELD = i18n.translate(
'xpack.securitySolution.groupsSelector.customGroupByOptionName',
{
defaultMessage: 'Custom field',
}
);

View file

@ -6,16 +6,15 @@
*/
import type { FieldSpec } from '@kbn/data-views-plugin/common';
import React, { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { GROUP_BY } from '../../../components/grouping/translations';
import { getGroupSelector, isNoneGroup } from '@kbn/securitysolution-grouping';
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[];
@ -23,11 +22,7 @@ export interface UseGetGroupSelectorArgs {
tableId: TableId;
}
export const useGetGroupingSelector = ({
fields,
groupingId,
tableId,
}: UseGetGroupSelectorArgs) => {
export const useGetGroupSelector = ({ fields, groupingId, tableId }: UseGetGroupSelectorArgs) => {
const dispatch = useDispatch();
const getGroupByIdSelector = groupSelectors.getGroupByIdSelector();
@ -76,45 +71,31 @@ export const useGetGroupingSelector = ({
);
}, [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);
const groupsSelector = getGroupSelector({
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_BY}
/>
),
[
defaultGroupingOptions,
fields,
options,
selectedGroup,
setGroupsActivePage,
setOptions,
setSelectedGroup,
]
);
if (!isNoneGroup(groupSelection) && !options.find((o) => o.key === groupSelection)) {
setOptions([
...defaultGroupingOptions,
{
label: groupSelection,
key: groupSelection,
},
]);
} else {
setOptions(defaultGroupingOptions);
}
},
fields,
options,
});
return groupsSelector;
};

View file

@ -7,6 +7,7 @@
import { useDispatch, useSelector } from 'react-redux';
import { useCallback, useMemo } from 'react';
import { tableDefaults } from '../../../store/data_table/defaults';
import { groupActions, groupSelectors } from '../../../store/grouping';
import type { State } from '../../../store';
import { defaultGroup } from '../../../store/grouping/defaults';
@ -45,6 +46,7 @@ export const useGroupingPagination = ({ groupingId }: UseGroupingPaginationArgs)
pageSize: itemsPerPage,
onChangeItemsPerPage: setGroupsItemsPerPage,
onChangePage: setGroupsActivePage,
itemsPerPageOptions: tableDefaults.itemsPerPageOptions,
}),
[activePage, itemsPerPage, setGroupsActivePage, setGroupsItemsPerPage]
);

View file

@ -34,7 +34,7 @@ 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 { GroupedAlertsTable } from '../../../../detections/components/alerts_table/alerts_grouping';
import { useDataTableFilters } from '../../../../common/hooks/use_data_table_filters';
import { FILTER_OPEN, TableId } from '../../../../../common/types';
import { isMlRule } from '../../../../../common/machine_learning/helpers';
@ -840,17 +840,19 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
</Display>
{ruleId != null && (
<GroupedAlertsTable
tableId={TableId.alertsOnRuleDetailsPage}
defaultFilters={alertMergedFilters}
hasIndexWrite={hasIndexWrite ?? false}
hasIndexMaintenance={hasIndexMaintenance ?? false}
from={from}
loading={loading}
to={to}
signalIndexName={signalIndexName}
runtimeMappings={runtimeMappings}
currentAlertStatusFilterValue={filterGroup}
defaultFilters={alertMergedFilters}
from={from}
globalFilters={filters}
globalQuery={query}
hasIndexMaintenance={hasIndexMaintenance ?? false}
hasIndexWrite={hasIndexWrite ?? false}
loading={loading}
renderChildComponent={renderGroupedAlertTable}
runtimeMappings={runtimeMappings}
signalIndexName={signalIndexName}
tableId={TableId.alertsOnRuleDetailsPage}
to={to}
/>
)}
</>

View file

@ -8,32 +8,30 @@
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 { useDispatch, useSelector } from 'react-redux';
import { v4 as uuidv4 } from 'uuid';
import type { Filter } from '@kbn/es-query';
import type { Filter, Query } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import { useGetGroupingSelector } from '../../../common/containers/grouping/hooks/use_get_group_selector';
import type {
GroupingFieldTotalAggregation,
GroupingAggregation,
RawBucket,
} from '@kbn/securitysolution-grouping';
import { getGrouping, isNoneGroup } from '@kbn/securitysolution-grouping';
import { useGetGroupSelector } 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 type { State } from '../../../common/store';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import { useInspectButton } from '../alerts_kpis/common/hooks';
@ -51,26 +49,25 @@ import {
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 {
currentAlertStatusFilterValue?: Status;
defaultFilters?: Filter[];
from: string;
globalFilters: Filter[];
globalQuery: Query;
hasIndexMaintenance: boolean;
hasIndexWrite: boolean;
loading: boolean;
tableId: TableIdLiteral;
to: string;
renderChildComponent: (groupingFilters: Filter[]) => React.ReactElement;
runtimeMappings: MappingRuntimeFields;
signalIndexName: string | null;
currentAlertStatusFilterValue?: Status;
renderChildComponent: (groupingFilters: Filter[]) => React.ReactElement;
tableId: TableIdLiteral;
to: string;
}
export type AlertsTableComponentProps = OwnProps & PropsFromRedux;
export type AlertsTableComponentProps = OwnProps;
export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
defaultFilters = [],
@ -95,20 +92,18 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
const { activeGroup: selectedGroup } =
useSelector((state: State) => getGroupByIdSelector(state, groupingId)) ?? defaultGroup;
const {
browserFields,
indexPattern: indexPatterns,
selectedPatterns,
} = useSourcererDataView(SourcererScopeName.detections);
const { browserFields, indexPattern, selectedPatterns } = useSourcererDataView(
SourcererScopeName.detections
);
const kibana = useKibana();
const getGlobalQuery = useCallback(
(customFilters: Filter[]) => {
if (browserFields != null && indexPatterns != null) {
if (browserFields != null && indexPattern != null) {
return combineQueries({
config: getEsQueryConfig(kibana.services.uiSettings),
dataProviders: [],
indexPattern: indexPatterns,
indexPattern,
browserFields,
filters: [
...(defaultFilters ?? []),
@ -122,7 +117,7 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
}
return null;
},
[browserFields, defaultFilters, globalFilters, globalQuery, indexPatterns, kibana, to, from]
[browserFields, defaultFilters, globalFilters, globalQuery, indexPattern, kibana, to, from]
);
useInvalidFilterQuery({
@ -188,7 +183,7 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
request,
response,
setQuery: setAlertsQuery,
} = useQueryAlerts<{}, GroupingTableAggregation & GroupingFieldTotalAggregation>({
} = useQueryAlerts<{}, GroupingAggregation & GroupingFieldTotalAggregation>({
query: queryGroups,
indexName: signalIndexName,
queryName: ALERTS_QUERY_NAMES.ALERTS_GROUPING,
@ -218,14 +213,14 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
[uniqueQueryId]
);
const groupsSelector = useGetGroupingSelector({
const groupsSelector = useGetGroupSelector({
tableId,
groupingId,
fields: indexPatterns.fields,
fields: indexPattern.fields,
});
const takeActionItems = useGroupTakeActionsItems({
indexName: indexPatterns.title,
indexName: indexPattern.title,
currentStatus: currentAlertStatusFilterValue,
showAlertStatusActions: hasIndexWrite && hasIndexMaintenance,
});
@ -238,30 +233,25 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
const groupedAlerts = useMemo(
() =>
isNoneGroup(selectedGroup) ? (
renderChildComponent([])
) : (
<GroupingContainer
badgeMetricStats={(fieldBucket: RawBucket) =>
getSelectedGroupBadgeMetrics(selectedGroup, fieldBucket)
}
customMetricStats={(fieldBucket: RawBucket) =>
getSelectedGroupCustomMetrics(selectedGroup, fieldBucket)
}
data={alertsGroupsData?.aggregations ?? {}}
groupPanelRenderer={(fieldBucket: RawBucket) =>
getSelectedGroupButtonContent(selectedGroup, fieldBucket)
}
groupsSelector={groupsSelector}
inspectButton={inspect}
isLoading={loading || isLoadingGroups}
pagination={pagination}
renderChildComponent={renderChildComponent}
selectedGroup={selectedGroup}
takeActionItems={getTakeActionItems}
unit={defaultUnit}
/>
),
isNoneGroup(selectedGroup)
? renderChildComponent([])
: getGrouping({
badgeMetricStats: (fieldBucket: RawBucket) =>
getSelectedGroupBadgeMetrics(selectedGroup, fieldBucket),
customMetricStats: (fieldBucket: RawBucket) =>
getSelectedGroupCustomMetrics(selectedGroup, fieldBucket),
data: alertsGroupsData?.aggregations,
groupPanelRenderer: (fieldBucket: RawBucket) =>
getSelectedGroupButtonContent(selectedGroup, fieldBucket),
groupsSelector,
inspectButton: inspect,
isLoading: loading || isLoadingGroups,
pagination,
renderChildComponent,
selectedGroup,
takeActionItems: getTakeActionItems,
unit: defaultUnit,
}),
[
alertsGroupsData?.aggregations,
getTakeActionItems,
@ -282,21 +272,4 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
return groupedAlerts;
};
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));
export const GroupedAlertsTable = React.memo(GroupedAlertsTableComponent);

View file

@ -18,9 +18,9 @@ import {
import { euiThemeVars } from '@kbn/ui-theme';
import { isArray } from 'lodash/fp';
import React from 'react';
import type { RawBucket } from '@kbn/securitysolution-grouping';
import { firstNonNullValue } from '../../../../../common/endpoint/models/ecs_safety_helpers';
import type { GenericBuckets } from '../../../../../common/search_strategy';
import type { RawBucket } from '../../../../common/components/grouping';
import { PopoverItems } from '../../../../common/components/popover_items';
import { COLUMN_TAGS } from '../../../pages/detection_engine/rules/translations';

View file

@ -7,11 +7,7 @@
import { EuiIcon } from '@elastic/eui';
import React from 'react';
import type {
BadgeMetric,
CustomMetric,
} from '../../../../common/components/grouping/accordion_panel';
import type { RawBucket } from '../../../../common/components/grouping';
import type { RawBucket } from '@kbn/securitysolution-grouping';
import * as i18n from '../translations';
const getSingleGroupSeverity = (severity?: string) => {
@ -67,10 +63,7 @@ const multiSeverity = (
</>
);
export const getSelectedGroupBadgeMetrics = (
selectedGroup: string,
bucket: RawBucket
): BadgeMetric[] => {
export const getSelectedGroupBadgeMetrics = (selectedGroup: string, bucket: RawBucket) => {
const defaultBadges = [
{
title: i18n.STATS_GROUP_ALERTS,
@ -138,10 +131,7 @@ export const getSelectedGroupBadgeMetrics = (
];
};
export const getSelectedGroupCustomMetrics = (
selectedGroup: string,
bucket: RawBucket
): CustomMetric[] => {
export const getSelectedGroupCustomMetrics = (selectedGroup: string, bucket: RawBucket) => {
const singleSeverityComponent =
bucket.severitiesSubAggregation?.buckets && bucket.severitiesSubAggregation?.buckets?.length
? getSingleGroupSeverity(bucket.severitiesSubAggregation?.buckets[0].key.toString())

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import { MAX_QUERY_SIZE } from '../../../../common/components/grouping';
import { getAlertsGroupingQuery } from '.';
describe('getAlertsGroupingQuery', () => {
@ -95,7 +94,7 @@ describe('getAlertsGroupingQuery', () => {
},
},
multi_terms: {
size: MAX_QUERY_SIZE,
size: 10000,
terms: [
{
field: 'kibana.alert.rule.name',

View file

@ -7,8 +7,8 @@
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import type { BoolQuery } from '@kbn/es-query';
import type { NamedAggregation } from '../../../../common/components/grouping';
import { getGroupingQuery } from '../../../../common/components/grouping';
import type { NamedAggregation } from '@kbn/securitysolution-grouping';
import { getGroupingQuery } from '@kbn/securitysolution-grouping';
const getGroupFields = (groupValue: string) => {
if (groupValue === 'kibana.alert.rule.name') {

View file

@ -18,8 +18,8 @@ import {
SUB_PLUGINS_REDUCER,
TestProviders,
} from '../../../common/mock';
import type { AlertsTableComponentProps } from './grouped_alerts';
import { GroupedAlertsTableComponent } from './grouped_alerts';
import type { AlertsTableComponentProps } from './alerts_grouping';
import { GroupedAlertsTableComponent } from './alerts_grouping';
import { TableId } from '../../../../common/types';
import { useSourcererDataView } from '../../../common/containers/sourcerer';
import type { UseFieldBrowserOptionsProps } from '../../../timelines/components/fields_browser';
@ -181,7 +181,6 @@ const renderChildComponent = (groupingFilters: Filter[]) => <p data-test-subj="a
const testProps: AlertsTableComponentProps = {
defaultFilters: [],
dispatch: jest.fn(),
from: '2020-07-07T08:20:18.966Z',
globalFilters: [],
globalQuery: {
@ -255,7 +254,6 @@ describe('GroupedAlertsTable', () => {
language: 'language',
}}
globalFilters={[]}
dispatch={jest.fn()}
runtimeMappings={{}}
signalIndexName={'test'}
renderChildComponent={() => <></>}

View file

@ -7,9 +7,9 @@
import React, { useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useGetGroupingSelector } from '../../../common/containers/grouping/hooks/use_get_group_selector';
import { isNoneGroup } from '@kbn/securitysolution-grouping';
import { useGetGroupSelector } 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';
@ -33,7 +33,7 @@ export const getPersistentControlsHook = (tableId: TableId) => {
const { indexPattern: indexPatterns } = useSourcererDataView(SourcererScopeName.detections);
const groupsSelector = useGetGroupingSelector({
const groupsSelector = useGetGroupSelector({
fields: indexPatterns.fields,
groupingId: tableId,
tableId,

View file

@ -78,7 +78,7 @@ import { DetectionPageFilterSet } from '../../components/detection_page_filters'
import type { FilterGroupHandler } from '../../../common/components/filter_group/types';
import type { Status } from '../../../../common/detection_engine/schemas/common/schemas';
import { AlertsTableFilterGroup } from '../../components/alerts_table/alerts_filter_group';
import { GroupedAlertsTable } from '../../components/alerts_table/grouped_alerts';
import { GroupedAlertsTable } from '../../components/alerts_table/alerts_grouping';
import { AlertsTableComponent } from '../../components/alerts_table';
/**
* Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space.
@ -358,7 +358,7 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
]
);
const renderGroupedAlertTable = useCallback(
const renderAlertTable = useCallback(
(groupingFilters: Filter[]) => {
return (
<AlertsTableComponent
@ -455,17 +455,19 @@ const DetectionEnginePageComponent: React.FC<DetectionEngineComponentProps> = ({
<EuiSpacer size="l" />
</Display>
<GroupedAlertsTable
tableId={TableId.alertsOnAlertsPage}
loading={isAlertTableLoading}
hasIndexWrite={hasIndexWrite ?? false}
hasIndexMaintenance={hasIndexMaintenance ?? false}
from={from}
defaultFilters={alertsTableDefaultFilters}
signalIndexName={signalIndexName}
runtimeMappings={runtimeMappings}
to={to}
currentAlertStatusFilterValue={filterGroup}
renderChildComponent={renderGroupedAlertTable}
defaultFilters={alertsTableDefaultFilters}
from={from}
globalFilters={filters}
globalQuery={query}
hasIndexMaintenance={hasIndexMaintenance ?? false}
hasIndexWrite={hasIndexWrite ?? false}
loading={isAlertTableLoading}
renderChildComponent={renderAlertTable}
runtimeMappings={runtimeMappings}
signalIndexName={signalIndexName}
tableId={TableId.alertsOnAlertsPage}
to={to}
/>
</SecuritySolutionPageWrapper>
</StyledFullHeightContainer>

View file

@ -144,5 +144,6 @@
"@kbn/shared-ux-router",
"@kbn/alerts-as-data-utils",
"@kbn/expandable-flyout",
"@kbn/securitysolution-grouping",
]
}

View file

@ -4841,6 +4841,10 @@
version "0.0.0"
uid ""
"@kbn/securitysolution-grouping@link:packages/kbn-securitysolution-grouping":
version "0.0.0"
uid ""
"@kbn/securitysolution-autocomplete@link:packages/kbn-securitysolution-autocomplete":
version "0.0.0"
uid ""