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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user