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
This commit is contained in:
Konrad Lalik
2025-07-29 16:23:36 +02:00
committed by GitHub
parent 2bd023edc0
commit 10e2c32b58
5 changed files with 272 additions and 65 deletions
@@ -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<TItem>(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;
@@ -57,36 +57,44 @@ describe('RuleList - FilterView', () => {
it('should filter results by group and rule name ', async () => {
render(
<FilterView
filterState={getFilter({ dataSourceNames: ['Mimir'], groupName: 'test-group-4501', ruleName: 'test-rule-2' })}
filterState={getFilter({
dataSourceNames: ['Mimir'],
groupName: 'test-group-4501',
ruleName: 'mimir-test-rule-1',
})}
/>
);
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(<FilterView filterState={getFilter({ groupName: 'test-group-181', ruleName: 'test-rule-2' })} />);
render(<FilterView filterState={getFilter({ groupName: 'test-group-123', ruleName: 'test-rule-3' })} />);
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();
@@ -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;
}
@@ -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);
});
});
});
@@ -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<TItem>(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;
}