mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
0be5528f21
commit
f99f83428c
60 changed files with 3023 additions and 162 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
3
packages/kbn-alerts-grouping/README.md
Normal file
3
packages/kbn-alerts-grouping/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/alerts-grouping
|
||||
|
||||
Platform components to create hierarchical alerts grouping UIs
|
11
packages/kbn-alerts-grouping/index.ts
Normal file
11
packages/kbn-alerts-grouping/index.ts
Normal 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';
|
14
packages/kbn-alerts-grouping/jest.config.js
Normal file
14
packages/kbn-alerts-grouping/jest.config.js
Normal 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'],
|
||||
};
|
5
packages/kbn-alerts-grouping/kibana.jsonc
Normal file
5
packages/kbn-alerts-grouping/kibana.jsonc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-browser",
|
||||
"id": "@kbn/alerts-grouping",
|
||||
"owner": "@elastic/response-ops"
|
||||
}
|
6
packages/kbn-alerts-grouping/package.json
Normal file
6
packages/kbn-alerts-grouping/package.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@kbn/alerts-grouping",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"license": "SSPL-1.0 OR Elastic License 2.0"
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
285
packages/kbn-alerts-grouping/src/components/alerts_grouping.tsx
Normal file
285
packages/kbn-alerts-grouping/src/components/alerts_grouping.tsx
Normal 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>
|
||||
);
|
||||
});
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
);
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
||||
};
|
11
packages/kbn-alerts-grouping/src/index.ts
Normal file
11
packages/kbn-alerts-grouping/src/index.ts
Normal 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';
|
|
@ -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'],
|
||||
},
|
||||
};
|
1382
packages/kbn-alerts-grouping/src/mocks/grouping_query.mock.ts
Normal file
1382
packages/kbn-alerts-grouping/src/mocks/grouping_query.mock.ts
Normal file
File diff suppressed because it is too large
Load diff
98
packages/kbn-alerts-grouping/src/types.ts
Normal file
98
packages/kbn-alerts-grouping/src/types.ts
Normal 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;
|
||||
};
|
||||
}
|
28
packages/kbn-alerts-grouping/tsconfig.json
Normal file
28
packages/kbn-alerts-grouping/tsconfig.json
Normal 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",
|
||||
]
|
||||
}
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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'],
|
||||
};
|
||||
|
|
|
@ -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';
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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`
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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 && (
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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} />,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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} />,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -97,7 +97,7 @@ describe('getGroupAggregations()', () => {
|
|||
expect(alertsClient.find).toHaveBeenCalledWith({
|
||||
featureIds,
|
||||
aggs: {
|
||||
groupByField: {
|
||||
groupByFields: {
|
||||
terms: {
|
||||
field: 'groupByField',
|
||||
size: MAX_ALERTS_GROUPING_QUERY_SIZE,
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue