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