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:
co-authored by
Konrad Lalik
parent
9d928a3ac6
commit
da5af29218
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user