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 <gilles.de.mey@gmail.com>
grambbledook/move-cloudwatch-config-to-binary
Konrad Lalik 1 month ago committed by GitHub
parent 3fe25d2f1b
commit 95efe7a388
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      .betterer.results
  2. 13
      public/app/features/alerting/unified/components/import-to-gma/ImportToGMARules.test.tsx
  3. 3
      public/app/features/alerting/unified/components/rules/Filter/RulesFilter.tsx
  4. 8
      public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx
  5. 77
      public/app/features/alerting/unified/components/rules/Filter/RulesViewModeSelector.tsx
  6. 23
      public/app/features/alerting/unified/hooks/useFilteredRules.ts
  7. 10
      public/app/features/alerting/unified/rule-list/FilterView.tsx
  8. 6
      public/app/features/alerting/unified/rule-list/GroupedView.test.tsx
  9. 29
      public/app/features/alerting/unified/rule-list/GroupedView.tsx
  10. 78
      public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx
  11. 50
      public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx
  12. 132
      public/app/features/alerting/unified/rule-list/RuleList.v2.test.tsx
  13. 18
      public/app/features/alerting/unified/rule-list/RuleList.v2.tsx
  14. 24
      public/app/features/alerting/unified/rule-list/components/NoRulesFound.tsx
  15. 10
      public/app/features/alerting/unified/rule-list/hooks/filters.ts
  16. 4
      public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts
  17. 8
      public/app/features/alerting/unified/rule-list/hooks/useLazyLoadPrometheusGroups.tsx
  18. 9
      public/app/features/alerting/unified/rule-list/paginationLimits.ts

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

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

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

@ -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) => {
<Label>
<Trans i18nKey="alerting.rules-filter.view-as">View as</Trans>
</Label>
<RulesViewModeSelector />
<RulesViewModeSelector viewMode={viewMode} onViewModeChange={onViewModeChange} />
</div>
</Stack>
{hasActiveFilters && (

@ -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<SelectableValue<SupportedView>> = [
{ icon: 'list-ul', label: 'List', value: 'list' },
];
function RulesViewModeSelectorV2() {
const [queryParams, updateQueryParams] = useURLSearchParams();
const { hasActiveFilters } = useRulesFilter();
const wantsListView = queryParams.get('view') === 'list';
interface RulesViewModeSelectorV2Props {
viewMode?: SupportedView;
onViewModeChange?: (viewMode: SupportedView) => void;
}
const selectedViewOption = hasActiveFilters || wantsListView ? 'list' : 'grouped';
/**
* 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 <RadioButtonGroup options={ViewOptions} value={viewMode} onChange={onViewModeChange} />;
}
/* If we change to the grouped view, we just remove the "list" and "search" params */
const handleViewChange = (view: SupportedView) => {
if (view === 'list') {
updateQueryParams({ view });
export function useListViewMode() {
const [queryParams, updateQueryParams] = useURLSearchParams();
const { activeFilters } = useRulesFilter();
const queryStringView: SupportedView = queryParams.get('view') === 'list' ? 'list' : 'grouped';
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]
);
const viewMode: SupportedView = showListView ? 'list' : 'grouped';
return <RadioButtonGroup options={ViewOptions} value={selectedViewOption} onChange={handleViewChange} />;
return {
viewMode,
handleViewChange,
};
}
const LegacyViewOptions: Array<SelectableValue<LegacySupportedView>> = [
@ -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 <RulesViewModeSelectorV2 viewMode={viewMode} onViewModeChange={onViewModeChange} />;
}
return <RulesViewModeSelectorV1 />;
}

@ -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<keyof RulesFilter> = 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);

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

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

@ -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 (
<Stack direction="column" gap={1} role="list">
<DataSourceErrorBoundary rulesSourceIdentifier={GrafanaRulesSource}>
<PaginatedGrafanaLoader />
<PaginatedGrafanaLoader
groupFilter={groupFilter}
namespaceFilter={namespaceFilter}
key={`${groupFilter}-${namespaceFilter}`}
/>
</DataSourceErrorBoundary>
{externalRuleSources.map((ruleSource) => {
return <DataSourceLoader key={ruleSource.uid} rulesSourceIdentifier={ruleSource} />;
return (
<DataSourceLoader
key={ruleSource.uid}
rulesSourceIdentifier={ruleSource}
groupFilter={groupFilter}
namespaceFilter={namespaceFilter}
/>
);
})}
</Stack>
);
@ -31,13 +47,15 @@ export function GroupedView() {
interface DataSourceLoaderProps {
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
groupFilter?: string;
namespaceFilter?: string;
}
export function GrafanaDataSourceLoader() {
return <DataSourceSection name="Grafana" application="grafana" uid="grafana" isLoading={true} />;
}
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 (
<DataSourceErrorBoundary rulesSourceIdentifier={rulesSourceIdentifier}>
<PaginatedDataSourceLoader
key={rulesSourceIdentifier.uid}
rulesSourceIdentifier={rulesSourceIdentifier}
application={dataSourceInfo.application}
groupFilter={groupFilter}
namespaceFilter={namespaceFilter}
/>
</DataSourceErrorBoundary>
);

@ -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<Pick<DataSourceSectionProps, 'application'>> {
interface LoaderProps extends Required<Pick<DataSourceSectionProps, 'application'>> {
rulesSourceIdentifier: DataSourceRulesSourceIdentifier;
groupFilter?: string;
namespaceFilter?: string;
}
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 (
<PaginatedGroupsLoader
key={key}
rulesSourceIdentifier={rulesSourceIdentifier}
application={application}
groupFilter={groupFilter}
namespaceFilter={namespaceFilter}
/>
);
}
export function PaginatedDataSourceLoader({ rulesSourceIdentifier, application }: PaginatedDataSourceLoaderProps) {
const styles = useStyles2(getStyles);
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 }
<Trans i18nKey="alerting.rule-list.loading-more-groups">Loading more groups...</Trans>
</Stack>
)}
{hasNoRules && (
<div className={styles.noRules}>
<Text color="secondary">
<Trans i18nKey="alerting.rule-list.empty-data-source">No rules found</Trans>
</Text>
</div>
)}
{hasNoRules && <NoRulesFound />}
</Stack>
</DataSourceSection>
);
@ -127,9 +161,3 @@ function RuleGroupListItem({ rulesSourceIdentifier, group, namespaceName }: Rule
</ListGroup>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
noRules: css({
margin: theme.spacing(1.5, 0, 0.5, 4),
}),
});

@ -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({ groupFilter, namespaceFilter }: LoaderProps) {
const key = `${groupFilter}-${namespaceFilter}`;
// Key is crucial. It resets the generator when filters change.
return <PaginatedGroupsLoader key={key} groupFilter={groupFilter} namespaceFilter={namespaceFilter} />;
}
export function PaginatedGrafanaLoader() {
const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ populateCache: true, limitAlerts: 0 });
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 groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(GRAFANA_GROUP_PAGE_SIZE)));
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() {
</ListSection>
);
})}
{hasNoRules && <GrafanaNoRulesCTA />}
{/* only show the CTA if the user has no rules and this isn't the result of a filter / search query */}
{hasNoRules && !hasFilters && <GrafanaNoRulesCTA />}
{hasNoRules && hasFilters && <NoRulesFound />}
{hasMoreGroups && (
// this div will make the button not stretch
<div>

@ -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(<RuleList />, { historyOptions: { initialEntries: ['/?search=rule:cpu-alert'] } });
it('should show grouped view when only group filter is applied', () => {
render(<RuleList />, { 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(<RuleList />, { 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(<RuleList />, { 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(<RuleList />, {
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(<RuleList />, { 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(<RuleList />, { 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(<RuleList />, { 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(<RuleList />, { 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(<RuleList />, { 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(<RuleList />, {
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(<RuleList />, {
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(<RuleList />, {
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();
});
});

@ -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 (
<>
<RulesFilter onClear={() => {}} />
{showListView ? <FilterView filterState={filterState} /> : <GroupedView />}
<RulesFilter viewMode={viewMode} onViewModeChange={handleViewChange} />
{viewMode === 'list' ? (
<FilterView filterState={filterState} />
) : (
<GroupedView groupFilter={filterState.groupName} namespaceFilter={filterState.namespace} />
)}
</>
);
}

@ -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 (
<div className={styles.noRules}>
<Text color="secondary">
<Trans i18nKey="alerting.rule-list.empty-data-source">No rules found</Trans>
</Text>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
noRules: css({
margin: theme.spacing(1.5, 0, 0.5, 4),
}),
});

@ -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<RulesFilter, 'namespace' | 'groupName'>
): 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;
}

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

@ -16,7 +16,8 @@ import { isLoading as isLoadingState, useAsync } from '../../hooks/useAsync';
*/
export function useLazyLoadPrometheusGroups<TGroup extends PromRuleGroupDTO>(
groupsGenerator: AsyncIterator<TGroup>,
pageSize: number
pageSize: number,
filter?: (group: TGroup) => boolean
) {
const [groups, setGroups] = useState<TGroup[]>([]);
const [hasMoreGroups, setHasMoreGroups] = useState<boolean>(true);
@ -31,7 +32,12 @@ export function useLazyLoadPrometheusGroups<TGroup extends PromRuleGroupDTO>(
done = true;
break;
}
const group = generatorResult.value;
if (filter && !filter(group)) {
continue;
}
currentGroups.push(group);
}

@ -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;
}
Loading…
Cancel
Save