Alerting: Add search.rule_group to search rules by rule group name (#113680)

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
This commit is contained in:
Alexander Akhmetov
2025-11-17 20:38:42 +01:00
committed by GitHub
co-authored by Konrad Lalik
parent 9d928a3ac6
commit da5af29218
11 changed files with 169 additions and 20 deletions
@@ -480,6 +480,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
receiverName := opts.Query.Get("receiver_name")
title := opts.Query.Get("search.rule_name")
searchRuleGroup := opts.Query.Get("search.rule_group")
var ruleType ngmodels.RuleTypeFilter
switch ngmodels.RuleType(opts.Query.Get("rule_type")) {
@@ -500,13 +501,14 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt
byGroupQuery := ngmodels.ListAlertRulesExtendedQuery{
ListAlertRulesQuery: ngmodels.ListAlertRulesQuery{
OrgID: opts.OrgID,
NamespaceUIDs: namespaceUIDs,
DashboardUID: dashboardUID,
PanelID: panelID,
RuleGroups: ruleGroups,
ReceiverName: receiverName,
SearchTitle: title,
OrgID: opts.OrgID,
NamespaceUIDs: namespaceUIDs,
DashboardUID: dashboardUID,
PanelID: panelID,
RuleGroups: ruleGroups,
ReceiverName: receiverName,
SearchTitle: title,
SearchRuleGroup: searchRuleGroup,
},
RuleType: ruleType,
Limit: maxGroups,
@@ -638,15 +640,17 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru
receiverName := opts.Query.Get("receiver_name")
title := opts.Query.Get("search.rule_name")
searchRuleGroup := opts.Query.Get("search.rule_group")
alertRuleQuery := ngmodels.ListAlertRulesQuery{
OrgID: opts.OrgID,
NamespaceUIDs: namespaceUIDs,
DashboardUID: dashboardUID,
PanelID: panelID,
RuleGroups: ruleGroups,
ReceiverName: receiverName,
SearchTitle: title,
OrgID: opts.OrgID,
NamespaceUIDs: namespaceUIDs,
DashboardUID: dashboardUID,
PanelID: panelID,
RuleGroups: ruleGroups,
ReceiverName: receiverName,
SearchTitle: title,
SearchRuleGroup: searchRuleGroup,
}
ruleList, err := store.ListAlertRules(opts.Ctx, &alertRuleQuery)
if err != nil {
@@ -981,6 +981,9 @@ type ListAlertRulesQuery struct {
// SearchTitle allows searching for alert rules that contain
// the given string in their title (case insensitive)
SearchTitle string
// SearchRuleGroup allows searching for alert rules in groups that contain
// the given string in their name (case insensitive)
SearchRuleGroup string
HasPrometheusRuleDefinition *bool
}
+12
View File
@@ -865,6 +865,18 @@ func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.Lis
}
}
if query.SearchRuleGroup != "" {
normalizedInput := strings.ToLower(query.SearchRuleGroup)
words := strings.Fields(normalizedInput)
if len(words) > 0 {
pattern := "%" + strings.Join(words, "%") + "%"
// In MySQL rule_group field has case-sensitive collation by default,
// so we need to use LOWER to perform case-insensitive search.
q = q.And("LOWER(rule_group) LIKE ?", pattern)
}
}
if query.HasPrometheusRuleDefinition != nil {
q, err = st.filterWithPrometheusRuleDefinition(*query.HasPrometheusRuleDefinition, q)
if err != nil {
@@ -2141,6 +2141,75 @@ func TestIntegration_ListAlertRules(t *testing.T) {
})
}
})
t.Run("filter by SearchRuleGroup", 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.WithGroupName("database-alerts")))
rule2 := createRule(t, store, ruleGen.With(models.RuleMuts.WithGroupName("application-alerts")))
rule3 := createRule(t, store, ruleGen.With(models.RuleMuts.WithGroupName("network-alerts")))
rule4 := createRule(t, store, ruleGen.With(models.RuleMuts.WithGroupName("critical-monitoring")))
tc := []struct {
name string
groupSearch string
expectedRules []*models.AlertRule
}{
{
name: "should find rules",
groupSearch: "alerts",
expectedRules: []*models.AlertRule{rule1, rule2, rule3},
},
{
name: "should find rule with partial match",
groupSearch: "mOnItOrInG",
expectedRules: []*models.AlertRule{rule4},
},
{
name: "should return no rules when no match",
groupSearch: "nonexistent",
expectedRules: []*models.AlertRule{},
},
{
name: "should return all rules when empty",
groupSearch: "",
expectedRules: []*models.AlertRule{rule1, rule2, rule3, rule4},
},
{
name: "should not find rules when word order is reversed",
groupSearch: "alerts database",
expectedRules: []*models.AlertRule{},
},
{
name: "should find multiple rules matching sequential words",
groupSearch: "database alert",
expectedRules: []*models.AlertRule{rule1},
},
{
name: "should handle extra whitespace between words",
groupSearch: " network alerts ",
expectedRules: []*models.AlertRule{rule3},
},
{
name: "should handle multiple words with partial matches",
groupSearch: "crit mon",
expectedRules: []*models.AlertRule{rule4},
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
query := &models.ListAlertRulesQuery{
OrgID: orgID,
SearchRuleGroup: tt.groupSearch,
}
result, err := store.ListAlertRules(context.Background(), query)
require.NoError(t, err)
require.ElementsMatch(t, tt.expectedRules, result)
})
}
})
}
func TestIntegration_ListAlertRulesPaginated(t *testing.T) {
@@ -43,6 +43,7 @@ export type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'nam
health?: RuleHealth[];
state?: PromAlertingRuleState[];
title?: string;
searchGroupName?: string;
type?: 'alerting' | 'recording';
};
@@ -95,6 +96,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
limitAlerts,
groupNextToken,
title,
searchGroupName,
dashboardUid,
}) => ({
url: `api/prometheus/grafana/api/v1/rules`,
@@ -110,6 +112,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
group_limit: groupLimit?.toFixed(0),
group_next_token: groupNextToken,
'search.rule_name': title,
'search.rule_group': searchGroupName,
dashboard_uid: dashboardUid,
},
}),
@@ -7,6 +7,7 @@ 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';
@@ -35,10 +36,13 @@ 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);
// 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 hasFilters = Boolean(groupFilter || namespaceFilter);
const grafanaGroupsGenerator = useGrafanaGroupsGenerator({
populateCache: hasFilters ? false : true,
limitAlerts: 0,
@@ -48,7 +52,9 @@ function PaginatedGroupsLoader({ groupFilter, namespaceFilter }: LoaderProps) {
// However, if there are filters, we need to fetch more groups from the API to populate one frontend page
const apiGroupPageSize = getApiGroupPageSize(hasFilters);
const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(apiGroupPageSize)));
const searchGroupName = useBackendFilters ? groupFilter : undefined;
const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(apiGroupPageSize, { searchGroupName })));
useEffect(() => {
const currentGenerator = groupsGenerator.current;
@@ -650,6 +650,31 @@ describe('grafana-managed rules', () => {
expect(frontendFilter.ruleMatches(ruleWithDashboard)).toBe(true);
});
it('should include searchGroupName in backend filter when provided', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ groupName: 'my-group' }));
expect(backendFilter.searchGroupName).toBe('my-group');
});
it('should not include searchGroupName in backend filter when not provided', () => {
const { backendFilter } = getGrafanaFilter(getFilter({}));
expect(backendFilter.searchGroupName).toBeUndefined();
});
it('should skip groupName filtering on frontend when backend filtering is enabled', () => {
const group: PromRuleGroupDTO = {
name: 'CPU Usage Alerts',
file: 'production/alerts',
rules: [],
interval: 60,
};
const { frontendFilter } = getGrafanaFilter(getFilter({ groupName: 'memory' }));
// Should return true because groupName filter is null (handled by backend)
expect(frontendFilter.groupMatches(group)).toBe(true);
});
it('should still apply other frontend filters', () => {
const rule = mockGrafanaPromAlertingRule({
name: 'High CPU Usage',
@@ -739,6 +764,27 @@ describe('grafana-managed rules', () => {
expect(frontendFilter2.ruleMatches(ruleDashboardA)).toBe(false);
expect(frontendFilter2.ruleMatches(ruleDashboardB)).toBe(true);
});
it('should not include searchGroupName in backend filter', () => {
const { backendFilter } = getGrafanaFilter(getFilter({ groupName: 'my-group' }));
expect(backendFilter.searchGroupName).toBeUndefined();
});
it('should perform groupName filtering on frontend', () => {
const group: PromRuleGroupDTO = {
name: 'CPU Usage Alerts',
file: 'production/alerts',
rules: [],
interval: 60,
};
const { frontendFilter } = getGrafanaFilter(getFilter({ groupName: 'cpu' }));
expect(frontendFilter.groupMatches(group)).toBe(true);
const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ groupName: 'memory' }));
expect(frontendFilter2.groupMatches(group)).toBe(false);
});
});
});
});
@@ -42,6 +42,7 @@ export function getGrafanaFilter(filterState: RulesFilter) {
title: useBackendFilters ? titleSearch : undefined,
type: useBackendFilters ? normalizedFilterState.ruleType : undefined,
dashboardUid: useBackendFilters ? normalizedFilterState.dashboardUid : undefined,
searchGroupName: useBackendFilters ? normalizedFilterState.groupName : undefined,
};
const grafanaFilterProcessingConfig: RuleFilterConfig = {
@@ -60,7 +61,7 @@ export function getGrafanaFilter(filterState: RulesFilter) {
const grafanaGroupFilterConfig: GroupFilterConfig = {
namespace: namespaceFilter,
groupName: groupNameFilter,
groupName: useBackendFilters ? null : groupNameFilter,
};
return {
@@ -47,6 +47,7 @@ interface GrafanaPromApiFilter {
health?: RuleHealth[];
contactPoint?: string;
title?: string;
searchGroupName?: string;
type?: 'alerting' | 'recording';
dashboardUid?: string;
}
@@ -28,6 +28,7 @@ describe('hasClientSideFilters', () => {
expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false);
expect(hasClientSideFilters(getFilter({ groupName: 'my-group' }))).toBe(false);
});
it('should return true for client-side only filters', () => {
@@ -52,6 +53,7 @@ describe('hasClientSideFilters', () => {
expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true);
expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ groupName: 'my-group' }))).toBe(true);
});
it('should return true for client-side only filters', () => {
@@ -78,6 +80,7 @@ describe('hasClientSideFilters', () => {
expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true);
expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ groupName: 'my-group' }))).toBe(true);
});
});
});
@@ -153,12 +153,13 @@ export function hasClientSideFilters(filterState: RulesFilter): boolean {
const useBackendFilters = shouldUseBackendFilters();
return (
// When backend filters are disabled, title search, type filter, and dashboard filter need client-side filtering
// When backend filters are disabled, title search, type filter, dashboard filter, and group name filter need client-side filtering
(!useBackendFilters &&
(filterState.freeFormWords.length > 0 ||
Boolean(filterState.ruleName) ||
Boolean(filterState.ruleType) ||
Boolean(filterState.dashboardUid))) ||
Boolean(filterState.dashboardUid) ||
Boolean(filterState.groupName))) ||
// Client-side only filters:
Boolean(filterState.namespace) ||
filterState.dataSourceNames.length > 0 ||