diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index e31e713fb33..25acf56f7d3 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "slices" "testing" "time" @@ -2790,6 +2791,101 @@ func TestRouteGetRuleStatuses(t *testing.T) { require.NotEmpty(t, res.Data.NextToken) }) }) + + t.Run("with search.folder filter", func(t *testing.T) { + fakeStore, fakeAIM, api := setupAPI(t) + + // Create folders with different paths + folder1 := &folder.Folder{UID: "prod-uid", Title: "Production", Fullpath: "Production", OrgID: orgID} + folder2 := &folder.Folder{UID: "prod-alerts-uid", Title: "Alerts", Fullpath: "Production/Alerts", OrgID: orgID} + folder3 := &folder.Folder{UID: "dev-uid", Title: "Monitoring", Fullpath: "Development/Monitoring", OrgID: orgID} + folder4 := &folder.Folder{UID: "prod-crit-uid", Title: "Critical", Fullpath: "Production/Critical", OrgID: orgID} + fakeStore.Folders[orgID] = []*folder.Folder{folder1, folder2, folder3, folder4} + + // Create rules in different folders + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithNamespaceUID("prod-uid"), gen.WithUID("rule1"), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithNamespaceUID("prod-alerts-uid"), gen.WithUID("rule2"), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithNamespaceUID("dev-uid"), gen.WithUID("rule3"), gen.WithNoNotificationSettings()) + generateRuleAndInstanceWithQuery(t, orgID, fakeAIM, fakeStore, withClassicConditionSingleQuery(), + gen.WithNamespaceUID("prod-crit-uid"), gen.WithUID("rule4"), gen.WithNoNotificationSettings()) + + testCases := []struct { + name string + searchFolder string + expectedUIDs []string + }{ + { + name: "search 'production' matches Production folders", + searchFolder: "production", + expectedUIDs: []string{"rule1", "rule2", "rule4"}, + }, + { + name: "search 'prod alerts' matches Production/Alerts", + searchFolder: "prod alerts", + expectedUIDs: []string{"rule2"}, + }, + { + name: "search 'dev' matches Development", + searchFolder: "dev", + expectedUIDs: []string{"rule3"}, + }, + { + name: "search 'critical' matches Critical folder", + searchFolder: "critical", + expectedUIDs: []string{"rule4"}, + }, + { + name: "case insensitive search", + searchFolder: "PRODUCTION", + expectedUIDs: []string{"rule1", "rule2", "rule4"}, + }, + { + name: "empty search returns all rules", + searchFolder: "", + expectedUIDs: []string{"rule1", "rule2", "rule3", "rule4"}, + }, + { + name: "non-matching search returns no rules", + searchFolder: "nonexistent", + expectedUIDs: []string{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + reqURL := "/api/v1/rules" + if tc.searchFolder != "" { + reqURL += "?search.folder=" + url.QueryEscape(tc.searchFolder) + } + req, err := http.NewRequest("GET", reqURL, nil) + require.NoError(t, err) + ctx := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{OrgID: orgID, Permissions: queryPermissions}, + } + + resp := api.RouteGetRuleStatuses(ctx) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + require.Equal(t, "success", res.Status) + + // Collect rule UIDs from response + actualUIDs := []string{} + for _, group := range res.Data.RuleGroups { + for _, rule := range group.Rules { + actualUIDs = append(actualUIDs, rule.UID) + } + } + + require.ElementsMatch(t, tc.expectedUIDs, actualUIDs) + }) + } + }) } func setupAPI(t *testing.T) (*fakes.RuleStore, *fakeAlertInstanceManager, PrometheusSrv) { diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index 082c1bfd333..d07e07d41fe 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -689,9 +689,20 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt namespaceUIDs := make([]string, 0, len(opts.AllowedNamespaces)) folderUID := opts.Query.Get("folder_uid") + searchFolder := opts.Query.Get("search.folder") + _, exists := opts.AllowedNamespaces[folderUID] if folderUID != "" && exists { + // Exact folder UID match namespaceUIDs = append(namespaceUIDs, folderUID) + } else if searchFolder != "" { + // Search folders by full path + matcher := NewTextMatcher(searchFolder) + for uid, fullpath := range opts.AllowedNamespaces { + if matcher.Match(fullpath) { + namespaceUIDs = append(namespaceUIDs, uid) + } + } } else { for k := range opts.AllowedNamespaces { namespaceUIDs = append(namespaceUIDs, k) @@ -700,9 +711,15 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt span.SetAttributes( attribute.Bool("folder_uid_set", folderUID != ""), + attribute.Bool("search_folder_set", searchFolder != ""), attribute.Int("namespace_count", len(namespaceUIDs)), ) + if searchFolder != "" && len(namespaceUIDs) == 0 { + log.Debug("No folders matched search.folder, returning empty response") + return ruleResponse + } + ruleGroups := opts.Query["rule_group"] ruleUIDs := opts.Query["rule_uid"] diff --git a/pkg/services/ngalert/api/prometheus/util.go b/pkg/services/ngalert/api/prometheus/util.go new file mode 100644 index 00000000000..9c5ecb50d82 --- /dev/null +++ b/pkg/services/ngalert/api/prometheus/util.go @@ -0,0 +1,41 @@ +package api + +import ( + "strings" +) + +// TextMatcher performs case-insensitive sequential substring matching. +// It checks if text contains all search words in the order provided, +// but not necessarily consecutively. +// +// Example: NewTextMatcher("api time").Match("API Response Time") returns true +type TextMatcher struct { + words []string +} + +func NewTextMatcher(search string) *TextMatcher { + words := strings.Fields(search) + for i := range words { + words[i] = strings.ToLower(words[i]) + } + return &TextMatcher{words: words} +} + +func (m *TextMatcher) Match(text string) bool { + if len(m.words) == 0 { + return true + } + lowerText := strings.ToLower(text) + start := 0 + for _, word := range m.words { + if start > len(lowerText) { + return false + } + idx := strings.Index(lowerText[start:], word) + if idx == -1 { + return false + } + start += idx + len(word) + } + return true +} diff --git a/pkg/services/ngalert/api/prometheus/util_test.go b/pkg/services/ngalert/api/prometheus/util_test.go new file mode 100644 index 00000000000..21940a5a8b8 --- /dev/null +++ b/pkg/services/ngalert/api/prometheus/util_test.go @@ -0,0 +1,132 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewTextMatcher(t *testing.T) { + tests := []struct { + name string + input string + expectedWords []string + }{ + { + name: "empty string", + input: "", + expectedWords: []string{}, + }, + { + name: "whitespace only", + input: " ", + expectedWords: []string{}, + }, + { + name: "single word", + input: "alerts", + expectedWords: []string{"alerts"}, + }, + { + name: "multiple words", + input: "parent alerts", + expectedWords: []string{"parent", "alerts"}, + }, + { + name: "mixed case normalized to lowercase", + input: "Parent Alerts", + expectedWords: []string{"parent", "alerts"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher := NewTextMatcher(tt.input) + assert.Equal(t, tt.expectedWords, matcher.words) + }) + } +} + +func TestTextMatcher_Match(t *testing.T) { + tests := []struct { + name string + search string + text string + expected bool + }{ + { + name: "empty search matches everything", + search: "", + text: "any text", + expected: true, + }, + { + name: "whitespace search matches everything", + search: " ", + text: "any text", + expected: true, + }, + { + name: "exact match", + search: "alerts", + text: "alerts", + expected: true, + }, + { + name: "case insensitive match", + search: "parent alerts", + text: "Parent Folder/Alerts", + expected: true, + }, + { + name: "partial match", + search: "folder", + text: "Parent Folder/Alerts", + expected: true, + }, + { + name: "sequential words match", + search: "api time", + text: "API Response Time", + expected: true, + }, + { + name: "non-consecutive but sequential words match", + search: "parent rules", + text: "Parent Folder/Alert Rules", + expected: true, + }, + { + name: "word not found", + search: "missing", + text: "Parent Folder/Alerts", + expected: false, + }, + { + name: "words out of order", + search: "alerts parent", + text: "Parent Folder/Alerts", + expected: false, + }, + { + name: "folder path search", + search: "prod alerts", + text: "Monitoring/Production/Alerts", + expected: true, + }, + { + name: "multiple spaces in search", + search: "api response time", + text: "API Response Time", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matcher := NewTextMatcher(tt.search) + result := matcher.Match(tt.text) + assert.Equal(t, tt.expected, result) + }) + } +}