[ResponseOps][Alerts] Implement platform alerts grouping components (#184635)

## Summary

Adds solution-agnostic components to create hierarchical alerts grouping
UIs, adapting the original implementation from Security Solution.

Closes #184398 

## To Verify

For existing usages of the `@kbn/grouping` package: verify that the
grouped UIs work correctly (Security Alerts, Cloud Security Posture).

New alerting UI components: checkout
https://github.com/elastic/kibana/pull/183114 (PoC PR), where the
updated `@kbn/grouping` package and these new components are used in
Observability's main Alerts page.

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Gerard Soldevila <gerard.soldevila@elastic.co>
Co-authored-by: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com>
Co-authored-by: Alex Szabo <alex.szabo@elastic.co>
Co-authored-by: Tre <wayne.seymour@elastic.co>
This commit is contained in:
Umberto Pepato 2024-07-08 19:23:49 +02:00 committed by GitHub
parent 0be5528f21
commit f99f83428c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 3023 additions and 162 deletions

1
.github/CODEOWNERS vendored
View file

@ -28,6 +28,7 @@ x-pack/plugins/alerting @elastic/response-ops
x-pack/packages/kbn-alerting-state-types @elastic/response-ops
packages/kbn-alerting-types @elastic/response-ops
packages/kbn-alerts-as-data-utils @elastic/response-ops
packages/kbn-alerts-grouping @elastic/response-ops
x-pack/test/alerting_api_integration/common/plugins/alerts_restricted @elastic/response-ops
packages/kbn-alerts-ui-shared @elastic/response-ops
packages/kbn-ambient-common-types @elastic/kibana-operations

View file

@ -59,6 +59,7 @@
"flot": "packages/kbn-flot-charts/lib",
"generateCsv": "packages/kbn-generate-csv",
"grouping": "packages/kbn-grouping/src",
"alertsGrouping": "packages/kbn-alerts-grouping",
"guidedOnboarding": "src/plugins/guided_onboarding",
"guidedOnboardingPackage": "packages/kbn-guided-onboarding",
"home": "src/plugins/home",

View file

@ -163,6 +163,7 @@
"@kbn/alerting-state-types": "link:x-pack/packages/kbn-alerting-state-types",
"@kbn/alerting-types": "link:packages/kbn-alerting-types",
"@kbn/alerts-as-data-utils": "link:packages/kbn-alerts-as-data-utils",
"@kbn/alerts-grouping": "link:packages/kbn-alerts-grouping",
"@kbn/alerts-restricted-fixtures-plugin": "link:x-pack/test/alerting_api_integration/common/plugins/alerts_restricted",
"@kbn/alerts-ui-shared": "link:packages/kbn-alerts-ui-shared",
"@kbn/analytics": "link:packages/kbn-analytics",

View file

@ -0,0 +1,3 @@
# @kbn/alerts-grouping
Platform components to create hierarchical alerts grouping UIs

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { AlertsGrouping } from './src/components/alerts_grouping';
export { type AlertsGroupingProps } from './src/types';
export { useAlertsGroupingState } from './src/contexts/alerts_grouping_context';

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-alerts-grouping'],
setupFilesAfterEnv: ['<rootDir>/packages/kbn-alerts-grouping/setup_tests.ts'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-browser",
"id": "@kbn/alerts-grouping",
"owner": "@elastic/response-ops"
}

View file

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

View file

@ -0,0 +1,495 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
/**
* Adapted from x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_grouping.test.tsx
*/
import React from 'react';
import { render, within, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { Filter } from '@kbn/es-query';
import { AlertsGrouping } from './alerts_grouping';
import { useGetAlertsGroupAggregationsQuery } from '@kbn/alerts-ui-shared';
import useResizeObserver from 'use-resize-observer/polyfilled';
import { groupingSearchResponse } from '../mocks/grouping_query.mock';
import { useAlertsGroupingState } from '../contexts/alerts_grouping_context';
import { I18nProvider } from '@kbn/i18n-react';
import {
mockFeatureIds,
mockDate,
mockGroupingProps,
mockGroupingId,
mockOptions,
} from '../mocks/grouping_props.mock';
jest.mock('@kbn/alerts-ui-shared/src/common/hooks/use_get_alerts_group_aggregations_query', () => ({
useGetAlertsGroupAggregationsQuery: jest.fn(),
}));
jest.mock('@kbn/alerts-ui-shared/src/common/hooks/use_alert_data_view', () => ({
useAlertDataView: jest.fn().mockReturnValue({ dataViews: [{ fields: [] }] }),
}));
jest.mock('../contexts/alerts_grouping_context', () => {
const original = jest.requireActual('../contexts/alerts_grouping_context');
return {
...original,
useAlertsGroupingState: jest.fn(),
};
});
const mockUseAlertsGroupingState = useAlertsGroupingState as jest.Mock;
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('test-uuid'),
}));
const mockUseGetAlertsGroupAggregationsQuery = useGetAlertsGroupAggregationsQuery as jest.Mock;
const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock;
jest.mock('use-resize-observer/polyfilled');
mockUseResizeObserver.mockImplementation(() => ({}));
const renderChildComponent = (_groupingFilters: Filter[]) => <p data-test-subj="alerts-table" />;
const getMockStorageState = (groups: string[] = ['none']) =>
JSON.stringify({
[mockGroupingId]: {
activeGroups: groups,
options: mockOptions,
},
});
const mockQueryResponse = {
loading: false,
data: {
aggregations: {
groupsCount: {
value: 0,
},
},
},
};
const TestProviders = ({ children }: { children: React.ReactNode }) => (
<I18nProvider>{children}</I18nProvider>
);
const mockAlertsGroupingState = {
grouping: {
options: mockOptions,
activeGroups: ['kibana.alert.rule.name'],
},
updateGrouping: jest.fn(),
};
describe('AlertsGrouping', () => {
beforeEach(() => {
window.localStorage.clear();
mockUseGetAlertsGroupAggregationsQuery.mockImplementation(() => ({
loading: false,
data: groupingSearchResponse,
}));
mockUseAlertsGroupingState.mockReturnValue(mockAlertsGroupingState);
});
afterEach(() => {
jest.clearAllMocks();
});
it('renders empty grouping table when group is selected without data', () => {
mockUseGetAlertsGroupAggregationsQuery.mockReturnValue(mockQueryResponse);
window.localStorage.setItem(
`grouping-table-${mockGroupingId}`,
getMockStorageState(['kibana.alert.rule.name'])
);
render(
<TestProviders>
<AlertsGrouping {...mockGroupingProps}>{renderChildComponent}</AlertsGrouping>
</TestProviders>
);
expect(screen.queryByTestId('alerts-table')).not.toBeInTheDocument();
expect(screen.getByTestId('empty-results-panel')).toBeInTheDocument();
});
it('renders grouping table in first accordion level when single group is selected', () => {
render(
<TestProviders>
<AlertsGrouping {...mockGroupingProps}>{renderChildComponent}</AlertsGrouping>
</TestProviders>
);
userEvent.click(
within(screen.getByTestId('level-0-group-0')).getByTestId('group-panel-toggle')
);
expect(
within(screen.getByTestId('level-0-group-0')).getByTestId('alerts-table')
).toBeInTheDocument();
});
it('Query gets passed correctly', () => {
render(
<TestProviders>
<AlertsGrouping {...mockGroupingProps}>{renderChildComponent}</AlertsGrouping>
</TestProviders>
);
expect(mockUseGetAlertsGroupAggregationsQuery).toHaveBeenLastCalledWith(
expect.objectContaining({
params: {
aggregations: {},
featureIds: mockFeatureIds,
groupByField: 'kibana.alert.rule.name',
filters: [
{
bool: {
filter: [],
must: [],
must_not: [],
should: [],
},
},
{
range: {
'@timestamp': {
gte: mockDate.from,
lte: mockDate.to,
},
},
},
],
pageIndex: 0,
pageSize: 25,
},
})
);
});
it('renders grouping table in second accordion level when 2 groups are selected', () => {
mockUseAlertsGroupingState.mockReturnValue({
...mockAlertsGroupingState,
grouping: {
...mockAlertsGroupingState.grouping,
activeGroups: ['kibana.alert.rule.name', 'user.name'],
},
});
render(
<TestProviders>
<AlertsGrouping {...mockGroupingProps}>{renderChildComponent}</AlertsGrouping>
</TestProviders>
);
userEvent.click(
within(screen.getByTestId('level-0-group-0')).getByTestId('group-panel-toggle')
);
expect(
within(screen.getByTestId('level-0-group-0')).queryByTestId('alerts-table')
).not.toBeInTheDocument();
userEvent.click(
within(screen.getByTestId('level-1-group-0')).getByTestId('group-panel-toggle')
);
expect(
within(screen.getByTestId('level-1-group-0')).getByTestId('alerts-table')
).toBeInTheDocument();
});
it('resets all levels pagination when selected group changes', async () => {
mockUseAlertsGroupingState.mockReturnValue({
...mockAlertsGroupingState,
grouping: {
...mockAlertsGroupingState.grouping,
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
},
});
render(
<TestProviders>
<AlertsGrouping {...mockGroupingProps}>{renderChildComponent}</AlertsGrouping>
</TestProviders>
);
userEvent.click(screen.getByTestId('pagination-button-1'));
userEvent.click(
within(screen.getByTestId('level-0-group-0')).getByTestId('group-panel-toggle')
);
userEvent.click(
within(screen.getByTestId('level-0-group-0')).getByTestId('pagination-button-1')
);
userEvent.click(
within(screen.getByTestId('level-1-group-0')).getByTestId('group-panel-toggle')
);
userEvent.click(
within(screen.getByTestId('level-1-group-0')).getByTestId('pagination-button-1')
);
[
screen.getByTestId('grouping-level-0-pagination'),
screen.getByTestId('grouping-level-1-pagination'),
screen.getByTestId('grouping-level-2-pagination'),
].forEach((pagination) => {
expect(
within(pagination).getByTestId('pagination-button-0').getAttribute('aria-current')
).toEqual(null);
expect(
within(pagination).getByTestId('pagination-button-1').getAttribute('aria-current')
).toEqual('true');
});
userEvent.click(screen.getAllByTestId('group-selector-dropdown')[0]);
// Wait for element to have pointer events enabled
await waitFor(() => userEvent.click(screen.getAllByTestId('panel-user.name')[0]));
[
screen.getByTestId('grouping-level-0-pagination'),
screen.getByTestId('grouping-level-1-pagination'),
// level 2 has been removed with the group selection change
].forEach((pagination) => {
expect(
within(pagination).getByTestId('pagination-button-0').getAttribute('aria-current')
).toEqual('true');
expect(
within(pagination).getByTestId('pagination-button-1').getAttribute('aria-current')
).toEqual(null);
});
});
it('resets all levels pagination when global query updates', () => {
mockUseAlertsGroupingState.mockReturnValue({
...mockAlertsGroupingState,
grouping: {
...mockAlertsGroupingState.grouping,
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
},
});
const { rerender } = render(
<TestProviders>
<AlertsGrouping {...mockGroupingProps}>{renderChildComponent}</AlertsGrouping>
</TestProviders>
);
userEvent.click(screen.getByTestId('pagination-button-1'));
userEvent.click(
within(screen.getByTestId('level-0-group-0')).getByTestId('group-panel-toggle')
);
userEvent.click(
within(screen.getByTestId('level-0-group-0')).getByTestId('pagination-button-1')
);
userEvent.click(
within(screen.getByTestId('level-1-group-0')).getByTestId('group-panel-toggle')
);
userEvent.click(
within(screen.getByTestId('level-1-group-0')).getByTestId('pagination-button-1')
);
rerender(
<TestProviders>
<AlertsGrouping
{...{ ...mockGroupingProps, globalQuery: { query: 'updated', language: 'language' } }}
>
{renderChildComponent}
</AlertsGrouping>
</TestProviders>
);
[
screen.getByTestId('grouping-level-0-pagination'),
screen.getByTestId('grouping-level-1-pagination'),
screen.getByTestId('grouping-level-2-pagination'),
].forEach((pagination) => {
expect(
within(pagination).getByTestId('pagination-button-0').getAttribute('aria-current')
).toEqual('true');
expect(
within(pagination).getByTestId('pagination-button-1').getAttribute('aria-current')
).toEqual(null);
});
});
it('resets only most inner group pagination when its parent groups open/close', () => {
mockUseAlertsGroupingState.mockReturnValue({
...mockAlertsGroupingState,
grouping: {
...mockAlertsGroupingState.grouping,
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
},
});
render(
<TestProviders>
<AlertsGrouping {...mockGroupingProps}>{renderChildComponent}</AlertsGrouping>
</TestProviders>
);
// set level 0 page to 2
userEvent.click(screen.getByTestId('pagination-button-1'));
userEvent.click(
within(screen.getByTestId('level-0-group-0')).getByTestId('group-panel-toggle')
);
// set level 1 page to 2
userEvent.click(
within(screen.getByTestId('level-0-group-0')).getByTestId('pagination-button-1')
);
userEvent.click(
within(screen.getByTestId('level-1-group-0')).getByTestId('group-panel-toggle')
);
// set level 2 page to 2
userEvent.click(
within(screen.getByTestId('level-1-group-0')).getByTestId('pagination-button-1')
);
userEvent.click(
within(screen.getByTestId('level-2-group-0')).getByTestId('group-panel-toggle')
);
// open different level 1 group
// level 0, 1 pagination is the same
userEvent.click(
within(screen.getByTestId('level-1-group-1')).getByTestId('group-panel-toggle')
);
[
screen.getByTestId('grouping-level-0-pagination'),
screen.getByTestId('grouping-level-1-pagination'),
].forEach((pagination) => {
expect(
within(pagination).getByTestId('pagination-button-0').getAttribute('aria-current')
).toEqual(null);
expect(
within(pagination).getByTestId('pagination-button-1').getAttribute('aria-current')
).toEqual('true');
});
// level 2 pagination is reset
expect(
within(screen.getByTestId('grouping-level-2-pagination'))
.getByTestId('pagination-button-0')
.getAttribute('aria-current')
).toEqual('true');
expect(
within(screen.getByTestId('grouping-level-2-pagination'))
.getByTestId('pagination-button-1')
.getAttribute('aria-current')
).toEqual(null);
});
it(`resets innermost level's current page when that level's page size updates`, async () => {
mockUseAlertsGroupingState.mockReturnValue({
...mockAlertsGroupingState,
grouping: {
...mockAlertsGroupingState.grouping,
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
},
});
render(
<TestProviders>
<AlertsGrouping {...mockGroupingProps}>{renderChildComponent}</AlertsGrouping>
</TestProviders>
);
userEvent.click(await screen.findByTestId('pagination-button-1'));
userEvent.click(
within(await screen.findByTestId('level-0-group-0')).getByTestId('group-panel-toggle')
);
userEvent.click(
within(await screen.findByTestId('level-0-group-0')).getByTestId('pagination-button-1')
);
userEvent.click(
within(await screen.findByTestId('level-1-group-0')).getByTestId('group-panel-toggle')
);
userEvent.click(
within(await screen.findByTestId('level-1-group-0')).getByTestId('pagination-button-1')
);
userEvent.click(
within(await screen.findByTestId('grouping-level-2')).getByTestId(
'tablePaginationPopoverButton'
)
);
userEvent.click(await screen.findByTestId('tablePagination-100-rows'));
[
await screen.findByTestId('grouping-level-0-pagination'),
await screen.findByTestId('grouping-level-1-pagination'),
await screen.findByTestId('grouping-level-2-pagination'),
].forEach((pagination, i) => {
if (i !== 2) {
expect(
within(pagination).getByTestId('pagination-button-0').getAttribute('aria-current')
).toEqual(null);
expect(
within(pagination).getByTestId('pagination-button-1').getAttribute('aria-current')
).toEqual('true');
} else {
expect(
within(pagination).getByTestId('pagination-button-0').getAttribute('aria-current')
).toEqual('true');
expect(within(pagination).queryByTestId('pagination-button-1')).not.toBeInTheDocument();
}
});
});
it(`resets outermost level's current page when that level's page size updates`, async () => {
mockUseAlertsGroupingState.mockReturnValue({
...mockAlertsGroupingState,
grouping: {
...mockAlertsGroupingState.grouping,
activeGroups: ['kibana.alert.rule.name', 'host.name', 'user.name'],
},
});
render(
<TestProviders>
<AlertsGrouping {...mockGroupingProps}>{renderChildComponent}</AlertsGrouping>
</TestProviders>
);
userEvent.click(screen.getByTestId('pagination-button-1'));
userEvent.click(
within(await screen.findByTestId('level-0-group-0')).getByTestId('group-panel-toggle')
);
userEvent.click(
within(await screen.findByTestId('level-0-group-0')).getByTestId('pagination-button-1')
);
userEvent.click(
within(await screen.findByTestId('level-1-group-0')).getByTestId('group-panel-toggle')
);
userEvent.click(
within(await screen.findByTestId('level-1-group-0')).getByTestId('pagination-button-1')
);
const tablePaginations = await screen.findAllByTestId('tablePaginationPopoverButton');
userEvent.click(tablePaginations[tablePaginations.length - 1]);
await waitFor(() => userEvent.click(screen.getByTestId('tablePagination-100-rows')));
[
screen.getByTestId('grouping-level-0-pagination'),
screen.getByTestId('grouping-level-1-pagination'),
screen.getByTestId('grouping-level-2-pagination'),
].forEach((pagination, i) => {
if (i !== 0) {
expect(
within(pagination).getByTestId('pagination-button-0').getAttribute('aria-current')
).toEqual(null);
expect(
within(pagination).getByTestId('pagination-button-1').getAttribute('aria-current')
).toEqual('true');
} else {
expect(
within(pagination).getByTestId('pagination-button-0').getAttribute('aria-current')
).toEqual('true');
expect(within(pagination).queryByTestId('pagination-button-1')).not.toBeInTheDocument();
}
});
});
});

View file

@ -0,0 +1,285 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, {
Dispatch,
memo,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { Filter } from '@kbn/es-query';
import { isNoneGroup, useGrouping } from '@kbn/grouping';
import { isEqual } from 'lodash/fp';
import { i18n } from '@kbn/i18n';
import { useAlertDataView } from '@kbn/alerts-ui-shared';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { AlertsGroupingLevel, AlertsGroupingLevelProps } from './alerts_grouping_level';
import { AlertsGroupingProps } from '../types';
import {
AlertsGroupingContextProvider,
useAlertsGroupingState,
} from '../contexts/alerts_grouping_context';
import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE, MAX_GROUPING_LEVELS } from '../constants';
/**
* Handles recursive rendering of grouping levels
*/
const NextLevel = ({
level,
selectedGroups,
children,
parentGroupingFilter,
groupingFilters,
getLevel,
}: Pick<AlertsGroupingLevelProps, 'children' | 'parentGroupingFilter'> & {
level: number;
selectedGroups: string[];
groupingFilters: Filter[];
getLevel: (level: number, selectedGroup: string, parentGroupingFilter?: Filter[]) => JSX.Element;
}): JSX.Element => {
const nextGroupingFilters = useMemo(
() => [...groupingFilters, ...(parentGroupingFilter ?? [])],
[groupingFilters, parentGroupingFilter]
);
if (level < selectedGroups.length - 1) {
return getLevel(level + 1, selectedGroups[level + 1], nextGroupingFilters)!;
}
return children(nextGroupingFilters)!;
};
const AlertsGroupingInternal = (props: AlertsGroupingProps) => {
const {
groupingId,
services,
featureIds,
defaultGroupingOptions,
defaultFilters,
globalFilters,
globalQuery,
renderGroupPanel,
getGroupStats,
children,
} = props;
const { dataViews, notifications, http } = services;
const { grouping, updateGrouping } = useAlertsGroupingState(groupingId);
const { dataViews: alertDataViews } = useAlertDataView({
featureIds,
dataViewsService: dataViews,
http,
toasts: notifications.toasts,
});
const dataView = useMemo(() => alertDataViews?.[0], [alertDataViews]);
const [pageSize, setPageSize] = useLocalStorage<number[]>(
`grouping-table-${groupingId}`,
Array(MAX_GROUPING_LEVELS).fill(DEFAULT_PAGE_SIZE)
) as [number[], Dispatch<SetStateAction<number[]>>, () => void];
const onOptionsChange = useCallback(
(options) => {
// useGrouping > useAlertsGroupingState options sync
// the available grouping options change when the user selects
// a new field not in the default ones
updateGrouping({
options,
});
},
[updateGrouping]
);
const { getGrouping, selectedGroups, setSelectedGroups } = useGrouping({
componentProps: {
groupPanelRenderer: renderGroupPanel,
getGroupStats,
unit: (totalCount) =>
i18n.translate('alertsGrouping.unit', {
values: { totalCount },
defaultMessage: `{totalCount, plural, =1 {alert} other {alerts}}`,
}),
},
defaultGroupingOptions,
fields: dataView?.fields ?? [],
groupingId,
maxGroupingLevels: MAX_GROUPING_LEVELS,
onOptionsChange,
});
useEffect(() => {
// The `none` grouping is managed from the internal selector state
if (isNoneGroup(selectedGroups)) {
// Set active groups from selected groups
updateGrouping({
activeGroups: selectedGroups,
});
}
}, [selectedGroups, updateGrouping]);
useEffect(() => {
if (!isNoneGroup(grouping.activeGroups)) {
// Set selected groups from active groups
setSelectedGroups(grouping.activeGroups);
}
}, [grouping.activeGroups, setSelectedGroups]);
const [pageIndex, setPageIndex] = useState<number[]>(
Array(MAX_GROUPING_LEVELS).fill(DEFAULT_PAGE_INDEX)
);
const resetAllPagination = useCallback(() => {
setPageIndex((curr) => curr.map(() => DEFAULT_PAGE_INDEX));
}, []);
const setPageVar = useCallback(
(newNumber: number, groupingLevel: number, pageType: 'index' | 'size') => {
if (pageType === 'index') {
setPageIndex((currentIndex) => {
const newArr = [...currentIndex];
newArr[groupingLevel] = newNumber;
return newArr;
});
}
if (pageType === 'size') {
setPageSize((currentIndex) => {
const newArr = [...currentIndex];
newArr[groupingLevel] = newNumber;
return newArr;
});
// set page index to 0 when page size is changed
setPageIndex((currentIndex) => {
const newArr = [...currentIndex];
newArr[groupingLevel] = 0;
return newArr;
});
}
},
[setPageSize]
);
const paginationResetTriggers = useRef({
defaultFilters,
globalFilters,
globalQuery,
selectedGroups,
});
useEffect(() => {
const triggers = {
defaultFilters,
globalFilters,
globalQuery,
selectedGroups,
};
if (!isEqual(paginationResetTriggers.current, triggers)) {
resetAllPagination();
paginationResetTriggers.current = triggers;
}
}, [defaultFilters, globalFilters, globalQuery, resetAllPagination, selectedGroups]);
const getLevel = useCallback(
(level: number, selectedGroup: string, parentGroupingFilter?: Filter[]) => {
const resetGroupChildrenPagination = (parentLevel: number) => {
setPageIndex((allPages) => {
const resetPages = allPages.splice(parentLevel + 1, allPages.length);
return [...allPages, ...resetPages.map(() => DEFAULT_PAGE_INDEX)];
});
};
return (
<AlertsGroupingLevel
{...props}
getGrouping={getGrouping}
groupingLevel={level}
onGroupClose={() => resetGroupChildrenPagination(level)}
pageIndex={pageIndex[level] ?? DEFAULT_PAGE_INDEX}
pageSize={pageSize[level] ?? DEFAULT_PAGE_SIZE}
parentGroupingFilter={parentGroupingFilter}
selectedGroup={selectedGroup}
setPageIndex={(newIndex: number) => setPageVar(newIndex, level, 'index')}
setPageSize={(newSize: number) => setPageVar(newSize, level, 'size')}
>
{(groupingFilters) => (
<NextLevel
selectedGroups={selectedGroups}
groupingFilters={groupingFilters}
getLevel={getLevel}
parentGroupingFilter={parentGroupingFilter}
level={level}
>
{children}
</NextLevel>
)}
</AlertsGroupingLevel>
);
},
[children, getGrouping, pageIndex, pageSize, props, selectedGroups, setPageVar]
);
if (!dataView) {
return null;
}
return getLevel(0, selectedGroups[0]);
};
/**
* A coordinator component to show multiple alert tables grouped by one or more fields
*
* @example Basic grouping
* ```ts
* const {
* notifications,
* dataViews,
* http,
* } = useKibana().services;
*
*
* return (
* <AlertsGrouping
* featureIds={[...]}
* globalQuery={{ query: ..., language: 'kql' }}
* globalFilters={...}
* from={...}
* to={...}
* groupingId={...}
* defaultGroupingOptions={...}
* getAggregationsByGroupingField={getAggregationsByGroupingField}
* renderGroupPanel={renderGroupPanel}
* getGroupStats={getStats}
* services={{
* notifications,
* dataViews,
* http,
* }}
* >
* {(groupingFilters) => {
* const query = buildEsQuery({
* filters: groupingFilters,
* });
* return (
* <AlertsTable
* query={query}
* ...
* />
* );
* }}
* </AlertsGrouping>
* );
* ```
*/
export const AlertsGrouping = memo((props: AlertsGroupingProps) => {
return (
<AlertsGroupingContextProvider>
<AlertsGroupingInternal {...props} />
</AlertsGroupingContextProvider>
);
});

View file

@ -0,0 +1,121 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import { AlertsGroupingLevel, type AlertsGroupingLevelProps } from './alerts_grouping_level';
import { useGetAlertsGroupAggregationsQuery } from '@kbn/alerts-ui-shared';
import * as buildEsQueryModule from '@kbn/es-query/src/es_query/build_es_query';
import { mockGroupingProps } from '../mocks/grouping_props.mock';
import { groupingSearchResponse } from '../mocks/grouping_query.mock';
jest.mock('@kbn/alerts-ui-shared/src/common/hooks/use_get_alerts_group_aggregations_query', () => ({
useGetAlertsGroupAggregationsQuery: jest.fn(),
}));
const mockUseGetAlertsGroupAggregationsQuery = useGetAlertsGroupAggregationsQuery as jest.Mock;
mockUseGetAlertsGroupAggregationsQuery.mockReturnValue({
loading: false,
data: groupingSearchResponse,
});
jest.mock('@kbn/alerts-ui-shared/src/common/hooks/use_alert_data_view', () => ({
useAlertDataView: jest.fn().mockReturnValue({ dataViews: [{ fields: [] }] }),
}));
jest.mock('../contexts/alerts_grouping_context', () => {
const original = jest.requireActual('../contexts/alerts_grouping_context');
return {
...original,
useAlertsGroupingState: jest.fn(),
};
});
const getGrouping = jest
.fn()
.mockImplementation(({ renderChildComponent }) => <span>{renderChildComponent()}</span>);
const mockGroupingLevelProps: Omit<AlertsGroupingLevelProps, 'children'> = {
...mockGroupingProps,
getGrouping,
onGroupClose: jest.fn(),
pageIndex: 0,
pageSize: 10,
selectedGroup: 'selectedGroup',
setPageIndex: jest.fn(),
setPageSize: jest.fn(),
};
describe('AlertsGroupingLevel', () => {
let buildEsQuerySpy: jest.SpyInstance;
beforeAll(() => {
buildEsQuerySpy = jest.spyOn(buildEsQueryModule, 'buildEsQuery');
});
it('should render', () => {
const { getByTestId } = render(
<AlertsGroupingLevel {...mockGroupingLevelProps}>
{() => <span data-test-subj="grouping-level" />}
</AlertsGroupingLevel>
);
expect(getByTestId('grouping-level')).toBeInTheDocument();
});
it('should account for global, default and parent filters', async () => {
const globalFilter = { meta: { value: 'global', disabled: false } };
const defaultFilter = { meta: { value: 'default' } };
const parentFilter = { meta: { value: 'parent' } };
render(
<AlertsGroupingLevel
{...mockGroupingLevelProps}
globalFilters={[globalFilter]}
defaultFilters={[defaultFilter]}
parentGroupingFilter={[parentFilter]}
>
{() => <span data-test-subj="grouping-level" />}
</AlertsGroupingLevel>
);
await waitFor(() =>
expect(buildEsQuerySpy).toHaveBeenLastCalledWith(undefined, expect.anything(), [
globalFilter,
defaultFilter,
parentFilter,
])
);
});
it('should discard disabled global filters', async () => {
const globalFilters = [
{ meta: { value: 'global1', disabled: false } },
{ meta: { value: 'global2', disabled: true } },
];
render(
<AlertsGroupingLevel {...mockGroupingLevelProps} globalFilters={globalFilters}>
{() => <span data-test-subj="grouping-level" />}
</AlertsGroupingLevel>
);
await waitFor(() =>
expect(buildEsQuerySpy).toHaveBeenLastCalledWith(undefined, expect.anything(), [
globalFilters[0],
])
);
});
it('should call getGrouping with the right aggregations', () => {
render(
<AlertsGroupingLevel {...mockGroupingLevelProps}>
{() => <span data-test-subj="grouping-level" />}
</AlertsGroupingLevel>
);
expect(Object.keys(getGrouping.mock.calls[0][0].data)).toMatchObject(
Object.keys(groupingSearchResponse.aggregations)
);
});
});

View file

@ -0,0 +1,173 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { memo, ReactElement, useMemo } from 'react';
import { v4 as uuidv4 } from 'uuid';
import type { Filter } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query';
import { type GroupingAggregation } from '@kbn/grouping';
import { isNoneGroup } from '@kbn/grouping';
import type { DynamicGroupingProps } from '@kbn/grouping/src';
import { parseGroupingQuery } from '@kbn/grouping/src';
import {
useGetAlertsGroupAggregationsQuery,
UseGetAlertsGroupAggregationsQueryProps,
} from '@kbn/alerts-ui-shared';
import { AlertsGroupingProps } from '../types';
export interface AlertsGroupingLevelProps<T extends Record<string, unknown> = {}>
extends AlertsGroupingProps<T> {
getGrouping: (
props: Omit<DynamicGroupingProps<T>, 'groupSelector' | 'pagination'>
) => ReactElement;
groupingLevel?: number;
onGroupClose: () => void;
pageIndex: number;
pageSize: number;
parentGroupingFilter?: Filter[];
selectedGroup: string;
setPageIndex: (newIndex: number) => void;
setPageSize: (newSize: number) => void;
}
const DEFAULT_FILTERS: Filter[] = [];
/**
* Renders an alerts grouping level
*/
export const AlertsGroupingLevel = memo(
<T extends Record<string, unknown> = {}>({
featureIds,
defaultFilters = DEFAULT_FILTERS,
from,
getGrouping,
globalFilters,
globalQuery,
groupingLevel,
loading = false,
onGroupClose,
pageIndex,
pageSize,
parentGroupingFilter,
children,
selectedGroup,
setPageIndex,
setPageSize,
to,
takeActionItems,
getAggregationsByGroupingField,
services: { http, notifications },
}: AlertsGroupingLevelProps<T>) => {
const filters = useMemo(() => {
try {
return [
buildEsQuery(undefined, globalQuery != null ? [globalQuery] : [], [
...(globalFilters?.filter((f) => f.meta.disabled === false) ?? []),
...(defaultFilters ?? []),
...(parentGroupingFilter ?? []),
]),
];
} catch (e) {
return [];
}
}, [defaultFilters, globalFilters, globalQuery, parentGroupingFilter]);
// Create a unique, but stable (across re-renders) value
const uniqueValue = useMemo(() => `alerts-grouping-level-${uuidv4()}`, []);
const aggregationsQuery = useMemo<UseGetAlertsGroupAggregationsQueryProps['params']>(() => {
return {
featureIds,
groupByField: selectedGroup,
aggregations: getAggregationsByGroupingField(selectedGroup)?.reduce(
(acc, val) => Object.assign(acc, val),
{}
),
filters: [
...filters,
{
range: {
'@timestamp': {
gte: from,
lte: to,
},
},
},
],
pageIndex,
pageSize,
};
}, [
featureIds,
filters,
from,
getAggregationsByGroupingField,
pageIndex,
pageSize,
selectedGroup,
to,
]);
const { data: alertGroupsData, isLoading: isLoadingGroups } =
useGetAlertsGroupAggregationsQuery<GroupingAggregation<T>>({
http,
toasts: notifications.toasts,
enabled: aggregationsQuery && !isNoneGroup([selectedGroup]),
params: aggregationsQuery,
});
const queriedGroup = useMemo<string | null>(
() => (!isNoneGroup([selectedGroup]) ? selectedGroup : null),
[selectedGroup]
);
const aggs = useMemo(
// queriedGroup because `selectedGroup` updates before the query response
() =>
parseGroupingQuery(
// fallback to selectedGroup if queriedGroup.current is null, this happens in tests
queriedGroup === null ? selectedGroup : queriedGroup,
uniqueValue,
alertGroupsData?.aggregations
),
[alertGroupsData?.aggregations, queriedGroup, selectedGroup, uniqueValue]
);
return useMemo(
() =>
getGrouping({
activePage: pageIndex,
data: aggs,
groupingLevel,
isLoading: loading || isLoadingGroups,
itemsPerPage: pageSize,
onChangeGroupsItemsPerPage: (size: number) => setPageSize(size),
onChangeGroupsPage: (index) => setPageIndex(index),
onGroupClose,
renderChildComponent: children,
selectedGroup,
takeActionItems,
}),
[
getGrouping,
pageIndex,
aggs,
groupingLevel,
loading,
isLoadingGroups,
pageSize,
onGroupClose,
children,
selectedGroup,
takeActionItems,
setPageSize,
setPageIndex,
]
);
}
);

View file

@ -6,6 +6,6 @@
* Side Public License, v 1.
*/
export * from './fetch_aad_fields';
export * from './fetch_alert_fields';
export * from './fetch_alert_index_names';
export const DEFAULT_PAGE_SIZE = 25;
export const DEFAULT_PAGE_INDEX = 0;
export const MAX_GROUPING_LEVELS = 3;

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, {
createContext,
Dispatch,
PropsWithChildren,
SetStateAction,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { AlertsGroupingState, GroupModel } from '../types';
const initialActiveGroups = ['none'];
export const AlertsGroupingContext = createContext({
groupingState: {} as AlertsGroupingState,
setGroupingState: (() => {}) as Dispatch<SetStateAction<AlertsGroupingState>>,
});
export const AlertsGroupingContextProvider = ({ children }: PropsWithChildren<{}>) => {
const [groupingState, setGroupingState] = useState<AlertsGroupingState>({});
return (
<AlertsGroupingContext.Provider
value={useMemo(
() => ({ groupingState, setGroupingState }),
[groupingState, setGroupingState]
)}
>
{children}
</AlertsGroupingContext.Provider>
);
};
export const useAlertsGroupingState = (groupingId: string) => {
const { groupingState, setGroupingState } = useContext(AlertsGroupingContext);
const updateGrouping = useCallback(
(groupModel: Partial<GroupModel> | null) => {
if (groupModel === null) {
setGroupingState((prevState) => {
const newState = { ...prevState };
delete newState[groupingId];
return newState;
});
return;
}
setGroupingState((prevState) => ({
...prevState,
[groupingId]: {
// @ts-expect-error options might not be defined
options: [],
// @ts-expect-error activeGroups might not be defined
activeGroups: initialActiveGroups,
...prevState[groupingId],
...groupModel,
},
}));
},
[setGroupingState, groupingId]
);
const grouping = useMemo(
() => groupingState[groupingId] ?? { activeGroups: ['none'] },
[groupingState, groupingId]
);
return {
grouping,
updateGrouping,
};
};

View file

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

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { AlertConsumers } from '@kbn/rule-data-utils';
import { AlertsGroupingProps } from '../types';
export const mockGroupingId = 'test';
export const mockFeatureIds = [AlertConsumers.STACK_ALERTS];
export const mockDate = {
from: '2020-07-07T08:20:18.966Z',
to: '2020-07-08T08:20:18.966Z',
};
export const mockOptions = [
{ label: 'ruleName', key: 'kibana.alert.rule.name' },
{ label: 'userName', key: 'user.name' },
{ label: 'hostName', key: 'host.name' },
{ label: 'sourceIP', key: 'source.ip' },
];
export const mockGroupingProps: Omit<AlertsGroupingProps, 'children'> = {
...mockDate,
groupingId: mockGroupingId,
featureIds: mockFeatureIds,
defaultGroupingOptions: mockOptions,
getAggregationsByGroupingField: () => [],
getGroupStats: () => [{ title: 'Stat', component: <span /> }],
renderGroupPanel: () => <span />,
takeActionItems: undefined,
defaultFilters: [],
globalFilters: [],
globalQuery: {
query: 'query',
language: 'language',
},
loading: false,
services: {
dataViews: {
clearInstanceCache: jest.fn(),
create: jest.fn(),
} as unknown as AlertsGroupingProps['services']['dataViews'],
http: {
get: jest.fn(),
} as unknown as AlertsGroupingProps['services']['http'],
notifications: {
toasts: {
addDanger: jest.fn(),
},
} as unknown as AlertsGroupingProps['services']['notifications'],
},
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { Filter, Query } from '@kbn/es-query';
import { ValidFeatureId } from '@kbn/rule-data-utils';
import type { NotificationsStart } from '@kbn/core-notifications-browser';
import type { DataViewsServicePublic } from '@kbn/data-views-plugin/public/types';
import type { HttpSetup } from '@kbn/core-http-browser';
import {
GroupingProps,
GroupOption,
GroupPanelRenderer,
GetGroupStats,
NamedAggregation,
} from '@kbn/grouping/src';
import { ReactElement } from 'react';
export interface GroupModel {
activeGroups: string[];
options: Array<{ key: string; label: string }>;
}
export interface AlertsGroupingState {
[groupingId: string]: GroupModel;
}
export interface AlertsGroupingProps<T extends Record<string, unknown> = {}> {
/**
* The leaf component that will be rendered in the grouping panels
*/
children: (groupingFilters: Filter[]) => ReactElement;
/**
* Render function for the group panel header
*/
renderGroupPanel?: GroupPanelRenderer<T>;
/**
* A function that given the current grouping field and aggregation results, returns an array of
* stat items to be rendered in the group panel
*/
getGroupStats?: GetGroupStats<T>;
/**
* Default search filters
*/
defaultFilters?: Filter[];
/**
* Global search filters
*/
globalFilters: Filter[];
/**
* Items that will be rendered in the `Take Actions` menu
*/
takeActionItems?: GroupingProps<T>['takeActionItems'];
/**
* The default fields available for grouping
*/
defaultGroupingOptions: GroupOption[];
/**
* The alerting feature ids this grouping covers
*/
featureIds: ValidFeatureId[];
/**
* Time filter start
*/
from: string;
/**
* Time filter end
*/
to: string;
/**
* Global search query (i.e. from the KQL bar)
*/
globalQuery: Query;
/**
* External loading state
*/
loading?: boolean;
/**
* ID used to retrieve the current grouping configuration from the state
*/
groupingId: string;
/**
* Resolves an array of aggregations for a given grouping field
*/
getAggregationsByGroupingField: (field: string) => NamedAggregation[];
/**
* Services required for the grouping component
*/
services: {
notifications: NotificationsStart;
dataViews: DataViewsServicePublic;
http: HttpSetup;
};
}

View file

@ -0,0 +1,28 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*"
],
"kbn_references": [
"@kbn/es-query",
"@kbn/grouping",
"@kbn/i18n",
"@kbn/alerts-ui-shared",
"@kbn/rule-data-utils",
"@kbn/core-notifications-browser",
"@kbn/data-views-plugin",
"@kbn/core-http-browser",
"@kbn/i18n-react",
]
}

View file

@ -11,8 +11,7 @@ export type { AlertLifecycleStatusBadgeProps } from './src/alert_lifecycle_statu
export { MaintenanceWindowCallout } from './src/maintenance_window_callout';
export { AddMessageVariables } from './src/add_message_variables';
export * from './src/alerts_search_bar/hooks';
export * from './src/alerts_search_bar/apis';
export * from './src/common/hooks';
export { AlertsSearchBar } from './src/alerts_search_bar';
export type { AlertsSearchBarProps } from './src/alerts_search_bar/types';

View file

@ -6,8 +6,7 @@
* Side Public License, v 1.
*/
import type { DataView, DataViewField } from '@kbn/data-views-plugin/common';
import type { DataView } from '@kbn/data-views-plugin/common';
export const NO_INDEX_PATTERNS: DataView[] = [];
export const EMPTY_AAD_FIELDS: DataViewField[] = [];
export * from '../common/constants';

View file

@ -13,9 +13,7 @@ import { AlertConsumers } from '@kbn/rule-data-utils';
import { NO_INDEX_PATTERNS } from './constants';
import { SEARCH_BAR_PLACEHOLDER } from './translations';
import type { AlertsSearchBarProps, QueryLanguageType } from './types';
import { useLoadRuleTypesQuery } from '../common/hooks/use_load_rule_types_query';
import { useAlertDataView } from './hooks/use_alert_data_view';
import { useRuleAADFields } from './hooks/use_rule_aad_fields';
import { useLoadRuleTypesQuery, useAlertDataView, useRuleAADFields } from '../common/hooks';
const SA_ALERTS = { type: 'alerts', fields: {} } as SuggestionsAbstraction;

View file

@ -6,8 +6,11 @@
* Side Public License, v 1.
*/
import type { DataViewField } from '@kbn/data-views-plugin/common';
export const ALERTS_FEATURE_ID = 'alerts';
export const BASE_ALERTING_API_PATH = '/api/alerting';
export const INTERNAL_BASE_ALERTING_API_PATH = '/internal/alerting';
export const BASE_RAC_ALERTS_API_PATH = '/internal/rac/alerts';
export const EMPTY_AAD_FIELDS: DataViewField[] = [];
export const BASE_TRIGGERS_ACTIONS_UI_API_PATH = '/internal/triggers_actions_ui';

View file

@ -6,7 +6,10 @@
* Side Public License, v 1.
*/
export * from './use_alert_data_view';
export * from './use_find_alerts_query';
export * from './use_load_rule_types_query';
export * from './use_rule_aad_fields';
export * from './use_load_ui_config';
export * from './use_health_check';
export * from './use_load_ui_health';

View file

@ -67,6 +67,7 @@ export function useAlertDataView(props: UseAlertDataViewProps): UseAlertDataView
queryFn: queryIndexNameFn,
onError: onErrorFn,
refetchOnWindowFocus: false,
staleTime: 60 * 1000, // To prevent duplicated requests
enabled: featureIds.length > 0 && !hasSecurityAndO11yFeatureIds,
});
@ -80,6 +81,7 @@ export function useAlertDataView(props: UseAlertDataViewProps): UseAlertDataView
queryFn: queryAlertFieldsFn,
onError: onErrorFn,
refetchOnWindowFocus: false,
staleTime: 60 * 1000,
enabled: hasNoSecuritySolution,
});

View file

@ -0,0 +1,56 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { useQuery } from '@tanstack/react-query';
import type { HttpStart } from '@kbn/core-http-browser';
import type { ToastsStart } from '@kbn/core-notifications-browser';
import { ISearchRequestParams } from '@kbn/search-types';
import { SearchResponseBody } from '@elastic/elasticsearch/lib/api/types';
import { BASE_RAC_ALERTS_API_PATH } from '../constants';
export interface UseFindAlertsQueryProps {
http: HttpStart;
toasts: ToastsStart;
enabled?: boolean;
params: ISearchRequestParams & { feature_ids?: string[] };
}
/**
* A generic hook to find alerts
*
* Still applies alerts authorization rules but, unlike triggers_actions_ui's `useFetchAlerts` hook,
* allows to perform arbitrary queries
*/
export const useFindAlertsQuery = <T>({
http,
toasts,
enabled = true,
params,
}: UseFindAlertsQueryProps) => {
const onErrorFn = (error: Error) => {
if (error) {
toasts.addDanger(
i18n.translate('alertsUIShared.hooks.useFindAlertsQuery.unableToFindAlertsQueryMessage', {
defaultMessage: 'Unable to find alerts',
})
);
}
};
return useQuery({
queryKey: ['findAlerts', JSON.stringify(params)],
queryFn: () =>
http.post<SearchResponseBody<{}, T>>(`${BASE_RAC_ALERTS_API_PATH}/find`, {
body: JSON.stringify(params),
}),
onError: onErrorFn,
refetchOnWindowFocus: false,
enabled,
});
};

View file

@ -37,6 +37,7 @@
"@kbn/core-doc-links-browser",
"@kbn/charts-plugin",
"@kbn/data-plugin",
"@kbn/search-types",
"@kbn/utility-types",
"@kbn/core-application-browser",
"@kbn/react-kibana-mount",

View file

@ -13,7 +13,7 @@ import type {
GroupingAggregation,
NamedAggregation,
RawBucket,
StatRenderer,
GroupStatsItem,
} from './src';
export { getGroupingQuery, isNoneGroup, useGrouping };
@ -24,5 +24,5 @@ export type {
GroupingAggregation,
NamedAggregation,
RawBucket,
StatRenderer,
GroupStatsItem,
};

View file

@ -22,5 +22,5 @@ module.exports = {
'!<rootDir>/packages/kbn-grouping/**/translations',
'!<rootDir>/packages/kbn-grouping/**/types/*',
],
setupFilesAfterEnv: ['<rootDir>/packages/kbn-grouping/setup_test.ts'],
setupFilesAfterEnv: ['<rootDir>/packages/kbn-grouping/setup_tests.ts'],
};

View file

@ -5,6 +5,5 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * from './use_alert_data_view';
export * from './use_rule_aad_fields';
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';

View file

@ -16,10 +16,10 @@ const testProps = {
groupFilter: [],
groupNumber: 0,
onTakeActionsOpen,
statRenderers: [
stats: [
{
title: 'Severity',
renderer: <p data-test-subj="customMetricStat" />,
component: <p data-test-subj="customMetricStat" />,
},
{ title: "IP's:", badge: { value: 1 } },
{ title: 'Rules:', badge: { value: 2 } },
@ -34,11 +34,12 @@ describe('Group stats', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders each stat item', () => {
const { getByTestId, queryByTestId } = render(<GroupStats {...testProps} />);
expect(getByTestId('group-stats')).toBeInTheDocument();
testProps.statRenderers.forEach(({ title: stat, renderer }) => {
if (renderer != null) {
testProps.stats.forEach(({ title: stat, component }) => {
if (component != null) {
expect(getByTestId(`customMetric-${stat}`)).toBeInTheDocument();
expect(queryByTestId(`metric-${stat}`)).not.toBeInTheDocument();
} else {
@ -47,6 +48,7 @@ describe('Group stats', () => {
}
});
});
it('when onTakeActionsOpen is defined, call onTakeActionsOpen on popover click', () => {
const { getByTestId, queryByTestId } = render(<GroupStats {...testProps} />);
fireEvent.click(getByTestId('take-action-button'));
@ -55,6 +57,7 @@ describe('Group stats', () => {
expect(queryByTestId(actionItem)).not.toBeInTheDocument();
});
});
it('when onTakeActionsOpen is undefined, render take actions dropdown on popover click', () => {
const { getByTestId } = render(<GroupStats {...testProps} onTakeActionsOpen={undefined} />);
fireEvent.click(getByTestId('take-action-button'));
@ -62,4 +65,16 @@ describe('Group stats', () => {
expect(getByTestId(actionItem)).toBeInTheDocument();
});
});
it('shows the Take Actions menu when action items are provided', () => {
const { queryByTestId } = render(
<GroupStats {...testProps} takeActionItems={() => [<span />]} />
);
expect(queryByTestId('take-action-button')).toBeInTheDocument();
});
it('hides the Take Actions menu when no action item is provided', () => {
const { queryByTestId } = render(<GroupStats {...testProps} takeActionItems={() => []} />);
expect(queryByTestId('take-action-button')).not.toBeInTheDocument();
});
});

View file

@ -15,9 +15,11 @@ import {
EuiPopover,
EuiToolTip,
} from '@elastic/eui';
import React, { useCallback, useMemo, useState } from 'react';
import React, { Fragment, useCallback, useMemo, useState } from 'react';
import { Filter } from '@kbn/es-query';
import { StatRenderer } from '../types';
import { css } from '@emotion/react';
import { euiThemeVars } from '@kbn/ui-theme';
import { GroupStatsItem } from '../types';
import { statsContainerCss } from '../styles';
import { TAKE_ACTION } from '../translations';
@ -26,38 +28,44 @@ interface GroupStatsProps<T> {
groupFilter: Filter[];
groupNumber: number;
onTakeActionsOpen?: () => void;
statRenderers?: StatRenderer[];
takeActionItems: (groupFilters: Filter[], groupNumber: number) => JSX.Element[];
stats?: GroupStatsItem[];
takeActionItems?: (groupFilters: Filter[], groupNumber: number) => JSX.Element[];
}
const Separator = () => {
return (
<EuiFlexItem
grow={false}
role="separator"
css={css`
align-self: center;
height: 20px;
border-right: ${euiThemeVars.euiBorderThin};
`}
/>
);
};
const GroupStatsComponent = <T,>({
bucketKey,
groupFilter,
groupNumber,
onTakeActionsOpen,
statRenderers,
stats,
takeActionItems: getTakeActionItems,
}: GroupStatsProps<T>) => {
const [isPopoverOpen, setPopover] = useState(false);
const [takeActionItems, setTakeActionItems] = useState<JSX.Element[]>([]);
const takeActionItems = useMemo(() => {
return getTakeActionItems?.(groupFilter, groupNumber) ?? [];
}, [getTakeActionItems, groupFilter, groupNumber]);
const onButtonClick = useCallback(() => {
if (!isPopoverOpen && takeActionItems.length === 0) {
setTakeActionItems(getTakeActionItems(groupFilter, groupNumber));
}
return !isPopoverOpen && onTakeActionsOpen ? onTakeActionsOpen() : setPopover(!isPopoverOpen);
}, [
getTakeActionItems,
groupFilter,
groupNumber,
isPopoverOpen,
onTakeActionsOpen,
takeActionItems.length,
]);
}, [isPopoverOpen, onTakeActionsOpen]);
const statsComponent = useMemo(
const statsComponents = useMemo(
() =>
statRenderers?.map((stat) => {
stats?.map((stat) => {
const { dataTestSubj, component } =
stat.badge != null
? {
@ -73,7 +81,7 @@ const GroupStatsComponent = <T,>({
</EuiToolTip>
),
}
: { dataTestSubj: `customMetric-${stat.title}`, component: stat.renderer };
: { dataTestSubj: `customMetric-${stat.title}`, component: stat.component };
return (
<EuiFlexItem grow={false} key={stat.title}>
@ -83,33 +91,34 @@ const GroupStatsComponent = <T,>({
</span>
</EuiFlexItem>
);
}),
[statRenderers]
}) ?? [],
[stats]
);
const takeActionMenu = useMemo(
() => (
<EuiFlexItem grow={false}>
<EuiPopover
anchorPosition="downLeft"
button={
<EuiButtonEmpty
data-test-subj="take-action-button"
onClick={onButtonClick}
iconType="arrowDown"
iconSide="right"
>
{TAKE_ACTION}
</EuiButtonEmpty>
}
closePopover={() => setPopover(false)}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenuPanel items={takeActionItems} />
</EuiPopover>
</EuiFlexItem>
),
() =>
takeActionItems.length ? (
<EuiFlexItem grow={false}>
<EuiPopover
anchorPosition="downLeft"
button={
<EuiButtonEmpty
data-test-subj="take-action-button"
onClick={onButtonClick}
iconType="arrowDown"
iconSide="right"
>
{TAKE_ACTION}
</EuiButtonEmpty>
}
closePopover={() => setPopover(false)}
isOpen={isPopoverOpen}
panelPaddingSize="none"
>
<EuiContextMenuPanel items={takeActionItems} />
</EuiPopover>
</EuiFlexItem>
) : null,
[isPopoverOpen, onButtonClick, takeActionItems]
);
@ -117,11 +126,15 @@ const GroupStatsComponent = <T,>({
<EuiFlexGroup
data-test-subj="group-stats"
key={`stats-${bucketKey}`}
gutterSize="none"
gutterSize="m"
alignItems="center"
>
{statsComponent}
{takeActionMenu}
{[...statsComponents, takeActionMenu].filter(Boolean).map((component, index, { length }) => (
<Fragment key={index}>
{component}
{index < length - 1 && <Separator />}
</Fragment>
))}
</EuiFlexGroup>
);
};

View file

@ -18,7 +18,7 @@ interface GroupPanelProps<T> {
extraAction?: React.ReactNode;
forceState?: 'open' | 'closed';
groupBucket: GroupingBucket<T>;
groupPanelRenderer?: JSX.Element;
groupPanel?: JSX.Element;
groupingLevel?: number;
isLoading: boolean;
isNullGroup?: boolean;
@ -62,7 +62,7 @@ const GroupPanelComponent = <T,>({
extraAction,
forceState,
groupBucket,
groupPanelRenderer,
groupPanel,
groupingLevel = 0,
isLoading,
isNullGroup = false,
@ -115,7 +115,7 @@ const GroupPanelComponent = <T,>({
buttonClassName={customAccordionButtonClassName}
buttonContent={
<div data-test-subj="group-panel-toggle" className="groupingPanelRenderer">
{groupPanelRenderer ?? (
{groupPanel ?? (
<DefaultGroupPanelRenderer
title={groupFieldValue.asString}
isNullGroup={isNullGroup}

View file

@ -118,7 +118,7 @@ describe('grouping container', () => {
});
it('Renders a null group and passes the correct filter to take actions and child component', () => {
takeActionItems.mockReturnValue([]);
takeActionItems.mockReturnValue([<span />]);
const { getAllByTestId, getByTestId } = render(
<I18nProvider>
<Grouping {...testProps} />

View file

@ -23,8 +23,8 @@ import { GroupStats } from './accordion_panel/group_stats';
import { EmptyGroupingComponent } from './empty_results_panel';
import { countCss, groupingContainerCss, groupingContainerCssLevel } from './styles';
import { GROUPS_UNIT, NULL_GROUP } from './translations';
import type { ParsedGroupingAggregation, GroupPanelRenderer } from './types';
import { GroupingBucket, GroupStatsRenderer, OnGroupToggle } from './types';
import type { ParsedGroupingAggregation, GroupPanelRenderer, GetGroupStats } from './types';
import { GroupingBucket, OnGroupToggle } from './types';
import { getTelemetryEvent } from '../telemetry/const';
export interface GroupingProps<T> {
@ -33,7 +33,7 @@ export interface GroupingProps<T> {
groupPanelRenderer?: GroupPanelRenderer<T>;
groupSelector?: JSX.Element;
// list of custom UI components which correspond to your custom rendered metrics aggregations
groupStatsRenderer?: GroupStatsRenderer<T>;
getGroupStats?: GetGroupStats<T>;
groupingId: string;
groupingLevel?: number;
inspectButton?: JSX.Element;
@ -45,7 +45,7 @@ export interface GroupingProps<T> {
renderChildComponent: (groupFilter: Filter[]) => React.ReactElement;
onGroupClose: () => void;
selectedGroup: string;
takeActionItems: (groupFilters: Filter[], groupNumber: number) => JSX.Element[];
takeActionItems?: (groupFilters: Filter[], groupNumber: number) => JSX.Element[];
tracker?: (
type: UiCounterMetricType,
event: string | string[],
@ -59,8 +59,8 @@ const GroupingComponent = <T,>({
activePage,
data,
groupPanelRenderer,
getGroupStats,
groupSelector,
groupStatsRenderer,
groupingId,
groupingLevel = 0,
inspectButton,
@ -124,15 +124,13 @@ const GroupingComponent = <T,>({
)
}
groupNumber={groupNumber}
statRenderers={
groupStatsRenderer && groupStatsRenderer(selectedGroup, groupBucket)
}
stats={getGroupStats && getGroupStats(selectedGroup, groupBucket)}
takeActionItems={takeActionItems}
/>
}
forceState={(trigger[groupKey] && trigger[groupKey].state) ?? 'closed'}
groupBucket={groupBucket}
groupPanelRenderer={
groupPanel={
groupPanelRenderer &&
groupPanelRenderer(selectedGroup, groupBucket, nullGroupMessage, isLoading)
}
@ -166,7 +164,7 @@ const GroupingComponent = <T,>({
[
data?.groupByFields?.buckets,
groupPanelRenderer,
groupStatsRenderer,
getGroupStats,
groupingId,
groupingLevel,
isLoading,

View file

@ -22,9 +22,6 @@ export const countCss = css`
export const statsContainerCss = css`
font-size: ${euiThemeVars.euiFontSizeXS};
font-weight: ${euiThemeVars.euiFontWeightSemiBold};
border-right: ${euiThemeVars.euiBorderThin};
margin-right: 16px;
padding-right: 16px;
.smallDot {
width: 3px !important;
display: inline-block;

View file

@ -62,16 +62,16 @@ export interface BadgeMetric {
width?: number;
}
export interface StatRenderer {
export interface GroupStatsItem {
title: string;
renderer?: JSX.Element;
component?: JSX.Element;
badge?: BadgeMetric;
}
export type GroupStatsRenderer<T> = (
export type GetGroupStats<T> = (
selectedGroup: string,
fieldBucket: RawBucket<T>
) => StatRenderer[];
) => GroupStatsItem[];
export type GroupPanelRenderer<T> = (
selectedGroup: string,

View file

@ -31,7 +31,7 @@ export interface UseGrouping<T> {
*/
type StaticGroupingProps<T> = Pick<
GroupingProps<T>,
'groupPanelRenderer' | 'groupStatsRenderer' | 'onGroupToggle' | 'unit' | 'groupsUnit'
'groupPanelRenderer' | 'getGroupStats' | 'onGroupToggle' | 'unit' | 'groupsUnit'
>;
/** Type for dynamic grouping component props where T is the consumer `GroupingAggregation`

View file

@ -50,6 +50,8 @@
"@kbn/alerting-types/*": ["packages/kbn-alerting-types/*"],
"@kbn/alerts-as-data-utils": ["packages/kbn-alerts-as-data-utils"],
"@kbn/alerts-as-data-utils/*": ["packages/kbn-alerts-as-data-utils/*"],
"@kbn/alerts-grouping": ["packages/kbn-alerts-grouping"],
"@kbn/alerts-grouping/*": ["packages/kbn-alerts-grouping/*"],
"@kbn/alerts-restricted-fixtures-plugin": ["x-pack/test/alerting_api_integration/common/plugins/alerts_restricted"],
"@kbn/alerts-restricted-fixtures-plugin/*": ["x-pack/test/alerting_api_integration/common/plugins/alerts_restricted/*"],
"@kbn/alerts-ui-shared": ["packages/kbn-alerts-ui-shared"],

View file

@ -77,13 +77,6 @@ export const CloudSecurityGrouping = ({
data-test-subj={CSP_GROUPING}
css={css`
position: relative;
&& [data-test-subj='group-stats'] > .euiFlexItem:last-child {
display: none;
}
&& [data-test-subj='group-stats'] > .euiFlexItem:not(:first-child) > span {
border-right: none;
margin-right: 0;
}
`}
>
{groupSelectorComponent && (

View file

@ -8,7 +8,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { isNoneGroup, useGrouping } from '@kbn/grouping';
import * as uuid from 'uuid';
import type { DataView } from '@kbn/data-views-plugin/common';
import { GroupOption, GroupPanelRenderer, GroupStatsRenderer } from '@kbn/grouping/src';
import { GroupOption, GroupPanelRenderer, GetGroupStats } from '@kbn/grouping/src';
import { useUrlQuery } from '../../common/hooks/use_url_query';
@ -28,7 +28,7 @@ export const useCloudSecurityGrouping = ({
getDefaultQuery,
unit,
groupPanelRenderer,
groupStatsRenderer,
getGroupStats,
groupingLevel,
groupingLocalStorageKey,
maxGroupingLevels = DEFAULT_MAX_GROUPING_LEVELS,
@ -40,7 +40,7 @@ export const useCloudSecurityGrouping = ({
getDefaultQuery: (params: FindingsBaseURLQuery) => FindingsBaseURLQuery;
unit: (count: number) => string;
groupPanelRenderer?: GroupPanelRenderer<any>;
groupStatsRenderer?: GroupStatsRenderer<any>;
getGroupStats?: GetGroupStats<any>;
groupingLevel?: number;
groupingLocalStorageKey: string;
maxGroupingLevels?: number;
@ -60,7 +60,7 @@ export const useCloudSecurityGrouping = ({
componentProps: {
unit,
groupPanelRenderer,
groupStatsRenderer,
getGroupStats,
groupsUnit,
},
defaultGroupingOptions,

View file

@ -41,7 +41,7 @@ const SubGrouping = ({
setActivePageIndex,
} = useLatestFindingsGrouping({
groupPanelRenderer,
groupStatsRenderer,
getGroupStats: groupStatsRenderer,
groupingLevel,
selectedGroup,
groupFilters: parentGroupFilters ? JSON.parse(parentGroupFilters) : [],
@ -76,7 +76,7 @@ const SubGrouping = ({
export const LatestFindingsContainer = () => {
const { grouping, isFetching, urlQuery, setUrlQuery, onResetFilters, error, isEmptyResults } =
useLatestFindingsGrouping({ groupPanelRenderer, groupStatsRenderer });
useLatestFindingsGrouping({ groupPanelRenderer, getGroupStats: groupStatsRenderer });
const renderChildComponent = ({
level,

View file

@ -14,14 +14,14 @@ import {
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { GroupPanelRenderer, RawBucket, StatRenderer } from '@kbn/grouping/src';
import { GroupPanelRenderer, GroupStatsItem, RawBucket } from '@kbn/grouping/src';
import React from 'react';
import { i18n } from '@kbn/i18n';
import { FINDINGS_GROUPING_OPTIONS } from '../../../common/constants';
import {
NullGroup,
LoadingGroup,
firstNonNullValue,
LoadingGroup,
NullGroup,
} from '../../../components/cloud_security_grouping';
import { getAbbreviatedNumber } from '../../../common/utils/get_abbreviated_number';
import { CISBenchmarkIcon } from '../../../components/cis_benchmark_icon';
@ -221,21 +221,17 @@ const ComplianceBar = React.memo(ComplianceBarComponent);
export const groupStatsRenderer = (
selectedGroup: string,
bucket: RawBucket<FindingsGroupingAggregation>
): StatRenderer[] => {
const defaultBadges = [
{
title: i18n.translate('xpack.csp.findings.grouping.stats.badges.findings', {
defaultMessage: 'Findings',
}),
renderer: <FindingsCount bucket={bucket} />,
},
{
title: i18n.translate('xpack.csp.findings.grouping.stats.badges.compliance', {
defaultMessage: 'Compliance',
}),
renderer: <ComplianceBar bucket={bucket} />,
},
];
return defaultBadges;
};
): GroupStatsItem[] => [
{
title: i18n.translate('xpack.csp.findings.grouping.stats.badges.findings', {
defaultMessage: 'Findings',
}),
component: <FindingsCount bucket={bucket} />,
},
{
title: i18n.translate('xpack.csp.findings.grouping.stats.badges.compliance', {
defaultMessage: 'Compliance',
}),
component: <ComplianceBar bucket={bucket} />,
},
];

View file

@ -8,7 +8,7 @@ import { getGroupingQuery } from '@kbn/grouping';
import {
GroupingAggregation,
GroupPanelRenderer,
GroupStatsRenderer,
GetGroupStats,
isNoneGroup,
NamedAggregation,
parseGroupingQuery,
@ -130,13 +130,13 @@ export const isFindingsRootGroupingAggregation = (
*/
export const useLatestFindingsGrouping = ({
groupPanelRenderer,
groupStatsRenderer,
getGroupStats,
groupingLevel = 0,
groupFilters = [],
selectedGroup,
}: {
groupPanelRenderer?: GroupPanelRenderer<FindingsGroupingAggregation>;
groupStatsRenderer?: GroupStatsRenderer<FindingsGroupingAggregation>;
getGroupStats?: GetGroupStats<FindingsGroupingAggregation>;
groupingLevel?: number;
groupFilters?: Filter[];
selectedGroup?: string;
@ -165,7 +165,7 @@ export const useLatestFindingsGrouping = ({
getDefaultQuery,
unit: FINDINGS_UNIT,
groupPanelRenderer,
groupStatsRenderer,
getGroupStats,
groupingLocalStorageKey: LOCAL_STORAGE_FINDINGS_GROUPING_KEY,
groupingLevel,
groupsUnit: MISCONFIGURATIONS_GROUPS_UNIT,

View file

@ -8,7 +8,7 @@ import { getGroupingQuery } from '@kbn/grouping';
import {
GroupingAggregation,
GroupPanelRenderer,
GroupStatsRenderer,
GetGroupStats,
isNoneGroup,
NamedAggregation,
parseGroupingQuery,
@ -109,13 +109,13 @@ export const isVulnerabilitiesRootGroupingAggregation = (
*/
export const useLatestVulnerabilitiesGrouping = ({
groupPanelRenderer,
groupStatsRenderer,
getGroupStats,
groupingLevel = 0,
groupFilters = [],
selectedGroup,
}: {
groupPanelRenderer?: GroupPanelRenderer<VulnerabilitiesGroupingAggregation>;
groupStatsRenderer?: GroupStatsRenderer<VulnerabilitiesGroupingAggregation>;
getGroupStats?: GetGroupStats<VulnerabilitiesGroupingAggregation>;
groupingLevel?: number;
groupFilters?: Filter[];
selectedGroup?: string;
@ -144,7 +144,7 @@ export const useLatestVulnerabilitiesGrouping = ({
getDefaultQuery,
unit: VULNERABILITIES_UNIT,
groupPanelRenderer,
groupStatsRenderer,
getGroupStats,
groupingLocalStorageKey: LOCAL_STORAGE_VULNERABILITIES_GROUPING_KEY,
groupingLevel,
groupsUnit: VULNERABILITIES_GROUPS_UNIT,

View file

@ -42,7 +42,7 @@ export const LatestVulnerabilitiesContainer = () => {
setActivePageIndex,
} = useLatestVulnerabilitiesGrouping({
groupPanelRenderer,
groupStatsRenderer,
getGroupStats: groupStatsRenderer,
groupingLevel,
selectedGroup,
groupFilters: parentGroupFilters ? JSON.parse(parentGroupFilters) : [],
@ -138,7 +138,7 @@ export const LatestVulnerabilitiesContainer = () => {
};
const { grouping, isFetching, urlQuery, setUrlQuery, onResetFilters, error, isEmptyResults } =
useLatestVulnerabilitiesGrouping({ groupPanelRenderer, groupStatsRenderer });
useLatestVulnerabilitiesGrouping({ groupPanelRenderer, getGroupStats: groupStatsRenderer });
if (error || isEmptyResults) {
return (

View file

@ -14,7 +14,7 @@ import {
useEuiTheme,
} from '@elastic/eui';
import { css } from '@emotion/react';
import { GroupPanelRenderer, RawBucket, StatRenderer } from '@kbn/grouping/src';
import { GroupPanelRenderer, GroupStatsItem, RawBucket } from '@kbn/grouping/src';
import React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { getCloudProviderNameFromAbbreviation } from '../../../common/utils/helpers';
@ -195,17 +195,13 @@ const SeverityStats = React.memo(SeverityStatsComponent);
export const groupStatsRenderer = (
selectedGroup: string,
bucket: RawBucket<VulnerabilitiesGroupingAggregation>
): StatRenderer[] => {
const defaultBadges = [
{
title: VULNERABILITIES,
renderer: <VulnerabilitiesCount bucket={bucket} />,
},
{
title: '',
renderer: <SeverityStats bucket={bucket} />,
},
];
return defaultBadges;
};
): GroupStatsItem[] => [
{
title: VULNERABILITIES,
component: <VulnerabilitiesCount bucket={bucket} />,
},
{
title: '',
component: <SeverityStats bucket={bucket} />,
},
];

View file

@ -199,6 +199,23 @@ const bucketAggsTempsSchemas: t.Type<BucketAggsSchemas> = t.exact(
]),
})
),
bucket_sort: t.exact(
t.partial({
sort: sortSchema,
from: t.number,
size: t.number,
gap_policy: t.union([
t.literal('skip'),
t.literal('insert_zeros'),
t.literal('keep_values'),
]),
})
),
value_count: t.exact(
t.partial({
field: t.string,
})
),
})
);

View file

@ -45,11 +45,6 @@ import { FieldDescriptor, IndexPatternsFetcher } from '@kbn/data-plugin/server';
import { isEmpty } from 'lodash';
import { RuleTypeRegistry } from '@kbn/alerting-plugin/server/types';
import { TypeOf } from 'io-ts';
import {
MAX_ALERTS_GROUPING_QUERY_SIZE,
MAX_ALERTS_PAGES,
MAX_PAGINATED_ALERTS,
} from './constants';
import { BrowserFields } from '../../common';
import { alertAuditEvent, operationAlertAuditActionMap } from './audit_events';
import {
@ -63,6 +58,11 @@ import { IRuleDataService } from '../rule_data_plugin_service';
import { getAuthzFilter, getSpacesFilter } from '../lib';
import { fieldDescriptorToBrowserFieldMapper } from './browser_fields';
import { alertsAggregationsSchema } from '../../common/types';
import {
MAX_ALERTS_GROUPING_QUERY_SIZE,
MAX_ALERTS_PAGES,
MAX_PAGINATED_ALERTS,
} from './constants';
// TODO: Fix typings https://github.com/elastic/kibana/issues/101776
type NonNullableProps<Obj extends {}, Props extends keyof Obj> = Omit<Obj, Props> & {
@ -1089,7 +1089,7 @@ export class AlertsClient {
return this.find({
featureIds,
aggs: {
groupByField: {
groupByFields: {
terms: {
field: 'groupByField',
size: MAX_ALERTS_GROUPING_QUERY_SIZE,

View file

@ -97,7 +97,7 @@ describe('getGroupAggregations()', () => {
expect(alertsClient.find).toHaveBeenCalledWith({
featureIds,
aggs: {
groupByField: {
groupByFields: {
terms: {
field: 'groupByField',
size: MAX_ALERTS_GROUPING_QUERY_SIZE,

View file

@ -11,10 +11,10 @@ import { transformError } from '@kbn/securitysolution-es-utils';
import { PositiveInteger } from '@kbn/securitysolution-io-ts-types';
import { SortOptions } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { bucketAggsSchemas, metricsAggsSchemas } from '../../common/types';
import { RacRequestHandlerContext } from '../types';
import { BASE_RAC_ALERTS_API_PATH } from '../../common/constants';
import { buildRouteValidation } from './utils/route_validation';
import { bucketAggsSchemas, metricsAggsSchemas } from '../../common/types';
export const findAlertsByQueryRoute = (router: IRouter<RacRequestHandlerContext>) => {
router.post(
@ -32,7 +32,7 @@ export const findAlertsByQueryRoute = (router: IRouter<RacRequestHandlerContext>
size: t.union([PositiveInteger, t.undefined]),
sort: t.union([t.array(t.object), t.undefined]),
track_total_hits: t.union([t.boolean, t.undefined]),
_source: t.union([t.array(t.string), t.undefined]),
_source: t.union([t.array(t.string), t.boolean, t.undefined]),
})
)
),
@ -67,7 +67,7 @@ export const findAlertsByQueryRoute = (router: IRouter<RacRequestHandlerContext>
size,
sort: sort as SortOptions[],
track_total_hits,
_source,
_source: _source as false | string[],
});
if (alerts == null) {
return response.notFound({

View file

@ -104,7 +104,7 @@ const GroupedAlertsTableComponent: React.FC<AlertsTableComponentProps> = (props)
const { getGrouping, selectedGroups, setSelectedGroups } = useGrouping({
componentProps: {
groupPanelRenderer: renderGroupPanel,
groupStatsRenderer: getStats,
getGroupStats: getStats,
onGroupToggle,
unit: defaultUnit,
},

View file

@ -13,8 +13,8 @@ import type { GroupingAggregation } from '@kbn/grouping';
import { isNoneGroup } from '@kbn/grouping';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import type { DynamicGroupingProps } from '@kbn/grouping/src';
import type { TableIdLiteral } from '@kbn/securitysolution-data-table';
import { parseGroupingQuery } from '@kbn/grouping/src';
import type { TableIdLiteral } from '@kbn/securitysolution-data-table';
import type { RunTimeMappings } from '../../../sourcerer/store/model';
import { combineQueries } from '../../../common/lib/kuery';
import { SourcererScopeName } from '../../../sourcerer/store/model';

View file

@ -7,7 +7,7 @@
import { EuiIcon } from '@elastic/eui';
import React from 'react';
import type { RawBucket, StatRenderer } from '@kbn/grouping';
import type { RawBucket, GroupStatsItem } from '@kbn/grouping';
import type { AlertsGroupingAggregation } from './types';
import * as i18n from '../translations';
@ -67,7 +67,7 @@ const multiSeverity = (
export const getStats = (
selectedGroup: string,
bucket: RawBucket<AlertsGroupingAggregation>
): StatRenderer[] => {
): GroupStatsItem[] => {
const singleSeverityComponent =
bucket.severitiesSubAggregation?.buckets && bucket.severitiesSubAggregation?.buckets?.length
? getSeverity(bucket.severitiesSubAggregation?.buckets[0].key.toString())

View file

@ -3313,6 +3313,10 @@
version "0.0.0"
uid ""
"@kbn/alerts-grouping@link:packages/kbn-alerts-grouping":
version "0.0.0"
uid ""
"@kbn/alerts-restricted-fixtures-plugin@link:x-pack/test/alerting_api_integration/common/plugins/alerts_restricted":
version "0.0.0"
uid ""