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 7cfe57b95f6..df6a317d071 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts +++ b/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts @@ -15,10 +15,19 @@ function getExternalRuleDataSources() { return getRulesDataSources().filter((ds: DataSourceInstanceSettings) => !!ds?.url); } +const THRESHOLD_LIMIT = 500; + +function createInfoOption(message: string): ComboboxOption { + return { + label: message, + value: '__GRAFANA_INFO_OPTION__', + infoOption: true, + }; +} + export function useNamespaceAndGroupOptions(): { namespaceOptions: (inputValue: string) => Promise>>; - allGroupNames: string[]; - isLoadingNamespaces: boolean; + groupOptions: (inputValue: string) => Promise>>; namespacePlaceholder: string; groupPlaceholder: string; } { @@ -50,17 +59,11 @@ export function useNamespaceAndGroupOptions(): { const namespaceOptions = useCallback( async (inputValue: string) => { - // Grafana namespaces - const grafanaResponse = await fetchGrafanaGroups({ limitAlerts: 0, groupLimit: 1000 }).unwrap(); - const grafanaFolders: Array> = Array.from( + // Grafana namespaces - fetch with limit to check threshold + const grafanaResponse = await fetchGrafanaGroups({ limitAlerts: 0, groupLimit: THRESHOLD_LIMIT + 1 }).unwrap(); + const grafanaFolderNames = Array.from( new Set(grafanaResponse.data.groups.map((g: GrafanaPromRuleGroupDTO) => g.file || 'default')) - ) - .map((name) => ({ - label: name, - value: name, - description: t('alerting.rules-filter.grafana-folder', 'Grafana folder'), - })) - .sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); + ); // External namespaces const namespaceNameSet = new Set(); @@ -68,7 +71,7 @@ export function useNamespaceAndGroupOptions(): { fetchExternalGroups({ ruleSource: { uid: ds.uid }, excludeAlerts: true, - groupLimit: 500, + groupLimit: THRESHOLD_LIMIT + 1, notificationOptions: { showErrorAlert: false }, }).unwrap() ); @@ -78,6 +81,29 @@ export function useNamespaceAndGroupOptions(): { res.value.data.groups.forEach((group: { file?: string }) => namespaceNameSet.add(group.file || 'default')); } } + + const totalNamespaces = grafanaFolderNames.length + namespaceNameSet.size; + + // If we have more than THRESHOLD_LIMIT unique namespaces, show info message + if (totalNamespaces > THRESHOLD_LIMIT) { + return [ + createInfoOption( + t( + 'alerting.rules-filter.namespace-autocomplete-unavailable', + 'Due to large number of folders, autocomplete is not available' + ) + ), + ]; + } + + const grafanaFolders: Array> = grafanaFolderNames + .map((name) => ({ + label: name, + value: name, + description: t('alerting.rules-filter.grafana-folder', 'Grafana folder'), + })) + .sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); + const externalNamespaces = Array.from(namespaceNameSet) .map(formatNamespaceOption) .sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); @@ -89,12 +115,37 @@ export function useNamespaceAndGroupOptions(): { [fetchGrafanaGroups, fetchExternalGroups, formatNamespaceOption] ); - const allGroupNames: string[] = []; - const isLoadingNamespaces = false; + 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) { + return [ + createInfoOption( + t( + 'alerting.rules-filter.group-autocomplete-unavailable', + 'Due to large number of groups, autocomplete is not available' + ) + ), + ]; + } + + const options: Array> = groupNames + .map((name) => ({ label: name, value: name })) + .sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); + + const filtered = filterBySearch(options, inputValue); + return filtered; + }, + [fetchGrafanaGroups] + ); + const namespacePlaceholder = t('alerting.rules-filter.filter-options.placeholder-namespace', 'Select namespace'); const groupPlaceholder = t('grafana.select-group', 'Select group'); - return { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder }; + return { namespaceOptions, groupOptions, namespacePlaceholder, groupPlaceholder }; } export function useLabelOptions(): { 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 dde3e983ad9..e55b4c9d930 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 @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Controller, FormProvider, SubmitHandler, useForm, useFormContext } from 'react-hook-form'; import { ContactPointSelector } from '@grafana/alerting/unstable'; @@ -55,6 +55,25 @@ const canRenderContactPointSelector = contextSrv.hasPermission(AccessControlActi const radioGroupCompactClass = css({ width: 'max-content' }); +// Helper to create a wrapped options function that captures typed input when threshold is exceeded +function createThresholdAwareOptions( + optionsFunc: (inputValue: string) => Promise, + setValue: (value: string) => void, + fieldName: string +) { + return async (inputValue: string): Promise => { + const options = await optionsFunc(inputValue); + const exceeded = options.length === 1 && options[0].infoOption === true; + + // If threshold exceeded and user is typing, capture the typed value + if (exceeded && inputValue) { + setValue(inputValue); + } + + return options; + }; +} + type SearchQueryForm = { query: string; }; @@ -85,11 +104,9 @@ export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterP }; const handleAdvancedFilters: SubmitHandler = (values) => { - const newFilter = formAdvancedFiltersToRuleFilter(values); - updateFilters(newFilter); - + updateFilters(formAdvancedFiltersToRuleFilter(values)); trackFilterButtonApplyClick(values, pluginsFilterEnabled); - setIsPopupOpen(false); // Should close popup after applying filters? + setIsPopupOpen(false); }; const handleClearFilters = () => { @@ -248,8 +265,7 @@ const FilterOptions = ({ onSubmit, onClear, pluginsFilterEnabled }: FilterOption const defaultValues = searchQueryToDefaultValues(filterState); // Fetch namespace and group data from all sources (optimized for filter UI) - const { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder } = - useNamespaceAndGroupOptions(); + const { namespaceOptions, groupOptions, namespacePlaceholder, groupPlaceholder } = useNamespaceAndGroupOptions(); const { labelOptions } = useLabelOptions(); @@ -294,13 +310,11 @@ const FilterOptions = ({ onSubmit, onClear, pluginsFilterEnabled }: FilterOption @@ -373,15 +387,22 @@ function LabelsField({ function NamespaceField({ namespaceOptions, namespacePlaceholder, - isLoadingNamespaces, portalContainer, }: { - namespaceOptions: (inputValue: string) => Promise>; + namespaceOptions: ( + inputValue: string + ) => Promise>; namespacePlaceholder: string; - isLoadingNamespaces: boolean; portalContainer?: HTMLElement; }) { - const { control } = useFormContext(); + const { control, setValue } = useFormContext(); + + const wrappedOptions = useCallback( + (inputValue: string) => + createThresholdAwareOptions(namespaceOptions, (value) => setValue('namespace', value), 'namespace')(inputValue), + [namespaceOptions, setValue] + ); + return ( <>