Alerting: Support rule title search on the backend (#113738)

This commit is contained in:
Alexander Akhmetov
2025-11-13 15:52:14 +01:00
committed by GitHub
parent 7fe5772888
commit 44a92d252b
18 changed files with 317 additions and 11 deletions
+4
View File
@@ -345,6 +345,10 @@ export interface FeatureToggles {
*/
alertingProvenanceLockWrites?: boolean;
/**
* Enables the UI to use certain backend-side filters
*/
alertingUIUseBackendFilters?: boolean;
/**
* Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.
*/
alertmanagerRemotePrimary?: boolean;
+8
View File
@@ -575,6 +575,14 @@ var (
HideFromAdminPage: true,
HideFromDocs: true,
},
{
Name: "alertingUIUseBackendFilters",
Description: "Enables the UI to use certain backend-side filters",
Stage: FeatureStageExperimental,
Owner: grafanaAlertingSquad,
HideFromAdminPage: true,
HideFromDocs: true,
},
{
Name: "alertmanagerRemotePrimary",
Description: "Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.",
+1
View File
@@ -76,6 +76,7 @@ cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-e
addFieldFromCalculationStatFunctions,GA,@grafana/datapro,false,false,true
alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false
alertingProvenanceLockWrites,experimental,@grafana/alerting-squad,false,false,false
alertingUIUseBackendFilters,experimental,@grafana/alerting-squad,false,false,false
alertmanagerRemotePrimary,experimental,@grafana/alerting-squad,false,false,false
annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false
dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
76 addFieldFromCalculationStatFunctions GA @grafana/datapro false false true
77 alertmanagerRemoteSecondary experimental @grafana/alerting-squad false false false
78 alertingProvenanceLockWrites experimental @grafana/alerting-squad false false false
79 alertingUIUseBackendFilters experimental @grafana/alerting-squad false false false
80 alertmanagerRemotePrimary experimental @grafana/alerting-squad false false false
81 annotationPermissionUpdate GA @grafana/identity-access-team false false false
82 dashboardSceneForViewers GA @grafana/dashboards-squad false false true
+4
View File
@@ -315,6 +315,10 @@ const (
// Enables a feature to avoid issues with concurrent writes to the alerting provenance table in MySQL
FlagAlertingProvenanceLockWrites = "alertingProvenanceLockWrites"
// FlagAlertingUIUseBackendFilters
// Enables the UI to use certain backend-side filters
FlagAlertingUIUseBackendFilters = "alertingUIUseBackendFilters"
// FlagAlertmanagerRemotePrimary
// Enable Grafana to have a remote Alertmanager instance as the primary Alertmanager.
FlagAlertmanagerRemotePrimary = "alertmanagerRemotePrimary"
+14
View File
@@ -614,6 +614,20 @@
"expression": "true"
}
},
{
"metadata": {
"name": "alertingUIUseBackendFilters",
"resourceVersion": "1762966218072",
"creationTimestamp": "2025-11-12T16:50:18Z"
},
"spec": {
"description": "Enables the UI to use certain backend-side filters",
"stage": "experimental",
"codeowner": "@grafana/alerting-squad",
"hideFromAdminPage": true,
"hideFromDocs": true
}
},
{
"metadata": {
"name": "alertingUseNewSimplifiedRoutingHashAlgorithm",
@@ -479,6 +479,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
ruleGroups := opts.Query["rule_group"]
receiverName := opts.Query.Get("receiver_name")
title := opts.Query.Get("search.rule_name")
maxGroups := getInt64WithDefault(opts.Query, "group_limit", -1)
nextToken := opts.Query.Get("group_next_token")
@@ -495,6 +496,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
PanelID: panelID,
RuleGroups: ruleGroups,
ReceiverName: receiverName,
SearchTitle: title,
},
Limit: maxGroups,
ContinueToken: nextToken,
@@ -624,6 +626,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru
ruleGroups := opts.Query["rule_group"]
receiverName := opts.Query.Get("receiver_name")
title := opts.Query.Get("search.rule_name")
alertRuleQuery := ngmodels.ListAlertRulesQuery{
OrgID: opts.OrgID,
@@ -632,6 +635,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru
PanelID: panelID,
RuleGroups: ruleGroups,
ReceiverName: receiverName,
SearchTitle: title,
}
ruleList, err := store.ListAlertRules(opts.Ctx, &alertRuleQuery)
if err != nil {
@@ -978,6 +978,9 @@ type ListAlertRulesQuery struct {
ReceiverName string
TimeIntervalName string
// SearchTitle allows searching for alert rules that contain
// the given string in their title (case insensitive)
SearchTitle string
HasPrometheusRuleDefinition *bool
}
+10
View File
@@ -855,6 +855,16 @@ func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.Lis
}
}
if query.SearchTitle != "" {
words := strings.Fields(query.SearchTitle)
if len(words) > 0 {
// Build sequential pattern: %word1%word2%word3%
pattern := strings.Join(words, "%")
sql, param := st.SQLStore.GetDialect().LikeOperator("title", true, pattern, true)
q = q.And(sql, param)
}
}
if query.HasPrometheusRuleDefinition != nil {
q, err = st.filterWithPrometheusRuleDefinition(*query.HasPrometheusRuleDefinition, q)
if err != nil {
+71 -2
View File
@@ -2020,12 +2020,10 @@ func Benchmark_ListAlertRules(b *testing.B) {
func TestIntegration_ListAlertRules(t *testing.T) {
tutil.SkipIntegrationTestInShortMode(t)
sqlStore := db.InitTestDB(t)
cfg := setting.NewCfg()
cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{
BaseInterval: time.Duration(rand.Int64N(100)) * time.Second,
}
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
b := &fakeBus{}
orgID := int64(1)
ruleGen := models.RuleGen
@@ -2034,6 +2032,8 @@ func TestIntegration_ListAlertRules(t *testing.T) {
ruleGen.WithOrgID(orgID),
)
t.Run("filter by HasPrometheusRuleDefinition", func(t *testing.T) {
sqlStore := db.InitTestDB(t)
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b)
regularRule := createRule(t, store, ruleGen)
importedRule := createRule(t, store, ruleGen.With(
@@ -2072,6 +2072,75 @@ func TestIntegration_ListAlertRules(t *testing.T) {
})
}
})
t.Run("filter by SearchTitle", func(t *testing.T) {
sqlStore := db.InitTestDB(t)
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b)
rule1 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("CPU Usage Alert")))
rule2 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("Memory Usage Alert")))
rule3 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("Disk Space Alert")))
rule4 := createRule(t, store, ruleGen.With(models.RuleMuts.WithTitle("Application Error Rate")))
tc := []struct {
name string
titleSearch string
expectedRules []*models.AlertRule
}{
{
name: "should find rules",
titleSearch: "alert",
expectedRules: []*models.AlertRule{rule1, rule2, rule3},
},
{
name: "should find rule with partial match",
titleSearch: "aPpl",
expectedRules: []*models.AlertRule{rule4},
},
{
name: "should return no rules when no match",
titleSearch: "nonexistent",
expectedRules: []*models.AlertRule{},
},
{
name: "should return all rules when empty",
titleSearch: "",
expectedRules: []*models.AlertRule{rule1, rule2, rule3, rule4},
},
{
name: "should not find rules when word order is reversed",
titleSearch: "usage cpu",
expectedRules: []*models.AlertRule{},
},
{
name: "should find multiple rules matching sequential words",
titleSearch: "usage alert",
expectedRules: []*models.AlertRule{rule1, rule2},
},
{
name: "should handle extra whitespace between words",
titleSearch: " cpu usage ",
expectedRules: []*models.AlertRule{rule1},
},
{
name: "should handle multiple words with partial matches",
titleSearch: "aPp erR",
expectedRules: []*models.AlertRule{rule4},
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
query := &models.ListAlertRulesQuery{
OrgID: orgID,
SearchTitle: tt.titleSearch,
}
result, err := store.ListAlertRules(context.Background(), query)
require.NoError(t, err)
require.ElementsMatch(t, tt.expectedRules, result)
})
}
})
}
func TestIntegration_ListAlertRulesPaginated(t *testing.T) {
@@ -42,6 +42,7 @@ type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'namespace'
contactPoint?: string;
health?: RuleHealth[];
state?: PromAlertingRuleState[];
title?: string;
};
export const prometheusApi = alertingApi.injectEndpoints({
@@ -91,6 +92,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
groupLimit,
limitAlerts,
groupNextToken,
title,
}) => ({
url: `api/prometheus/grafana/api/v1/rules`,
params: {
@@ -103,6 +105,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
limit_alerts: limitAlerts,
group_limit: groupLimit?.toFixed(0),
group_next_token: groupNextToken,
'search.rule_name': title,
},
}),
providesTags: (_result, _error, { folderUid, groupName, ruleName }) => {
@@ -21,3 +21,5 @@ export const shouldAllowRecoveringDeletedRules = () =>
export const shouldAllowPermanentlyDeletingRules = () =>
(shouldAllowRecoveringDeletedRules() && config.featureToggles.alertingRulePermanentlyDelete) ?? false;
export const shouldUseBackendFilters = () => config.featureToggles.alertingUIUseBackendFilters ?? false;
@@ -20,9 +20,10 @@ import {
GrafanaRuleWithOrigin,
PromRuleWithOrigin,
RuleWithOrigin,
hasClientSideFilters,
useFilteredRulesIteratorProvider,
} from './hooks/useFilteredRulesIterator';
import { FRONTEND_LIST_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits';
import { FRONTEND_LIST_PAGE_SIZE, getSearchApiGroupPageSize } from './paginationLimits';
interface FilterViewProps {
filterState: RulesFilter;
@@ -77,7 +78,10 @@ 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, getApiGroupPageSize(true));
const { iterable, abortController } = getFilteredRulesIterator(
filterState,
getSearchApiGroupPageSize(hasClientSideFilters(filterState))
);
const rulesBatchIterator = iterable
.pipe(
bufferCountOrTime(FRONTEND_LIST_PAGE_SIZE, 1000),
@@ -60,6 +60,34 @@ describe('ruleFilter', () => {
expect(ruleFilter(rule, getFilter({ ruleName: 'memory' }))).toBe(false);
});
describe('backendFiltered parameter for backend filtering', () => {
it('should skip title filtering when backendFiltered is true', () => {
const rule = mockPromAlertingRule({ name: 'High CPU Usage' });
// When backendFiltered is true, title search should be skipped (already filtered by backend)
expect(ruleFilter(rule, getFilter({ freeFormWords: ['memory'] }), true)).toBe(true);
expect(ruleFilter(rule, getFilter({ ruleName: 'memory' }), true)).toBe(true);
});
it('should perform title filtering when backendFiltered is false', () => {
const rule = mockPromAlertingRule({ name: 'High CPU Usage' });
// When backendFiltered is false, title search should be performed client-side
expect(ruleFilter(rule, getFilter({ freeFormWords: ['cpu'] }), false)).toBe(true);
expect(ruleFilter(rule, getFilter({ freeFormWords: ['memory'] }), false)).toBe(false);
expect(ruleFilter(rule, getFilter({ ruleName: 'cpu' }), false)).toBe(true);
expect(ruleFilter(rule, getFilter({ ruleName: 'memory' }), false)).toBe(false);
});
it('should perform title filtering when backendFiltered is not specified (backward compatibility)', () => {
const rule = mockPromAlertingRule({ name: 'High CPU Usage' });
// When backendFiltered is not provided, should perform client-side filtering (default behavior)
expect(ruleFilter(rule, getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
expect(ruleFilter(rule, getFilter({ freeFormWords: ['memory'] }))).toBe(false);
});
});
it('should filter by labels', () => {
const rule = mockPromAlertingRule({
labels: { severity: 'critical', team: 'ops' },
@@ -36,18 +36,20 @@ export function groupFilter(
/**
* @returns True if the rule matches the filter, false otherwise
* @param backendFiltered - If true, title search is skipped (already filtered by backend)
*/
export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) {
export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter, backendFiltered?: boolean) {
const { name, labels = {}, health, type } = rule;
if (filterState.freeFormWords.length > 0) {
if (filterState.freeFormWords.length > 0 && !backendFiltered) {
const nameMatches = fuzzyMatches(name, filterState.freeFormWords.join(' '));
if (!nameMatches) {
return false;
}
}
if (filterState.ruleName && !fuzzyMatches(name, filterState.ruleName)) {
// Rule name search: Backend-supported for backend-filtered rules, client-side otherwise
if (filterState.ruleName && !backendFiltered && !fuzzyMatches(name, filterState.ruleName)) {
return false;
}
@@ -46,6 +46,7 @@ interface GrafanaPromApiFilter {
state?: PromAlertingRuleState[];
health?: RuleHealth[];
contactPoint?: string;
title?: string;
}
interface GrafanaFetchGroupsOptions extends FetchGroupsOptions {
@@ -0,0 +1,96 @@
import { config } from '@grafana/runtime';
import { RuleSource } from '../../search/rulesSearchParser';
import { getFilter } from '../../utils/search';
import { buildTitleSearch, hasClientSideFilters } from './useFilteredRulesIterator';
describe('hasClientSideFilters', () => {
const originalFeatureToggles = config.featureToggles;
beforeEach(() => {
config.featureToggles = { ...originalFeatureToggles };
});
afterEach(() => {
config.featureToggles = originalFeatureToggles;
});
describe('when alertingUIUseBackendFilters is enabled', () => {
beforeEach(() => {
config.featureToggles.alertingUIUseBackendFilters = true;
});
it('should return false for title search filters (backend-supported)', () => {
expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(false);
});
it('should return true for client-side only filters', () => {
expect(hasClientSideFilters(getFilter({ namespace: 'test' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ ruleSource: RuleSource.DataSource }))).toBe(true);
});
it('should return false when no filters are applied', () => {
expect(hasClientSideFilters(getFilter({}))).toBe(false);
});
});
describe('when alertingUIUseBackendFilters is disabled', () => {
beforeEach(() => {
config.featureToggles.alertingUIUseBackendFilters = false;
});
it('should return true for title search filters (client-side fallback)', () => {
expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(true);
});
it('should return true for client-side only filters', () => {
expect(hasClientSideFilters(getFilter({ namespace: 'test' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ ruleSource: RuleSource.DataSource }))).toBe(true);
});
it('should return false when no filters are applied', () => {
expect(hasClientSideFilters(getFilter({}))).toBe(false);
});
});
describe('when alertingUIUseBackendFilters is undefined (default)', () => {
beforeEach(() => {
config.featureToggles.alertingUIUseBackendFilters = undefined;
});
it('should return true for title search filters (backward compatibility)', () => {
// Default behavior should be client-side filtering
expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(true);
});
});
});
describe('buildTitleSearch', () => {
it('returns undefined when no search terms are provided', () => {
expect(buildTitleSearch(getFilter({}))).toBeUndefined();
});
it('returns the rule name when only a rule search is provided', () => {
expect(buildTitleSearch(getFilter({ ruleName: 'cpu' }))).toBe('cpu');
});
it('returns joined free-form words when only text search is provided', () => {
expect(buildTitleSearch(getFilter({ freeFormWords: ['cpu', 'memory'] }))).toBe('cpu memory');
});
it('concatenates rule search and free-form text when both are provided', () => {
expect(buildTitleSearch(getFilter({ ruleName: 'cpu', freeFormWords: ['memory', 'latency'] }))).toBe(
'cpu memory latency'
);
});
});
@@ -15,6 +15,7 @@ import {
PromRuleGroupDTO,
} from 'app/types/unified-alerting-dto';
import { shouldUseBackendFilters } from '../../featureToggles';
import { RuleSource, RulesFilter } from '../../search/rulesSearchParser';
import {
getDataSourceByUid,
@@ -79,12 +80,16 @@ export function useFilteredRulesIteratorProvider() {
const normalizedFilterState = normalizeFilterState(filterState);
const hasDataSourceFilterActive = Boolean(filterState.dataSourceNames.length);
const useBackendFilters = shouldUseBackendFilters();
const titleSearch = useBackendFilters ? buildTitleSearch(filterState) : undefined;
const grafanaRulesGenerator: AsyncIterableX<RuleWithOrigin> = from(
grafanaGroupsGenerator(groupLimit, {
contactPoint: filterState.contactPoint ?? undefined,
health: filterState.ruleHealth ? [filterState.ruleHealth] : [],
state: filterState.ruleState ? [filterState.ruleState] : [],
title: titleSearch,
})
).pipe(
withAbort(abortController.signal),
@@ -92,7 +97,7 @@ export function useFilteredRulesIteratorProvider() {
groups
.filter((group) => groupFilter(group, normalizedFilterState))
.flatMap((group) => group.rules.map((rule) => ({ group, rule })))
.filter(({ rule }) => ruleFilter(rule, normalizedFilterState))
.filter(({ rule }) => ruleFilter(rule, normalizedFilterState, useBackendFilters))
.map(({ group, rule }) => mapGrafanaRuleToRuleWithOrigin(group, rule))
),
catchError(() => empty())
@@ -117,7 +122,7 @@ export function useFilteredRulesIteratorProvider() {
groups
.filter((group) => groupFilter(group, normalizedFilterState))
.flatMap((group) => group.rules.map((rule, index) => ({ group, rule, index })))
.filter(({ rule }) => ruleFilter(rule, normalizedFilterState))
.filter(({ rule }) => ruleFilter(rule, normalizedFilterState, false))
.map(({ group, rule, index }) => mapRuleToRuleWithOrigin(dataSourceIdentifier, group, rule, index))
),
catchError(() => empty())
@@ -146,6 +151,48 @@ export function useFilteredRulesIteratorProvider() {
return getFilteredRulesIterable;
}
/**
* Determines if client-side filtering is needed for Grafana-managed rules.
*/
export function hasClientSideFilters(filterState: RulesFilter): boolean {
const useBackendFilters = shouldUseBackendFilters();
return (
// When backend filters are disabled, title search needs client-side filtering
(!useBackendFilters && (filterState.freeFormWords.length > 0 || Boolean(filterState.ruleName))) ||
// Client-side only filters:
Boolean(filterState.namespace) ||
filterState.dataSourceNames.length > 0 ||
filterState.labels.length > 0 ||
Boolean(filterState.dashboardUid) ||
filterState.ruleSource === RuleSource.DataSource
);
}
export function buildTitleSearch(filterState: RulesFilter): string | undefined {
const titleParts: string[] = [];
const ruleName = filterState.ruleName?.trim();
if (ruleName) {
titleParts.push(ruleName);
}
const freeFormSegment = filterState.freeFormWords
.map((word) => word.trim())
.filter(Boolean)
.join(' ');
if (freeFormSegment) {
titleParts.push(freeFormSegment);
}
if (titleParts.length === 0) {
return undefined;
}
return titleParts.join(' ');
}
function mergeIterables(iterables: Array<AsyncIterableX<RuleWithOrigin>>): AsyncIterableX<RuleWithOrigin> {
if (iterables.length === 0) {
return empty();
@@ -1,9 +1,15 @@
export const FRONTEND_LIST_PAGE_SIZE = 100;
export const FILTERED_GROUPS_API_PAGE_SIZE = 2000;
export const FILTERED_GROUPS_LARGE_API_PAGE_SIZE = 2000;
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 function getApiGroupPageSize(hasFilters: boolean) {
return hasFilters ? FILTERED_GROUPS_API_PAGE_SIZE : DEFAULT_GROUPS_API_PAGE_SIZE;
return hasFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : DEFAULT_GROUPS_API_PAGE_SIZE;
}
export function getSearchApiGroupPageSize(hasFrontendFilters: boolean) {
return hasFrontendFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : FILTERED_GROUPS_SMALL_API_PAGE_SIZE;
}