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:
+124
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+46
-20
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user