diff --git a/src/platform/packages/shared/kbn-grouping/src/hooks/use_get_group_selector.tsx b/src/platform/packages/shared/kbn-grouping/src/hooks/use_get_group_selector.tsx index 1d0b290d83b17..3b4aaeeb10d2f 100644 --- a/src/platform/packages/shared/kbn-grouping/src/hooks/use_get_group_selector.tsx +++ b/src/platform/packages/shared/kbn-grouping/src/hooks/use_get_group_selector.tsx @@ -41,7 +41,7 @@ export interface UseGetGroupSelectorArgs { interface UseGetGroupSelectorStateless extends Pick< UseGetGroupSelectorArgs, - 'defaultGroupingOptions' | 'groupingId' | 'fields' | 'maxGroupingLevels' + 'defaultGroupingOptions' | 'groupingId' | 'fields' | 'maxGroupingLevels' | 'title' > { onGroupChange: (selectedGroups: string[]) => void; } @@ -57,6 +57,7 @@ export const useGetGroupSelectorStateless = ({ fields, onGroupChange, maxGroupingLevels, + title, }: UseGetGroupSelectorStateless) => { const onChange = useCallback( (groupSelection: string) => { @@ -76,10 +77,11 @@ export const useGetGroupSelectorStateless = ({ fields, maxGroupingLevels, options: defaultGroupingOptions, + title, }} /> ); - }, [groupingId, fields, maxGroupingLevels, defaultGroupingOptions, onChange]); + }, [groupingId, onChange, fields, maxGroupingLevels, defaultGroupingOptions, title]); }; export const useGetGroupSelector = ({ diff --git a/src/platform/plugins/shared/discover/public/application/context/components/grouping/constants.ts b/src/platform/plugins/shared/discover/public/application/context/components/grouping/constants.ts new file mode 100644 index 0000000000000..73b38cd978893 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/context/components/grouping/constants.ts @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { GroupOption } from '@kbn/grouping'; +import { i18n } from '@kbn/i18n'; +import { ALERT_RULE_NAME } from '@kbn/rule-data-utils'; + +export const DEFAULT_PAGE_SIZE = 25; +export const DEFAULT_PAGE_INDEX = 0; +export const MAX_GROUPING_LEVELS = 3; + +export const getDefaultGroupingOptionsPerDataView = (dataViewName: string): GroupOption[] => { + switch (dataViewName) { + case '.alerts-security.alerts-default,apm-*-transaction*,auditbeat-*,endgame-*,filebeat-*,logs-*,packetbeat-*,traces-apm*,winlogbeat-*,-*elastic-cloud-logs-*': + return [ + { + label: i18n.translate('discover.grouping.ungrouped.label', { + defaultMessage: 'Rule name', + }), + key: ALERT_RULE_NAME, + }, + { + label: i18n.translate('discover.grouping.ungrouped.label', { + defaultMessage: 'User name', + }), + key: 'user.name', + }, + { + label: i18n.translate('discover.grouping.ungrouped.label', { + defaultMessage: 'Host name', + }), + key: 'host.name', + }, + ]; + case '.kibana-event-log-*': + return [ + { + label: i18n.translate('discover.grouping.ungrouped.label', { + defaultMessage: 'Event action', + }), + key: 'event.action', + }, + { + label: i18n.translate('discover.grouping.ungrouped.label', { + defaultMessage: 'Event category', + }), + key: 'event.category', + }, + ]; + default: + return []; + } +}; diff --git a/src/platform/plugins/shared/discover/public/application/context/components/grouping/data_grouping.tsx b/src/platform/plugins/shared/discover/public/application/context/components/grouping/data_grouping.tsx new file mode 100644 index 0000000000000..767fc05842475 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/context/components/grouping/data_grouping.tsx @@ -0,0 +1,234 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", 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 { GroupOption, isNoneGroup, useGrouping } from '@kbn/grouping'; +import { isEqual } from 'lodash/fp'; +import { i18n } from '@kbn/i18n'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { DataGroupingLevel, DataGroupingLevelProps } from './data_grouping_level'; +import type { DataGroupingProps, BaseDataGroupAggregations, DataByGroupingAgg } from './types'; +import { DEFAULT_PAGE_INDEX, DEFAULT_PAGE_SIZE, MAX_GROUPING_LEVELS } from './constants'; +import { DataGroupingContextProvider, useDataGroupingState } from './data_grouping_context'; +import { renderGroupPanel } from './group_panels/render_group_panel'; +import { getGroupStats } from './get_group_stats'; + +/** + * Handles recursive rendering of grouping levels + */ +const NextLevel = ({ + level, + selectedGroups, + children, + parentGroupingFilter, + groupingFilters, + getLevel, +}: Pick, '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 queryClient = new QueryClient(); + +const DataGroupingInternal = (props: DataGroupingProps) => { + const { groupingId, dataView, stateContainer, defaultGroupingOptions, defaultFilters, children } = + props; + const { grouping, updateGrouping } = useDataGroupingState(groupingId); + + const globalState = stateContainer.globalState.get(); + const globalFilters = globalState?.filters; + const globalQuery = globalState?.query; + const [pageSize, setPageSize] = useLocalStorage( + `grouping-table-${groupingId}`, + Array(MAX_GROUPING_LEVELS).fill(DEFAULT_PAGE_SIZE) + ) as [number[], Dispatch>, () => void]; + + const onOptionsChange = useCallback( + (options: GroupOption[]) => { + // useGrouping > useDataGroupingState 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('dataGrouping.unit', { + values: { totalCount }, + defaultMessage: `{totalCount, plural, =1 {document} other {documents}}`, + }), + }, + defaultGroupingOptions, + fields: dataView?.fields ?? [], + groupingId, + maxGroupingLevels: MAX_GROUPING_LEVELS, + onOptionsChange, + title: 'Group documents by', + }); + + 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( + 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 ( + + {...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) => ( + + {children} + + )} + + ); + }, + [children, getGrouping, pageIndex, pageSize, props, selectedGroups, setPageVar] + ); + + if (!dataView) { + return null; + } + + return getLevel(0, selectedGroups[0]); +}; + +const typedMemo: (c: T) => T = memo; + +export const DataGrouping = typedMemo( + (props: DataGroupingProps) => { + return ( + + + + + + ); + } +); diff --git a/src/platform/plugins/shared/discover/public/application/context/components/grouping/data_grouping_context.tsx b/src/platform/plugins/shared/discover/public/application/context/components/grouping/data_grouping_context.tsx new file mode 100644 index 0000000000000..8c04c5d9b71bd --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/context/components/grouping/data_grouping_context.tsx @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React, { + createContext, + Dispatch, + PropsWithChildren, + SetStateAction, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import { DataGroupingState, GroupModel } from './types'; + +const initialActiveGroups = ['none']; + +export const DataGroupingContext = createContext({ + groupingState: {} as DataGroupingState, + setGroupingState: (() => {}) as Dispatch>, +}); + +export const DataGroupingContextProvider = ({ children }: PropsWithChildren<{}>) => { + const [groupingState, setGroupingState] = useState({}); + return ( + ({ groupingState, setGroupingState }), + [groupingState, setGroupingState] + )} + > + {children} + + ); +}; + +export const useDataGroupingState = (groupingId: string) => { + const { groupingState, setGroupingState } = useContext(DataGroupingContext); + const updateGrouping = useCallback( + (groupModel: Partial | null) => { + if (groupModel === null) { + setGroupingState((prevState) => { + const newState = { ...prevState }; + delete newState[groupingId]; + return newState; + }); + return; + } + setGroupingState((prevState) => ({ + ...prevState, + [groupingId]: { + 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, + }; +}; diff --git a/src/platform/plugins/shared/discover/public/application/context/components/grouping/data_grouping_level.tsx b/src/platform/plugins/shared/discover/public/application/context/components/grouping/data_grouping_level.tsx new file mode 100644 index 0000000000000..606a18d741fc6 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/context/components/grouping/data_grouping_level.tsx @@ -0,0 +1,164 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", 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, ParsedGroupingAggregation } from '@kbn/grouping/src'; +import { parseGroupingQuery } from '@kbn/grouping/src'; +import { + AggregationsAggregate, + AggregationsLongRareTermsBucketKeys, +} from '@elastic/elasticsearch/lib/api/types'; +import { BaseDataGroupAggregations, DataGroupingProps } from './types'; +import { useGetDataGroupAggregationsQuery } from './hooks/use_get_alerts_group_aggregations_query'; +import { getDataGroupingQuery } from './query_builder'; + +// ReactElement> +export interface DataGroupingLevelProps< + T extends BaseDataGroupAggregations = BaseDataGroupAggregations +> extends DataGroupingProps { + getGrouping: ( + props: Omit, '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 a data grouping level + */ +const typedMemo: (c: T) => T = memo; +export const DataGroupingLevel = typedMemo( + ({ + stateContainer, + getGrouping, + groupingLevel, + loading = false, + onGroupClose, + pageIndex, + pageSize, + parentGroupingFilter, + children, + selectedGroup, + setPageIndex, + setPageSize, + takeActionItems, + dataView, + defaultFilters = DEFAULT_FILTERS, + services: { data, notifications }, + }: DataGroupingLevelProps) => { + const globalState = stateContainer.globalState.get(); + const globalFilters = globalState?.filters; + const to = globalState?.time?.to; + const from = globalState?.time?.from; + const globalQuery = globalState?.query; + 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(() => `data-grouping-level-${uuidv4()}`, []); + + const aggregationsQuery = getDataGroupingQuery({ + additionalFilters: filters, + from: from ?? '', + selectedGroup, + pageIndex, + uniqueValue, + pageSize, + to: to ?? '', + }); + + const { data: result, isLoading: isLoadingGroups } = useGetDataGroupAggregationsQuery< + GroupingAggregation + >({ + data, + aggregationsQuery, + dataView, + toasts: notifications.toasts, + enabled: aggregationsQuery && !isNoneGroup([selectedGroup]), + }); + + const queriedGroup = useMemo( + () => (!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, + result?.rawResponse?.aggregations as GroupingAggregation< + AggregationsLongRareTermsBucketKeys & { + [property: string]: string | number | AggregationsAggregate; + } + > + ), + [result?.rawResponse?.aggregations, queriedGroup, selectedGroup, uniqueValue] + ); + + return useMemo( + () => + getGrouping({ + activePage: pageIndex, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: (aggs ?? {}) as ParsedGroupingAggregation, + 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, + ] + ); + } +); diff --git a/src/platform/plugins/shared/discover/public/application/context/components/grouping/get_aggregations_by_grouping_field.ts b/src/platform/plugins/shared/discover/public/application/context/components/grouping/get_aggregations_by_grouping_field.ts new file mode 100644 index 0000000000000..731690713d05e --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/context/components/grouping/get_aggregations_by_grouping_field.ts @@ -0,0 +1,49 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { NamedAggregation } from '@kbn/grouping'; +import { ALERT_INSTANCE_ID, ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils'; + +/** + * Resolves an array of aggregations for a given grouping field + */ +export const getAggregationsByGroupingField = (field: string): NamedAggregation[] => { + switch (field) { + case ALERT_RULE_NAME: + return [ + { + sourceCountAggregation: { + cardinality: { + field: ALERT_INSTANCE_ID, + }, + }, + }, + ]; + case '': + return [ + { + docsCountAggregation: { + cardinality: { + field: ALERT_RULE_UUID, + }, + }, + }, + ]; + default: + return [ + { + docsCountAggregation: { + cardinality: { + field: ALERT_RULE_UUID, + }, + }, + }, + ]; + } +}; diff --git a/src/platform/plugins/shared/discover/public/application/context/components/grouping/get_group_stats.tsx b/src/platform/plugins/shared/discover/public/application/context/components/grouping/get_group_stats.tsx new file mode 100644 index 0000000000000..f729893f4c4bc --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/context/components/grouping/get_group_stats.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { GetGroupStats } from '@kbn/grouping/src'; +import { ALERT_INSTANCE_ID, ALERT_RULE_NAME } from '@kbn/rule-data-utils'; +import { DataByGroupingAgg } from './types'; + +/** + * A function that given the current grouping field and aggregation results, returns an array of + * stat items to be rendered in the group panel + */ +export const getGroupStats: GetGroupStats = (field, bucket) => { + const defaultBadges = [ + { + title: 'Documents:', + badge: { + value: bucket.doc_count, + width: 50, + }, + }, + ]; + + switch (field) { + case ALERT_RULE_NAME: + return [ + { + title: 'Sources:', + badge: { + value: bucket.sourceCountAggregation?.value ?? 0, + width: 50, + }, + }, + ...defaultBadges, + ]; + case ALERT_INSTANCE_ID: + return [ + { + title: 'Docs:', + badge: { + value: bucket.docsCountAggregation?.value ?? 0, + width: 50, + }, + }, + ...defaultBadges, + ]; + } + return [...defaultBadges]; +}; diff --git a/src/platform/plugins/shared/discover/public/application/context/components/grouping/get_grouping_settings_selector.ts b/src/platform/plugins/shared/discover/public/application/context/components/grouping/get_grouping_settings_selector.ts new file mode 100644 index 0000000000000..8b7222e391164 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/context/components/grouping/get_grouping_settings_selector.ts @@ -0,0 +1,47 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { useMemo, useCallback } from 'react'; +import { useGetGroupSelectorStateless } from '@kbn/grouping/src/hooks/use_get_group_selector'; +import { DataView } from '@kbn/data-views-plugin/public'; +import { useDataGroupingState } from './data_grouping_context'; + +interface GetPersistentControlsParams { + groupingId: string; + maxGroupingLevels?: number; + dataView: DataView; +} + +export const getGroupingSettingsSelectorHook = + ({ groupingId, dataView, maxGroupingLevels = 3 }: GetPersistentControlsParams) => + () => { + const { grouping, updateGrouping } = useDataGroupingState(groupingId); + + const onGroupChange = useCallback( + (selectedGroups: string[]) => { + updateGrouping({ + activeGroups: + grouping.activeGroups?.filter((g) => g !== 'none').concat(selectedGroups) ?? [], + }); + }, + [grouping, updateGrouping] + ); + + const groupSelector = useGetGroupSelectorStateless({ + groupingId, + onGroupChange, + fields: dataView?.fields ?? [], + defaultGroupingOptions: + grouping.options?.filter((option) => !grouping.activeGroups.includes(option.key)) ?? [], + maxGroupingLevels, + title: 'Group documents by', + }); + + return useMemo(() => groupSelector, [groupSelector]); + }; diff --git a/src/platform/plugins/shared/discover/public/application/context/components/grouping/group_panels/group_panels.tsx b/src/platform/plugins/shared/discover/public/application/context/components/grouping/group_panels/group_panels.tsx new file mode 100644 index 0000000000000..19186973a0b9a --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/context/components/grouping/group_panels/group_panels.tsx @@ -0,0 +1,62 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { EuiFlexGroup, EuiIconTip, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { ungrouped } from '../translations'; + +export const RuleNameGroupContent = React.memo<{ + ruleName: string; + tags?: string[] | undefined; +}>(({ ruleName, tags }) => { + return ( +
+ + + +
{ruleName}
+
+
+
+
+ ); +}); +RuleNameGroupContent.displayName = 'RuleNameGroup'; + +export const InstanceIdGroupContent = React.memo<{ + instanceId?: string; +}>(({ instanceId }) => { + const isUngrouped = instanceId === '*'; + return ( +
+ + + +
+ {isUngrouped ? ungrouped : instanceId ?? '--'} +   + {isUngrouped && ( + + } + /> + )} +
+
+
+
+
+ ); +}); +InstanceIdGroupContent.displayName = 'InstanceIdGroupContent'; diff --git a/src/platform/plugins/shared/discover/public/application/context/components/grouping/group_panels/render_group_panel.tsx b/src/platform/plugins/shared/discover/public/application/context/components/grouping/group_panels/render_group_panel.tsx new file mode 100644 index 0000000000000..50c0f7d2f45b3 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/context/components/grouping/group_panels/render_group_panel.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { isArray } from 'lodash/fp'; +import { firstNonNullValue, GroupPanelRenderer } from '@kbn/grouping/src'; +import { DataByGroupingAgg } from '../types'; +import { InstanceIdGroupContent, RuleNameGroupContent } from './group_panels'; + +/** + * Render function for the group panel header + */ +export const renderGroupPanel: GroupPanelRenderer = (selectedGroup, bucket) => { + switch (selectedGroup) { + case 'kibana.alert.rule.name': + return isArray(bucket.key) ? : undefined; + case 'kibana.alert.instance.id': + return ; + } +}; diff --git a/src/platform/plugins/shared/discover/public/application/context/components/grouping/hooks/use_get_alerts_group_aggregations_query.ts b/src/platform/plugins/shared/discover/public/application/context/components/grouping/hooks/use_get_alerts_group_aggregations_query.ts new file mode 100644 index 0000000000000..c173d3a953757 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/context/components/grouping/hooks/use_get_alerts_group_aggregations_query.ts @@ -0,0 +1,65 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { i18n } from '@kbn/i18n'; +import { useQuery } from '@tanstack/react-query'; +import { DataView } from '@kbn/data-views-plugin/public'; +import type { ToastsStart } from '@kbn/core-notifications-browser'; +import { lastValueFrom } from 'rxjs'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { GroupingQuery } from '@kbn/grouping/src'; + +export interface UseGetDataGroupAggregationsQueryProps { + data: DataPublicPluginStart; + dataView: DataView; + aggregationsQuery: GroupingQuery; + toasts: ToastsStart; + enabled?: boolean; +} + +export const useGetDataGroupAggregationsQuery = ({ + data, + dataView, + aggregationsQuery, + toasts, + enabled = true, +}: UseGetDataGroupAggregationsQueryProps) => { + const onErrorFn = (error: Error) => { + if (error) { + toasts.addDanger( + i18n.translate( + 'discover.grouping.hooks.useGetAlertsGroupAggregationsQuery.unableToFetchAlertsGroupingAggregations', + { + defaultMessage: 'Unable to fetch data grouping aggregations', + } + ) + ); + } + }; + + return useQuery({ + queryKey: ['getDataGroupAggregations', JSON.stringify(aggregationsQuery)], + queryFn: () => + lastValueFrom( + data.search.search({ + params: { + index: dataView.getIndexPattern(), + size: 0, + track_total_hits: true, + body: { + ...aggregationsQuery, + }, + }, + }) + ), + onError: onErrorFn, + refetchOnWindowFocus: false, + enabled, + }); +}; diff --git a/src/platform/plugins/shared/discover/public/application/context/components/grouping/query_builder.ts b/src/platform/plugins/shared/discover/public/application/context/components/grouping/query_builder.ts new file mode 100644 index 0000000000000..396bf9f54a846 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/context/components/grouping/query_builder.ts @@ -0,0 +1,207 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { RuntimeFieldSpec, RuntimePrimitiveTypes } from '@kbn/data-views-plugin/common'; +import type { BoolQuery } from '@kbn/es-query'; +import type { NamedAggregation } from '@kbn/grouping'; +import { isNoneGroup, getGroupingQuery } from '@kbn/grouping'; + +type RunTimeMappings = + | Record & { type: RuntimePrimitiveTypes }> + | undefined; +interface DataGroupingQueryParams { + additionalFilters?: Array<{ + bool: BoolQuery; + }>; + from: string; + pageIndex: number; + pageSize: number; + runtimeMappings?: RunTimeMappings; + selectedGroup: string; + uniqueValue: string; + to: string; +} + +export const getDataGroupingQuery = ({ + additionalFilters, + from, + pageIndex, + pageSize, + runtimeMappings, + selectedGroup, + uniqueValue, + to, +}: DataGroupingQueryParams) => + getGroupingQuery({ + additionalFilters: additionalFilters ?? [], + from, + groupByField: selectedGroup, + statsAggregations: !isNoneGroup([selectedGroup]) + ? getAggregationsByGroupField(selectedGroup) + : [], + pageNumber: pageIndex * pageSize, + runtimeMappings, + uniqueValue, + size: pageSize, + sort: [{ unitsCount: { order: 'desc' } }], + to, + }); + +const getAggregationsByGroupField = (field: string): NamedAggregation[] => { + const aggMetrics: NamedAggregation[] = [ + { + unitsCount: { + cardinality: { + field: 'kibana.alert.uuid', + }, + }, + }, + ]; + switch (field) { + case 'kibana.alert.rule.name': + aggMetrics.push( + ...[ + { + description: { + terms: { + field: 'kibana.alert.rule.description', + size: 1, + }, + }, + }, + { + countSeveritySubAggregation: { + cardinality: { + field: 'kibana.alert.severity', + }, + }, + }, + { + severitiesSubAggregation: { + terms: { + field: 'kibana.alert.severity', + }, + }, + }, + { + usersCountAggregation: { + cardinality: { + field: 'user.name', + }, + }, + }, + { + hostsCountAggregation: { + cardinality: { + field: 'host.name', + }, + }, + }, + { + ruleTags: { + terms: { + field: 'kibana.alert.rule.tags', + }, + }, + }, + ] + ); + break; + case 'host.name': + aggMetrics.push( + ...[ + { + rulesCountAggregation: { + cardinality: { + field: 'kibana.alert.rule.rule_id', + }, + }, + }, + { + countSeveritySubAggregation: { + cardinality: { + field: 'kibana.alert.severity', + }, + }, + }, + { + severitiesSubAggregation: { + terms: { + field: 'kibana.alert.severity', + }, + }, + }, + { + usersCountAggregation: { + cardinality: { + field: 'user.name', + }, + }, + }, + ] + ); + break; + case 'user.name': + aggMetrics.push( + ...[ + { + docsCountAggregation: { + cardinality: { + field: 'kibana.alert.rule.uuid', + }, + }, + }, + ] + ); + break; + case 'source.ip': + aggMetrics.push( + ...[ + { + rulesCountAggregation: { + cardinality: { + field: 'kibana.alert.rule.rule_id', + }, + }, + }, + { + countSeveritySubAggregation: { + cardinality: { + field: 'kibana.alert.severity', + }, + }, + }, + { + severitiesSubAggregation: { + terms: { + field: 'kibana.alert.severity', + }, + }, + }, + { + hostsCountAggregation: { + cardinality: { + field: 'host.name', + }, + }, + }, + ] + ); + break; + default: + aggMetrics.push({ + rulesCountAggregation: { + cardinality: { + field: 'kibana.alert.rule.rule_id', + }, + }, + }); + } + return aggMetrics; +}; diff --git a/src/platform/plugins/shared/discover/public/application/context/components/grouping/translations.ts b/src/platform/plugins/shared/discover/public/application/context/components/grouping/translations.ts new file mode 100644 index 0000000000000..b114dc8094226 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/context/components/grouping/translations.ts @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { i18n } from '@kbn/i18n'; + +export const ungrouped = i18n.translate('discover.grouping.ungrouped.label', { + defaultMessage: 'Ungrouped', +}); diff --git a/src/platform/plugins/shared/discover/public/application/context/components/grouping/types.ts b/src/platform/plugins/shared/discover/public/application/context/components/grouping/types.ts new file mode 100644 index 0000000000000..7463e5265bb58 --- /dev/null +++ b/src/platform/plugins/shared/discover/public/application/context/components/grouping/types.ts @@ -0,0 +1,115 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { HttpSetup, NotificationsStart } from '@kbn/core/public'; +import { DataView } from '@kbn/data-views-plugin/public'; +import type { Filter } from '@kbn/es-query'; +import { DataViewsServicePublic } from '@kbn/data-views-plugin/public'; +import { GroupingProps, GroupOption } from '@kbn/grouping/src'; +import { ReactElement } from 'react'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { DiscoverStateContainer } from '../../../main/state_management/discover_state'; + +export interface BucketItem { + key: string; + doc_count: number; +} + +export interface DataByGroupingAgg extends Record { + groupByFields: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: BucketItem[]; + }; + docsCountAggregation?: { + value: number; + }; + sourceCountAggregation?: { + value: number; + }; + groupsCount: { + value: number; + }; + unitsCount: { + value: number; + }; +} + +export interface GroupModel { + activeGroups: string[]; + options?: Array<{ key: string; label: string }>; +} + +export interface DataGroupingState { + [groupingId: string]: GroupModel; +} + +export interface DataGroupingProps< + T extends BaseDataGroupAggregations = BaseDataGroupAggregations +> { + /** + * The leaf component that will be rendered in the grouping panels + */ + children: (groupingFilters: Filter[]) => ReactElement; + /** + * Default search filters + */ + defaultFilters?: Filter[]; + /** + * Items that will be rendered in the `Take Actions` menu + */ + takeActionItems?: GroupingProps['takeActionItems']; + /** + * The default fields available for grouping + */ + defaultGroupingOptions: GroupOption[]; + /** + * External loading state + */ + loading?: boolean; + /** + * ID used to retrieve the current grouping configuration from the state + */ + groupingId: string; + /** + * Services required for the grouping component + */ + services: { + notifications: NotificationsStart; + dataViews?: DataViewsServicePublic; + http: HttpSetup; + data: DataPublicPluginStart; + }; + dataView: DataView; + stateContainer: DiscoverStateContainer; + onGroupClose: () => void; +} + +export interface DataGroupAggregationBucket { + key: string; + doc_count: number; + isNullGroup?: boolean; + unitsCount?: { + value: number; + }; +} + +export interface BaseDataGroupAggregations { + groupByFields: { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: DataGroupAggregationBucket[]; + }; + groupsCount: { + value: number; + }; + unitsCount: { + value: number; + }; +} diff --git a/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_documents.tsx b/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_documents.tsx index ee00abbe5659d..97eee6dcb7f20 100644 --- a/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_documents.tsx +++ b/src/platform/plugins/shared/discover/public/application/main/components/layout/discover_documents.tsx @@ -73,6 +73,10 @@ import { useAdditionalCellActions, useProfileAccessor, } from '../../../../context_awareness'; +import { DataGrouping } from '../../../context/components/grouping/data_grouping'; +import { DataByGroupingAgg } from '../../../context/components/grouping/types'; +import { getDefaultGroupingOptionsPerDataView } from '../../../context/components/grouping/constants'; +import { getGroupingSettingsSelectorHook } from '../../../context/components/grouping/get_grouping_settings_selector'; const containerStyles = css` position: relative; @@ -370,6 +374,16 @@ function DiscoverDocumentsComponent({ [isDataLoading] ); + const groupSelector = getGroupingSettingsSelectorHook({ + groupingId: `DISCOVER_TABLE_CONFIG_ID_${dataView.id}`, + dataView, + }); + + const defaultGroupingOptions = useMemo( + () => getDefaultGroupingOptionsPerDataView(dataView.name), + [dataView] + ); + const renderCustomToolbarWithElements = useMemo( () => getRenderCustomToolbarWithElements({ @@ -405,64 +419,91 @@ function DiscoverDocumentsComponent({
- dataView={dataView} - loadingState={ - isDataLoading - ? DataLoadingState.loading - : isMoreDataLoading - ? DataLoadingState.loadingMore - : DataLoadingState.loaded - } - rows={rows} - sort={(sort as SortOrder[]) || []} - searchDescription={savedSearch.description} - searchTitle={savedSearch.title} - setExpandedDoc={setExpandedDoc} - showTimeCol={showTimeCol} - settings={grid} - onFilter={onAddFilter as DocViewFilterFn} - onSetColumns={onSetColumns} - onSort={onSort} - onResize={onResizeDataGrid} - configHeaderRowHeight={3} - headerRowHeightState={headerRowHeight} - onUpdateHeaderRowHeight={onUpdateHeaderRowHeight} - rowHeightState={rowHeight} - onUpdateRowHeight={onUpdateRowHeight} - isSortEnabled={true} - isPlainRecord={isEsqlMode} - isPaginationEnabled={!isEsqlMode} - rowsPerPageState={rowsPerPage ?? getDefaultRowsPerPage(services.uiSettings)} - onUpdateRowsPerPage={onUpdateRowsPerPage} - maxAllowedSampleSize={getMaxAllowedSampleSize(services.uiSettings)} - sampleSizeState={getAllowedSampleSize(sampleSizeState, services.uiSettings)} - onUpdateSampleSize={!isEsqlMode ? onUpdateSampleSize : undefined} - onFieldEdited={onFieldEdited} - configRowHeight={configRowHeight} - showMultiFields={uiSettings.get(SHOW_MULTIFIELDS)} - maxDocFieldsDisplayed={uiSettings.get(MAX_DOC_FIELDS_DISPLAYED)} - renderDocumentView={renderDocumentView} - renderCustomToolbar={renderCustomToolbarWithElements} - services={services} - totalHits={totalHits} - onFetchMoreRecords={onFetchMoreRecords} - externalCustomRenderers={cellRenderers} - customGridColumnsConfiguration={customGridColumnsConfiguration} - rowAdditionalLeadingControls={rowAdditionalLeadingControls} - additionalFieldGroups={additionalFieldGroups} - dataGridDensityState={density} - onUpdateDataGridDensity={onUpdateDensity} - onUpdateESQLQuery={stateContainer.actions.updateESQLQuery} - query={query} - cellActionsTriggerId={DISCOVER_CELL_ACTIONS_TRIGGER.id} - cellActionsMetadata={cellActionsMetadata} - cellActionsHandling="append" - /> + stateContainer={stateContainer} + groupingId={`DISCOVER_TABLE_CONFIG_ID_${dataView.id}`} + defaultGroupingOptions={defaultGroupingOptions} + onGroupClose={() => { + services.filterManager.setAppFilters([]); + }} + services={{ + notifications: services.notifications, + http: services.http, + data: services.data, + }} + > + {(groupingFilters) => { + services.filterManager.setAppFilters( + groupingFilters.filter((q) => q.meta.type === 'phrase') + ); + return ( + {groupSelector()} + ) : undefined + } + cellActionsHandling="append" + /> + ); + }} +
diff --git a/src/platform/plugins/shared/discover/tsconfig.json b/src/platform/plugins/shared/discover/tsconfig.json index ad6498e95171d..f5aaf054b02b6 100644 --- a/src/platform/plugins/shared/discover/tsconfig.json +++ b/src/platform/plugins/shared/discover/tsconfig.json @@ -98,7 +98,9 @@ "@kbn/logs-data-access-plugin", "@kbn/core-lifecycle-browser", "@kbn/esql-ast", - "@kbn/discover-shared-plugin" + "@kbn/discover-shared-plugin", + "@kbn/grouping", + "@kbn/core-notifications-browser" ], "exclude": ["target/**/*"] }