mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Security solution] More grouping cleanup (#153382)
This commit is contained in:
parent
de252d3cba
commit
f0b3519aa4
22 changed files with 539 additions and 391 deletions
|
@ -27,7 +27,7 @@
|
|||
"label": "getGroupingQuery",
|
||||
"description": [],
|
||||
"signature": [
|
||||
"({ additionalFilters, from, groupByFields, metricsAggregations, pageNumber, rootAggregations, runtimeMappings, size, sort, to, }: ",
|
||||
"({ additionalFilters, from, groupByFields, statsAggregations, pageNumber, rootAggregations, runtimeMappings, size, sort, to, }: ",
|
||||
"GroupingQueryArgs",
|
||||
") => ",
|
||||
"GroupingQuery"
|
||||
|
@ -41,7 +41,7 @@
|
|||
"id": "def-common.getGroupingQuery.$1",
|
||||
"type": "Object",
|
||||
"tags": [],
|
||||
"label": "{\n additionalFilters = [],\n from,\n groupByFields,\n metricsAggregations,\n pageNumber,\n rootAggregations,\n runtimeMappings,\n size = DEFAULT_GROUP_BY_FIELD_SIZE,\n sort,\n to,\n}",
|
||||
"label": "{\n additionalFilters = [],\n from,\n groupByFields,\n statsAggregations,\n pageNumber,\n rootAggregations,\n runtimeMappings,\n size = DEFAULT_GROUP_BY_FIELD_SIZE,\n sort,\n to,\n}",
|
||||
"description": [],
|
||||
"signature": [
|
||||
"GroupingQueryArgs"
|
||||
|
@ -133,7 +133,7 @@
|
|||
"label": "useGrouping",
|
||||
"description": [],
|
||||
"signature": [
|
||||
"<T>({ defaultGroupingOptions, fields, groupingId, onGroupChangeCallback, tracker, }: GroupingArgs) => Grouping<T>"
|
||||
"<T>({ defaultGroupingOptions, fields, groupingId, onGroupChange, tracker, }: GroupingArgs) => Grouping<T>"
|
||||
],
|
||||
"path": "packages/kbn-securitysolution-grouping/src/hooks/use_grouping.tsx",
|
||||
"deprecated": false,
|
||||
|
@ -144,7 +144,7 @@
|
|||
"id": "def-common.useGrouping.$1",
|
||||
"type": "Object",
|
||||
"tags": [],
|
||||
"label": "{\n defaultGroupingOptions,\n fields,\n groupingId,\n onGroupChangeCallback,\n tracker,\n}",
|
||||
"label": "{\n defaultGroupingOptions,\n fields,\n groupingId,\n onGroupChange,\n tracker,\n}",
|
||||
"description": [],
|
||||
"signature": [
|
||||
"GroupingArgs"
|
||||
|
@ -326,4 +326,4 @@
|
|||
],
|
||||
"objects": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,15 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
GroupSelector,
|
||||
GroupSelectorProps,
|
||||
RawBucket,
|
||||
getGroupingQuery,
|
||||
isNoneGroup,
|
||||
useGrouping,
|
||||
} from './src';
|
||||
import { RawBucket, StatRenderer, getGroupingQuery, isNoneGroup, useGrouping } from './src';
|
||||
import type {
|
||||
GroupOption,
|
||||
GroupingAggregation,
|
||||
|
@ -22,10 +14,6 @@ import type {
|
|||
NamedAggregation,
|
||||
} from './src';
|
||||
|
||||
export const getGroupSelector = (
|
||||
props: GroupSelectorProps
|
||||
): React.ReactElement<GroupSelectorProps> => <GroupSelector {...props} />;
|
||||
|
||||
export { getGroupingQuery, isNoneGroup, useGrouping };
|
||||
|
||||
export type {
|
||||
|
@ -34,4 +22,5 @@ export type {
|
|||
GroupingFieldTotalAggregation,
|
||||
NamedAggregation,
|
||||
RawBucket,
|
||||
StatRenderer,
|
||||
};
|
||||
|
|
|
@ -12,32 +12,16 @@ import { GroupStats } from './group_stats';
|
|||
|
||||
const onTakeActionsOpen = jest.fn();
|
||||
const testProps = {
|
||||
badgeMetricStats: [
|
||||
{ title: "IP's:", value: 1 },
|
||||
{ title: 'Rules:', value: 2 },
|
||||
{ title: 'Alerts:', value: 2, width: 50, color: '#a83632' },
|
||||
],
|
||||
bucket: {
|
||||
key: '9nk5mo2fby',
|
||||
doc_count: 2,
|
||||
hostsCountAggregation: { value: 1 },
|
||||
ruleTags: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [] },
|
||||
alertsCount: { value: 2 },
|
||||
rulesCountAggregation: { value: 2 },
|
||||
severitiesSubAggregation: {
|
||||
doc_count_error_upper_bound: 0,
|
||||
sum_other_doc_count: 0,
|
||||
buckets: [{ key: 'low', doc_count: 2 }],
|
||||
},
|
||||
countSeveritySubAggregation: { value: 1 },
|
||||
usersCountAggregation: { value: 1 },
|
||||
},
|
||||
bucketKey: '9nk5mo2fby',
|
||||
onTakeActionsOpen,
|
||||
customMetricStats: [
|
||||
statRenderers: [
|
||||
{
|
||||
title: 'Severity',
|
||||
customStatRenderer: <p data-test-subj="customMetricStat" />,
|
||||
renderer: <p data-test-subj="customMetricStat" />,
|
||||
},
|
||||
{ title: "IP's:", badge: { value: 1 } },
|
||||
{ title: 'Rules:', badge: { value: 2 } },
|
||||
{ title: 'Alerts:', badge: { value: 2, width: 50, color: '#a83632' } },
|
||||
],
|
||||
takeActionItems: [
|
||||
<p data-test-subj="takeActionItem-1" key={1} />,
|
||||
|
@ -49,13 +33,16 @@ describe('Group stats', () => {
|
|||
jest.clearAllMocks();
|
||||
});
|
||||
it('renders each stat item', () => {
|
||||
const { getByTestId } = render(<GroupStats {...testProps} />);
|
||||
const { getByTestId, queryByTestId } = render(<GroupStats {...testProps} />);
|
||||
expect(getByTestId('group-stats')).toBeInTheDocument();
|
||||
testProps.badgeMetricStats.forEach(({ title: stat }) => {
|
||||
expect(getByTestId(`metric-${stat}`)).toBeInTheDocument();
|
||||
});
|
||||
testProps.customMetricStats.forEach(({ title: stat }) => {
|
||||
expect(getByTestId(`customMetric-${stat}`)).toBeInTheDocument();
|
||||
testProps.statRenderers.forEach(({ title: stat, renderer }) => {
|
||||
if (renderer != null) {
|
||||
expect(getByTestId(`customMetric-${stat}`)).toBeInTheDocument();
|
||||
expect(queryByTestId(`metric-${stat}`)).not.toBeInTheDocument();
|
||||
} else {
|
||||
expect(getByTestId(`metric-${stat}`)).toBeInTheDocument();
|
||||
expect(queryByTestId(`customMetric-${stat}`)).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
it('when onTakeActionsOpen is defined, call onTakeActionsOpen on popover click', () => {
|
||||
|
|
|
@ -16,23 +16,20 @@ import {
|
|||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import type { BadgeMetric, CustomMetric } from '.';
|
||||
import { StatRenderer } from '../types';
|
||||
import { statsContainerCss } from '../styles';
|
||||
import { TAKE_ACTION } from '../translations';
|
||||
import type { RawBucket } from '../types';
|
||||
|
||||
interface GroupStatsProps<T> {
|
||||
badgeMetricStats?: BadgeMetric[];
|
||||
bucket: RawBucket<T>;
|
||||
customMetricStats?: CustomMetric[];
|
||||
bucketKey: string;
|
||||
statRenderers?: StatRenderer[];
|
||||
onTakeActionsOpen?: () => void;
|
||||
takeActionItems: JSX.Element[];
|
||||
}
|
||||
|
||||
const GroupStatsComponent = <T,>({
|
||||
badgeMetricStats,
|
||||
bucket,
|
||||
customMetricStats,
|
||||
bucketKey,
|
||||
statRenderers,
|
||||
onTakeActionsOpen,
|
||||
takeActionItems,
|
||||
}: GroupStatsProps<T>) => {
|
||||
|
@ -43,42 +40,39 @@ const GroupStatsComponent = <T,>({
|
|||
[isPopoverOpen, onTakeActionsOpen]
|
||||
);
|
||||
|
||||
const badgesComponents = useMemo(
|
||||
const statsComponent = useMemo(
|
||||
() =>
|
||||
badgeMetricStats?.map((metric) => (
|
||||
<EuiFlexItem grow={false} key={metric.title}>
|
||||
<span css={statsContainerCss} data-test-subj={`metric-${metric.title}`}>
|
||||
<>
|
||||
{metric.title}
|
||||
<EuiToolTip position="top" content={metric.value}>
|
||||
<EuiBadge
|
||||
style={{ marginLeft: 10, width: metric.width ?? 35 }}
|
||||
color={metric.color ?? 'hollow'}
|
||||
>
|
||||
{metric.value > 99 ? '99+' : metric.value.toString()}
|
||||
</EuiBadge>
|
||||
</EuiToolTip>
|
||||
</>
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
)),
|
||||
[badgeMetricStats]
|
||||
statRenderers?.map((stat) => {
|
||||
const { dataTestSubj, component } =
|
||||
stat.badge != null
|
||||
? {
|
||||
dataTestSubj: `metric-${stat.title}`,
|
||||
component: (
|
||||
<EuiToolTip position="top" content={stat.badge.value}>
|
||||
<EuiBadge
|
||||
style={{ marginLeft: 10, width: stat.badge.width ?? 35 }}
|
||||
color={stat.badge.color ?? 'hollow'}
|
||||
>
|
||||
{stat.badge.value > 99 ? '99+' : stat.badge.value.toString()}
|
||||
</EuiBadge>
|
||||
</EuiToolTip>
|
||||
),
|
||||
}
|
||||
: { dataTestSubj: `customMetric-${stat.title}`, component: stat.renderer };
|
||||
|
||||
return (
|
||||
<EuiFlexItem grow={false} key={stat.title}>
|
||||
<span css={statsContainerCss} data-test-subj={dataTestSubj}>
|
||||
{stat.title}
|
||||
{component}
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
);
|
||||
}),
|
||||
[statRenderers]
|
||||
);
|
||||
|
||||
const customComponents = useMemo(
|
||||
() =>
|
||||
customMetricStats?.map((customMetric) => (
|
||||
<EuiFlexItem grow={false} key={customMetric.title}>
|
||||
<span css={statsContainerCss} data-test-subj={`customMetric-${customMetric.title}`}>
|
||||
{customMetric.title}
|
||||
{customMetric.customStatRenderer}
|
||||
</span>
|
||||
</EuiFlexItem>
|
||||
)),
|
||||
[customMetricStats]
|
||||
);
|
||||
|
||||
const popoverComponent = useMemo(
|
||||
const takeActionMenu = useMemo(
|
||||
() => (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPopover
|
||||
|
@ -107,13 +101,12 @@ const GroupStatsComponent = <T,>({
|
|||
return (
|
||||
<EuiFlexGroup
|
||||
data-test-subj="group-stats"
|
||||
key={`stats-${bucket.key[0]}`}
|
||||
key={`stats-${bucketKey}`}
|
||||
gutterSize="none"
|
||||
alignItems="center"
|
||||
>
|
||||
{customComponents}
|
||||
{badgesComponents}
|
||||
{popoverComponent}
|
||||
{statsComponent}
|
||||
{takeActionMenu}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,18 +13,6 @@ import { firstNonNullValue } from '../../helpers';
|
|||
import type { RawBucket } from '../types';
|
||||
import { createGroupFilter } from './helpers';
|
||||
|
||||
export interface BadgeMetric {
|
||||
title: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export interface CustomMetric {
|
||||
title: string;
|
||||
customStatRenderer: JSX.Element;
|
||||
}
|
||||
|
||||
interface GroupPanelProps<T> {
|
||||
customAccordionButtonClassName?: string;
|
||||
customAccordionClassName?: string;
|
||||
|
@ -35,7 +23,7 @@ interface GroupPanelProps<T> {
|
|||
isLoading: boolean;
|
||||
level?: number;
|
||||
onToggleGroup?: (isOpen: boolean, groupBucket: RawBucket<T>) => void;
|
||||
renderChildComponent: (groupFilter: Filter[]) => React.ReactNode;
|
||||
renderChildComponent: (groupFilter: Filter[]) => React.ReactElement;
|
||||
selectedGroup: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -18,30 +18,30 @@ import React, { useMemo, useState } from 'react';
|
|||
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
|
||||
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_results_panel';
|
||||
import { groupingContainerCss, countCss } from './styles';
|
||||
import { GROUPS_UNIT } from './translations';
|
||||
import type { GroupingAggregation, GroupingFieldTotalAggregation, RawBucket } from './types';
|
||||
import type {
|
||||
GroupingAggregation,
|
||||
GroupingFieldTotalAggregation,
|
||||
GroupPanelRenderer,
|
||||
RawBucket,
|
||||
} from './types';
|
||||
import { getTelemetryEvent } from '../telemetry/const';
|
||||
import { GroupStatsRenderer, OnGroupToggle } from './types';
|
||||
|
||||
export interface GroupingProps<T> {
|
||||
badgeMetricStats?: (fieldBucket: RawBucket<T>) => BadgeMetric[];
|
||||
customMetricStats?: (fieldBucket: RawBucket<T>) => CustomMetric[];
|
||||
data?: GroupingAggregation<T> & GroupingFieldTotalAggregation;
|
||||
data?: GroupingAggregation<T> & GroupingFieldTotalAggregation<T>;
|
||||
groupingId: string;
|
||||
groupPanelRenderer?: (fieldBucket: RawBucket<T>) => JSX.Element | undefined;
|
||||
groupPanelRenderer?: GroupPanelRenderer<T>;
|
||||
groupSelector?: JSX.Element;
|
||||
// list of custom UI components which correspond to your custom rendered metrics aggregations
|
||||
groupStatsRenderer?: GroupStatsRenderer<T>;
|
||||
inspectButton?: JSX.Element;
|
||||
isLoading: boolean;
|
||||
onToggleCallback?: (params: {
|
||||
isOpen: boolean;
|
||||
groupName?: string | undefined;
|
||||
groupNumber: number;
|
||||
groupingId: string;
|
||||
}) => void;
|
||||
onGroupToggle?: OnGroupToggle;
|
||||
pagination: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
|
@ -49,7 +49,7 @@ export interface GroupingProps<T> {
|
|||
onChangePage: (pageNumber: number) => void;
|
||||
itemsPerPageOptions: number[];
|
||||
};
|
||||
renderChildComponent: (groupFilter: Filter[]) => React.ReactNode;
|
||||
renderChildComponent: (groupFilter: Filter[]) => React.ReactElement;
|
||||
selectedGroup: string;
|
||||
takeActionItems: (groupFilters: Filter[], groupNumber: number) => JSX.Element[];
|
||||
tracker?: (
|
||||
|
@ -61,15 +61,14 @@ export interface GroupingProps<T> {
|
|||
}
|
||||
|
||||
const GroupingComponent = <T,>({
|
||||
badgeMetricStats,
|
||||
customMetricStats,
|
||||
data,
|
||||
groupingId,
|
||||
groupPanelRenderer,
|
||||
groupSelector,
|
||||
groupStatsRenderer,
|
||||
inspectButton,
|
||||
isLoading,
|
||||
onToggleCallback,
|
||||
onGroupToggle,
|
||||
pagination,
|
||||
renderChildComponent,
|
||||
selectedGroup,
|
||||
|
@ -103,18 +102,21 @@ const GroupingComponent = <T,>({
|
|||
<GroupPanel
|
||||
extraAction={
|
||||
<GroupStats
|
||||
bucket={groupBucket}
|
||||
bucketKey={groupKey}
|
||||
takeActionItems={takeActionItems(
|
||||
createGroupFilter(selectedGroup, group),
|
||||
groupNumber
|
||||
)}
|
||||
badgeMetricStats={badgeMetricStats && badgeMetricStats(groupBucket)}
|
||||
customMetricStats={customMetricStats && customMetricStats(groupBucket)}
|
||||
statRenderers={
|
||||
groupStatsRenderer && groupStatsRenderer(selectedGroup, groupBucket)
|
||||
}
|
||||
/>
|
||||
}
|
||||
forceState={(trigger[groupKey] && trigger[groupKey].state) ?? 'closed'}
|
||||
groupBucket={groupBucket}
|
||||
groupPanelRenderer={groupPanelRenderer && groupPanelRenderer(groupBucket)}
|
||||
groupPanelRenderer={
|
||||
groupPanelRenderer && groupPanelRenderer(selectedGroup, groupBucket)
|
||||
}
|
||||
isLoading={isLoading}
|
||||
onToggleGroup={(isOpen) => {
|
||||
// built-in telemetry: UI-counter
|
||||
|
@ -129,12 +131,12 @@ const GroupingComponent = <T,>({
|
|||
selectedBucket: groupBucket,
|
||||
},
|
||||
});
|
||||
onToggleCallback?.({ isOpen, groupName: group, groupNumber, groupingId });
|
||||
onGroupToggle?.({ isOpen, groupName: group, groupNumber, groupingId });
|
||||
}}
|
||||
renderChildComponent={
|
||||
trigger[groupKey] && trigger[groupKey].state === 'open'
|
||||
? renderChildComponent
|
||||
: () => null
|
||||
: () => <span />
|
||||
}
|
||||
selectedGroup={selectedGroup}
|
||||
/>
|
||||
|
@ -143,13 +145,12 @@ const GroupingComponent = <T,>({
|
|||
);
|
||||
}),
|
||||
[
|
||||
badgeMetricStats,
|
||||
customMetricStats,
|
||||
data?.groupByFields?.buckets,
|
||||
groupPanelRenderer,
|
||||
groupStatsRenderer,
|
||||
groupingId,
|
||||
isLoading,
|
||||
onToggleCallback,
|
||||
onGroupToggle,
|
||||
renderChildComponent,
|
||||
selectedGroup,
|
||||
takeActionItems,
|
||||
|
@ -193,6 +194,9 @@ const GroupingComponent = <T,>({
|
|||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<div css={groupingContainerCss} className="eui-xScroll">
|
||||
{isLoading && (
|
||||
<EuiProgress data-test-subj="is-loading-grouping-table" size="xs" color="accent" />
|
||||
)}
|
||||
{groupCount > 0 ? (
|
||||
<>
|
||||
{groupPanels}
|
||||
|
@ -209,12 +213,7 @@ const GroupingComponent = <T,>({
|
|||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isLoading && (
|
||||
<EuiProgress data-test-subj="is-loading-grouping-table" size="xs" color="accent" />
|
||||
)}
|
||||
<EmptyGroupingComponent />
|
||||
</>
|
||||
<EmptyGroupingComponent />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
@ -12,4 +12,10 @@ export * from './group_selector';
|
|||
export * from './types';
|
||||
export * from './grouping';
|
||||
|
||||
/**
|
||||
* Checks if no group is selected
|
||||
* @param groupKey selected group field value
|
||||
*
|
||||
* @returns {boolean} True if no group is selected
|
||||
*/
|
||||
export const isNoneGroup = (groupKey: string | null) => groupKey === NONE_GROUP_KEY;
|
||||
|
|
|
@ -31,7 +31,39 @@ export interface GroupingAggregation<T> {
|
|||
};
|
||||
}
|
||||
|
||||
export type GroupingFieldTotalAggregation = Record<
|
||||
export type GroupingFieldTotalAggregation<T> = Record<
|
||||
string,
|
||||
{ value?: number | null; buckets?: Array<{ doc_count?: number | null }> }
|
||||
{
|
||||
value?: number | null;
|
||||
buckets?: Array<RawBucket<T>>;
|
||||
}
|
||||
>;
|
||||
|
||||
export interface BadgeMetric {
|
||||
value: number;
|
||||
color?: string;
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export interface StatRenderer {
|
||||
title: string;
|
||||
renderer?: JSX.Element;
|
||||
badge?: BadgeMetric;
|
||||
}
|
||||
|
||||
export type GroupStatsRenderer<T> = (
|
||||
selectedGroup: string,
|
||||
fieldBucket: RawBucket<T>
|
||||
) => StatRenderer[];
|
||||
|
||||
export type GroupPanelRenderer<T> = (
|
||||
selectedGroup: string,
|
||||
fieldBucket: RawBucket<T>
|
||||
) => JSX.Element | undefined;
|
||||
|
||||
export type OnGroupToggle = (params: {
|
||||
isOpen: boolean;
|
||||
groupName?: string | undefined;
|
||||
groupNumber: number;
|
||||
groupingId: string;
|
||||
}) => void;
|
||||
|
|
|
@ -13,7 +13,7 @@ const testProps: GroupingQueryArgs = {
|
|||
additionalFilters: [],
|
||||
from: '2022-12-28T15:35:32.871Z',
|
||||
groupByFields: ['host.name'],
|
||||
metricsAggregations: [
|
||||
statsAggregations: [
|
||||
{
|
||||
alertsCount: {
|
||||
cardinality: {
|
||||
|
|
|
@ -13,16 +13,34 @@ export const DEFAULT_GROUP_BY_FIELD_SIZE = 10;
|
|||
// our pagination will be broken if the stackBy field cardinality exceeds 10,000
|
||||
// https://github.com/elastic/kibana/issues/151913
|
||||
export const MAX_QUERY_SIZE = 10000;
|
||||
|
||||
/**
|
||||
* Composes grouping query and aggregations
|
||||
* @param additionalFilters Global filtering applicable to the grouping component.
|
||||
* Array of {@link BoolAgg} to be added to the query
|
||||
* @param from starting timestamp
|
||||
* @param groupByFields array of field names to group by
|
||||
* @param pageNumber starting grouping results page number
|
||||
* @param rootAggregations Top level aggregations to get the groups number or overall groups metrics.
|
||||
* Array of {@link NamedAggregation}
|
||||
* @param runtimeMappings mappings of runtime fields [see runtimeMappings]{@link GroupingQueryArgs.runtimeMappings}
|
||||
* @param size number of grouping results per page
|
||||
* @param sort add one or more sorts on specific fields
|
||||
* @param statsAggregations group level aggregations which correspond to {@link GroupStatsRenderer} configuration
|
||||
* @param to ending timestamp
|
||||
*
|
||||
* @returns query dsl {@link GroupingQuery}
|
||||
*/
|
||||
export const getGroupingQuery = ({
|
||||
additionalFilters = [],
|
||||
from,
|
||||
groupByFields,
|
||||
metricsAggregations,
|
||||
pageNumber,
|
||||
rootAggregations,
|
||||
runtimeMappings,
|
||||
size = DEFAULT_GROUP_BY_FIELD_SIZE,
|
||||
sort,
|
||||
statsAggregations,
|
||||
to,
|
||||
}: GroupingQueryArgs): GroupingQuery => ({
|
||||
size: 0,
|
||||
|
@ -51,8 +69,8 @@ export const getGroupingQuery = ({
|
|||
size,
|
||||
},
|
||||
},
|
||||
...(metricsAggregations
|
||||
? metricsAggregations.reduce((aggObj, subAgg) => Object.assign(aggObj, subAgg), {})
|
||||
...(statsAggregations
|
||||
? statsAggregations.reduce((aggObj, subAgg) => Object.assign(aggObj, subAgg), {})
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
|
|
|
@ -24,12 +24,12 @@ export interface GroupingQueryArgs {
|
|||
additionalFilters: BoolAgg[];
|
||||
from: string;
|
||||
groupByFields: string[];
|
||||
metricsAggregations?: NamedAggregation[];
|
||||
pageNumber?: number;
|
||||
rootAggregations?: NamedAggregation[];
|
||||
runtimeMappings?: MappingRuntimeFields;
|
||||
size?: number;
|
||||
sort?: Array<{ [category: string]: { order: 'asc' | 'desc' } }>;
|
||||
statsAggregations?: NamedAggregation[];
|
||||
to: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ const defaultArgs = {
|
|||
groupingId,
|
||||
groupingState: initialState,
|
||||
tracker: jest.fn(),
|
||||
onGroupChangeCallback: jest.fn(),
|
||||
onGroupChange: jest.fn(),
|
||||
};
|
||||
const customField = 'custom.field';
|
||||
describe('useGetGroupSelector', () => {
|
||||
|
@ -167,8 +167,8 @@ describe('useGetGroupSelector', () => {
|
|||
},
|
||||
});
|
||||
act(() => result.current.props.onGroupChange(customField));
|
||||
expect(defaultArgs.onGroupChangeCallback).toHaveBeenCalledTimes(1);
|
||||
expect(defaultArgs.onGroupChangeCallback).toHaveBeenCalledWith({
|
||||
expect(defaultArgs.onGroupChange).toHaveBeenCalledTimes(1);
|
||||
expect(defaultArgs.onGroupChange).toHaveBeenCalledWith({
|
||||
tableId: groupingId,
|
||||
groupByField: customField,
|
||||
});
|
||||
|
|
|
@ -7,10 +7,10 @@
|
|||
*/
|
||||
|
||||
import type { FieldSpec } from '@kbn/data-views-plugin/common';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
|
||||
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
|
||||
import { getGroupSelector, isNoneGroup } from '../..';
|
||||
import { GroupSelector, isNoneGroup } from '..';
|
||||
import { groupActions, groupByIdSelector } from './state';
|
||||
import type { GroupOption } from './types';
|
||||
import { Action, defaultGroup, GroupMap } from './types';
|
||||
|
@ -22,8 +22,8 @@ export interface UseGetGroupSelectorArgs {
|
|||
fields: FieldSpec[];
|
||||
groupingId: string;
|
||||
groupingState: GroupMap;
|
||||
onGroupChangeCallback?: (param: { groupByField: string; tableId: string }) => void;
|
||||
tracker: (
|
||||
onGroupChange?: (param: { groupByField: string; tableId: string }) => void;
|
||||
tracker?: (
|
||||
type: UiCounterMetricType,
|
||||
event: string | string[],
|
||||
count?: number | undefined
|
||||
|
@ -36,7 +36,7 @@ export const useGetGroupSelector = ({
|
|||
fields,
|
||||
groupingId,
|
||||
groupingState,
|
||||
onGroupChangeCallback,
|
||||
onGroupChange,
|
||||
tracker,
|
||||
}: UseGetGroupSelectorArgs) => {
|
||||
const { activeGroup: selectedGroup, options } =
|
||||
|
@ -63,7 +63,7 @@ export const useGetGroupSelector = ({
|
|||
[dispatch, groupingId]
|
||||
);
|
||||
|
||||
const onGroupChange = useCallback(
|
||||
const onChange = useCallback(
|
||||
(groupSelection: string) => {
|
||||
if (groupSelection === selectedGroup) {
|
||||
return;
|
||||
|
@ -77,7 +77,7 @@ export const useGetGroupSelector = ({
|
|||
getTelemetryEvent.groupChanged({ groupingId, selected: groupSelection })
|
||||
);
|
||||
|
||||
onGroupChangeCallback?.({ tableId: groupingId, groupByField: groupSelection });
|
||||
onGroupChange?.({ tableId: groupingId, groupByField: groupSelection });
|
||||
|
||||
// only update options if the new selection is a custom field
|
||||
if (
|
||||
|
@ -96,7 +96,7 @@ export const useGetGroupSelector = ({
|
|||
[
|
||||
defaultGroupingOptions,
|
||||
groupingId,
|
||||
onGroupChangeCallback,
|
||||
onGroupChange,
|
||||
options,
|
||||
selectedGroup,
|
||||
setGroupsActivePage,
|
||||
|
@ -126,12 +126,16 @@ export const useGetGroupSelector = ({
|
|||
);
|
||||
}, [defaultGroupingOptions, options.length, selectedGroup, setOptions]);
|
||||
|
||||
return getGroupSelector({
|
||||
groupingId,
|
||||
groupSelected: selectedGroup,
|
||||
'data-test-subj': 'alerts-table-group-selector',
|
||||
onGroupChange,
|
||||
fields,
|
||||
options,
|
||||
});
|
||||
return (
|
||||
<GroupSelector
|
||||
{...{
|
||||
groupingId,
|
||||
groupSelected: selectedGroup,
|
||||
'data-test-subj': 'alerts-table-group-selector',
|
||||
onGroupChange: onChange,
|
||||
fields,
|
||||
options,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,15 +5,18 @@
|
|||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import React from 'react';
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
import { useGrouping } from './use_grouping';
|
||||
|
||||
const defaultGroupingOptions = [
|
||||
{ label: 'ruleName', key: 'kibana.alert.rule.name' },
|
||||
{ label: 'userName', key: 'user.name' },
|
||||
{ label: 'hostName', key: 'host.name' },
|
||||
{ label: 'sourceIP', key: 'source.ip' },
|
||||
{ label: 'Rule name', key: 'kibana.alert.rule.name' },
|
||||
{ label: 'User name', key: 'user.name' },
|
||||
{ label: 'Host name', key: 'host.name' },
|
||||
{ label: 'Source IP', key: 'source.ip' },
|
||||
];
|
||||
const groupingId = 'test-table';
|
||||
|
||||
|
@ -22,33 +25,106 @@ const defaultArgs = {
|
|||
fields: [],
|
||||
groupingId,
|
||||
tracker: jest.fn(),
|
||||
componentProps: {
|
||||
groupPanelRenderer: jest.fn(),
|
||||
groupStatsRenderer: jest.fn(),
|
||||
inspectButton: <></>,
|
||||
onGroupToggle: jest.fn(),
|
||||
renderChildComponent: () => <p data-test-subj="innerTable">{'hello'}</p>,
|
||||
},
|
||||
};
|
||||
|
||||
const groupingArgs = {
|
||||
from: '2020-07-07T08:20:18.966Z',
|
||||
globalFilters: [],
|
||||
hasIndexMaintenance: true,
|
||||
globalQuery: {
|
||||
query: 'query',
|
||||
language: 'language',
|
||||
},
|
||||
hasIndexWrite: true,
|
||||
data: {},
|
||||
isLoading: false,
|
||||
renderChildComponent: jest.fn(),
|
||||
runtimeMappings: {},
|
||||
signalIndexName: 'test',
|
||||
groupingId,
|
||||
takeActionItems: jest.fn(),
|
||||
to: '2020-07-08T08:20:18.966Z',
|
||||
};
|
||||
|
||||
describe('useGrouping', () => {
|
||||
it('Returns the expected default results on initial mount', () => {
|
||||
const { result } = renderHook(() => useGrouping(defaultArgs));
|
||||
expect(result.current.selectedGroup).toEqual('none');
|
||||
expect(result.current.getGrouping(groupingArgs).props.selectedGroup).toEqual('none');
|
||||
expect(result.current.groupSelector.props.options).toEqual(defaultGroupingOptions);
|
||||
const { reset, ...withoutReset } = result.current.pagination;
|
||||
expect(withoutReset).toEqual({ pageIndex: 0, pageSize: 25 });
|
||||
it('Renders child component without grouping table wrapper when no group is selected', async () => {
|
||||
await act(async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => useGrouping(defaultArgs));
|
||||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<IntlProvider locale="en">
|
||||
{result.current.getGrouping({
|
||||
...groupingArgs,
|
||||
data: {
|
||||
groupsCount: {
|
||||
value: 9,
|
||||
},
|
||||
groupByFields: {
|
||||
buckets: [
|
||||
{
|
||||
key: ['critical hosts', 'description'],
|
||||
key_as_string: 'critical hosts|description',
|
||||
doc_count: 3,
|
||||
unitsCount: {
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
unitsCount: {
|
||||
value: 18,
|
||||
},
|
||||
},
|
||||
})}
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
expect(getByTestId('innerTable')).toBeInTheDocument();
|
||||
expect(queryByTestId('grouping-table')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it('Renders child component with grouping table wrapper when group is selected', async () => {
|
||||
await act(async () => {
|
||||
const getItem = jest.spyOn(window.localStorage.__proto__, 'getItem');
|
||||
getItem.mockReturnValue(
|
||||
JSON.stringify({
|
||||
'test-table': {
|
||||
activePage: 0,
|
||||
itemsPerPage: 25,
|
||||
activeGroup: 'kibana.alert.rule.name',
|
||||
options: defaultGroupingOptions,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const { result, waitForNextUpdate } = renderHook(() => useGrouping(defaultArgs));
|
||||
await waitForNextUpdate();
|
||||
await waitForNextUpdate();
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<IntlProvider locale="en">
|
||||
{result.current.getGrouping({
|
||||
...groupingArgs,
|
||||
data: {
|
||||
groupsCount: {
|
||||
value: 9,
|
||||
},
|
||||
groupByFields: {
|
||||
buckets: [
|
||||
{
|
||||
key: ['critical hosts', 'description'],
|
||||
key_as_string: 'critical hosts|description',
|
||||
doc_count: 3,
|
||||
unitsCount: {
|
||||
value: 3,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
unitsCount: {
|
||||
value: 18,
|
||||
},
|
||||
},
|
||||
})}
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
expect(getByTestId('grouping-table')).toBeInTheDocument();
|
||||
expect(queryByTestId('innerTable')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -10,17 +10,18 @@ import { FieldSpec } from '@kbn/data-views-plugin/common';
|
|||
import React, { useCallback, useMemo, useReducer } from 'react';
|
||||
import { UiCounterMetricType } from '@kbn/analytics';
|
||||
import { groupsReducerWithStorage, initialState } from './state/reducer';
|
||||
import { GroupingProps, GroupSelectorProps } from '..';
|
||||
import { GroupingProps, GroupSelectorProps, isNoneGroup } from '..';
|
||||
import { useGroupingPagination } from './use_grouping_pagination';
|
||||
import { groupActions, groupByIdSelector } from './state';
|
||||
import { useGetGroupSelector } from './use_get_group_selector';
|
||||
import { defaultGroup, GroupOption } from './types';
|
||||
import { Grouping as GroupingComponent } from '../components/grouping';
|
||||
|
||||
/** Interface for grouping object where T is the `GroupingAggregation`
|
||||
* @interface GroupingArgs<T>
|
||||
*/
|
||||
interface Grouping<T> {
|
||||
getGrouping: (
|
||||
props: Omit<GroupingProps<T>, 'groupSelector' | 'pagination' | 'selectedGroup'>
|
||||
) => React.ReactElement<GroupingProps<T>>;
|
||||
getGrouping: (props: DynamicGroupingProps<T>) => React.ReactElement;
|
||||
groupSelector: React.ReactElement<GroupSelectorProps>;
|
||||
pagination: {
|
||||
reset: () => void;
|
||||
|
@ -30,24 +31,62 @@ interface Grouping<T> {
|
|||
selectedGroup: string;
|
||||
}
|
||||
|
||||
interface GroupingArgs {
|
||||
/** Type for static grouping component props where T is the `GroupingAggregation`
|
||||
* @interface StaticGroupingProps<T>
|
||||
*/
|
||||
type StaticGroupingProps<T> = Pick<
|
||||
GroupingProps<T>,
|
||||
| 'groupPanelRenderer'
|
||||
| 'groupStatsRenderer'
|
||||
| 'inspectButton'
|
||||
| 'onGroupToggle'
|
||||
| 'renderChildComponent'
|
||||
| 'unit'
|
||||
>;
|
||||
|
||||
/** Type for dynamic grouping component props where T is the `GroupingAggregation`
|
||||
* @interface DynamicGroupingProps<T>
|
||||
*/
|
||||
type DynamicGroupingProps<T> = Pick<GroupingProps<T>, 'data' | 'isLoading' | 'takeActionItems'>;
|
||||
|
||||
/** Interface for configuring grouping package where T is the `GroupingAggregation`
|
||||
* @interface GroupingArgs<T>
|
||||
*/
|
||||
interface GroupingArgs<T> {
|
||||
componentProps: StaticGroupingProps<T>;
|
||||
defaultGroupingOptions: GroupOption[];
|
||||
fields: FieldSpec[];
|
||||
groupingId: string;
|
||||
onGroupChangeCallback?: (param: { groupByField: string; tableId: string }) => void;
|
||||
tracker: (
|
||||
/** for tracking
|
||||
* @param param { groupByField: string; tableId: string } selected group and table id
|
||||
*/
|
||||
onGroupChange?: (param: { groupByField: string; tableId: string }) => void;
|
||||
tracker?: (
|
||||
type: UiCounterMetricType,
|
||||
event: string | string[],
|
||||
count?: number | undefined
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to configure grouping component
|
||||
* @param componentProps {@link StaticGroupingProps} props passed to the grouping component.
|
||||
* These props are static compared to the dynamic props passed later to getGrouping
|
||||
* @param defaultGroupingOptions defines the grouping options as an array of {@link GroupOption}
|
||||
* @param fields FieldSpec array serialized version of DataViewField fields. Available in the custom grouping options
|
||||
* @param groupingId Unique identifier of the grouping component. Used in local storage
|
||||
* @param onGroupChange callback executed when selected group is changed, used for tracking
|
||||
* @param tracker telemetry handler
|
||||
* @returns {@link Grouping} the grouping constructor { getGrouping, groupSelector, pagination, selectedGroup }
|
||||
*/
|
||||
export const useGrouping = <T,>({
|
||||
componentProps,
|
||||
defaultGroupingOptions,
|
||||
fields,
|
||||
groupingId,
|
||||
onGroupChangeCallback,
|
||||
onGroupChange,
|
||||
tracker,
|
||||
}: GroupingArgs): Grouping<T> => {
|
||||
}: GroupingArgs<T>): Grouping<T> => {
|
||||
const [groupingState, dispatch] = useReducer(groupsReducerWithStorage, initialState);
|
||||
|
||||
const { activeGroup: selectedGroup } = useMemo(
|
||||
|
@ -61,24 +100,32 @@ export const useGrouping = <T,>({
|
|||
fields,
|
||||
groupingId,
|
||||
groupingState,
|
||||
onGroupChangeCallback,
|
||||
onGroupChange,
|
||||
tracker,
|
||||
});
|
||||
|
||||
const pagination = useGroupingPagination({ groupingId, groupingState, dispatch });
|
||||
|
||||
const getGrouping = useCallback(
|
||||
(
|
||||
props: Omit<GroupingProps<T>, 'groupSelector' | 'pagination' | 'selectedGroup'>
|
||||
): React.ReactElement<GroupingProps<T>> => (
|
||||
<GroupingComponent
|
||||
{...props}
|
||||
groupSelector={groupSelector}
|
||||
pagination={pagination}
|
||||
selectedGroup={selectedGroup}
|
||||
/>
|
||||
),
|
||||
[groupSelector, pagination, selectedGroup]
|
||||
/**
|
||||
*
|
||||
* @param props {@link DynamicGroupingProps}
|
||||
*/
|
||||
(props: DynamicGroupingProps<T>): React.ReactElement =>
|
||||
isNoneGroup(selectedGroup) ? (
|
||||
componentProps.renderChildComponent([])
|
||||
) : (
|
||||
<GroupingComponent
|
||||
{...componentProps}
|
||||
{...props}
|
||||
groupingId={groupingId}
|
||||
groupSelector={groupSelector}
|
||||
pagination={pagination}
|
||||
selectedGroup={selectedGroup}
|
||||
tracker={tracker}
|
||||
/>
|
||||
),
|
||||
[componentProps, groupSelector, groupingId, pagination, selectedGroup, tracker]
|
||||
);
|
||||
|
||||
const resetPagination = useCallback(() => {
|
||||
|
|
|
@ -16,7 +16,6 @@ import { getEsQueryConfig } from '@kbn/data-plugin/common';
|
|||
import type {
|
||||
GroupingFieldTotalAggregation,
|
||||
GroupingAggregation,
|
||||
RawBucket,
|
||||
} from '@kbn/securitysolution-grouping';
|
||||
import { isNoneGroup, useGrouping } from '@kbn/securitysolution-grouping';
|
||||
import type { AlertsGroupingAggregation } from './grouping_settings/types';
|
||||
|
@ -39,9 +38,8 @@ import { ALERTS_QUERY_NAMES } from '../../containers/detection_engine/alerts/con
|
|||
import {
|
||||
getAlertsGroupingQuery,
|
||||
getDefaultGroupingOptions,
|
||||
getBadgeMetrics,
|
||||
renderGroupPanel,
|
||||
getCustomMetrics,
|
||||
getStats,
|
||||
useGroupTakeActionsItems,
|
||||
} from './grouping_settings';
|
||||
import { updateGroupSelector, updateSelectedGroup } from '../../../common/store/grouping/actions';
|
||||
|
@ -49,7 +47,7 @@ import { track } from '../../../common/lib/telemetry';
|
|||
|
||||
const ALERTS_GROUPING_ID = 'alerts-grouping';
|
||||
|
||||
interface OwnProps {
|
||||
export interface AlertsTableComponentProps {
|
||||
currentAlertStatusFilterValue?: Status;
|
||||
defaultFilters?: Filter[];
|
||||
from: string;
|
||||
|
@ -65,8 +63,6 @@ interface OwnProps {
|
|||
to: string;
|
||||
}
|
||||
|
||||
export type AlertsTableComponentProps = OwnProps;
|
||||
|
||||
export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = ({
|
||||
defaultFilters = [],
|
||||
from,
|
||||
|
@ -114,18 +110,44 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
|
|||
[browserFields, indexPattern, uiSettings, defaultFilters, globalFilters, from, to, globalQuery]
|
||||
);
|
||||
|
||||
const onGroupChangeCallback = useCallback(
|
||||
(param) => {
|
||||
telemetry.reportAlertsGroupingChanged(param);
|
||||
},
|
||||
const { onGroupChange, onGroupToggle } = useMemo(
|
||||
() => ({
|
||||
onGroupChange: (param: { groupByField: string; tableId: string }) => {
|
||||
telemetry.reportAlertsGroupingChanged(param);
|
||||
},
|
||||
onGroupToggle: (param: {
|
||||
isOpen: boolean;
|
||||
groupName?: string | undefined;
|
||||
groupNumber: number;
|
||||
groupingId: string;
|
||||
}) => telemetry.reportAlertsGroupingToggled({ ...param, tableId: param.groupingId }),
|
||||
}),
|
||||
[telemetry]
|
||||
);
|
||||
|
||||
// create a unique, but stable (across re-renders) query id
|
||||
const uniqueQueryId = useMemo(() => `${ALERTS_GROUPING_ID}-${uuidv4()}`, []);
|
||||
|
||||
const inspect = useMemo(
|
||||
() => (
|
||||
<InspectButton queryId={uniqueQueryId} inspectIndex={0} title={i18n.INSPECT_GROUPING_TITLE} />
|
||||
),
|
||||
[uniqueQueryId]
|
||||
);
|
||||
|
||||
const { groupSelector, getGrouping, selectedGroup, pagination } = useGrouping({
|
||||
componentProps: {
|
||||
groupPanelRenderer: renderGroupPanel,
|
||||
groupStatsRenderer: getStats,
|
||||
inspectButton: inspect,
|
||||
onGroupToggle,
|
||||
renderChildComponent,
|
||||
unit: defaultUnit,
|
||||
},
|
||||
defaultGroupingOptions: getDefaultGroupingOptions(tableId),
|
||||
groupingId: tableId,
|
||||
fields: indexPattern.fields,
|
||||
onGroupChangeCallback,
|
||||
groupingId: tableId,
|
||||
onGroupChange,
|
||||
tracker: track,
|
||||
});
|
||||
const resetPagination = pagination.reset;
|
||||
|
@ -148,9 +170,6 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
|
|||
});
|
||||
|
||||
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(() => {
|
||||
resetPagination();
|
||||
try {
|
||||
|
@ -196,7 +215,8 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
|
|||
setQuery: setAlertsQuery,
|
||||
} = useQueryAlerts<
|
||||
{},
|
||||
GroupingAggregation<AlertsGroupingAggregation> & GroupingFieldTotalAggregation
|
||||
GroupingAggregation<AlertsGroupingAggregation> &
|
||||
GroupingFieldTotalAggregation<AlertsGroupingAggregation>
|
||||
>({
|
||||
query: queryGroups,
|
||||
indexName: signalIndexName,
|
||||
|
@ -220,13 +240,6 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
|
|||
uniqueQueryId,
|
||||
});
|
||||
|
||||
const inspect = useMemo(
|
||||
() => (
|
||||
<InspectButton queryId={uniqueQueryId} inspectIndex={0} title={i18n.INSPECT_GROUPING_TITLE} />
|
||||
),
|
||||
[uniqueQueryId]
|
||||
);
|
||||
|
||||
const takeActionItems = useGroupTakeActionsItems({
|
||||
indexName: indexPattern.title,
|
||||
currentStatus: currentAlertStatusFilterValue,
|
||||
|
@ -246,39 +259,12 @@ export const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> =
|
|||
|
||||
const groupedAlerts = useMemo(
|
||||
() =>
|
||||
isNoneGroup(selectedGroup)
|
||||
? renderChildComponent([])
|
||||
: getGrouping({
|
||||
badgeMetricStats: (fieldBucket: RawBucket<AlertsGroupingAggregation>) =>
|
||||
getBadgeMetrics(selectedGroup, fieldBucket),
|
||||
customMetricStats: (fieldBucket: RawBucket<AlertsGroupingAggregation>) =>
|
||||
getCustomMetrics(selectedGroup, fieldBucket),
|
||||
data: alertsGroupsData?.aggregations,
|
||||
groupingId: tableId,
|
||||
groupPanelRenderer: (fieldBucket: RawBucket<AlertsGroupingAggregation>) =>
|
||||
renderGroupPanel(selectedGroup, fieldBucket),
|
||||
inspectButton: inspect,
|
||||
isLoading: loading || isLoadingGroups,
|
||||
onToggleCallback: (param) => {
|
||||
telemetry.reportAlertsGroupingToggled({ ...param, tableId: param.groupingId });
|
||||
},
|
||||
renderChildComponent,
|
||||
takeActionItems: getTakeActionItems,
|
||||
tracker: track,
|
||||
unit: defaultUnit,
|
||||
}),
|
||||
[
|
||||
alertsGroupsData?.aggregations,
|
||||
getGrouping,
|
||||
getTakeActionItems,
|
||||
inspect,
|
||||
isLoadingGroups,
|
||||
loading,
|
||||
renderChildComponent,
|
||||
selectedGroup,
|
||||
tableId,
|
||||
telemetry,
|
||||
]
|
||||
getGrouping({
|
||||
data: alertsGroupsData?.aggregations,
|
||||
isLoading: loading || isLoadingGroups,
|
||||
takeActionItems: getTakeActionItems,
|
||||
}),
|
||||
[alertsGroupsData?.aggregations, getGrouping, getTakeActionItems, isLoadingGroups, loading]
|
||||
);
|
||||
|
||||
if (isEmpty(selectedPatterns)) {
|
||||
|
|
|
@ -58,15 +58,15 @@ const RuleNameGroupContent = React.memo<{
|
|||
</EuiBadge>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'table', tableLayout: 'fixed', width: '100%' }}>
|
||||
<EuiFlexGroup data-test-subj="rule-name-group-renderer" gutterSize="m" alignItems="center">
|
||||
<EuiFlexItem grow={false} style={{ display: 'table', tableLayout: 'fixed', width: '100%' }}>
|
||||
<EuiFlexItem grow={false} style={{ display: 'contents' }}>
|
||||
<EuiTitle size="xs">
|
||||
<h5 className="eui-textTruncate">{ruleName.trim()}</h5>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
{tags && tags.length > 0 ? (
|
||||
<EuiFlexItem onClick={(e) => e.stopPropagation()} grow={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<PopoverItems
|
||||
items={tags.map((tag) => tag.key.toString())}
|
||||
popoverTitle={COLUMN_TAGS}
|
||||
|
@ -84,7 +84,7 @@ const RuleNameGroupContent = React.memo<{
|
|||
<EuiTextColor color="subdued">{ruleDescription}</EuiTextColor>
|
||||
</p>
|
||||
</EuiText>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
RuleNameGroupContent.displayName = 'RuleNameGroup';
|
||||
|
|
|
@ -5,11 +5,11 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { getBadgeMetrics } from '.';
|
||||
import { getStats } from '.';
|
||||
|
||||
describe('getBadgeMetrics', () => {
|
||||
it('returns array of badges which roccespondes to the field name', () => {
|
||||
const badgesRuleName = getBadgeMetrics('kibana.alert.rule.name', {
|
||||
describe('getStats', () => {
|
||||
it('returns array of badges which corresponds to the field name', () => {
|
||||
const badgesRuleName = getStats('kibana.alert.rule.name', {
|
||||
key: ['Rule name test', 'Some description'],
|
||||
usersCountAggregation: {
|
||||
value: 10,
|
||||
|
@ -18,13 +18,17 @@ describe('getBadgeMetrics', () => {
|
|||
});
|
||||
|
||||
expect(
|
||||
badgesRuleName.find((badge) => badge.title === 'Users:' && badge.value === 10)
|
||||
badgesRuleName.find(
|
||||
(badge) => badge.badge != null && badge.title === 'Users:' && badge.badge.value === 10
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
badgesRuleName.find((badge) => badge.title === 'Alerts:' && badge.value === 10)
|
||||
badgesRuleName.find(
|
||||
(badge) => badge.badge != null && badge.title === 'Alerts:' && badge.badge.value === 10
|
||||
)
|
||||
).toBeTruthy();
|
||||
|
||||
const badgesHostName = getBadgeMetrics('host.name', {
|
||||
const badgesHostName = getStats('host.name', {
|
||||
key: 'Host',
|
||||
rulesCountAggregation: {
|
||||
value: 3,
|
||||
|
@ -33,10 +37,12 @@ describe('getBadgeMetrics', () => {
|
|||
});
|
||||
|
||||
expect(
|
||||
badgesHostName.find((badge) => badge.title === 'Rules:' && badge.value === 3)
|
||||
badgesHostName.find(
|
||||
(badge) => badge.badge != null && badge.title === 'Rules:' && badge.badge.value === 3
|
||||
)
|
||||
).toBeTruthy();
|
||||
|
||||
const badgesUserName = getBadgeMetrics('user.name', {
|
||||
const badgesUserName = getStats('user.name', {
|
||||
key: 'User test',
|
||||
hostsCountAggregation: {
|
||||
value: 1,
|
||||
|
@ -44,12 +50,14 @@ describe('getBadgeMetrics', () => {
|
|||
doc_count: 1,
|
||||
});
|
||||
expect(
|
||||
badgesUserName.find((badge) => badge.title === `IP's:` && badge.value === 1)
|
||||
badgesUserName.find(
|
||||
(badge) => badge.badge != null && badge.title === `IP's:` && badge.badge.value === 1
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('returns default badges if the field specific does not exist', () => {
|
||||
const badges = getBadgeMetrics('process.name', {
|
||||
const badges = getStats('process.name', {
|
||||
key: 'process',
|
||||
rulesCountAggregation: {
|
||||
value: 3,
|
||||
|
@ -58,7 +66,15 @@ describe('getBadgeMetrics', () => {
|
|||
});
|
||||
|
||||
expect(badges.length).toBe(2);
|
||||
expect(badges.find((badge) => badge.title === 'Rules:' && badge.value === 3)).toBeTruthy();
|
||||
expect(badges.find((badge) => badge.title === 'Alerts:' && badge.value === 10)).toBeTruthy();
|
||||
expect(
|
||||
badges.find(
|
||||
(badge) => badge.badge != null && badge.title === 'Rules:' && badge.badge.value === 3
|
||||
)
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
badges.find(
|
||||
(badge) => badge.badge != null && badge.title === 'Alerts:' && badge.badge.value === 10
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { EuiIcon } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import type { RawBucket } from '@kbn/securitysolution-grouping';
|
||||
import type { RawBucket, StatRenderer } from '@kbn/securitysolution-grouping';
|
||||
import type { AlertsGroupingAggregation } from './types';
|
||||
import * as i18n from '../translations';
|
||||
|
||||
|
@ -64,81 +64,10 @@ const multiSeverity = (
|
|||
</>
|
||||
);
|
||||
|
||||
export const getBadgeMetrics = (
|
||||
export const getStats = (
|
||||
selectedGroup: string,
|
||||
bucket: RawBucket<AlertsGroupingAggregation>
|
||||
) => {
|
||||
const defaultBadges = [
|
||||
{
|
||||
title: i18n.STATS_GROUP_ALERTS,
|
||||
value: bucket.doc_count,
|
||||
width: 50,
|
||||
color: '#a83632',
|
||||
},
|
||||
];
|
||||
switch (selectedGroup) {
|
||||
case 'kibana.alert.rule.name':
|
||||
return [
|
||||
{
|
||||
title: i18n.STATS_GROUP_USERS,
|
||||
value: bucket.usersCountAggregation?.value ?? 0,
|
||||
},
|
||||
{
|
||||
title: i18n.STATS_GROUP_HOSTS,
|
||||
value: bucket.hostsCountAggregation?.value ?? 0,
|
||||
},
|
||||
...defaultBadges,
|
||||
];
|
||||
case 'host.name':
|
||||
return [
|
||||
{
|
||||
title: i18n.STATS_GROUP_USERS,
|
||||
value: bucket.usersCountAggregation?.value ?? 0,
|
||||
},
|
||||
{
|
||||
title: i18n.STATS_GROUP_RULES,
|
||||
value: bucket.rulesCountAggregation?.value ?? 0,
|
||||
},
|
||||
...defaultBadges,
|
||||
];
|
||||
case 'user.name':
|
||||
return [
|
||||
{
|
||||
title: i18n.STATS_GROUP_IPS,
|
||||
value: bucket.hostsCountAggregation?.value ?? 0,
|
||||
},
|
||||
{
|
||||
title: i18n.STATS_GROUP_RULES,
|
||||
value: bucket.rulesCountAggregation?.value ?? 0,
|
||||
},
|
||||
...defaultBadges,
|
||||
];
|
||||
case 'source.ip':
|
||||
return [
|
||||
{
|
||||
title: i18n.STATS_GROUP_IPS,
|
||||
value: bucket.hostsCountAggregation?.value ?? 0,
|
||||
},
|
||||
{
|
||||
title: i18n.STATS_GROUP_RULES,
|
||||
value: bucket.rulesCountAggregation?.value ?? 0,
|
||||
},
|
||||
...defaultBadges,
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: i18n.STATS_GROUP_RULES,
|
||||
value: bucket.rulesCountAggregation?.value ?? 0,
|
||||
},
|
||||
...defaultBadges,
|
||||
];
|
||||
};
|
||||
|
||||
export const getCustomMetrics = (
|
||||
selectedGroup: string,
|
||||
bucket: RawBucket<AlertsGroupingAggregation>
|
||||
) => {
|
||||
): StatRenderer[] => {
|
||||
const singleSeverityComponent =
|
||||
bucket.severitiesSubAggregation?.buckets && bucket.severitiesSubAggregation?.buckets?.length
|
||||
? getSeverity(bucket.severitiesSubAggregation?.buckets[0].key.toString())
|
||||
|
@ -147,31 +76,105 @@ export const getCustomMetrics = (
|
|||
bucket.countSeveritySubAggregation?.value && bucket.countSeveritySubAggregation?.value > 1
|
||||
? multiSeverity
|
||||
: singleSeverityComponent;
|
||||
if (!severityComponent) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const severityStat = !severityComponent
|
||||
? []
|
||||
: [
|
||||
{
|
||||
title: i18n.STATS_GROUP_SEVERITY,
|
||||
renderer: severityComponent,
|
||||
},
|
||||
];
|
||||
|
||||
const defaultBadges = [
|
||||
{
|
||||
title: i18n.STATS_GROUP_ALERTS,
|
||||
badge: {
|
||||
value: bucket.doc_count,
|
||||
width: 50,
|
||||
color: '#a83632',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
switch (selectedGroup) {
|
||||
case 'kibana.alert.rule.name':
|
||||
return [
|
||||
...severityStat,
|
||||
{
|
||||
title: i18n.STATS_GROUP_SEVERITY,
|
||||
customStatRenderer: severityComponent,
|
||||
title: i18n.STATS_GROUP_USERS,
|
||||
badge: {
|
||||
value: bucket.usersCountAggregation?.value ?? 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18n.STATS_GROUP_HOSTS,
|
||||
badge: {
|
||||
value: bucket.hostsCountAggregation?.value ?? 0,
|
||||
},
|
||||
},
|
||||
...defaultBadges,
|
||||
];
|
||||
case 'host.name':
|
||||
return [
|
||||
...severityStat,
|
||||
{
|
||||
title: i18n.STATS_GROUP_SEVERITY,
|
||||
customStatRenderer: severityComponent,
|
||||
title: i18n.STATS_GROUP_USERS,
|
||||
badge: {
|
||||
value: bucket.usersCountAggregation?.value ?? 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18n.STATS_GROUP_RULES,
|
||||
badge: {
|
||||
value: bucket.rulesCountAggregation?.value ?? 0,
|
||||
},
|
||||
},
|
||||
...defaultBadges,
|
||||
];
|
||||
case 'user.name':
|
||||
return [
|
||||
...severityStat,
|
||||
{
|
||||
title: i18n.STATS_GROUP_SEVERITY,
|
||||
customStatRenderer: severityComponent,
|
||||
title: i18n.STATS_GROUP_IPS,
|
||||
badge: {
|
||||
value: bucket.hostsCountAggregation?.value ?? 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18n.STATS_GROUP_RULES,
|
||||
badge: {
|
||||
value: bucket.rulesCountAggregation?.value ?? 0,
|
||||
},
|
||||
},
|
||||
...defaultBadges,
|
||||
];
|
||||
case 'source.ip':
|
||||
return [
|
||||
...severityStat,
|
||||
{
|
||||
title: i18n.STATS_GROUP_IPS,
|
||||
badge: {
|
||||
value: bucket.hostsCountAggregation?.value ?? 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: i18n.STATS_GROUP_RULES,
|
||||
badge: {
|
||||
value: bucket.rulesCountAggregation?.value ?? 0,
|
||||
},
|
||||
},
|
||||
...defaultBadges,
|
||||
];
|
||||
}
|
||||
return [];
|
||||
return [
|
||||
...severityStat,
|
||||
{
|
||||
title: i18n.STATS_GROUP_RULES,
|
||||
badge: {
|
||||
value: bucket.rulesCountAggregation?.value ?? 0,
|
||||
},
|
||||
},
|
||||
...defaultBadges,
|
||||
];
|
||||
};
|
||||
|
|
|
@ -43,7 +43,7 @@ export const getAlertsGroupingQuery = ({
|
|||
additionalFilters,
|
||||
from,
|
||||
groupByFields: !isNoneGroup(selectedGroup) ? getGroupFields(selectedGroup) : [],
|
||||
metricsAggregations: !isNoneGroup(selectedGroup)
|
||||
statsAggregations: !isNoneGroup(selectedGroup)
|
||||
? getAggregationsByGroupField(selectedGroup)
|
||||
: [],
|
||||
pageNumber: pageIndex * pageSize,
|
||||
|
|
|
@ -222,26 +222,15 @@ describe('GroupedAlertsTable', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('renders alerts table when no group selected', () => {
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<TestProviders store={store}>
|
||||
<GroupedAlertsTableComponent {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(getByTestId('alerts-table')).toBeInTheDocument();
|
||||
expect(queryByTestId('grouping-table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders grouped alerts when group selected', async () => {
|
||||
it('renders grouping table', async () => {
|
||||
(isNoneGroup as jest.Mock).mockReturnValue(false);
|
||||
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
const { getByTestId } = render(
|
||||
<TestProviders store={groupingStore}>
|
||||
<GroupedAlertsTableComponent {...testProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
expect(getByTestId('grouping-table')).toBeInTheDocument();
|
||||
expect(queryByTestId('alerts-table')).not.toBeInTheDocument();
|
||||
expect(getGrouping.mock.calls[0][0].isLoading).toEqual(false);
|
||||
});
|
||||
|
||||
|
|
|
@ -60,7 +60,8 @@ const SeverityWrapper = styled(EuiFlexItem)`
|
|||
const StyledEuiText = styled(EuiText)`
|
||||
border-left: 1px solid ${({ theme }) => theme.eui.euiColorLightShade};
|
||||
padding-left: ${({ theme }) => theme.eui.euiSizeL};
|
||||
white-space: nowrap;
|
||||
// allows text to truncate
|
||||
max-width: 250px;
|
||||
`;
|
||||
interface Props {
|
||||
groupBySelection: GroupBySelection;
|
||||
|
@ -124,22 +125,36 @@ export const ChartCollapse: React.FC<Props> = ({
|
|||
</EuiFlexGroup>
|
||||
</SeverityWrapper>
|
||||
<EuiFlexItem grow={false}>
|
||||
<StyledEuiText size="xs" data-test-subj="chart-collapse-top-rule">
|
||||
<StyledEuiText
|
||||
size="xs"
|
||||
className="eui-textTruncate"
|
||||
data-test-subj="chart-collapse-top-rule"
|
||||
>
|
||||
<strong>{i18n.TOP_RULE_TITLE}</strong>
|
||||
{topRule}
|
||||
</StyledEuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<StyledEuiText size="xs" data-test-subj="chart-collapse-top-group">
|
||||
<strong>{`${i18n.TOP_GROUP_TITLE} ${groupBy}: `}</strong>
|
||||
{topGroup}
|
||||
</StyledEuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<InspectButton isDisabled={false} queryId={uniqueQueryId} title={'chart collapse'} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<StyledEuiText
|
||||
size="xs"
|
||||
className="eui-textTruncate"
|
||||
data-test-subj="chart-collapse-top-group"
|
||||
>
|
||||
<strong>{`${i18n.TOP_GROUP_TITLE} ${groupBy}: `}</strong>
|
||||
{topGroup}
|
||||
</StyledEuiText>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<InspectButton
|
||||
isDisabled={false}
|
||||
queryId={uniqueQueryId}
|
||||
title={'chart collapse'}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</StyledEuiFlexGroup>
|
||||
)}
|
||||
</InspectButtonContainer>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue