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
This commit is contained in:
Lauren
2025-09-02 14:12:20 +00:00
committed by GitHub
parent f8cd7049e8
commit 95f167bb8b
4 changed files with 156 additions and 170 deletions
@@ -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<ComboboxOption<string>>;
namespaceOptions: (inputValue: string) => Promise<Array<ComboboxOption<string>>>;
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<string> => {
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<string, { name: string; groups: GrafanaPromRuleGroupDTO[] }>();
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<ComboboxOption<string>> = 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<string>();
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<ComboboxOption<string>> => {
const grafanaFolders: Array<ComboboxOption<string>> = [];
const externalNamespaces: Array<ComboboxOption<string>> = [];
// 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<string>();
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<string>();
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<ComboboxOption<string>>;
isLoadingGrafanaLabels: boolean;
labelOptions: (inputValue: string) => Promise<Array<ComboboxOption<string>>>;
} {
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<ComboboxOption<string>> => {
const infoOption: ComboboxOption<string> = {
const createInfoOption = useCallback((): ComboboxOption<string> => {
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<string, Set<string>>): Array<ComboboxOption<string>> => {
const selectable: Array<ComboboxOption<string>> = Array.from(labelsMap.entries()).flatMap(([key, values]) =>
Array.from(values).map<ComboboxOption<string>>((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<Array<ComboboxOption<string>>> => {
// 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<ComboboxOption<string>> {
return useMemo(() => {
return getDataSourceSrv()
export function useAlertingDataSourceOptions(): (inputValue: string) => Promise<Array<ComboboxOption<string>>> {
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<string, string> }> }>) {
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<string, Set<string>>());
}
// Removed rulerRulesToLabels since label autocomplete only uses Prometheus namespaces for simplicity
function filterBySearch(options: Array<ComboboxOption<string>>, 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)
);
}
@@ -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
<Stack direction="column" alignItems="end" gap={2}>
<div className={styles.grid}>
<RuleNameField />
<LabelsField
labelOptions={labelOptions}
isLoadingGrafanaLabels={isLoadingGrafanaLabels}
portalContainer={portalContainer}
/>
<LabelsField labelOptions={labelOptions} portalContainer={portalContainer} />
<NamespaceField
namespaceOptions={namespaceOptions}
namespacePlaceholder={namespacePlaceholder}
@@ -335,11 +331,9 @@ function RuleNameField() {
function LabelsField({
labelOptions,
isLoadingGrafanaLabels,
portalContainer,
}: {
labelOptions: Array<{ label?: string; value: string; infoOption?: boolean }>;
isLoadingGrafanaLabels: boolean;
labelOptions: (inputValue: string) => Promise<Array<{ label?: string; value: string; infoOption?: boolean }>>;
portalContainer?: HTMLElement;
}) {
const { control } = useFormContext<AdvancedFilters>();
@@ -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<Array<{ label?: string; value: string; description?: string }>>;
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<Array<{ label?: string; value: string }>>;
portalContainer?: HTMLElement;
}) {
const { control } = useFormContext<AdvancedFilters>();
@@ -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(
<QueryEditorRows
@@ -216,7 +216,7 @@ describe('QueryEditorRows', () => {
/>
);
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 () => {
-2
View File
@@ -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",