diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index a8e4279de7f..3c4fe219dd0 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -37,6 +37,7 @@ type PromRulesOptions = WithNotificationOptions<{ export type GrafanaPromRulesOptions = Omit & { 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, diff --git a/public/app/features/alerting/unified/rule-list/hooks/filterPredicates.ts b/public/app/features/alerting/unified/rule-list/hooks/filterPredicates.ts index d268a336f08..c9639f71030 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filterPredicates.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filterPredicates.ts @@ -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); }, diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts index 6a714382a96..12a46a87bdd 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts @@ -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); }); }); }); diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts index 0cc89ceafcf..c0fd9fea4d8 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts @@ -14,6 +14,7 @@ import { groupMatches, groupNameFilter, labelsFilter, + mapDataSourceNamesToUids, namespaceFilter, pluginsFilter, ruleMatches, @@ -63,6 +64,17 @@ export function getGrafanaFilter(filterState: Partial) { // 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) { 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) { 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, diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts index 7c5533beea5..5202c580542 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts @@ -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 = from( grafanaGroupsGenerator(options.grafanaManagedLimit, backendFilter) diff --git a/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts index 5ef8431aced..2e1db699883 100644 --- a/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts +++ b/public/app/features/alerting/unified/rule-list/paginationLimits.test.ts @@ -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>([ { 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>([ - { 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>([{ 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 }); + } + ); }); }); });