From 1672bbada0367ea22ad551360ac5c2e4d32f9697 Mon Sep 17 00:00:00 2001 From: Lauren <61048546+laurenashleigh@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:39:18 +0000 Subject: [PATCH] Alerting: Change group filtering to search-based using lightweight BE endpoint (#114347) * change group filtering from load-all to search-based * generate translations * refactoring * Resolve design comments * resolve PR comment --- .../Filter/useRuleFilterAutocomplete.test.ts | 124 ++++++++++++++++++ .../rules/Filter/useRuleFilterAutocomplete.ts | 66 +++++++--- .../rule-list/filter/RulesFilter.v2.tsx | 10 +- public/locales/en-US/grafana.json | 8 +- 4 files changed, 177 insertions(+), 31 deletions(-) create mode 100644 public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.test.ts diff --git a/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.test.ts b/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.test.ts new file mode 100644 index 00000000000..0f4c2b49741 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.test.ts @@ -0,0 +1,124 @@ +import { renderHook } from 'test/test-utils'; + +import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto'; + +import { prometheusApi } from '../../../api/prometheusApi'; +import { setupMswServer } from '../../../mockApi'; + +import { useNamespaceAndGroupOptions } from './useRuleFilterAutocomplete'; + +setupMswServer(); + +jest.mock('../../../api/prometheusApi', () => ({ + prometheusApi: { + useLazyGetGrafanaGroupsQuery: jest.fn(), + useLazyGetGroupsQuery: jest.fn(), + }, +})); + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getList: jest.fn().mockReturnValue([]), + }), +})); + +describe('useNamespaceAndGroupOptions', () => { + let mockFetchGrafanaGroups: jest.Mock; + let mockFetchExternalGroups: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + mockFetchGrafanaGroups = jest.fn(); + mockFetchExternalGroups = jest.fn(); + + (prometheusApi.useLazyGetGrafanaGroupsQuery as jest.Mock).mockReturnValue([mockFetchGrafanaGroups]); + (prometheusApi.useLazyGetGroupsQuery as jest.Mock).mockReturnValue([mockFetchExternalGroups]); + }); + + describe('groupOptions', () => { + it('should require minimum 3 characters before searching', async () => { + const { result } = renderHook(() => useNamespaceAndGroupOptions()); + + const options = await result.current.groupOptions('ab'); + + expect(options).toHaveLength(1); + expect(options[0]).toEqual({ + label: 'Type at least 3 characters to search groups', + value: '__GRAFANA_INFO_OPTION__', + infoOption: true, + }); + expect(mockFetchGrafanaGroups).not.toHaveBeenCalled(); + }); + + it('should call API with searchGroupName when 3+ characters entered', async () => { + const mockGroups: GrafanaPromRuleGroupDTO[] = [ + { name: 'cpu-alerts', file: 'folder1', folderUid: 'uid1', interval: 60, rules: [] }, + { name: 'cpu-usage', file: 'folder2', folderUid: 'uid2', interval: 60, rules: [] }, + ]; + + mockFetchGrafanaGroups.mockReturnValue({ + unwrap: () => + Promise.resolve({ + data: { groups: mockGroups }, + }), + }); + + const { result } = renderHook(() => useNamespaceAndGroupOptions()); + + const options = await result.current.groupOptions('cpu'); + + expect(mockFetchGrafanaGroups).toHaveBeenCalledWith({ + limitAlerts: 0, + searchGroupName: 'cpu', + groupLimit: 100, + }); + + expect(options).toHaveLength(2); + expect(options[0]).toEqual({ label: 'cpu-alerts', value: 'cpu-alerts' }); + expect(options[1]).toEqual({ label: 'cpu-usage', value: 'cpu-usage' }); + }); + + it('should show message when no groups match search', async () => { + mockFetchGrafanaGroups.mockReturnValue({ + unwrap: () => + Promise.resolve({ + data: { groups: [] }, + }), + }); + + const { result } = renderHook(() => useNamespaceAndGroupOptions()); + + const options = await result.current.groupOptions('xyz123'); + + expect(options).toHaveLength(1); + expect(options[0]).toEqual({ + label: 'No groups found matching "xyz123"', + value: '__GRAFANA_INFO_OPTION__', + infoOption: true, + }); + }); + + it('should handle API errors gracefully', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + mockFetchGrafanaGroups.mockReturnValue({ + unwrap: () => Promise.reject(new Error('API Error')), + }); + + const { result } = renderHook(() => useNamespaceAndGroupOptions()); + + const options = await result.current.groupOptions('cpu'); + + expect(options).toHaveLength(1); + expect(options[0]).toEqual({ + label: 'Error searching groups', + value: '__GRAFANA_INFO_OPTION__', + infoOption: true, + }); + + consoleErrorSpy.mockRestore(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts b/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts index df6a317d071..67b3591eae0 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts +++ b/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts @@ -1,3 +1,4 @@ +import { chain } from 'lodash'; import { useCallback } from 'react'; import { DataSourceInstanceSettings } from '@grafana/data'; @@ -15,7 +16,9 @@ function getExternalRuleDataSources() { return getRulesDataSources().filter((ds: DataSourceInstanceSettings) => !!ds?.url); } -const THRESHOLD_LIMIT = 500; +const NAMESPACE_THRESHOLD_LIMIT = 500; +const MIN_GROUP_SEARCH_CHARACTERS = 3; +const GROUP_SEARCH_LIMIT = 100; function createInfoOption(message: string): ComboboxOption { return { @@ -60,7 +63,10 @@ export function useNamespaceAndGroupOptions(): { const namespaceOptions = useCallback( async (inputValue: string) => { // Grafana namespaces - fetch with limit to check threshold - const grafanaResponse = await fetchGrafanaGroups({ limitAlerts: 0, groupLimit: THRESHOLD_LIMIT + 1 }).unwrap(); + const grafanaResponse = await fetchGrafanaGroups({ + limitAlerts: 0, + groupLimit: NAMESPACE_THRESHOLD_LIMIT + 1, + }).unwrap(); const grafanaFolderNames = Array.from( new Set(grafanaResponse.data.groups.map((g: GrafanaPromRuleGroupDTO) => g.file || 'default')) ); @@ -71,7 +77,7 @@ export function useNamespaceAndGroupOptions(): { fetchExternalGroups({ ruleSource: { uid: ds.uid }, excludeAlerts: true, - groupLimit: THRESHOLD_LIMIT + 1, + groupLimit: NAMESPACE_THRESHOLD_LIMIT + 1, notificationOptions: { showErrorAlert: false }, }).unwrap() ); @@ -84,8 +90,8 @@ export function useNamespaceAndGroupOptions(): { const totalNamespaces = grafanaFolderNames.length + namespaceNameSet.size; - // If we have more than THRESHOLD_LIMIT unique namespaces, show info message - if (totalNamespaces > THRESHOLD_LIMIT) { + // If we have more than NAMESPACE_THRESHOLD_LIMIT unique namespaces, show info message + if (totalNamespaces > NAMESPACE_THRESHOLD_LIMIT) { return [ createInfoOption( t( @@ -117,33 +123,53 @@ export function useNamespaceAndGroupOptions(): { const groupOptions = useCallback( async (inputValue: string) => { - const grafanaResponse = await fetchGrafanaGroups({ limitAlerts: 0, groupLimit: THRESHOLD_LIMIT + 1 }).unwrap(); - const groupNames = Array.from(new Set(grafanaResponse.data.groups.map((g: GrafanaPromRuleGroupDTO) => g.name))); - - // If we have more than THRESHOLD_LIMIT unique groups, show info message - if (groupNames.length > THRESHOLD_LIMIT) { + // Require minimum characters for search + const trimmedInput = inputValue?.trim() || ''; + if (trimmedInput.length < MIN_GROUP_SEARCH_CHARACTERS) { return [ createInfoOption( - t( - 'alerting.rules-filter.group-autocomplete-unavailable', - 'Due to large number of groups, autocomplete is not available' - ) + t('alerting.rules-filter.group-search-prompt', 'Type at least 3 characters to search groups') ), ]; } - const options: Array> = groupNames - .map((name) => ({ label: name, value: name })) - .sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); + try { + // Use the backend search with lightweight response + const grafanaResponse = await fetchGrafanaGroups({ + limitAlerts: 0, // Lightweight - no alert data + searchGroupName: trimmedInput, // Backend filtering via search.rule_group parameter + groupLimit: GROUP_SEARCH_LIMIT, // Reasonable limit for dropdown results + }).unwrap(); - const filtered = filterBySearch(options, inputValue); - return filtered; + // Deduplicate group names + const groupNames = chain(grafanaResponse.data.groups).map('name').compact().uniq().value(); + + // No results found + if (groupNames.length === 0) { + return [ + createInfoOption( + t('alerting.rules-filter.group-no-results', 'No groups found matching "{{search}}"', { + search: trimmedInput, + }) + ), + ]; + } + + const options: Array> = groupNames + .map((name) => ({ label: name, value: name })) + .sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); + + return options; + } catch (error) { + console.error('Error fetching groups:', error); + return [createInfoOption(t('alerting.rules-filter.group-search-error', 'Error searching groups'))]; + } }, [fetchGrafanaGroups] ); const namespacePlaceholder = t('alerting.rules-filter.filter-options.placeholder-namespace', 'Select namespace'); - const groupPlaceholder = t('grafana.select-group', 'Select group'); + const groupPlaceholder = t('alerting.rules-filter.placeholder-group-search', 'Search group'); return { namespaceOptions, groupOptions, namespacePlaceholder, groupPlaceholder }; } diff --git a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx index 1cc702038ac..a2a1566be29 100644 --- a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx @@ -439,13 +439,7 @@ function GroupField({ groupPlaceholder: string; portalContainer?: HTMLElement; }) { - const { control, setValue } = useFormContext(); - - const wrappedOptions = useCallback( - (inputValue: string) => - createThresholdAwareOptions(groupOptions, (value) => setValue('groupName', value), 'groupName')(inputValue), - [groupOptions, setValue] - ); + const { control } = useFormContext(); return ( <> @@ -458,7 +452,7 @@ function GroupField({ render={({ field }) => ( placeholder={groupPlaceholder} - options={wrappedOptions} + options={groupOptions} onChange={(option) => { if (!option?.infoOption) { field.onChange(option?.value || null); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index fd2b50edb93..e8d4b15d05f 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2624,7 +2624,9 @@ "placeholder-search-input": "Search by name or enter filter query..." }, "grafana-folder": "Grafana folder", - "group-autocomplete-unavailable": "Due to large number of groups, autocomplete is not available", + "group-no-results": "No groups found matching \"{{search}}\"", + "group-search-error": "Error searching groups", + "group-search-prompt": "Type at least 3 characters to search groups", "health": "Health", "label": { "hide": "Hide", @@ -2635,6 +2637,7 @@ "placeholder-all-data-sources": "All data sources", "placeholder-contact-point": "Select contact point", "placeholder-data-sources": "Select data sources", + "placeholder-group-search": "Search group", "placeholder-labels": "Select labels", "plugin-rules": "Plugin rules", "rule-source": { @@ -8123,8 +8126,7 @@ "edit-pane": { "go-back": "Go back" } - }, - "select-group": "Select group" + } }, "grafana-data": { "datetime": {