Alerting: Add datasource_uid query param to search for rules (#114697)

* Alerting: Add datasource param to BE search

* use array for param, datasource -> datasources

* tests

* remove comments

* tests, short-circuit request if all data source names are invalid

* rephrase comment

* update some tests...

* make linter happy

* datasource_uid -> datasource_uids

* added test

* datasource_uids -> datasource_uid

---------

Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com>
This commit is contained in:
Santiago
2025-12-04 09:01:12 +00:00
committed by GitHub
co-authored by Sonia Aguilar
parent f4fbbcc4f4
commit 73b9a8c3af
6 changed files with 117 additions and 35 deletions
@@ -37,6 +37,7 @@ type PromRulesOptions = WithNotificationOptions<{
export type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'namespace' | 'excludeAlerts'> & {
folderUid?: string;
dashboardUid?: string;
datasources?: string[];
panelId?: number;
limitAlerts?: number;
ruleLimit?: number;
@@ -98,6 +99,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
limitAlerts,
groupNextToken,
title,
datasources,
searchGroupName,
dashboardUid,
}) => ({
@@ -114,6 +116,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
rule_limit: ruleLimit?.toFixed(0),
group_limit: groupLimit?.toFixed(0),
group_next_token: groupNextToken,
datasource_uid: datasources,
'search.rule_name': title,
'search.rule_group': searchGroupName,
dashboard_uid: dashboardUid,
@@ -259,7 +259,7 @@ function looseParseMatcher(matcherQuery: string): Matcher | undefined {
}
// Memoize the function to avoid calling getDatasourceAPIUid for the filter values multiple times
const mapDataSourceNamesToUids = memoize(
export const mapDataSourceNamesToUids = memoize(
(names: string[]): string[] => {
return names.map((name) => attempt(getDatasourceAPIUid, name)).filter(isString);
},
@@ -257,12 +257,55 @@ describe('grafana-managed rules', () => {
expect(backendFilter.health).toEqual([]);
expect(backendFilter.contactPoint).toBeUndefined();
});
it('should not set hasInvalidDataSourceNames flag when no data source names are provided', () => {
const { hasInvalidDataSourceNames } = getGrafanaFilter(getFilter({}));
expect(hasInvalidDataSourceNames).toBe(false);
});
});
describe('backend filtering with alertingUIUseBackendFilters feature toggle', () => {
describe('when alertingUIUseBackendFilters is enabled', () => {
testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] });
it('should include datasources in backend filter when valid data source names are provided', () => {
const { backendFilter, hasInvalidDataSourceNames } = getGrafanaFilter(
getFilter({ dataSourceNames: ['prometheus', 'loki'] })
);
expect(backendFilter.datasources).toEqual(['datasource-uid-1', 'datasource-uid-3']);
expect(hasInvalidDataSourceNames).toBe(false);
});
it('should detect invalid data source names and set hasInvalidDataSourceNames flag', () => {
const { backendFilter, hasInvalidDataSourceNames } = getGrafanaFilter(
getFilter({ dataSourceNames: ['non-existent-datasource'] })
);
expect(backendFilter.datasources).toEqual([]);
expect(hasInvalidDataSourceNames).toBe(true);
});
it('should include only valid datasource UIDs when some names are invalid', () => {
const { backendFilter, hasInvalidDataSourceNames } = getGrafanaFilter(
getFilter({ dataSourceNames: ['prometheus', 'non-existent-datasource'] })
);
expect(backendFilter.datasources).toEqual(['datasource-uid-1']);
expect(hasInvalidDataSourceNames).toBe(false); // Not all are invalid
});
it('should skip dataSourceNames filtering on frontend when backend filtering is enabled', () => {
const rule = mockGrafanaPromAlertingRule({
queriedDatasourceUIDs: ['datasource-uid-1'],
});
const { frontendFilter } = getGrafanaFilter(getFilter({ dataSourceNames: ['loki'] }));
// Should return true because dataSourceNames filter is null (handled by backend).
expect(frontendFilter.ruleMatches(rule)).toBe(true);
});
it('should include title in backend filter when freeFormWords are provided', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ freeFormWords: ['cpu', 'usage'] }));
@@ -556,25 +599,32 @@ describe('grafana-managed rules', () => {
const { frontendFilter: groupNoMatch } = getGrafanaFilter(getFilter({ groupName: 'memory' }));
expect(groupNoMatch.groupMatches(group)).toBe(false);
// Always-frontend filters (labels, dataSourceNames, namespace) should work
// Always-frontend filters (labels, namespace) should work.
const { frontendFilter: labelsMatch } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] }));
expect(labelsMatch.ruleMatches(alertingRule)).toBe(true);
const { frontendFilter: labelsNoMatch } = getGrafanaFilter(getFilter({ labels: ['severity=warning'] }));
expect(labelsNoMatch.ruleMatches(alertingRule)).toBe(false);
const { frontendFilter: dsMatch } = getGrafanaFilter(getFilter({ dataSourceNames: ['prometheus'] }));
expect(dsMatch.ruleMatches(alertingRule)).toBe(true);
const { frontendFilter: dsNoMatch } = getGrafanaFilter(getFilter({ dataSourceNames: ['loki'] }));
expect(dsNoMatch.ruleMatches(alertingRule)).toBe(false);
const { frontendFilter: nsMatch } = getGrafanaFilter(getFilter({ namespace: 'production' }));
expect(nsMatch.groupMatches(group)).toBe(true);
const { frontendFilter: nsNoMatch } = getGrafanaFilter(getFilter({ namespace: 'staging' }));
expect(nsNoMatch.groupMatches(group)).toBe(false);
});
it('should skip dataSourceNames filtering on frontend (handled by backend)', () => {
const alertingRule = mockGrafanaPromAlertingRule({
queriedDatasourceUIDs: ['datasource-uid-1'],
});
// DataSourceNames is backend-filtered when feature toggle is enabled.
const { frontendFilter: dsMatch } = getGrafanaFilter(getFilter({ dataSourceNames: ['prometheus'] }));
expect(dsMatch.ruleMatches(alertingRule)).toBe(true);
const { frontendFilter: dsNoMatch } = getGrafanaFilter(getFilter({ dataSourceNames: ['loki'] }));
expect(dsNoMatch.ruleMatches(alertingRule)).toBe(true);
});
});
describe('when both alertingUIUseBackendFilters and alertingUIUseFullyCompatBackendFilters are enabled', () => {
@@ -631,11 +681,10 @@ describe('grafana-managed rules', () => {
expect(frontendFilter.groupMatches(group)).toBe(true);
});
it('should still apply always-frontend filters (labels, dataSourceNames, namespace)', () => {
it('should still apply always-frontend filters (labels, namespace)', () => {
const rule = mockGrafanaPromAlertingRule({
name: 'High CPU Usage',
labels: { severity: 'critical' },
queriedDatasourceUIDs: ['datasource-uid-1'],
alerts: [],
});
@@ -646,13 +695,6 @@ describe('grafana-managed rules', () => {
const { frontendFilter: labelFilter2 } = getGrafanaFilter(getFilter({ labels: ['severity=critical'] }));
expect(labelFilter2.ruleMatches(rule)).toBe(true);
// DataSourceNames filter should still work
const { frontendFilter: dsFilter } = getGrafanaFilter(getFilter({ dataSourceNames: ['prometheus'] }));
expect(dsFilter.ruleMatches(rule)).toBe(true);
const { frontendFilter: dsFilter2 } = getGrafanaFilter(getFilter({ dataSourceNames: ['loki'] }));
expect(dsFilter2.ruleMatches(rule)).toBe(false);
// Namespace filter should still work
const group: PromRuleGroupDTO = {
name: 'Test Group',
@@ -667,6 +709,19 @@ describe('grafana-managed rules', () => {
const { frontendFilter: nsFilter2 } = getGrafanaFilter(getFilter({ namespace: 'staging' }));
expect(nsFilter2.groupMatches(group)).toBe(false);
});
it('should skip dataSourceNames filtering on frontend (handled by backend)', () => {
const rule = mockGrafanaPromAlertingRule({
queriedDatasourceUIDs: ['datasource-uid-1'],
});
// DataSourceNames is backend-filtered when both feature toggles are enabled.
const { frontendFilter: dsFilter } = getGrafanaFilter(getFilter({ dataSourceNames: ['prometheus'] }));
expect(dsFilter.ruleMatches(rule)).toBe(true);
const { frontendFilter: dsFilter2 } = getGrafanaFilter(getFilter({ dataSourceNames: ['loki'] }));
expect(dsFilter2.ruleMatches(rule)).toBe(true);
});
});
});
@@ -732,9 +787,12 @@ describe('grafana-managed rules', () => {
expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false);
});
it('should return false for dataSourceNames (handled by backend when feature toggle is enabled)', () => {
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false);
});
it('should return true for client-side only filters', () => {
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
});
@@ -749,10 +807,11 @@ describe('grafana-managed rules', () => {
testWithFeatureToggles({ enable: ['alertingUIUseFullyCompatBackendFilters'] });
it('should return correct values for all filter types', () => {
// Should return false for: empty, backend-handled (ruleType, dashboardUid), and backend-only filters
// Should return false for: empty, backend-handled (ruleType, dashboardUid, dataSourceNames), and backend-only filters
expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
@@ -762,7 +821,6 @@ describe('grafana-managed rules', () => {
expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
});
});
@@ -782,10 +840,12 @@ describe('grafana-managed rules', () => {
expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
// Should return true for: always-frontend filters only
// Should return true for: always-frontend filters only (namespace, labels)
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
// Should return false for: backend-handled dataSourceNames when feature toggles are enabled
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(false);
});
});
});
@@ -14,6 +14,7 @@ import {
groupMatches,
groupNameFilter,
labelsFilter,
mapDataSourceNamesToUids,
namespaceFilter,
pluginsFilter,
ruleMatches,
@@ -63,6 +64,17 @@ export function getGrafanaFilter(filterState: Partial<RulesFilter>) {
// Build title search for backend filtering
const titleSearch = buildTitleSearch(normalizedFilterState);
// Check if data source names were provided but none are valid.
let hasInvalidDataSourceNames = false;
let datasourceUids: string[] | undefined = undefined;
// Only map datasources if data source filter should be applied on backend (when ruleFilterConfig.dataSourceNames is null).
if (ruleFilterConfig.dataSourceNames === null && normalizedFilterState.dataSourceNames.length > 0) {
datasourceUids = mapDataSourceNamesToUids(normalizedFilterState.dataSourceNames);
// If names were provided but no valid UIDs were found, all names are invalid.
hasInvalidDataSourceNames = datasourceUids.length === 0;
}
const backendFilter: GrafanaPromRulesOptions = {
state: normalizedFilterState.ruleState ? [normalizedFilterState.ruleState] : [],
health: normalizedFilterState.ruleHealth ? [normalizedFilterState.ruleHealth] : [],
@@ -72,6 +84,7 @@ export function getGrafanaFilter(filterState: Partial<RulesFilter>) {
type: ruleFilterConfig.ruleType ? undefined : normalizedFilterState.ruleType,
dashboardUid: ruleFilterConfig.dashboardUid ? undefined : normalizedFilterState.dashboardUid,
searchGroupName: groupFilterConfig.groupName ? undefined : normalizedFilterState.groupName,
datasources: ruleFilterConfig.dataSourceNames ? undefined : datasourceUids,
};
return {
@@ -80,6 +93,7 @@ export function getGrafanaFilter(filterState: Partial<RulesFilter>) {
groupMatches: (group: PromRuleGroupDTO) => groupMatches(group, normalizedFilterState, groupFilterConfig),
ruleMatches: (rule: PromRuleDTO) => ruleMatches(rule, normalizedFilterState, ruleFilterConfig),
},
hasInvalidDataSourceNames,
};
}
@@ -100,7 +114,7 @@ function buildGrafanaFilterConfigs() {
ruleName: useBackendFilters ? null : ruleNameFilter,
ruleState: null,
ruleType: useBackendFilters || useFullyCompatibleBackendFilters ? null : ruleTypeFilter,
dataSourceNames: dataSourceNamesFilter,
dataSourceNames: useBackendFilters || useFullyCompatibleBackendFilters ? null : dataSourceNamesFilter,
labels: labelsFilter,
ruleHealth: null,
dashboardUid: useBackendFilters || useFullyCompatibleBackendFilters ? null : dashboardUidFilter,
@@ -84,7 +84,12 @@ export function useFilteredRulesIteratorProvider() {
const hasDataSourceFilterActive = Boolean(filterState.dataSourceNames.length);
const { backendFilter, frontendFilter } = getGrafanaFilter(filterState);
const { backendFilter, frontendFilter, hasInvalidDataSourceNames } = getGrafanaFilter(filterState);
// Short-circuit: if all provided data source names are invalid, return empty results (no rules can match).
if (hasInvalidDataSourceNames) {
return { iterable: empty(), abortController };
}
const grafanaRulesGenerator: AsyncIterableX<RuleWithOrigin> = from(
grafanaGroupsGenerator(options.grafanaManagedLimit, backendFilter)
@@ -73,6 +73,7 @@ describe('paginationLimits', () => {
{ ruleState: PromAlertingRuleState.Firing },
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
{ dataSourceNames: ['prometheus'] },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
@@ -85,7 +86,6 @@ describe('paginationLimits', () => {
it.each<Partial<RulesFilter>>([
{ namespace: 'production' },
{ dataSourceNames: ['prometheus'] },
{ labels: ['severity=critical'] },
{ ruleState: PromAlertingRuleState.Firing, namespace: 'production' },
])('should return large limits for both when frontend filters are used: %p', (filterState) => {
@@ -112,6 +112,7 @@ describe('paginationLimits', () => {
{ ruleState: PromAlertingRuleState.Firing },
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
{ dataSourceNames: ['prometheus'] },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
@@ -127,7 +128,6 @@ describe('paginationLimits', () => {
{ ruleName: 'alert' },
{ groupName: 'test-group' },
{ namespace: 'production' },
{ dataSourceNames: ['prometheus'] },
{ labels: ['severity=critical'] },
])('should return large limits for both when frontend filters are used: %p', (filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
@@ -156,6 +156,7 @@ describe('paginationLimits', () => {
{ ruleState: PromAlertingRuleState.Firing },
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
{ dataSourceNames: ['prometheus'] },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
@@ -166,16 +167,15 @@ describe('paginationLimits', () => {
}
);
it.each<Partial<RulesFilter>>([
{ namespace: 'production' },
{ dataSourceNames: ['prometheus'] },
{ labels: ['severity=critical'] },
])('should return large limits for both when frontend filters are used: %p', (filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
it.each<Partial<RulesFilter>>([{ namespace: 'production' }, { labels: ['severity=critical'] }])(
'should return large limits for both when frontend filters are used: %p',
(filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
});
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
}
);
});
});
});