[Security solution] More grouping cleanup (#153382)

This commit is contained in:
Steph Milovic 2023-03-23 10:57:35 -06:00 committed by GitHub
parent de252d3cba
commit f0b3519aa4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 539 additions and 391 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@ const testProps: GroupingQueryArgs = {
additionalFilters: [],
from: '2022-12-28T15:35:32.871Z',
groupByFields: ['host.name'],
metricsAggregations: [
statsAggregations: [
{
alertsCount: {
cardinality: {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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