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
This commit is contained in:
Lauren
2025-11-25 12:39:18 +00:00
committed by GitHub
parent 1d4067216d
commit 1672bbada0
4 changed files with 177 additions and 31 deletions
@@ -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();
});
});
});
@@ -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<string> {
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<ComboboxOption<string>> = 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<ComboboxOption<string>> = 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 };
}
@@ -439,13 +439,7 @@ function GroupField({
groupPlaceholder: string;
portalContainer?: HTMLElement;
}) {
const { control, setValue } = useFormContext<AdvancedFilters>();
const wrappedOptions = useCallback(
(inputValue: string) =>
createThresholdAwareOptions(groupOptions, (value) => setValue('groupName', value), 'groupName')(inputValue),
[groupOptions, setValue]
);
const { control } = useFormContext<AdvancedFilters>();
return (
<>
@@ -458,7 +452,7 @@ function GroupField({
render={({ field }) => (
<Combobox<string>
placeholder={groupPlaceholder}
options={wrappedOptions}
options={groupOptions}
onChange={(option) => {
if (!option?.infoOption) {
field.onChange(option?.value || null);
+5 -3
View File
@@ -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": {