From 10e2c32b58683a23a027bc837424b611b03b5fed Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Tue, 29 Jul 2025 16:23:36 +0200 Subject: [PATCH] Alerting: List V2 - add fuzzy search (#108656) * Extract fuzzySearch to separate files and add fuzzy search to the listV2 filter * Use the new fuzzy helpers in the old list view * Extract more code to common fuzzy functions * Update tests * Add comments explaining outOfOrder parameter, improve tests * Migrate the alerting fuzzySearch to @grafana/data fuzzySearch --- .../unified/hooks/useFilteredRules.ts | 48 +--- .../unified/rule-list/FilterView.test.tsx | 24 +- .../unified/rule-list/hooks/filters.ts | 19 +- .../unified/utils/fuzzySearch.test.ts | 217 ++++++++++++++++++ .../alerting/unified/utils/fuzzySearch.ts | 29 +++ 5 files changed, 272 insertions(+), 65 deletions(-) create mode 100644 public/app/features/alerting/unified/utils/fuzzySearch.test.ts create mode 100644 public/app/features/alerting/unified/utils/fuzzySearch.ts diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index 7cd831550d2..544b0db9975 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -1,4 +1,3 @@ -import uFuzzy from '@leeoniya/ufuzzy'; import { produce } from 'immer'; import { chain, compact, isEmpty } from 'lodash'; import { useCallback, useDeferredValue, useEffect, useMemo } from 'react'; @@ -13,22 +12,13 @@ import { RulesFilter, applySearchFilterToQuery, getSearchFilterFromQuery } from import { labelsMatchMatchers, matcherToMatcherField } from '../utils/alertmanager'; import { Annotation } from '../utils/constants'; import { isCloudRulesSource } from '../utils/datasource'; +import { fuzzyFilter } from '../utils/fuzzySearch'; import { parseMatcher, parsePromQLStyleMatcherLoose } from '../utils/matchers'; import { getRuleHealth, isPluginProvidedRule, isPromRuleType, prometheusRuleType, rulerRuleType } from '../utils/rules'; import { calculateGroupTotals, calculateRuleFilteredTotals, calculateRuleTotals } from './useCombinedRuleNamespaces'; import { useURLSearchParams } from './useURLSearchParams'; -// if the search term is longer than MAX_NEEDLE_SIZE we disable Levenshtein distance -const MAX_NEEDLE_SIZE = 25; -const INFO_THRESHOLD = Infinity; -const MAX_FUZZY_TERMS = 5; -// https://catonmat.net/my-favorite-regex :) -const REGEXP_NON_ASCII = /[^ -~]/m; -// https://www.asciitable.com/ -// matches only these: `~!@#$%^&*()_+-=[]\{}|;':",./<>? -const REGEXP_ONLY_SYMBOLS = /^[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+$/m; - export function useRulesFilter() { const [queryParams, updateQueryParams] = useURLSearchParams(); const searchQuery = queryParams.get('search') ?? ''; @@ -311,42 +301,6 @@ function looseParseMatcher(matcherQuery: string): Matcher | undefined { } } -function fuzzyFilter(items: TItem[], filterBy: (item: TItem) => string, searchTerm: string) { - let filteredItems = items; - - // Options details can be found here https://github.com/leeoniya/uFuzzy#options - // The following configuration complies with Damerau-Levenshtein distance - // https://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance - const ufuzzy = new uFuzzy({ intraMode: 1 }); - const needleTermsCount = ufuzzy.split(searchTerm).length; - - // If the search term is very long or contains non-ascii characters or only special characters we don't use fuzzy search - // and need to fallback to simple string search - const fuzzySearchNotApplicable = - REGEXP_NON_ASCII.test(searchTerm) || - REGEXP_ONLY_SYMBOLS.test(searchTerm) || - searchTerm.length > MAX_NEEDLE_SIZE || - needleTermsCount > MAX_FUZZY_TERMS; - - if (fuzzySearchNotApplicable) { - return items.filter((item) => filterBy(item).toLowerCase().includes(searchTerm.toLowerCase())); - } - - const haystack = items.map(filterBy); - // apply an outOfOrder limit which helps to limit the number of permutations to search for - // and prevents the browser from hanging - const outOfOrderLimit = needleTermsCount < 5 ? 4 : 0; - - const [idxs, info, order] = ufuzzy.search(haystack, searchTerm, outOfOrderLimit, INFO_THRESHOLD); - if (info && order) { - filteredItems = order.map((idx) => filteredItems[info.idx[idx]]); - } else if (idxs) { - filteredItems = idxs.map((idx) => filteredItems[idx]); - } - - return filteredItems; -} - const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filterState: RulesFilter): boolean => { if (!filterState.dataSourceNames?.length) { return true; diff --git a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx index b37e08dda81..908e5978d88 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.test.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.test.tsx @@ -57,36 +57,44 @@ describe('RuleList - FilterView', () => { it('should filter results by group and rule name ', async () => { render( ); await loadMoreResults(); const matchingRule = await screen.findByRole('treeitem', { - name: /mimir-test-rule-2/, + name: /mimir-test-rule-1/, }); - expect(matchingRule).toHaveTextContent('mimir-test-rule-2'); + expect(matchingRule).toHaveTextContent('mimir-test-rule-1'); expect(matchingRule).toHaveTextContent('test-mimir-namespace'); expect(matchingRule).toHaveTextContent('test-group-4501'); expect(await screen.findByText(/No more results/)).toBeInTheDocument(); }); it('should display rules from multiple datasources', async () => { - render(); + render(); await loadMoreResults(); - // Mimir has 11 matching rules, 181, 1810, 1811 ... 1819 + // Fuzzy search for 'test-group-123' matches: + // Mimir: 15 groups (123, 1123, 1230-1239, 2123, 3123, 4123) + // Prometheus: 1 group (123) const matchingMimirRules = await screen.findAllByRole('treeitem', { - name: /mimir-test-rule-2/, + name: /mimir-test-rule-3/, }); const matchingPrometheusRule = await screen.findByRole('treeitem', { - name: /prometheus-test-rule-2/, + name: /prometheus-test-rule-3/, }); - expect(matchingMimirRules).toHaveLength(11); + // Mimir: 15 groups × 1 rule each = 15 rules + // Prometheus: 1 group × 1 rule = 1 rule + expect(matchingMimirRules).toHaveLength(15); expect(matchingPrometheusRule).toBeInTheDocument(); expect(await screen.findByText(/No more results/)).toBeInTheDocument(); diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.ts index 975216e498d..3602081e7c0 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.ts @@ -8,6 +8,7 @@ import { RulesFilter } from '../../search/rulesSearchParser'; import { labelsMatchMatchers } from '../../utils/alertmanager'; import { Annotation } from '../../utils/constants'; import { getDatasourceAPIUid } from '../../utils/datasource'; +import { fuzzyMatches } from '../../utils/fuzzySearch'; import { parseMatcher } from '../../utils/matchers'; import { isPluginProvidedRule, prometheusRuleType } from '../../utils/rules'; @@ -21,13 +22,13 @@ export function groupFilter( const { name, file } = group; const { namespace, groupName } = filterState; - // Add fuzzy search for namespace - if (namespace && !file.toLocaleLowerCase().includes(namespace.toLocaleLowerCase())) { + // Use fuzzy search for namespace + if (namespace && !fuzzyMatches(file, namespace)) { return false; } - // Add fuzzy search for group name - if (groupName && !name.toLocaleLowerCase().includes(groupName.toLocaleLowerCase())) { + // Use fuzzy search for group name + if (groupName && !fuzzyMatches(name, groupName)) { return false; } @@ -40,15 +41,13 @@ export function groupFilter( export function ruleFilter(rule: PromRuleDTO, filterState: RulesFilter) { const { name, labels = {}, health, type } = rule; - const nameLower = name.toLowerCase(); - - // Free form words filter (matches if any word is part of the rule name) - if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => nameLower.includes(word))) { + // Free form words filter (uses fuzzy matching for each word) + if (filterState.freeFormWords.length > 0 && !filterState.freeFormWords.some((word) => fuzzyMatches(name, word))) { return false; } - // Rule name filter (exact match) - if (filterState.ruleName && !nameLower.includes(filterState.ruleName)) { + // Rule name filter (uses fuzzy matching) + if (filterState.ruleName && !fuzzyMatches(name, filterState.ruleName)) { return false; } diff --git a/public/app/features/alerting/unified/utils/fuzzySearch.test.ts b/public/app/features/alerting/unified/utils/fuzzySearch.test.ts new file mode 100644 index 00000000000..61098ec699f --- /dev/null +++ b/public/app/features/alerting/unified/utils/fuzzySearch.test.ts @@ -0,0 +1,217 @@ +import { fuzzyFilter, fuzzyMatches } from './fuzzySearch'; + +describe('fuzzySearch', () => { + describe('fuzzyMatches', () => { + describe('should match with typos and fuzzy logic', () => { + it.each([ + ['High CPU usage', 'cpu'], + ['High CPU usage', 'hi usage'], + ['High CPU usage', 'usge'], // typo + ['Memory Alert Rule', 'memory'], + ['Memory Alert Rule', 'alrt'], // typo + ['k8s-pod-memory-high', 'pod memory'], + ['API-Response-Time[5xx]', 'response'], + ])('matches "%s" with search term "%s"', (target, searchTerm) => { + expect(fuzzyMatches(target, searchTerm)).toBe(true); + }); + }); + + describe('should be case insensitive', () => { + it.each([ + ['High CPU Usage', 'cpu'], + ['high cpu usage', 'CPU'], + ['Memory Alert', 'MEMORY'], + ['DISK ALERT', 'disk'], + ])('matches "%s" with search term "%s"', (target, searchTerm) => { + expect(fuzzyMatches(target, searchTerm)).toBe(true); + }); + }); + + describe('should handle edge cases with fallback', () => { + it('matches with non-ASCII characters using fallback', () => { + expect(fuzzyMatches('Café Alert', 'café')).toBe(true); + expect(fuzzyMatches('règle alerte', 'règle')).toBe(true); + }); + + it('matches with symbol-only searches using fallback', () => { + expect(fuzzyMatches('API[5xx] Error', '[5xx]')).toBe(true); + expect(fuzzyMatches('Memory > 90%', '> 90%')).toBe(true); + }); + + it('matches long search terms using fallback', () => { + const longTarget = 'This:is:a:very:long:rule:name:that:definitely:exceeds:max:needle:length'; + const longSearchTerm = 'very:long:rule:name:that:definitely:exceeds:max:needle:length'; + expect(fuzzyMatches(longTarget, longSearchTerm)).toBe(true); + }); + }); + + describe('should handle empty and whitespace searches', () => { + it('returns true for empty search terms', () => { + expect(fuzzyMatches('Any Rule Name', '')).toBe(true); + expect(fuzzyMatches('Any Rule Name', ' ')).toBe(true); + }); + }); + + describe('should not match unrelated terms', () => { + it.each([ + ['CPU Alert', 'memory'], + ['Memory Usage', 'disk'], + ['API Response', 'database'], + ])('does not match "%s" with search term "%s"', (target, searchTerm) => { + expect(fuzzyMatches(target, searchTerm)).toBe(false); + }); + }); + }); + + describe('fuzzyFilter', () => { + const testRules = [ + { name: 'High CPU usage', id: '1' }, + { name: 'Memory too low', id: '2' }, + { name: 'Disk space alert', id: '3' }, + { name: 'API Response Time', id: '4' }, + { name: 'k8s-pod-memory-high', id: '5' }, + ]; + + describe('should filter with fuzzy matching', () => { + it('filters by exact matches', () => { + const result = fuzzyFilter(testRules, (rule) => rule.name, 'CPU'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('High CPU usage'); + }); + + it('filters with typos', () => { + const result = fuzzyFilter(testRules, (rule) => rule.name, 'usge'); // typo for "usage" + expect(result).toHaveLength(1); + expect(result[0].name).toBe('High CPU usage'); + }); + + it('filters with partial matches', () => { + const result = fuzzyFilter(testRules, (rule) => rule.name, 'memory'); + expect(result).toHaveLength(2); + expect(result.map((r) => r.name)).toContain('Memory too low'); + expect(result.map((r) => r.name)).toContain('k8s-pod-memory-high'); + }); + + it('filters with multiple words', () => { + const result = fuzzyFilter(testRules, (rule) => rule.name, 'api response'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('API Response Time'); + }); + + it('filters with non-consecutive words', () => { + const result = fuzzyFilter(testRules, (rule) => rule.name, 'api time'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('API Response Time'); + }); + }); + + describe('should handle edge cases', () => { + it('returns all items for empty search', () => { + const result = fuzzyFilter(testRules, (rule) => rule.name, ''); + expect(result).toHaveLength(testRules.length); + }); + + it('returns all items for whitespace search', () => { + const result = fuzzyFilter(testRules, (rule) => rule.name, ' '); + expect(result).toHaveLength(testRules.length); + }); + + it('uses fallback for non-ASCII characters', () => { + const rulesWithAccents = [ + { name: 'Café Alert', id: '1' }, + { name: 'Regular Alert', id: '2' }, + ]; + const result = fuzzyFilter(rulesWithAccents, (rule) => rule.name, 'café'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Café Alert'); + }); + + it('uses fallback for symbol-only searches', () => { + const rulesWithSymbols = [ + { name: 'API[5xx] Error', id: '1' }, + { name: 'Normal Error', id: '2' }, + ]; + const result = fuzzyFilter(rulesWithSymbols, (rule) => rule.name, '[5xx]'); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('API[5xx] Error'); + }); + + it('uses fallback for very long search terms', () => { + const longRuleName = 'This:is:a:very:long:rule:name:that:definitely:exceeds:max:needle:length'; + const rulesWithLongName = [ + { name: longRuleName, id: '1' }, + { name: 'Short rule name', id: '2' }, + ]; + const longSearchTerm = 'very:long:rule:name:that:definitely:exceeds:max:needle:length'; + const result = fuzzyFilter(rulesWithLongName, (rule) => rule.name, longSearchTerm); + expect(result).toHaveLength(1); + expect(result[0].name).toBe(longRuleName); + }); + + it('handles complex searches without hanging', () => { + const result = fuzzyFilter( + testRules, + (rule) => rule.name, + 'cpu high memory low disk space alert response time' + ); + // Should not hang and should return some results + expect(Array.isArray(result)).toBe(true); + }); + }); + }); + + describe('real-world scenarios', () => { + const realWorldRules = [ + { name: 'grafana_dashboard_sync_failed', id: '1' }, + { name: 'k8s-pod-cpu-usage-high', id: '2' }, + { name: 'PostgreSQL Connection Pool Exhausted', id: '3' }, + { name: 'HTTP 5xx Error Rate High', id: '4' }, + { name: 'Memory Usage > 90%', id: '5' }, + { name: 'Disk I/O Latency Critical', id: '6' }, + { name: 'API Response Time P99 > 500ms', id: '7' }, + ]; + + it('handles common alerting rule patterns', () => { + // Test various real-world search patterns + expect(fuzzyFilter(realWorldRules, (r) => r.name, 'grafana sync')).toContainEqual({ + name: 'grafana_dashboard_sync_failed', + id: '1', + }); + expect(fuzzyFilter(realWorldRules, (r) => r.name, 'k8s cpu')).toContainEqual({ + name: 'k8s-pod-cpu-usage-high', + id: '2', + }); + expect(fuzzyFilter(realWorldRules, (r) => r.name, 'postgres pool')).toContainEqual({ + name: 'PostgreSQL Connection Pool Exhausted', + id: '3', + }); + expect(fuzzyFilter(realWorldRules, (r) => r.name, '5xx error')).toContainEqual({ + name: 'HTTP 5xx Error Rate High', + id: '4', + }); + expect(fuzzyFilter(realWorldRules, (r) => r.name, 'memory 90')).toContainEqual({ + name: 'Memory Usage > 90%', + id: '5', + }); + expect(fuzzyFilter(realWorldRules, (r) => r.name, 'disk latency')).toContainEqual({ + name: 'Disk I/O Latency Critical', + id: '6', + }); + expect(fuzzyFilter(realWorldRules, (r) => r.name, 'api p99')).toContainEqual({ + name: 'API Response Time P99 > 500ms', + id: '7', + }); + }); + + it('handles typos in common alert terms', () => { + expect(fuzzyMatches('PostgreSQL Connection Pool Exhausted', 'postgrs')).toBe(true); + expect(fuzzyMatches('HTTP 5xx Error Rate High', 'eror rate')).toBe(true); + expect(fuzzyMatches('Disk I/O Latency Critical', 'latentcy')).toBe(true); + }); + + it('handles mixed case and symbols', () => { + expect(fuzzyMatches('Memory Usage > 90%', 'memory > 90')).toBe(true); + expect(fuzzyMatches('API Response Time P99 > 500ms', 'p99 500ms')).toBe(true); + }); + }); +}); diff --git a/public/app/features/alerting/unified/utils/fuzzySearch.ts b/public/app/features/alerting/unified/utils/fuzzySearch.ts new file mode 100644 index 00000000000..7c67c823e9c --- /dev/null +++ b/public/app/features/alerting/unified/utils/fuzzySearch.ts @@ -0,0 +1,29 @@ +import { fuzzySearch } from '@grafana/data'; + +/** + * Applies fuzzy search to a list of items using the provided filter function. + * Uses @grafana/data fuzzySearch implementation with all built-in optimizations. + */ +export function fuzzyFilter(items: TItem[], filterBy: (item: TItem) => string, searchTerm: string): TItem[] { + if (!searchTerm.trim()) { + return items; + } + + const haystack = items.map(filterBy); + const matchingIndices = fuzzySearch(haystack, searchTerm); + return matchingIndices.map((idx) => items[idx]); +} + +/** + * Checks if a search term matches a target string using fuzzy matching. + * Returns true if there's a match, false otherwise. + */ +export function fuzzyMatches(target: string, searchTerm: string): boolean { + if (!searchTerm.trim()) { + return true; + } + + const haystack = [target]; + const matchingIndices = fuzzySearch(haystack, searchTerm); + return matchingIndices.length > 0; +}