Alerting: Add rule_limits to rule list requests (#114176)

* Alerting: Add rule_limits to rule list requests

* Unify pagination limits calculation for GMA and DMA rules

* Fix limits, add tests

* Alerting: Rename filter functions and limit properties for clarity

- hasClientSideFilters → hasGrafanaClientSideFilters
- hasDatasourceFilters → hasDatasourceClientSideFilters
- gmaLimit → grafanaManagedLimit
- dmaLimit → datasourceManagedLimit

---------

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
This commit is contained in:
Alexander Akhmetov
2025-11-27 14:19:38 +00:00
committed by GitHub
co-authored by Konrad Lalik
parent 8e73cc2f70
commit 42d3673d04
11 changed files with 362 additions and 103 deletions
@@ -39,6 +39,7 @@ export type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'nam
dashboardUid?: string;
panelId?: number;
limitAlerts?: number;
ruleLimit?: number;
contactPoint?: string;
health?: RuleHealth[];
state?: PromAlertingRuleState[];
@@ -93,6 +94,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
state,
type,
groupLimit,
ruleLimit,
limitAlerts,
groupNextToken,
title,
@@ -109,6 +111,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
state: state,
rule_type: type,
limit_alerts: limitAlerts,
rule_limit: ruleLimit?.toFixed(0),
group_limit: groupLimit?.toFixed(0),
group_next_token: groupNextToken,
'search.rule_name': title,
@@ -16,14 +16,13 @@ import { GrafanaRuleListItem } from './GrafanaRuleListItem';
import LoadMoreHelper from './LoadMoreHelper';
import { UnknownRuleListItem } from './components/AlertRuleListItem';
import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader';
import { hasClientSideFilters } from './hooks/grafanaFilter';
import {
GrafanaRuleWithOrigin,
PromRuleWithOrigin,
RuleWithOrigin,
useFilteredRulesIteratorProvider,
} from './hooks/useFilteredRulesIterator';
import { FRONTEND_LIST_PAGE_SIZE, getSearchApiGroupPageSize } from './paginationLimits';
import { FRONTEND_LIST_PAGE_SIZE, getFilteredRulesLimits } from './paginationLimits';
interface FilterViewProps {
filterState: RulesFilter;
@@ -78,10 +77,7 @@ function FilterViewResults({ filterState }: FilterViewProps) {
* ⚠️ Make sure we are returning / using a "iterator" and not an "iterable" since the iterable is only a blueprint
* and the iterator will allow us to exhaust the iterable in a stateful way
*/
const { iterable, abortController } = getFilteredRulesIterator(
filterState,
getSearchApiGroupPageSize(hasClientSideFilters(filterState))
);
const { iterable, abortController } = getFilteredRulesIterator(filterState, getFilteredRulesLimits(filterState));
const rulesBatchIterator = iterable
.pipe(
bufferCountOrTime(FRONTEND_LIST_PAGE_SIZE, 1000),
@@ -7,7 +7,6 @@ import { GrafanaPromRuleGroupDTO, PromRuleGroupDTO } from 'app/types/unified-ale
import { FolderActionsButton } from '../components/folder-actions/FolderActionsButton';
import { GrafanaNoRulesCTA } from '../components/rules/NoRulesCTA';
import { shouldUseBackendFilters } from '../featureToggles';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { groups } from '../utils/navigation';
@@ -18,7 +17,7 @@ import { ListGroup } from './components/ListGroup';
import { ListSection } from './components/ListSection';
import { LoadMoreButton } from './components/LoadMoreButton';
import { NoRulesFound } from './components/NoRulesFound';
import { getGrafanaFilter } from './hooks/grafanaFilter';
import { getGrafanaFilter, hasGrafanaClientSideFilters } from './hooks/grafanaFilter';
import { toIndividualRuleGroups, useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator';
import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups';
import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits';
@@ -36,25 +35,27 @@ export function PaginatedGrafanaLoader({ groupFilter, namespaceFilter }: LoaderP
}
function PaginatedGroupsLoader({ groupFilter, namespaceFilter }: LoaderProps) {
const useBackendFilters = shouldUseBackendFilters();
// When backend filters are enabled, groupFilter is handled on the backend
const hasFilters = useBackendFilters ? Boolean(namespaceFilter) : Boolean(groupFilter || namespaceFilter);
const filterState = { namespace: namespaceFilter, groupName: groupFilter };
const { backendFilter } = getGrafanaFilter(filterState);
const hasFilters = Boolean(groupFilter || namespaceFilter);
const needsClientSideFiltering = hasGrafanaClientSideFilters(filterState);
// If there are filters, we don't want to populate the cache to avoid performance issues
// Filtering may trigger multiple HTTP requests, which would populate the cache with a lot of groups hurting performance
const grafanaGroupsGenerator = useGrafanaGroupsGenerator({
populateCache: hasFilters ? false : true,
populateCache: needsClientSideFiltering ? false : true,
limitAlerts: 0,
});
// If there are no filters we can match one frontend page to one API page.
// However, if there are filters, we need to fetch more groups from the API to populate one frontend page
const apiGroupPageSize = getApiGroupPageSize(hasFilters);
const apiGroupPageSize = getApiGroupPageSize(needsClientSideFiltering);
const searchGroupName = useBackendFilters ? groupFilter : undefined;
const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(apiGroupPageSize, { searchGroupName })));
const groupsGenerator = useRef(
toIndividualRuleGroups(grafanaGroupsGenerator({ groupLimit: apiGroupPageSize }, backendFilter))
);
useEffect(() => {
const currentGenerator = groupsGenerator.current;
@@ -22,6 +22,27 @@ import {
ruleTypeFilter,
} from './filterPredicates';
/**
* Determines if client-side filtering is needed for data source-managed rules.
*/
export function hasDatasourceClientSideFilters(filterState: Partial<RulesFilter>): boolean {
// Check if any filter that applies to datasource rules is active
return (
(filterState.freeFormWords && filterState.freeFormWords.length > 0) ||
Boolean(filterState.ruleName) ||
Boolean(filterState.ruleState) ||
Boolean(filterState.ruleType) ||
(filterState.dataSourceNames && filterState.dataSourceNames.length > 0) ||
(filterState.labels && filterState.labels.length > 0) ||
Boolean(filterState.ruleHealth) ||
Boolean(filterState.dashboardUid) ||
Boolean(filterState.plugins) ||
Boolean(filterState.contactPoint) ||
Boolean(filterState.namespace) ||
Boolean(filterState.groupName)
);
}
/**
* Builds filter configurations for data source-managed alert rules.
*
@@ -32,12 +32,14 @@ export function buildTitleSearch(filterState: RulesFilter): string | undefined {
* Normalize filter state for case-insensitive matching
* Lowercase free form words, rule name, group name and namespace
*/
export function normalizeFilterState(filterState: RulesFilter): RulesFilter {
export function normalizeFilterState(filterState: Partial<RulesFilter>): RulesFilter {
return {
...filterState,
freeFormWords: filterState.freeFormWords.map((word) => word.toLowerCase()),
freeFormWords: filterState.freeFormWords?.map((word) => word.toLowerCase()) ?? [],
ruleName: filterState.ruleName?.toLowerCase(),
groupName: filterState.groupName?.toLowerCase(),
namespace: filterState.namespace?.toLowerCase(),
dataSourceNames: filterState.dataSourceNames ?? [],
labels: filterState.labels ?? [],
};
}
@@ -8,7 +8,7 @@ import { Annotation } from '../../utils/constants';
import { getDatasourceAPIUid } from '../../utils/datasource';
import { getFilter } from '../../utils/search';
import { getGrafanaFilter, hasClientSideFilters } from './grafanaFilter';
import { getGrafanaFilter, hasGrafanaClientSideFilters } from './grafanaFilter';
jest.mock('../../utils/datasource');
@@ -670,41 +670,41 @@ describe('grafana-managed rules', () => {
});
});
describe('hasClientSideFilters', () => {
describe('hasGrafanaClientSideFilters', () => {
describe('when alertingUIUseBackendFilters is disabled', () => {
testWithFeatureToggles({ disable: ['alertingUIUseBackendFilters'] });
it('should return false when no filters are applied', () => {
expect(hasClientSideFilters(getFilter({}))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false);
});
it('should return true for title-related filters (freeFormWords, ruleName)', () => {
expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true);
});
it('should return true for ruleType filter', () => {
expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true);
});
it('should return true for dashboardUid filter', () => {
expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true);
});
it('should return true for groupName filter', () => {
expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true);
});
it('should return true for client-side only filters', () => {
expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
});
it('should return false for backend-only filters (state, health, contactPoint)', () => {
expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).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);
});
});
@@ -712,36 +712,36 @@ describe('grafana-managed rules', () => {
testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] });
it('should return false when no filters are applied', () => {
expect(hasClientSideFilters(getFilter({}))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false);
});
it('should return false for title-related filters (handled by backend)', () => {
expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false);
});
it('should return false for ruleType filter (handled by backend)', () => {
expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
});
it('should return false for dashboardUid filter (handled by backend)', () => {
expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false);
});
it('should return false for groupName filter (handled by backend)', () => {
expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false);
});
it('should return true for client-side only filters', () => {
expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
});
it('should return false for backend-only filters (state, health, contactPoint)', () => {
expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).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);
});
});
@@ -750,20 +750,20 @@ describe('grafana-managed rules', () => {
it('should return correct values for all filter types', () => {
// Should return false for: empty, backend-handled (ruleType, dashboardUid), and backend-only filters
expect(hasClientSideFilters(getFilter({}))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).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);
// Should return true for: frontend-handled filters
expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
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);
});
});
@@ -772,20 +772,20 @@ describe('grafana-managed rules', () => {
it('should return correct values for all filter types', () => {
// Should return false for: empty, all backend-handled filters, and backend-only filters
expect(hasClientSideFilters(getFilter({}))).toBe(false);
expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false);
expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).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);
// Should return true for: always-frontend filters only
expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
});
});
});
@@ -24,26 +24,26 @@ import {
/**
* Determines if client-side filtering is needed for Grafana-managed rules.
*/
export function hasClientSideFilters(filterState: RulesFilter): boolean {
export function hasGrafanaClientSideFilters(filterState: Partial<RulesFilter>): boolean {
const { ruleFilterConfig, groupFilterConfig } = buildGrafanaFilterConfigs();
// Check each rule filter: if the config has a non-null handler AND the filter state has a value, we need client-side filtering
const hasActiveRuleFilters =
(ruleFilterConfig.freeFormWords !== null && filterState.freeFormWords.length > 0) ||
(ruleFilterConfig.ruleName !== null && Boolean(filterState.ruleName)) ||
(ruleFilterConfig.ruleState !== null && Boolean(filterState.ruleState)) ||
(ruleFilterConfig.ruleType !== null && Boolean(filterState.ruleType)) ||
(ruleFilterConfig.dataSourceNames !== null && filterState.dataSourceNames.length > 0) ||
(ruleFilterConfig.labels !== null && filterState.labels.length > 0) ||
(ruleFilterConfig.ruleHealth !== null && Boolean(filterState.ruleHealth)) ||
(ruleFilterConfig.dashboardUid !== null && Boolean(filterState.dashboardUid)) ||
(ruleFilterConfig.plugins !== null && Boolean(filterState.plugins)) ||
(ruleFilterConfig.contactPoint !== null && Boolean(filterState.contactPoint));
(ruleFilterConfig.freeFormWords !== null && Boolean(filterState?.freeFormWords?.length)) ||
(ruleFilterConfig.ruleName !== null && Boolean(filterState?.ruleName)) ||
(ruleFilterConfig.ruleState !== null && Boolean(filterState?.ruleState)) ||
(ruleFilterConfig.ruleType !== null && Boolean(filterState?.ruleType)) ||
(ruleFilterConfig.dataSourceNames !== null && Boolean(filterState?.dataSourceNames?.length)) ||
(ruleFilterConfig.labels !== null && Boolean(filterState?.labels?.length)) ||
(ruleFilterConfig.ruleHealth !== null && Boolean(filterState?.ruleHealth)) ||
(ruleFilterConfig.dashboardUid !== null && Boolean(filterState?.dashboardUid)) ||
(ruleFilterConfig.plugins !== null && Boolean(filterState?.plugins)) ||
(ruleFilterConfig.contactPoint !== null && Boolean(filterState?.contactPoint));
// Check each group filter: if the config has a non-null handler AND the filter state has a value, we need client-side filtering
const hasActiveGroupFilters =
(groupFilterConfig.namespace !== null && Boolean(filterState.namespace)) ||
(groupFilterConfig.groupName !== null && Boolean(filterState.groupName));
(groupFilterConfig.namespace !== null && Boolean(filterState?.namespace)) ||
(groupFilterConfig.groupName !== null && Boolean(filterState?.groupName));
return hasActiveRuleFilters || hasActiveGroupFilters;
}
@@ -55,7 +55,7 @@ export function hasClientSideFilters(filterState: RulesFilter): boolean {
* The backend filter is used for server-side filtering when `shouldUseBackendFilters()` is enabled,
* while the frontend filter provides client-side matching functions for rules and groups.
*/
export function getGrafanaFilter(filterState: RulesFilter) {
export function getGrafanaFilter(filterState: Partial<RulesFilter>) {
const normalizedFilterState = normalizeFilterState(filterState);
const { ruleFilterConfig, groupFilterConfig } = buildGrafanaFilterConfigs();
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { MergeExclusive } from 'type-fest';
import { DataSourceRulesSourceIdentifier, RuleHealth } from 'app/types/unified-alerting';
import { PromAlertingRuleState, PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
@@ -16,27 +17,23 @@ interface UseGeneratorHookOptions {
limitAlerts?: number;
}
interface FetchGroupsOptions {
groupLimit?: number;
groupNextToken?: string;
}
export function usePrometheusGroupsGenerator() {
const [getGroups] = useLazyGetGroupsQuery();
return useCallback(
async function* (ruleSource: DataSourceRulesSourceIdentifier, groupLimit: number) {
const getRuleSourceGroupsWithCache = async (fetchOptions: FetchGroupsOptions) => {
const getRuleSourceGroupsWithCache = async (fetchOptions: GroupsNextPageOptions) => {
const response = await getGroups({
ruleSource: { uid: ruleSource.uid },
notificationOptions: { showErrorAlert: false },
groupLimit,
...fetchOptions,
}).unwrap();
return response;
};
yield* genericGroupsGenerator(getRuleSourceGroupsWithCache, groupLimit);
yield* genericGroupsGenerator(getRuleSourceGroupsWithCache);
},
[getGroups]
);
@@ -52,8 +49,21 @@ interface GrafanaPromApiFilter {
dashboardUid?: string;
}
interface GrafanaFetchGroupsOptions extends FetchGroupsOptions {
interface GrafanaFetchGroupsOptions extends GroupsNextPageOptions {
filter?: GrafanaPromApiFilter;
groupLimit?: number;
// Limits the number of total rules returned across all groups
// Rounds up to full groups, so the response may contain more rules than the group limit
ruleLimit?: number;
}
export type GrafanaFetchGroupsLimit = MergeExclusive<{ groupLimit: number }, { ruleLimit: number }>;
export type DataSourceFetchGroupsLimit = { groupLimit: number };
export interface FetchGroupsLimitOptions {
grafanaManagedLimit: GrafanaFetchGroupsLimit;
datasourceManagedLimit: DataSourceFetchGroupsLimit;
}
export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions = {}) {
@@ -78,11 +88,16 @@ export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions =
);
return useCallback(
async function* (groupLimit: number, filter?: GrafanaPromApiFilter) {
yield* genericGroupsGenerator(
(fetchOptions) => getGroupsAndProvideCache({ ...fetchOptions, filter }),
groupLimit
);
async function* (limit: GrafanaFetchGroupsLimit, filter?: GrafanaPromApiFilter) {
const fetchGroups = (fetchOptions: GroupsNextPageOptions) =>
getGroupsAndProvideCache({
...fetchOptions,
filter,
groupLimit: 'groupLimit' in limit ? limit.groupLimit : undefined,
ruleLimit: 'ruleLimit' in limit ? limit.ruleLimit : undefined,
});
yield* genericGroupsGenerator(fetchGroups);
},
[getGroupsAndProvideCache]
);
@@ -105,21 +120,24 @@ export function toIndividualRuleGroups<TGroup extends PromRuleGroupDTO>(
})();
}
interface GroupsNextPageOptions {
groupNextToken?: string;
}
// Generator lazily provides groups one by one only when needed
// This might look a bit complex but it allows us to have one API for paginated and non-paginated Prometheus data sources
// For unpaginated data sources we fetch everything in one go
// For paginated we fetch the next page when needed
async function* genericGroupsGenerator<TGroup>(
fetchGroups: (options: FetchGroupsOptions) => Promise<PromRulesResponse<TGroup>>,
groupLimit: number
fetchGroups: (options: GroupsNextPageOptions) => Promise<PromRulesResponse<TGroup>>
) {
let response = await fetchGroups({ groupLimit });
let response = await fetchGroups({ groupNextToken: undefined });
yield response.data.groups;
let lastToken: string | undefined = response.data?.groupNextToken;
while (lastToken) {
response = await fetchGroups({ groupNextToken: lastToken, groupLimit: groupLimit });
response = await fetchGroups({ groupNextToken: lastToken });
yield response.data.groups;
lastToken = response.data?.groupNextToken;
}
@@ -26,7 +26,11 @@ import { RulePositionHash, createRulePositionHash } from '../rulePositionHash';
import { getDatasourceFilter } from './datasourceFilter';
import { getGrafanaFilter } from './grafanaFilter';
import { useGrafanaGroupsGenerator, usePrometheusGroupsGenerator } from './prometheusGroupsGenerator';
import {
FetchGroupsLimitOptions,
useGrafanaGroupsGenerator,
usePrometheusGroupsGenerator,
} from './prometheusGroupsGenerator';
export type RuleWithOrigin = PromRuleWithOrigin | GrafanaRuleWithOrigin;
@@ -74,7 +78,7 @@ export function useFilteredRulesIteratorProvider() {
const prometheusGroupsGenerator = usePrometheusGroupsGenerator();
const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ limitAlerts: 0 });
const getFilteredRulesIterable = (filterState: RulesFilter, groupLimit: number): GetIteratorResult => {
const getFilteredRulesIterable = (filterState: RulesFilter, options: FetchGroupsLimitOptions): GetIteratorResult => {
/* this is the abort controller that allows us to stop an AsyncIterable */
const abortController = new AbortController();
@@ -83,7 +87,7 @@ export function useFilteredRulesIteratorProvider() {
const { backendFilter, frontendFilter } = getGrafanaFilter(filterState);
const grafanaRulesGenerator: AsyncIterableX<RuleWithOrigin> = from(
grafanaGroupsGenerator(groupLimit, backendFilter)
grafanaGroupsGenerator(options.grafanaManagedLimit, backendFilter)
).pipe(
withAbort(abortController.signal),
concatMap((groups) =>
@@ -110,7 +114,7 @@ export function useFilteredRulesIteratorProvider() {
const dataSourceGenerators: Array<AsyncIterableX<RuleWithOrigin>> = externalRulesSourcesToFetchFrom.map(
(dataSourceIdentifier) => {
const promGroupsGenerator: AsyncIterableX<RuleWithOrigin> = from(
prometheusGroupsGenerator(dataSourceIdentifier, groupLimit)
prometheusGroupsGenerator(dataSourceIdentifier, options.datasourceManagedLimit.groupLimit)
).pipe(
withAbort(abortController.signal),
concatMap((groups) =>
@@ -0,0 +1,181 @@
import { testWithFeatureToggles } from 'test/test-utils';
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
import { RuleHealth, RulesFilter } from '../search/rulesSearchParser';
import { getFilter } from '../utils/search';
import {
FILTERED_GROUPS_LARGE_API_PAGE_SIZE,
FILTERED_GROUPS_SMALL_API_PAGE_SIZE,
RULE_LIMIT_WITH_BACKEND_FILTERS,
getFilteredRulesLimits,
} from './paginationLimits';
describe('paginationLimits', () => {
describe('getFilteredRulesLimits', () => {
describe('when backend filters are disabled', () => {
testWithFeatureToggles({ disable: ['alertingUIUseBackendFilters', 'alertingUIUseFullyCompatBackendFilters'] });
it('should return small limits when no filters are applied', () => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({}));
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE });
});
it.each<Partial<RulesFilter>>([
{ ruleState: PromAlertingRuleState.Firing },
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
])('should return small grafana limit + large datasource limit for backend-only filter: %p', (filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
});
it.each<Partial<RulesFilter>>([
{ freeFormWords: ['cpu'] },
{ ruleName: 'alert' },
{ ruleType: PromRuleType.Alerting },
{ dataSourceNames: ['prometheus'] },
{ labels: ['severity=critical'] },
{ dashboardUid: 'test-dashboard' },
{ plugins: 'hide' as const },
{ namespace: 'production' },
{ groupName: 'test-group' },
{ namespace: 'production', freeFormWords: ['cpu'] },
])('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 });
});
});
describe('when alertingUIUseBackendFilters is enabled', () => {
testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] });
it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({}));
expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE });
});
it.each<Partial<RulesFilter>>([
{ freeFormWords: ['cpu'] },
{ ruleName: 'alert' },
{ ruleType: PromRuleType.Alerting },
{ dashboardUid: 'test-dashboard' },
{ groupName: 'test-group' },
{ ruleState: PromAlertingRuleState.Firing },
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
}
);
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) => {
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 });
});
});
describe('when alertingUIUseFullyCompatBackendFilters is enabled', () => {
testWithFeatureToggles({ enable: ['alertingUIUseFullyCompatBackendFilters'] });
it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({}));
expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE });
});
it.each<Partial<RulesFilter>>([
{ ruleType: PromRuleType.Alerting },
{ dashboardUid: 'test-dashboard' },
{ ruleState: PromAlertingRuleState.Firing },
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
}
);
it.each<Partial<RulesFilter>>([
{ freeFormWords: ['cpu'] },
{ 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));
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
});
});
describe('when both backend filter toggles are enabled', () => {
testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters', 'alertingUIUseFullyCompatBackendFilters'] });
it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({}));
expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE });
});
it.each<Partial<RulesFilter>>([
{ freeFormWords: ['cpu'] },
{ ruleName: 'alert' },
{ ruleType: PromRuleType.Alerting },
{ dashboardUid: 'test-dashboard' },
{ groupName: 'test-group' },
{ ruleState: PromAlertingRuleState.Firing },
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
}
);
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));
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
});
});
});
});
@@ -1,3 +1,10 @@
import { shouldUseBackendFilters, shouldUseFullyCompatibleBackendFilters } from '../featureToggles';
import { RulesFilter } from '../search/rulesSearchParser';
import { hasDatasourceClientSideFilters } from './hooks/datasourceFilter';
import { hasGrafanaClientSideFilters } from './hooks/grafanaFilter';
import { FetchGroupsLimitOptions } from './hooks/prometheusGroupsGenerator';
export const FRONTEND_LIST_PAGE_SIZE = 100;
export const FILTERED_GROUPS_LARGE_API_PAGE_SIZE = 2000;
@@ -6,6 +13,8 @@ export const FILTERED_GROUPS_SMALL_API_PAGE_SIZE = 100;
export const DEFAULT_GROUPS_API_PAGE_SIZE = 40;
export const FRONTED_GROUPED_PAGE_SIZE = DEFAULT_GROUPS_API_PAGE_SIZE;
export const RULE_LIMIT_WITH_BACKEND_FILTERS = 100;
export function getApiGroupPageSize(hasFilters: boolean) {
return hasFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : DEFAULT_GROUPS_API_PAGE_SIZE;
}
@@ -13,3 +22,27 @@ export function getApiGroupPageSize(hasFilters: boolean) {
export function getSearchApiGroupPageSize(hasFrontendFilters: boolean) {
return hasFrontendFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : FILTERED_GROUPS_SMALL_API_PAGE_SIZE;
}
export function getFilteredRulesLimits(filterState: RulesFilter): FetchGroupsLimitOptions {
return {
grafanaManagedLimit: getGrafanaFilterLimits(filterState),
datasourceManagedLimit: {
groupLimit: hasDatasourceClientSideFilters(filterState)
? FILTERED_GROUPS_LARGE_API_PAGE_SIZE
: FILTERED_GROUPS_SMALL_API_PAGE_SIZE,
},
};
}
function getGrafanaFilterLimits(filterState: RulesFilter) {
const backendFiltersEnabled = shouldUseFullyCompatibleBackendFilters() || shouldUseBackendFilters();
const frontendFiltersInUse = hasGrafanaClientSideFilters(filterState);
const onlyBackendFiltersInUse = frontendFiltersInUse === false;
if (backendFiltersEnabled && onlyBackendFiltersInUse) {
return { ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS };
}
return { groupLimit: getSearchApiGroupPageSize(frontendFiltersInUse) };
}