From 95f167bb8b23d358e630aa1ab97b2dcc4a3f507e Mon Sep 17 00:00:00 2001 From: Lauren <61048546+laurenashleigh@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:12:20 +0100 Subject: [PATCH] Alerting: Reduce failed network requests in Filter V2 (#110280) * update options type to promise, fetch only when dropdown is open * add GMA/DMA section WIP * remove rule manager radio * use default no options found in dropdowns * fix failing test * resolve PR comments * replace fetchPromNamespaces with fetchGrafanaGroups --- .../rules/Filter/useRuleFilterAutocomplete.ts | 294 +++++++++--------- .../rule-list/filter/RulesFilter.v2.tsx | 26 +- .../query/components/QueryEditorRows.test.tsx | 4 +- public/locales/en-US/grafana.json | 2 - 4 files changed, 156 insertions(+), 170 deletions(-) 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 98b669b103c..7cfe57b95f6 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts +++ b/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useCallback } from 'react'; import { DataSourceInstanceSettings } from '@grafana/data'; import { t } from '@grafana/i18n'; @@ -6,182 +6,184 @@ import { getDataSourceSrv } from '@grafana/runtime'; import { ComboboxOption } from '@grafana/ui'; import { GrafanaPromRuleGroupDTO } from 'app/types/unified-alerting-dto'; -import { alertRuleApi } from '../../../api/alertRuleApi'; -import { GRAFANA_RULER_CONFIG } from '../../../api/featureDiscoveryApi'; import { prometheusApi } from '../../../api/prometheusApi'; -import { useGetLabelsFromDataSourceName } from '../../../components/rule-editor/useAlertRuleSuggestions'; -import { GRAFANA_RULES_SOURCE_NAME, getRulesDataSources } from '../../../utils/datasource'; +import { getRulesDataSources } from '../../../utils/datasource'; + +// Module-scope utilities +const collator = new Intl.Collator(); +function getExternalRuleDataSources() { + return getRulesDataSources().filter((ds: DataSourceInstanceSettings) => !!ds?.url); +} export function useNamespaceAndGroupOptions(): { - namespaceOptions: Array>; + namespaceOptions: (inputValue: string) => Promise>>; allGroupNames: string[]; isLoadingNamespaces: boolean; namespacePlaceholder: string; groupPlaceholder: string; } { - const { currentData: grafanaPromRulesResponse, isLoading: isLoadingGrafanaPromRules } = - prometheusApi.endpoints.getGrafanaGroups.useQuery({ - limitAlerts: 0, - groupLimit: 1000, - }); + const [fetchGrafanaGroups] = prometheusApi.useLazyGetGrafanaGroupsQuery(); + const [fetchExternalGroups] = prometheusApi.useLazyGetGroupsQuery(); - // Transform Grafana groups to namespace structure - const grafanaPromRules = useMemo(() => { - const groups = grafanaPromRulesResponse?.data?.groups ?? []; + // Formats a raw namespace string into a user-friendly combobox option. + const formatNamespaceOption = useCallback((namespaceName: string): ComboboxOption => { + if (namespaceName.includes('/') && (namespaceName.endsWith('.yml') || namespaceName.endsWith('.yaml'))) { + const filename = namespaceName.split('/').pop() || namespaceName; + const maxDescriptionLength = 100; + const truncatedDescription = + namespaceName.length > maxDescriptionLength + ? `${namespaceName.substring(0, maxDescriptionLength)}...` + : namespaceName; + return { label: filename, value: namespaceName, description: truncatedDescription }; + } - const namespaceMap = new Map(); - groups.forEach((group) => { - const namespaceName = group.file || 'default'; - const existing = namespaceMap.get(namespaceName); - if (existing) { - existing.groups.push(group); - } else { - namespaceMap.set(namespaceName, { name: namespaceName, groups: [group] }); + const maxLength = 50; + const maxDescriptionLength = 100; + const truncatedName = + namespaceName.length > maxLength ? `${namespaceName.substring(0, maxLength)}...` : namespaceName; + const truncatedDescription = + namespaceName.length > maxDescriptionLength + ? `${namespaceName.substring(0, maxDescriptionLength)}...` + : namespaceName; + return { label: truncatedName, value: namespaceName, description: truncatedDescription }; + }, []); + + const namespaceOptions = useCallback( + async (inputValue: string) => { + // Grafana namespaces + const grafanaResponse = await fetchGrafanaGroups({ limitAlerts: 0, groupLimit: 1000 }).unwrap(); + const grafanaFolders: Array> = 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(); + const calls = getExternalRuleDataSources().map((ds) => + fetchExternalGroups({ + ruleSource: { uid: ds.uid }, + excludeAlerts: true, + groupLimit: 500, + notificationOptions: { showErrorAlert: false }, + }).unwrap() + ); + const results = await Promise.allSettled(calls); + for (const res of results) { + if (res.status === 'fulfilled') { + res.value.data.groups.forEach((group: { file?: string }) => namespaceNameSet.add(group.file || 'default')); + } } - }); + const externalNamespaces = Array.from(namespaceNameSet) + .map(formatNamespaceOption) + .sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); - return Array.from(namespaceMap.values()); - }, [grafanaPromRulesResponse]); - - const { isLoading: isLoadingGrafanaRulerRules } = alertRuleApi.endpoints.rulerRules.useQuery({ - rulerConfig: GRAFANA_RULER_CONFIG, - }); - - const externalDataSources = useMemo(getRulesDataSources, []); - - const externalPromRulesQueries = externalDataSources.map((ds) => - prometheusApi.endpoints.getGroups.useQuery({ - ruleSource: { uid: ds.uid }, - excludeAlerts: true, - groupLimit: 500, - notificationOptions: { showErrorAlert: false }, - }) + const options = [...grafanaFolders, ...externalNamespaces]; + const filtered = filterBySearch(options, inputValue); + return filtered; + }, + [fetchGrafanaGroups, fetchExternalGroups, formatNamespaceOption] ); - const isLoadingNamespaces = useMemo(() => { - return ( - isLoadingGrafanaPromRules || - isLoadingGrafanaRulerRules || - externalPromRulesQueries.some((query) => query.isLoading) - ); - }, [isLoadingGrafanaPromRules, isLoadingGrafanaRulerRules, externalPromRulesQueries]); - - const namespaceOptions = useMemo((): Array> => { - const grafanaFolders: Array> = []; - const externalNamespaces: Array> = []; - - // Grafana folders - grafanaPromRules.forEach((namespace) => { - grafanaFolders.push({ - label: namespace.name, - value: namespace.name, - description: t('alerting.rules-filter.grafana-folder', 'Grafana folder'), - }); - }); - - // External namespaces (dedupe by file) - externalPromRulesQueries.forEach((query) => { - const namespaces = new Set(); - query.currentData?.data?.groups?.forEach((group) => { - namespaces.add(group.file || 'default'); - }); - - namespaces.forEach((namespaceName) => { - if (namespaceName.includes('/') && (namespaceName.endsWith('.yml') || namespaceName.endsWith('.yaml'))) { - const filename = namespaceName.split('/').pop() || namespaceName; - const maxDescriptionLength = 100; - const truncatedDescription = - namespaceName.length > maxDescriptionLength - ? `${namespaceName.substring(0, maxDescriptionLength)}...` - : namespaceName; - externalNamespaces.push({ label: filename, value: namespaceName, description: truncatedDescription }); - } else { - const maxLength = 50; - const maxDescriptionLength = 100; - const truncatedName = - namespaceName.length > maxLength ? `${namespaceName.substring(0, maxLength)}...` : namespaceName; - const truncatedDescription = - namespaceName.length > maxDescriptionLength - ? `${namespaceName.substring(0, maxDescriptionLength)}...` - : namespaceName; - externalNamespaces.push({ label: truncatedName, value: namespaceName, description: truncatedDescription }); - } - }); - }); - - const collator = new Intl.Collator(); - grafanaFolders.sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); - externalNamespaces.sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); - - return [...grafanaFolders, ...externalNamespaces]; - }, [grafanaPromRules, externalPromRulesQueries]); - - const allGroupNames = useMemo(() => { - const groupSet = new Set(); - grafanaPromRules.forEach((namespace) => { - namespace.groups.forEach((group) => groupSet.add(group.name)); - }); - externalPromRulesQueries.forEach((query) => { - query.currentData?.data?.groups?.forEach((group) => { - groupSet.add(group.name); - }); - }); - return Array.from(groupSet).sort(); - }, [grafanaPromRules, externalPromRulesQueries]); - - const namespacePlaceholder = useMemo(() => { - if (isLoadingNamespaces) { - return t('common.loading', 'Loading...'); - } - if (namespaceOptions.length === 0) { - return t('alerting.rules-filter.no-namespaces', 'No folders available'); - } - return t('alerting.rules-filter.filter-options.placeholder-namespace', 'Select namespace'); - }, [isLoadingNamespaces, namespaceOptions.length]); - - const groupPlaceholder = useMemo(() => { - if (isLoadingNamespaces) { - return t('common.loading', 'Loading...'); - } - if (allGroupNames.length === 0) { - return t('alerting.rules-filter.no-groups', 'No groups available'); - } - return t('grafana.select-group', 'Select group'); - }, [isLoadingNamespaces, allGroupNames.length]); + const allGroupNames: string[] = []; + const isLoadingNamespaces = false; + 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 }; } export function useLabelOptions(): { - labelOptions: Array>; - isLoadingGrafanaLabels: boolean; + labelOptions: (inputValue: string) => Promise>>; } { - const { labels: grafanaLabels, isLoading: isLoadingGrafanaLabels } = - useGetLabelsFromDataSourceName(GRAFANA_RULES_SOURCE_NAME); + // Use lazy queries so we only fetch when the dropdown is opened or the user types + const [fetchGrafanaGroups] = prometheusApi.useLazyGetGrafanaGroupsQuery(); - const labelOptions = useMemo((): Array> => { - const infoOption: ComboboxOption = { + const createInfoOption = useCallback((): ComboboxOption => { + return { label: t('label-dropdown-info', "Can't find your label? Enter it manually"), value: '__GRAFANA_LABEL_DROPDOWN_INFO__', infoOption: true, }; + }, []); - const selectableOptions = Array.from(grafanaLabels.entries()) - .flatMap(([key, values]) => - Array.from(values).map((value: string) => ({ label: `${key}=${value}`, value: `${key}=${value}` })) - ) - .sort((a, b) => new Intl.Collator().compare(a.label, b.label)); + const toOptions = useCallback((labelsMap: Map>): Array> => { + const selectable: Array> = Array.from(labelsMap.entries()).flatMap(([key, values]) => + Array.from(values).map>((value) => ({ + label: `${key}=${value}`, + value: `${key}=${value}`, + })) + ); - return [...selectableOptions, infoOption]; - }, [grafanaLabels]); + selectable.sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); + return selectable; + }, []); - return { labelOptions, isLoadingGrafanaLabels }; + const labelOptions = useCallback( + async (inputValue: string): Promise>> => { + // Fetch grafana groups and prefer cache when available + const response = await fetchGrafanaGroups({ limitAlerts: 0, groupLimit: 1000 }, true).unwrap(); + const labelsMap = groupsToLabels(response.data.groups); + + const selectable = toOptions(labelsMap); + if (selectable.length === 0) { + return []; + } + + const options = [...selectable, createInfoOption()]; + return filterBySearch(options, inputValue, true); + }, + [fetchGrafanaGroups, toOptions, createInfoOption] + ); + + return { labelOptions }; } -export function useAlertingDataSourceOptions(): Array> { - return useMemo(() => { - return getDataSourceSrv() +export function useAlertingDataSourceOptions(): (inputValue: string) => Promise>> { + return useCallback(async (inputValue: string) => { + const options = getDataSourceSrv() .getList({ alerting: true }) .map((ds: DataSourceInstanceSettings) => ({ label: ds.name, value: ds.name })); + return filterBySearch(options, inputValue); }, []); } + +function groupsToLabels(groups: Array<{ rules: Array<{ labels?: Record }> }>) { + const rules = groups.flatMap((group) => group.rules); + + return rules.reduce((result, rule) => { + if (!rule.labels) { + return result; + } + + Object.entries(rule.labels).forEach(([labelKey, labelValue]) => { + if (!labelKey || !labelValue) { + return; + } + const existing = result.get(labelKey); + if (existing) { + existing.add(labelValue); + } else { + result.set(labelKey, new Set([labelValue])); + } + }); + + return result; + }, new Map>()); +} + +// Removed rulerRulesToLabels since label autocomplete only uses Prometheus namespaces for simplicity + +function filterBySearch(options: Array>, inputValue: string, keepInfoOption = false) { + const search = (inputValue ?? '').toLowerCase(); + if (!search) { + return options; + } + return options.filter( + (opt) => (opt.label ?? opt.value).toLowerCase().includes(search) || (keepInfoOption && !!opt.infoOption) + ); +} 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 69b3b865a43..e2bda7ee725 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 @@ -244,7 +244,7 @@ const FilterOptions = ({ onSubmit, onClear, pluginsFilterEnabled }: FilterOption const { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder } = useNamespaceAndGroupOptions(); - const { labelOptions, isLoadingGrafanaLabels } = useLabelOptions(); + const { labelOptions } = useLabelOptions(); // Create label options for the multi-select dropdown const dataSourceOptions = useAlertingDataSourceOptions(); @@ -283,11 +283,7 @@ const FilterOptions = ({ onSubmit, onClear, pluginsFilterEnabled }: FilterOption
- + ; - isLoadingGrafanaLabels: boolean; + labelOptions: (inputValue: string) => Promise>; portalContainer?: HTMLElement; }) { const { control } = useFormContext(); @@ -356,13 +350,7 @@ function LabelsField({ options={labelOptions} value={field.value} onChange={(selections) => field.onChange(selections.map((s) => s.value))} - placeholder={ - isLoadingGrafanaLabels - ? t('common.loading', 'Loading...') - : t('alerting.rules-filter.placeholder-labels', 'Select labels') - } - loading={isLoadingGrafanaLabels} - disabled={isLoadingGrafanaLabels || labelOptions.filter((option) => !option.infoOption).length === 0} + placeholder={t('alerting.rules-filter.placeholder-labels', 'Select labels')} portalContainer={portalContainer} width="auto" minWidth={40} @@ -380,7 +368,7 @@ function NamespaceField({ isLoadingNamespaces, portalContainer, }: { - namespaceOptions: Array<{ label?: string; value: string; description?: string }>; + namespaceOptions: (inputValue: string) => Promise>; namespacePlaceholder: string; isLoadingNamespaces: boolean; portalContainer?: HTMLElement; @@ -402,7 +390,6 @@ function NamespaceField({ onChange={(option) => field.onChange(option?.value || null)} value={field.value} loading={isLoadingNamespaces} - disabled={isLoadingNamespaces || namespaceOptions.length === 0} isClearable portalContainer={portalContainer} /> @@ -441,7 +428,6 @@ function GroupField({ onChange={(option) => field.onChange(option?.value || null)} value={field.value} loading={isLoadingNamespaces} - disabled={isLoadingNamespaces || allGroupNames.length === 0} isClearable portalContainer={portalContainer} /> @@ -456,7 +442,7 @@ function DataSourceNamesField({ dataSourceOptions, portalContainer, }: { - dataSourceOptions: Array<{ label?: string; value: string }>; + dataSourceOptions: (inputValue: string) => Promise>; portalContainer?: HTMLElement; }) { const { control } = useFormContext(); diff --git a/public/app/features/query/components/QueryEditorRows.test.tsx b/public/app/features/query/components/QueryEditorRows.test.tsx index c61ec4b15c1..42cf86bb0b8 100644 --- a/public/app/features/query/components/QueryEditorRows.test.tsx +++ b/public/app/features/query/components/QueryEditorRows.test.tsx @@ -202,7 +202,7 @@ describe('QueryEditorRows', () => { const { renderResult: { rerender }, } = renderScenario(); - expect((await screen.findByTestId('query-editor-rows')).children.length).toBe(2); + expect(await screen.findAllByTestId('query-editor-row')).toHaveLength(2); rerender( { /> ); - expect((await screen.findByTestId('query-editor-rows')).children.length).toBe(1); + expect(await screen.findAllByTestId('query-editor-row')).toHaveLength(1); }); it('Should be able to expand and collapse queries', async () => { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index df0b8a4d53d..1bb2e7623a2 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2638,8 +2638,6 @@ "show": "Show" }, "manage-alerts": "In these data sources, you can select Manage alerts via Alerting UI to be able to manage these alert rules in the Grafana UI as well as in the data source where they were configured.", - "no-groups": "No groups available", - "no-namespaces": "No folders available", "placeholder-all-data-sources": "All data sources", "placeholder-contact-point": "Select contact point", "placeholder-data-sources": "Select data sources",