From 95efe7a388b735b53ec52560492c967b55c3ac48 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Wed, 11 Jun 2025 09:51:53 +0200 Subject: [PATCH] Alerting: List V2 - Grouped view filters (#106400) * Add group and namespace filtering for GMA rules * Add group and namespace filtering for DMA rules * Fix view mode handling * Preserve group and namespace filters when switching views * update "no rules" logic for Grafana managed rules * use groupFilter function for filter logic * Add populateCache docs, tidy up api consts * Fix imports in tests * Fix failing import tests --------- Co-authored-by: Gilles De Mey --- .betterer.results | 3 + .../import-to-gma/ImportToGMARules.test.tsx | 13 +- .../components/rules/Filter/RulesFilter.tsx | 3 + .../rules/Filter/RulesFilter.v1.tsx | 8 +- .../rules/Filter/RulesViewModeSelector.tsx | 75 ++++++++-- .../unified/hooks/useFilteredRules.ts | 23 ++- .../alerting/unified/rule-list/FilterView.tsx | 10 +- .../unified/rule-list/GroupedView.test.tsx | 6 +- .../unified/rule-list/GroupedView.tsx | 29 +++- .../rule-list/PaginatedDataSourceLoader.tsx | 78 +++++++---- .../rule-list/PaginatedGrafanaLoader.tsx | 50 ++++++- .../unified/rule-list/RuleList.v2.test.tsx | 132 +++++++++++++++++- .../unified/rule-list/RuleList.v2.tsx | 18 +-- .../rule-list/components/NoRulesFound.tsx | 24 ++++ .../unified/rule-list/hooks/filters.ts | 10 +- .../hooks/prometheusGroupsGenerator.ts | 4 + .../hooks/useLazyLoadPrometheusGroups.tsx | 8 +- .../unified/rule-list/paginationLimits.ts | 9 ++ 18 files changed, 420 insertions(+), 83 deletions(-) create mode 100644 public/app/features/alerting/unified/rule-list/components/NoRulesFound.tsx create mode 100644 public/app/features/alerting/unified/rule-list/paginationLimits.ts diff --git a/.betterer.results b/.betterer.results index a9efbf6fe5e..4439c212997 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1400,6 +1400,9 @@ exports[`better eslint`] = { "public/app/features/alerting/unified/hooks/useControlledFieldArray.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], + "public/app/features/alerting/unified/hooks/useFilteredRules.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/features/alerting/unified/insights/InsightsMenuButton.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.test.tsx b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.test.tsx index b95be4c0850..ad0d4feb52f 100644 --- a/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.test.tsx +++ b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.test.tsx @@ -1,4 +1,4 @@ -import { render } from 'test/test-utils'; +import { render, waitFor } from 'test/test-utils'; import { byLabelText, byRole } from 'testing-library-selector'; import { setPluginComponentsHook, setPluginLinksHook } from '@grafana/runtime'; @@ -47,7 +47,7 @@ const ui = { alertingFactory.dataSource.mimir().build({ meta: { alerting: true } }); -describe.skip('ImportToGMARules', () => { +describe('ImportToGMARules', () => { grantUserPermissions([AccessControlAction.AlertingRuleExternalRead, AccessControlAction.AlertingRuleCreate]); testWithFeatureToggles(['alertingImportYAMLUI', 'alertingMigrationUI']); @@ -62,8 +62,9 @@ describe.skip('ImportToGMARules', () => { it('should render datasource options', async () => { const { user } = render(); - // Wait for the data source picker to be ready + // Wait for the data source picker to be ready and enabled const dsPicker = await ui.dsImport.dsPicker.find(); + await waitFor(() => expect(dsPicker).toBeEnabled()); await user.click(dsPicker); await user.click(await ui.dsImport.mimirDsOption.find()); @@ -91,8 +92,12 @@ describe.skip('ImportToGMARules', () => { it('should show confirmation dialog when importing from data source', async () => { const { user } = render(); + // Wait for the data source picker to be enabled + const dsPicker = ui.dsImport.dsPicker.get(); + await waitFor(() => expect(dsPicker).toBeEnabled()); + // Select a data source - await user.click(ui.dsImport.dsPicker.get()); + await user.click(dsPicker); await user.click(await ui.dsImport.mimirDsOption.find()); // Click the import button diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx index 8a7b0a83a9c..b904a5c1427 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx @@ -3,11 +3,14 @@ import { Suspense, lazy } from 'react'; import { config } from '@grafana/runtime'; import RulesFilterV1 from './RulesFilter.v1'; +import { SupportedView } from './RulesViewModeSelector'; const RulesFilterV2 = lazy(() => import('./RulesFilter.v2')); interface RulesFilerProps { onClear?: () => void; + viewMode?: SupportedView; + onViewModeChange?: (viewMode: SupportedView) => void; } const RulesFilter = (props: RulesFilerProps) => { diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx index 4ff5da3f8e1..97fff1abff5 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx @@ -26,7 +26,7 @@ import { alertStateToReadable } from '../../../utils/rules'; import { PopupCard } from '../../HoverCard'; import { MultipleDataSourcePicker } from '../MultipleDataSourcePicker'; -import { RulesViewModeSelector } from './RulesViewModeSelector'; +import { RulesViewModeSelector, SupportedView } from './RulesViewModeSelector'; const RuleTypeOptions: SelectableValue[] = [ { label: 'Alert ', value: PromRuleType.Alerting }, @@ -44,6 +44,8 @@ const canRenderContactPointSelector = contextSrv.hasPermission(AccessControlActi interface RulesFilerProps { onClear?: () => void; + viewMode?: SupportedView; + onViewModeChange?: (viewMode: SupportedView) => void; } const RuleStateOptions = Object.entries(PromAlertingRuleState).map(([key, value]) => ({ @@ -51,7 +53,7 @@ const RuleStateOptions = Object.entries(PromAlertingRuleState).map(([key, value] value, })); -const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => { +const RulesFilter = ({ onClear = () => undefined, viewMode, onViewModeChange }: RulesFilerProps) => { const styles = useStyles2(getStyles); const { pluginsFilterEnabled } = usePluginsFilterStatus(); const { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters } = useRulesFilter(); @@ -317,7 +319,7 @@ const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => { - + {hasActiveFilters && ( diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx index fe8303fdfd1..661507d6ac8 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx @@ -1,3 +1,5 @@ +import { useCallback } from 'react'; + import { SelectableValue } from '@grafana/data'; import { RadioButtonGroup } from '@grafana/ui'; @@ -15,24 +17,58 @@ const ViewOptions: Array> = [ { icon: 'list-ul', label: 'List', value: 'list' }, ]; -function RulesViewModeSelectorV2() { +interface RulesViewModeSelectorV2Props { + viewMode?: SupportedView; + onViewModeChange?: (viewMode: SupportedView) => void; +} + +/** + * Selecting a view mode is no longer a simple toggle relying on the URL query params. + * We now need to check if the current filters are compatible with the grouped view. + * If they are, we show the grouped view by default. + * If they are not, we show the list view. + * Use the complementary {@link useListViewMode} hook to get the current view mode and a handler for changing it. + */ +function RulesViewModeSelectorV2({ viewMode, onViewModeChange }: RulesViewModeSelectorV2Props) { + return ; +} + +export function useListViewMode() { const [queryParams, updateQueryParams] = useURLSearchParams(); - const { hasActiveFilters } = useRulesFilter(); - const wantsListView = queryParams.get('view') === 'list'; + const { activeFilters } = useRulesFilter(); - const selectedViewOption = hasActiveFilters || wantsListView ? 'list' : 'grouped'; + const queryStringView: SupportedView = queryParams.get('view') === 'list' ? 'list' : 'grouped'; - /* If we change to the grouped view, we just remove the "list" and "search" params */ - const handleViewChange = (view: SupportedView) => { - if (view === 'list') { - updateQueryParams({ view }); + const areFiltersGroupedViewCompatible = activeFilters.every( + (filter) => filter === 'groupName' || filter === 'namespace' + ); + const showListView = areFiltersGroupedViewCompatible === false || queryStringView === 'list'; + + const handleViewChange = useCallback( + (view: SupportedView) => { + if (view === 'grouped') { + // When switching to grouped view, preserve filters only if they are grouped-view compatible + if (areFiltersGroupedViewCompatible) { + // Only remove view parameter, keep search (preserve group/namespace filters) + updateQueryParams({ view: undefined }); + } else { + // Clear both view and search (clear all filters) + updateQueryParams({ view: undefined, search: undefined }); + } + } else { + updateQueryParams({ view }); + } trackRulesListViewChange({ view }); - } else { - updateQueryParams({ view: undefined, search: undefined }); - } - }; + }, + [updateQueryParams, areFiltersGroupedViewCompatible] + ); - return ; + const viewMode: SupportedView = showListView ? 'list' : 'grouped'; + + return { + viewMode, + handleViewChange, + }; } const LegacyViewOptions: Array> = [ @@ -66,4 +102,15 @@ function viewParamToLegacyView(viewParam: string | null): LegacySupportedView { return 'grouped'; } -export const RulesViewModeSelector = shouldUseAlertingListViewV2() ? RulesViewModeSelectorV2 : RulesViewModeSelectorV1; +interface RulesViewModeSelectorProps { + viewMode?: SupportedView; + onViewModeChange?: (viewMode: SupportedView) => void; +} + +export function RulesViewModeSelector({ viewMode, onViewModeChange }: RulesViewModeSelectorProps) { + if (shouldUseAlertingListViewV2()) { + return ; + } + + return ; +} diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index c8123de32c4..7cd831550d2 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -38,6 +38,10 @@ export function useRulesFilter() { }, [searchQuery]); const hasActiveFilters = useMemo(() => Object.values(filterState).some((filter) => !isEmpty(filter)), [filterState]); + const activeFilters = useMemo(() => { + return chain(filterState).omitBy(isEmpty).keys().filter(isRuleFilterKey).value(); + }, [filterState]); + const updateFilters = useCallback( (newFilter: RulesFilter) => { const newSearchQuery = applySearchFilterToQuery(searchQuery, newFilter); @@ -86,7 +90,7 @@ export function useRulesFilter() { } }, [queryParams, updateFilters, filterState, updateQueryParams]); - return { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters }; + return { filterState, hasActiveFilters, activeFilters, searchQuery, setSearchQuery, updateFilters }; } export const useFilteredRules = (namespaces: CombinedRuleNamespace[], filterState: RulesFilter) => { @@ -356,3 +360,20 @@ const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filterState: Rules return ds?.name && filterState?.dataSourceNames?.includes(ds.name); }); }; + +const RULES_FILTER_KEYS: Set = new Set([ + 'freeFormWords', + 'namespace', + 'groupName', + 'ruleName', + 'ruleState', + 'ruleType', + 'dataSourceNames', + 'labels', + 'ruleHealth', + 'dashboardUid', + 'plugins', + 'contactPoint', +]); + +const isRuleFilterKey = (key: string): key is keyof RulesFilter => RULES_FILTER_KEYS.has(key as keyof RulesFilter); diff --git a/public/app/features/alerting/unified/rule-list/FilterView.tsx b/public/app/features/alerting/unified/rule-list/FilterView.tsx index 69260588c1d..27dd21bda6f 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.tsx @@ -22,14 +22,12 @@ import { RuleWithOrigin, useFilteredRulesIteratorProvider, } from './hooks/useFilteredRulesIterator'; +import { FRONTEND_LIST_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits'; interface FilterViewProps { filterState: RulesFilter; } -const FRONTENT_PAGE_SIZE = 100; -const API_PAGE_SIZE = 2000; - export function FilterView({ filterState }: FilterViewProps) { // ⚠️ We use a key to force the component to unmount and remount when the filter state changes // filterState is a complex object including arrays and is constructed from URL params @@ -79,10 +77,10 @@ function FilterViewResults({ filterState }: FilterViewProps) { * ⚠️ Make sure we are returning / using a "iterator" and not an "iterable" since the iterable is only a blueprint * and the iterator will allow us to exhaust the iterable in a stateful way */ - const { iterable, abortController } = getFilteredRulesIterator(filterState, API_PAGE_SIZE); + const { iterable, abortController } = getFilteredRulesIterator(filterState, getApiGroupPageSize(true)); const rulesBatchIterator = iterable .pipe( - bufferCountOrTime(FRONTENT_PAGE_SIZE, 1000), + bufferCountOrTime(FRONTEND_LIST_PAGE_SIZE, 1000), onFinished(() => setDoneSearching(true)) ) [Symbol.asyncIterator](); @@ -98,7 +96,7 @@ function FilterViewResults({ filterState }: FilterViewProps) { let loadedRulesCount = 0; - while (loadedRulesCount < FRONTENT_PAGE_SIZE) { + while (loadedRulesCount < FRONTEND_LIST_PAGE_SIZE) { const nextRulesBatch = await rulesIterator.next(); if (nextRulesBatch.done) { return; diff --git a/public/app/features/alerting/unified/rule-list/GroupedView.test.tsx b/public/app/features/alerting/unified/rule-list/GroupedView.test.tsx index 680101129a4..32dcf3d56d0 100644 --- a/public/app/features/alerting/unified/rule-list/GroupedView.test.tsx +++ b/public/app/features/alerting/unified/rule-list/GroupedView.test.tsx @@ -10,7 +10,7 @@ import { setPrometheusRules } from '../mocks/server/configure'; import { alertingFactory } from '../mocks/server/db'; import { GroupedView } from './GroupedView'; -import { DATA_SOURCE_GROUP_PAGE_SIZE } from './PaginatedDataSourceLoader'; +import { FRONTED_GROUPED_PAGE_SIZE } from './paginationLimits'; setPluginLinksHook(() => ({ links: [], isLoading: false })); setPluginComponentsHook(() => ({ components: [], isLoading: false })); @@ -67,7 +67,7 @@ describe('RuleList - GroupedView', () => { const mimirNamespace = await ui.namespace(/test-mimir-namespace/).find(mimirSection); const firstPageGroups = await ui.group(/test-group-([1-9]|[1-3][0-9]|40)/).findAll(mimirNamespace); - expect(firstPageGroups).toHaveLength(DATA_SOURCE_GROUP_PAGE_SIZE); + expect(firstPageGroups).toHaveLength(FRONTED_GROUPED_PAGE_SIZE); expect(firstPageGroups[0]).toHaveTextContent('test-group-1'); expect(firstPageGroups[24]).toHaveTextContent('test-group-25'); expect(firstPageGroups[39]).toHaveTextContent('test-group-40'); @@ -79,7 +79,7 @@ describe('RuleList - GroupedView', () => { const secondPageGroups = await ui.group(/test-group-(4[1-9]|[5-7][0-9]|80)/).findAll(mimirNamespace); - expect(secondPageGroups).toHaveLength(DATA_SOURCE_GROUP_PAGE_SIZE); + expect(secondPageGroups).toHaveLength(FRONTED_GROUPED_PAGE_SIZE); expect(secondPageGroups[0]).toHaveTextContent('test-group-41'); expect(secondPageGroups[24]).toHaveTextContent('test-group-65'); expect(secondPageGroups[39]).toHaveTextContent('test-group-80'); diff --git a/public/app/features/alerting/unified/rule-list/GroupedView.tsx b/public/app/features/alerting/unified/rule-list/GroupedView.tsx index 3458ad6822c..6c1ca667df1 100644 --- a/public/app/features/alerting/unified/rule-list/GroupedView.tsx +++ b/public/app/features/alerting/unified/rule-list/GroupedView.tsx @@ -14,16 +14,32 @@ import { DataSourceSection } from './components/DataSourceSection'; const { useDiscoverDsFeaturesQuery } = featureDiscoveryApi; -export function GroupedView() { +interface GroupedViewProps { + groupFilter?: string; + namespaceFilter?: string; +} + +export function GroupedView({ groupFilter, namespaceFilter }: GroupedViewProps) { const externalRuleSources = useMemo(() => getExternalRulesSources(), []); return ( - + {externalRuleSources.map((ruleSource) => { - return ; + return ( + + ); })} ); @@ -31,13 +47,15 @@ export function GroupedView() { interface DataSourceLoaderProps { rulesSourceIdentifier: DataSourceRulesSourceIdentifier; + groupFilter?: string; + namespaceFilter?: string; } export function GrafanaDataSourceLoader() { return ; } -function DataSourceLoader({ rulesSourceIdentifier }: DataSourceLoaderProps) { +function DataSourceLoader({ rulesSourceIdentifier, groupFilter, namespaceFilter }: DataSourceLoaderProps) { const { data: dataSourceInfo, isLoading, error } = useDiscoverDsFeaturesQuery({ uid: rulesSourceIdentifier.uid }); const { uid, name } = rulesSourceIdentifier; @@ -55,9 +73,10 @@ function DataSourceLoader({ rulesSourceIdentifier }: DataSourceLoaderProps) { return ( ); diff --git a/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx index c37e07491f8..df4f033d6f9 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx @@ -1,11 +1,10 @@ -import { css } from '@emotion/css'; import { groupBy, isEmpty } from 'lodash'; import { useEffect, useMemo, useRef } from 'react'; -import { GrafanaTheme2 } from '@grafana/data'; import { Trans } from '@grafana/i18n'; -import { Icon, Spinner, Stack, Text, useStyles2 } from '@grafana/ui'; +import { Icon, Spinner, Stack, Text } from '@grafana/ui'; import { DataSourceRuleGroupIdentifier, DataSourceRulesSourceIdentifier, RuleGroup } from 'app/types/unified-alerting'; +import { PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { groups } from '../utils/navigation'; @@ -15,23 +14,54 @@ import { GroupIntervalIndicator } from './components/GroupIntervalMetadata'; import { ListGroup } from './components/ListGroup'; import { ListSection } from './components/ListSection'; import { LoadMoreButton } from './components/LoadMoreButton'; +import { NoRulesFound } from './components/NoRulesFound'; +import { groupFilter as groupFilterFn } from './hooks/filters'; import { toIndividualRuleGroups, usePrometheusGroupsGenerator } from './hooks/prometheusGroupsGenerator'; import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups'; +import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits'; -export const DATA_SOURCE_GROUP_PAGE_SIZE = 40; - -interface PaginatedDataSourceLoaderProps extends Required> { +interface LoaderProps extends Required> { rulesSourceIdentifier: DataSourceRulesSourceIdentifier; + groupFilter?: string; + namespaceFilter?: string; } -export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }: PaginatedDataSourceLoaderProps) { - const styles = useStyles2(getStyles); +export function PaginatedDataSourceLoader({ + rulesSourceIdentifier, + application, + groupFilter, + namespaceFilter, +}: LoaderProps) { + const key = `${rulesSourceIdentifier.uid}-${groupFilter}-${namespaceFilter}`; + + // Key is crucial. It resets the generator when filters change. + return ( + + ); +} + +function PaginatedGroupsLoader({ rulesSourceIdentifier, application, groupFilter, namespaceFilter }: LoaderProps) { + // If there are filters, we don't want to populate the cache to avoid performance issues + // Filtering may trigger multiple HTTP requests, which would populate the cache with a lot of groups hurting performance + const hasFilters = Boolean(groupFilter || namespaceFilter); const { uid, name } = rulesSourceIdentifier; - const prometheusGroupsGenerator = usePrometheusGroupsGenerator({ populateCache: true }); + const prometheusGroupsGenerator = usePrometheusGroupsGenerator({ + populateCache: hasFilters ? false : true, + }); + + // If there are no filters we can match one frontend page to one API page. + // However, if there are filters, we need to fetch more groups from the API to populate one frontend page + const apiGroupPageSize = getApiGroupPageSize(hasFilters); const groupsGenerator = useRef( - toIndividualRuleGroups(prometheusGroupsGenerator(rulesSourceIdentifier, DATA_SOURCE_GROUP_PAGE_SIZE)) + toIndividualRuleGroups(prometheusGroupsGenerator(rulesSourceIdentifier, apiGroupPageSize)) ); useEffect(() => { @@ -39,11 +69,21 @@ export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application } return () => { currentGenerator.return(); }; - }, [groupsGenerator]); + }, []); + + const filterFn = useMemo( + () => (group: PromRuleGroupDTO) => + groupFilterFn(group, { + namespace: namespaceFilter, + groupName: groupFilter, + }), + [namespaceFilter, groupFilter] + ); const { isLoading, groups, hasMoreGroups, fetchMoreGroups, error } = useLazyLoadPrometheusGroups( groupsGenerator.current, - DATA_SOURCE_GROUP_PAGE_SIZE + FRONTED_GROUPED_PAGE_SIZE, + filterFn ); const hasNoRules = isEmpty(groups) && !isLoading; @@ -86,13 +126,7 @@ export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application } Loading more groups... )} - {hasNoRules && ( -
- - No rules found - -
- )} + {hasNoRules && }
); @@ -127,9 +161,3 @@ function RuleGroupListItem({ rulesSourceIdentifier, group, namespaceName }: Rule ); } - -const getStyles = (theme: GrafanaTheme2) => ({ - noRules: css({ - margin: theme.spacing(1.5, 0, 0.5, 4), - }), -}); diff --git a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx index c2ef3fa2863..d3f1c62d43a 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx @@ -4,7 +4,7 @@ import { useEffect, useMemo, useRef } from 'react'; import { Trans } from '@grafana/i18n'; import { Icon, Spinner, Stack, Text } from '@grafana/ui'; import { GrafanaRuleGroupIdentifier, GrafanaRulesSourceSymbol } from 'app/types/unified-alerting'; -import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto'; +import { GrafanaPromRuleGroupDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { FolderActionsButton } from '../components/folder-actions/FolderActionsButton'; import { GrafanaNoRulesCTA } from '../components/rules/NoRulesCTA'; @@ -17,15 +17,39 @@ import { GroupIntervalIndicator } from './components/GroupIntervalMetadata'; import { ListGroup } from './components/ListGroup'; import { ListSection } from './components/ListSection'; import { LoadMoreButton } from './components/LoadMoreButton'; +import { NoRulesFound } from './components/NoRulesFound'; +import { groupFilter as groupFilterFn } from './hooks/filters'; import { toIndividualRuleGroups, useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator'; import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups'; +import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits'; -export const GRAFANA_GROUP_PAGE_SIZE = 40; +interface LoaderProps { + groupFilter?: string; + namespaceFilter?: string; +} -export function PaginatedGrafanaLoader() { - const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ populateCache: true, limitAlerts: 0 }); +export function PaginatedGrafanaLoader({ groupFilter, namespaceFilter }: LoaderProps) { + const key = `${groupFilter}-${namespaceFilter}`; - const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(GRAFANA_GROUP_PAGE_SIZE))); + // Key is crucial. It resets the generator when filters change. + return ; +} + +function PaginatedGroupsLoader({ groupFilter, namespaceFilter }: LoaderProps) { + // If there are filters, we don't want to populate the cache to avoid performance issues + // Filtering may trigger multiple HTTP requests, which would populate the cache with a lot of groups hurting performance + const hasFilters = Boolean(groupFilter || namespaceFilter); + + const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ + populateCache: hasFilters ? false : true, + limitAlerts: 0, + }); + + // If there are no filters we can match one frontend page to one API page. + // However, if there are filters, we need to fetch more groups from the API to populate one frontend page + const apiGroupPageSize = getApiGroupPageSize(hasFilters); + + const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(apiGroupPageSize))); useEffect(() => { const currentGenerator = groupsGenerator.current; @@ -34,9 +58,19 @@ export function PaginatedGrafanaLoader() { }; }, []); + const filterFn = useMemo( + () => (group: PromRuleGroupDTO) => + groupFilterFn(group, { + namespace: namespaceFilter, + groupName: groupFilter, + }), + [namespaceFilter, groupFilter] + ); + const { isLoading, groups, hasMoreGroups, fetchMoreGroups, error } = useLazyLoadPrometheusGroups( groupsGenerator.current, - GRAFANA_GROUP_PAGE_SIZE + FRONTED_GROUPED_PAGE_SIZE, + filterFn ); const groupsByFolder = useMemo(() => groupBy(groups, 'folderUid'), [groups]); @@ -78,7 +112,9 @@ export function PaginatedGrafanaLoader() { ); })} - {hasNoRules && } + {/* only show the CTA if the user has no rules and this isn't the result of a filter / search query */} + {hasNoRules && !hasFilters && } + {hasNoRules && hasFilters && } {hasMoreGroups && ( // this div will make the button not stretch
diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx index ae5e0e479e4..e29d4c2f65b 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx @@ -7,6 +7,8 @@ import { AccessControlAction } from 'app/types'; import { setupMswServer } from '../mockApi'; import { grantUserPermissions } from '../mocks'; import { alertingFactory } from '../mocks/server/db'; +import { RulesFilter } from '../search/rulesSearchParser'; +import { testWithFeatureToggles } from '../test/test-utils'; import RuleList, { RuleListActions } from './RuleList.v2'; @@ -23,12 +25,18 @@ jest.mock('./GroupedView', () => ({ const ui = { filterView: byTestId('filter-view'), groupedView: byTestId('grouped-view'), + modeSelector: { + grouped: byRole('radio', { name: /grouped/i }), + list: byRole('radio', { name: /list/i }), + }, + searchInput: byTestId('search-query-input'), }; setPluginLinksHook(() => ({ links: [], isLoading: false })); setPluginComponentsHook(() => ({ components: [], isLoading: false })); grantUserPermissions([AccessControlAction.AlertingRuleExternalRead]); +testWithFeatureToggles(['alertingListViewV2']); setupMswServer(); @@ -61,8 +69,84 @@ describe('RuleList v2', () => { expect(ui.groupedView.query()).not.toBeInTheDocument(); }); - it('should show list view when a filter is applied', () => { - render(, { historyOptions: { initialEntries: ['/?search=rule:cpu-alert'] } }); + it('should show grouped view when only group filter is applied', () => { + render(, { historyOptions: { initialEntries: ['/?search=group:cpu-usage'] } }); + + expect(ui.groupedView.get()).toBeInTheDocument(); + expect(ui.filterView.query()).not.toBeInTheDocument(); + }); + + it('should show grouped view when only namespace filter is applied', () => { + render(, { historyOptions: { initialEntries: ['/?search=namespace:global'] } }); + + expect(ui.groupedView.get()).toBeInTheDocument(); + expect(ui.filterView.query()).not.toBeInTheDocument(); + }); + + it('should show grouped view when both group and namespace filters are applied', () => { + render(, { historyOptions: { initialEntries: ['/?search=group:cpu-usage namespace:global'] } }); + + expect(ui.groupedView.get()).toBeInTheDocument(); + expect(ui.filterView.query()).not.toBeInTheDocument(); + }); + + it('should show list view when group and namespace filters are combined with other filter types', () => { + render(, { + historyOptions: { initialEntries: ['/?search=group:cpu-usage namespace:global state:firing'] }, + }); + + expect(ui.filterView.get()).toBeInTheDocument(); + expect(ui.groupedView.query()).not.toBeInTheDocument(); + }); + + it('should show grouped view when view parameter is empty', () => { + render(, { historyOptions: { initialEntries: ['/?view='] } }); + + expect(ui.groupedView.get()).toBeInTheDocument(); + expect(ui.filterView.query()).not.toBeInTheDocument(); + }); + + it('should show grouped view when search parameter is empty', () => { + render(, { historyOptions: { initialEntries: ['/?search='] } }); + + expect(ui.groupedView.get()).toBeInTheDocument(); + expect(ui.filterView.query()).not.toBeInTheDocument(); + }); + + it.each<{ filterType: keyof RulesFilter; searchQuery: string }>([ + { filterType: 'freeFormWords', searchQuery: 'cpu alert' }, + { filterType: 'ruleName', searchQuery: 'rule:"cpu 80%"' }, + { filterType: 'ruleState', searchQuery: 'state:firing' }, + { filterType: 'ruleType', searchQuery: 'type:alerting' }, + { filterType: 'dataSourceNames', searchQuery: 'datasource:prometheus' }, + { filterType: 'labels', searchQuery: 'label:team=backend' }, + { filterType: 'ruleHealth', searchQuery: 'health:error' }, + { filterType: 'contactPoint', searchQuery: 'contactPoint:slack' }, + ])('should show list view when %s filter is applied', ({ filterType, searchQuery }) => { + render(, { historyOptions: { initialEntries: [`/?search=${encodeURIComponent(searchQuery)}`] } }); + + expect(ui.filterView.get()).toBeInTheDocument(); + expect(ui.groupedView.query()).not.toBeInTheDocument(); + }); + + it('should show list view when "view=list" URL parameter is present with group filter', () => { + render(, { historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage'] } }); + + expect(ui.filterView.get()).toBeInTheDocument(); + expect(ui.groupedView.query()).not.toBeInTheDocument(); + }); + + it('should show list view when "view=list" URL parameter is present with namespace filter', () => { + render(, { historyOptions: { initialEntries: ['/?view=list&search=namespace:global'] } }); + + expect(ui.filterView.get()).toBeInTheDocument(); + expect(ui.groupedView.query()).not.toBeInTheDocument(); + }); + + it('should show list view when "view=list" URL parameter is present with both group and namespace filters', () => { + render(, { + historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage namespace:global'] }, + }); expect(ui.filterView.get()).toBeInTheDocument(); expect(ui.groupedView.query()).not.toBeInTheDocument(); @@ -160,3 +244,47 @@ describe('RuleListActions', () => { expect(ui.menuOptions.newDataSourceRecordingRule.query(menu)).toBeInTheDocument(); }); }); + +describe('RuleList v2 - View switching', () => { + it('should preserve both group and namespace filters when switching from list view to grouped view', async () => { + // Start with list view and both group and namespace filters + const { user } = render(, { + historyOptions: { initialEntries: ['/?view=list&search=group:cpu-usage namespace:global'] }, + }); + expect(ui.filterView.get()).toBeInTheDocument(); + + // Click the "Grouped" view button + const groupedButton = await ui.modeSelector.grouped.find(); + await user.click(groupedButton); + + // Should preserve both filters and switch to grouped view + expect(ui.groupedView.get()).toBeInTheDocument(); + expect(ui.filterView.query()).not.toBeInTheDocument(); + + // Verify filters are preserved + expect(ui.searchInput.get()).toHaveValue('group:cpu-usage namespace:global'); + expect(ui.modeSelector.list.query()).not.toBeChecked(); + }); + + it('should clear all filters when switching from list view to grouped view with group, namespace and other filters', async () => { + // Start with list view with all types of filters + const { user } = render(, { + historyOptions: { + initialEntries: ['/?view=list&search=group:cpu-usage namespace:global state:firing rule:"test"'], + }, + }); + expect(ui.filterView.get()).toBeInTheDocument(); + + // Click the "Grouped" view button + const groupedButton = await ui.modeSelector.grouped.find(); + await user.click(groupedButton); + + // Should clear all filters because other filters are present + expect(ui.groupedView.get()).toBeInTheDocument(); + expect(ui.filterView.query()).not.toBeInTheDocument(); + + // Verify all filters are cleared + expect(ui.searchInput.get()).toHaveValue(''); + expect(ui.modeSelector.list.query()).not.toBeChecked(); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index 124e230aeef..f3c79b42b4b 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -6,10 +6,9 @@ import { Button, Dropdown, Icon, LinkButton, Menu, Stack } from '@grafana/ui'; import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; import RulesFilter from '../components/rules/Filter/RulesFilter'; -import { SupportedView } from '../components/rules/Filter/RulesViewModeSelector'; +import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelector'; import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; import { useRulesFilter } from '../hooks/useFilteredRules'; -import { useURLSearchParams } from '../hooks/useURLSearchParams'; import { isAdmin } from '../utils/misc'; import { FilterView } from './FilterView'; @@ -17,16 +16,17 @@ import { GroupedView } from './GroupedView'; import { RuleListPageTitle } from './RuleListPageTitle'; function RuleList() { - const [queryParams] = useURLSearchParams(); - const { filterState, hasActiveFilters } = useRulesFilter(); - - const view: SupportedView = queryParams.get('view') === 'list' ? 'list' : 'grouped'; - const showListView = hasActiveFilters || view === 'list'; + const { filterState } = useRulesFilter(); + const { viewMode, handleViewChange } = useListViewMode(); return ( <> - {}} /> - {showListView ? : } + + {viewMode === 'list' ? ( + + ) : ( + + )} ); } diff --git a/public/app/features/alerting/unified/rule-list/components/NoRulesFound.tsx b/public/app/features/alerting/unified/rule-list/components/NoRulesFound.tsx new file mode 100644 index 00000000000..f8778712dd9 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/components/NoRulesFound.tsx @@ -0,0 +1,24 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { Text, useStyles2 } from '@grafana/ui'; + +// @TODO I don't like applying the margins to this component here, ideally the parent component should be layouting this. +export const NoRulesFound = () => { + const styles = useStyles2(getStyles); + + return ( +
+ + No rules found + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + noRules: css({ + margin: theme.spacing(1.5, 0, 0.5, 4), + }), +}); diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.ts index e66ddb559a9..975216e498d 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.ts @@ -14,16 +14,20 @@ import { isPluginProvidedRule, prometheusRuleType } from '../../utils/rules'; /** * @returns True if the group matches the filter, false otherwise. Keeps rules intact */ -export function groupFilter(group: PromRuleGroupDTO, filterState: RulesFilter): boolean { +export function groupFilter( + group: PromRuleGroupDTO, + filterState: Pick +): boolean { const { name, file } = group; + const { namespace, groupName } = filterState; // Add fuzzy search for namespace - if (filterState.namespace && !file.toLowerCase().includes(filterState.namespace)) { + if (namespace && !file.toLocaleLowerCase().includes(namespace.toLocaleLowerCase())) { return false; } // Add fuzzy search for group name - if (filterState.groupName && !name.toLowerCase().includes(filterState.groupName)) { + if (groupName && !name.toLocaleLowerCase().includes(groupName.toLocaleLowerCase())) { return false; } diff --git a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts index 5165996b05c..649594f893e 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts @@ -10,6 +10,10 @@ import { PromRulesResponse, prometheusApi } from '../../api/prometheusApi'; const { useLazyGetGroupsQuery, useLazyGetGrafanaGroupsQuery } = prometheusApi; interface UseGeneratorHookOptions { + /** + * Whether to populate the RTKQ cache with the groups. + * Populating cache might harm performance when fetching a lot of groups or fetching multiple pages + */ populateCache?: boolean; limitAlerts?: number; } diff --git a/public/app/features/alerting/unified/rule-list/hooks/useLazyLoadPrometheusGroups.tsx b/public/app/features/alerting/unified/rule-list/hooks/useLazyLoadPrometheusGroups.tsx index df24ed95571..5181e1a1cd9 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useLazyLoadPrometheusGroups.tsx +++ b/public/app/features/alerting/unified/rule-list/hooks/useLazyLoadPrometheusGroups.tsx @@ -16,7 +16,8 @@ import { isLoading as isLoadingState, useAsync } from '../../hooks/useAsync'; */ export function useLazyLoadPrometheusGroups( groupsGenerator: AsyncIterator, - pageSize: number + pageSize: number, + filter?: (group: TGroup) => boolean ) { const [groups, setGroups] = useState([]); const [hasMoreGroups, setHasMoreGroups] = useState(true); @@ -31,7 +32,12 @@ export function useLazyLoadPrometheusGroups( done = true; break; } + const group = generatorResult.value; + if (filter && !filter(group)) { + continue; + } + currentGroups.push(group); } diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.ts new file mode 100644 index 00000000000..41c5293cbde --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/paginationLimits.ts @@ -0,0 +1,9 @@ +export const FRONTEND_LIST_PAGE_SIZE = 100; + +export const FILTERED_GROUPS_API_PAGE_SIZE = 2000; +export const DEFAULT_GROUPS_API_PAGE_SIZE = 40; +export const FRONTED_GROUPED_PAGE_SIZE = DEFAULT_GROUPS_API_PAGE_SIZE; + +export function getApiGroupPageSize(hasFilters: boolean) { + return hasFilters ? FILTERED_GROUPS_API_PAGE_SIZE : DEFAULT_GROUPS_API_PAGE_SIZE; +}