Alerting: Add fuzzy search to alert list view (#63931)

* Add basic fuzzy search

* Add fuzzy search to rule name, group and namespace filters

* Add tests

* Apply sort order when filtering

* Filter rules on Enter instead of onChange

* Add minor rule stats performance improvements

* Fix tests

* Remove unused code, add ufuzzy inline docs

* Use form submit to set query string, add debounce docs
This commit is contained in:
Konrad Lalik 2023-03-09 16:24:32 +01:00 committed by GitHub
parent e8c131eb6f
commit 5179a830ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 245 additions and 163 deletions

View File

@ -2688,9 +2688,6 @@ exports[`better eslint`] = {
"public/app/features/alerting/unified/components/rules/RuleDetailsDataSources.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/alerting/unified/components/rules/RulesFilter.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/alerting/unified/components/silences/SilencesEditor.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],

View File

@ -322,6 +322,9 @@ describe('RuleList', () => {
const groups = await ui.ruleGroup.findAll();
expect(groups).toHaveLength(2);
await waitFor(() => expect(groups[0]).toHaveTextContent(/firing|pending|normal/));
expect(groups[0]).toHaveTextContent('1 firing');
expect(groups[1]).toHaveTextContent('1 firing');
expect(groups[1]).toHaveTextContent('1 pending');
@ -489,11 +492,12 @@ describe('RuleList', () => {
});
await renderRuleList();
const groups = await ui.ruleGroup.findAll();
expect(groups).toHaveLength(2);
const filterInput = ui.rulesFilterInput.get();
await userEvent.type(filterInput, 'label:foo=bar');
await userEvent.type(filterInput, 'label:foo=bar{Enter}');
// Input is debounced so wait for it to be visible
await waitFor(() => expect(filterInput).toHaveValue('label:foo=bar'));
@ -512,17 +516,17 @@ describe('RuleList', () => {
// Check for different label matchers
await userEvent.clear(filterInput);
await userEvent.type(filterInput, 'label:foo!=bar label:foo!=baz');
await userEvent.type(filterInput, 'label:foo!=bar label:foo!=baz{Enter}');
// Group doesn't contain matching labels
await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1));
await waitFor(() => expect(ui.ruleGroup.get()).toHaveTextContent('group-2'));
await userEvent.clear(filterInput);
await userEvent.type(filterInput, 'label:"foo=~b.+"');
await userEvent.type(filterInput, 'label:"foo=~b.+"{Enter}');
await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(2));
await userEvent.clear(filterInput);
await userEvent.type(filterInput, 'label:region=US');
await userEvent.type(filterInput, 'label:region=US{Enter}');
await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1));
await waitFor(() => expect(ui.ruleGroup.get()).toHaveTextContent('group-2'));
});

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useAsyncFn, useInterval } from 'react-use';
@ -42,6 +42,8 @@ const RuleList = withErrorBoundary(
const location = useLocation();
const [expandAll, setExpandAll] = useState(false);
const onFilterCleared = useCallback(() => setExpandAll(false), []);
const [queryParams] = useQueryParams();
const { filterState, hasActiveFilters } = useRulesFilter();
@ -90,7 +92,7 @@ const RuleList = withErrorBoundary(
// We show separate indicators for Grafana-managed and Cloud rules
<AlertingPageWrapper pageId="alert-list" isLoading={false}>
<RuleListErrors />
<RulesFilter onFilterCleared={() => setExpandAll(false)} />
<RulesFilter onFilterCleared={onFilterCleared} />
{!hasNoAlertRulesCreatedYet && (
<>
<div className={styles.break} />

View File

@ -1,5 +1,6 @@
import pluralize from 'pluralize';
import React, { FC, Fragment, useMemo } from 'react';
import React, { FC, Fragment, useState } from 'react';
import { useDebounce } from 'react-use';
import { Stack } from '@grafana/experimental';
import { Badge } from '@grafana/ui';
@ -26,39 +27,48 @@ const emptyStats = {
export const RuleStats: FC<Props> = ({ group, namespaces, includeTotal }) => {
const evaluationInterval = group?.interval;
const [calculated, setCalculated] = useState(emptyStats);
const calculated = useMemo(() => {
const stats = { ...emptyStats };
// Performance optimization allowing reducing number of stats calculation
// The problem occurs when we load many data sources.
// Then redux store gets updated multiple times in a pretty short period, triggering calculating stats many times.
// debounce allows to skip calculations which results would be abandoned in milliseconds
useDebounce(
() => {
const stats = { ...emptyStats };
const calcRule = (rule: CombinedRule) => {
if (rule.promRule && isAlertingRule(rule.promRule)) {
if (isGrafanaRulerRulePaused(rule)) {
stats.paused += 1;
const calcRule = (rule: CombinedRule) => {
if (rule.promRule && isAlertingRule(rule.promRule)) {
if (isGrafanaRulerRulePaused(rule)) {
stats.paused += 1;
}
stats[rule.promRule.state] += 1;
}
stats[rule.promRule.state] += 1;
}
if (ruleHasError(rule)) {
stats.error += 1;
}
if (
(rule.promRule && isRecordingRule(rule.promRule)) ||
(rule.rulerRule && isRecordingRulerRule(rule.rulerRule))
) {
stats.recording += 1;
}
stats.total += 1;
};
if (ruleHasError(rule)) {
stats.error += 1;
}
if (
(rule.promRule && isRecordingRule(rule.promRule)) ||
(rule.rulerRule && isRecordingRulerRule(rule.rulerRule))
) {
stats.recording += 1;
}
stats.total += 1;
};
if (group) {
group.rules.forEach(calcRule);
}
if (group) {
group.rules.forEach(calcRule);
}
if (namespaces) {
namespaces.forEach((namespace) => namespace.groups.forEach((group) => group.rules.forEach(calcRule)));
}
if (namespaces) {
namespaces.forEach((namespace) => namespace.groups.forEach((group) => group.rules.forEach(calcRule)));
}
return stats;
}, [group, namespaces]);
setCalculated(stats);
},
400,
[group, namespaces]
);
const statsComponents: React.ReactNode[] = [];

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { debounce } from 'lodash';
import React, { FormEvent, useState } from 'react';
import React, { useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Stack } from '@grafana/experimental';
@ -54,22 +54,21 @@ interface RulesFilerProps {
onFilterCleared?: () => void;
}
const RuleStateOptions = Object.entries(PromAlertingRuleState).map(([key, value]) => ({
label: alertStateToReadable(value),
value,
}));
const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) => {
const styles = useStyles2(getStyles);
const [queryParams, setQueryParams] = useQueryParams();
const { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters } = useRulesFilter();
// This key is used to force a rerender on the inputs when the filters are cleared
const [filterKey, setFilterKey] = useState<number>(Math.floor(Math.random() * 100));
const dataSourceKey = `dataSource-${filterKey}`;
const queryStringKey = `queryString-${filterKey}`;
const { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters } = useRulesFilter();
const styles = useStyles2(getStyles);
const stateOptions = Object.entries(PromAlertingRuleState).map(([key, value]) => ({
label: alertStateToReadable(value),
value,
}));
const handleDataSourceChange = (dataSourceValue: DataSourceInstanceSettings) => {
updateFilters({ ...filterState, dataSourceName: dataSourceValue.name });
setFilterKey((key) => key + 1);
@ -80,11 +79,6 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
setFilterKey((key) => key + 1);
};
const handleQueryStringChange = debounce((e: FormEvent<HTMLInputElement>) => {
const target = e.target as HTMLInputElement;
setSearchQuery(target.value);
}, 600);
const handleAlertStateChange = (value: PromAlertingRuleState) => {
logInfo(LogMessages.clickingAlertStateFilters);
updateFilters({ ...filterState, ruleState: value });
@ -112,6 +106,10 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
setTimeout(() => setFilterKey(filterKey + 1), 100);
};
const searchQueryRef = useRef<HTMLInputElement | null>(null);
const { handleSubmit, register } = useForm<{ searchQuery: string }>({ defaultValues: { searchQuery } });
const { ref, ...rest } = register('searchQuery');
const searchIcon = <Icon name={'search'} />;
return (
<div className={styles.container}>
@ -130,7 +128,11 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
</Field>
<div>
<Label>State</Label>
<RadioButtonGroup options={stateOptions} value={filterState.ruleState} onChange={handleAlertStateChange} />
<RadioButtonGroup
options={RuleStateOptions}
value={filterState.ruleState}
onChange={handleAlertStateChange}
/>
</div>
<div>
<Label>Rule type</Label>
@ -147,28 +149,39 @@ const RulesFilter = ({ onFilterCleared = () => undefined }: RulesFilerProps) =>
</Stack>
<Stack direction="column" gap={1}>
<Stack direction="row" gap={1}>
<Field
<form
className={styles.searchInput}
label={
<Label>
<Stack gap={0.5}>
<span>Search</span>
<HoverCard content={<SearchQueryHelp />}>
<Icon name="info-circle" size="sm" />
</HoverCard>
</Stack>
</Label>
}
onSubmit={handleSubmit((data) => {
setSearchQuery(data.searchQuery);
searchQueryRef.current?.blur();
})}
>
<Input
key={queryStringKey}
prefix={searchIcon}
onChange={handleQueryStringChange}
defaultValue={searchQuery}
placeholder="Search"
data-testid="search-query-input"
/>
</Field>
<Field
label={
<Label>
<Stack gap={0.5}>
<span>Search</span>
<HoverCard content={<SearchQueryHelp />}>
<Icon name="info-circle" size="sm" />
</HoverCard>
</Stack>
</Label>
}
>
<Input
key={queryStringKey}
prefix={searchIcon}
ref={(e) => {
ref(e);
searchQueryRef.current = e;
}}
{...rest}
placeholder="Search"
data-testid="search-query-input"
/>
</Field>
<input type="submit" hidden />
</form>
<div>
<Label>View as</Label>
<RadioButtonGroup

View File

@ -48,50 +48,48 @@ export function useCombinedRuleNamespaces(rulesSourceName?: string): CombinedRul
return getAllRulesSources();
}, [rulesSourceName]);
return useMemo(
() =>
rulesSources
.map((rulesSource): CombinedRuleNamespace[] => {
const rulesSourceName = isCloudRulesSource(rulesSource) ? rulesSource.name : rulesSource;
const promRules = promRulesResponses[rulesSourceName]?.result;
const rulerRules = rulerRulesResponses[rulesSourceName]?.result;
return useMemo(() => {
return rulesSources
.map((rulesSource): CombinedRuleNamespace[] => {
const rulesSourceName = isCloudRulesSource(rulesSource) ? rulesSource.name : rulesSource;
const promRules = promRulesResponses[rulesSourceName]?.result;
const rulerRules = rulerRulesResponses[rulesSourceName]?.result;
const cached = cache.current[rulesSourceName];
if (cached && cached.promRules === promRules && cached.rulerRules === rulerRules) {
return cached.result;
}
const namespaces: Record<string, CombinedRuleNamespace> = {};
const cached = cache.current[rulesSourceName];
if (cached && cached.promRules === promRules && cached.rulerRules === rulerRules) {
return cached.result;
}
const namespaces: Record<string, CombinedRuleNamespace> = {};
// first get all the ruler rules in
Object.entries(rulerRules || {}).forEach(([namespaceName, groups]) => {
const namespace: CombinedRuleNamespace = {
rulesSource,
name: namespaceName,
groups: [],
};
namespaces[namespaceName] = namespace;
addRulerGroupsToCombinedNamespace(namespace, groups);
// first get all the ruler rules in
Object.entries(rulerRules || {}).forEach(([namespaceName, groups]) => {
const namespace: CombinedRuleNamespace = {
rulesSource,
name: namespaceName,
groups: [],
};
namespaces[namespaceName] = namespace;
addRulerGroupsToCombinedNamespace(namespace, groups);
});
// then correlate with prometheus rules
promRules?.forEach(({ name: namespaceName, groups }) => {
const ns = (namespaces[namespaceName] = namespaces[namespaceName] || {
rulesSource,
name: namespaceName,
groups: [],
});
// then correlate with prometheus rules
promRules?.forEach(({ name: namespaceName, groups }) => {
const ns = (namespaces[namespaceName] = namespaces[namespaceName] || {
rulesSource,
name: namespaceName,
groups: [],
});
addPromGroupsToCombinedNamespace(ns, groups);
});
addPromGroupsToCombinedNamespace(ns, groups);
});
const result = Object.values(namespaces);
const result = Object.values(namespaces);
cache.current[rulesSourceName] = { promRules, rulerRules, result };
return result;
})
.flat(),
[promRulesResponses, rulerRulesResponses, rulesSources]
);
cache.current[rulesSourceName] = { promRules, rulerRules, result };
return result;
})
.flat();
}, [promRulesResponses, rulerRulesResponses, rulesSources]);
}
// merge all groups in case of grafana managed, essentially treating namespaces (folders) as groups

View File

@ -26,32 +26,37 @@ beforeAll(() => {
});
describe('filterRules', function () {
it('should filter out rules by name filter', function () {
// Typos there are deliberate to test the fuzzy search
it.each(['cpu', 'hi usage', 'usge'])('should filter out rules by name filter = "%s"', function (nameFilter) {
const rules = [mockCombinedRule({ name: 'High CPU usage' }), mockCombinedRule({ name: 'Memory too low' })];
const ns = mockCombinedRuleNamespace({
groups: [mockCombinedRuleGroup('Resources usage group', rules)],
});
const filtered = filterRules([ns], getFilter({ ruleName: 'cpu' }));
const filtered = filterRules([ns], getFilter({ ruleName: nameFilter }));
expect(filtered[0].groups[0].rules).toHaveLength(1);
expect(filtered[0].groups[0].rules[0].name).toBe('High CPU usage');
});
it('should filter out rules by evaluation group name', function () {
const ns = mockCombinedRuleNamespace({
groups: [
mockCombinedRuleGroup('Performance group', [mockCombinedRule({ name: 'High CPU usage' })]),
mockCombinedRuleGroup('Availability group', [mockCombinedRule({ name: 'Memory too low' })]),
],
});
// Typos there are deliberate to test the fuzzy search
it.each(['availability', 'avialability', 'avail group'])(
'should filter out rules by evaluation group name = "%s"',
function (groupFilter) {
const ns = mockCombinedRuleNamespace({
groups: [
mockCombinedRuleGroup('Performance group', [mockCombinedRule({ name: 'High CPU usage' })]),
mockCombinedRuleGroup('Availability group', [mockCombinedRule({ name: 'Memory too low' })]),
],
});
const filtered = filterRules([ns], getFilter({ groupName: 'availability' }));
const filtered = filterRules([ns], getFilter({ groupName: groupFilter }));
expect(filtered[0].groups).toHaveLength(1);
expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low');
});
expect(filtered[0].groups).toHaveLength(1);
expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low');
}
);
it('should filter out rules by label filter', function () {
const rules = [
@ -160,4 +165,25 @@ describe('filterRules', function () {
expect(filtered[0].groups[0].rules).toHaveLength(1);
expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low');
});
// Typos there are deliberate to test the fuzzy search
it.each(['nasa', 'alrt rul', 'nasa ruls'])('should filter out rules by namespace = "%s"', (namespaceFilter) => {
const cpuRule = mockCombinedRule({ name: 'High CPU usage' });
const memoryRule = mockCombinedRule({ name: 'Memory too low' });
const teamEmeaNs = mockCombinedRuleNamespace({
name: 'EMEA Alerting',
groups: [mockCombinedRuleGroup('CPU group', [cpuRule])],
});
const teamNasaNs = mockCombinedRuleNamespace({
name: 'NASA Alert Rules',
groups: [mockCombinedRuleGroup('Memory group', [memoryRule])],
});
const filtered = filterRules([teamEmeaNs, teamNasaNs], getFilter({ namespace: namespaceFilter }));
expect(filtered[0].groups[0].rules).toHaveLength(1);
expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low');
});
});

View File

@ -1,3 +1,4 @@
import uFuzzy from '@leeoniya/ufuzzy';
import produce from 'immer';
import { compact, isEmpty } from 'lodash';
import { useCallback, useEffect, useMemo } from 'react';
@ -18,8 +19,8 @@ export function useRulesFilter() {
const [queryParams, updateQueryParams] = useURLSearchParams();
const searchQuery = queryParams.get('search') ?? '';
const filterState = getSearchFilterFromQuery(searchQuery);
const hasActiveFilters = Object.values(filterState).some((filter) => !isEmpty(filter));
const filterState = useMemo(() => getSearchFilterFromQuery(searchQuery), [searchQuery]);
const hasActiveFilters = useMemo(() => Object.values(filterState).some((filter) => !isEmpty(filter)), [filterState]);
const updateFilters = useCallback(
(newFilter: RulesFilter) => {
@ -76,37 +77,67 @@ export const useFilteredRules = (namespaces: CombinedRuleNamespace[], filterStat
return useMemo(() => filterRules(namespaces, filterState), [namespaces, filterState]);
};
// 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,
intraIns: 1,
intraSub: 1,
intraTrn: 1,
intraDel: 1,
});
export const filterRules = (
namespaces: CombinedRuleNamespace[],
filterState: RulesFilter = { labels: [], freeFormWords: [] }
): CombinedRuleNamespace[] => {
return (
namespaces
.filter((ns) =>
filterState.namespace ? ns.name.toLowerCase().includes(filterState.namespace.toLowerCase()) : true
)
.filter(({ rulesSource }) =>
filterState.dataSourceName && isCloudRulesSource(rulesSource)
? rulesSource.name === filterState.dataSourceName
: true
)
// If a namespace and group have rules that match the rules filters then keep them.
.reduce(reduceNamespaces(filterState), [] as CombinedRuleNamespace[])
);
let filteredNamespaces = namespaces;
const dataSourceFilter = filterState.dataSourceName;
if (dataSourceFilter) {
filteredNamespaces = filteredNamespaces.filter(({ rulesSource }) =>
isCloudRulesSource(rulesSource) ? rulesSource.name === dataSourceFilter : true
);
}
const namespaceFilter = filterState.namespace;
if (namespaceFilter) {
const namespaceHaystack = filteredNamespaces.map((ns) => ns.name);
const [idxs, info, order] = ufuzzy.search(namespaceHaystack, namespaceFilter);
if (info && order) {
filteredNamespaces = order.map((idx) => filteredNamespaces[info.idx[idx]]);
} else {
filteredNamespaces = idxs.map((idx) => filteredNamespaces[idx]);
}
}
// If a namespace and group have rules that match the rules filters then keep them.
return filteredNamespaces.reduce(reduceNamespaces(filterState), [] as CombinedRuleNamespace[]);
};
const reduceNamespaces = (filterStateFilters: RulesFilter) => {
const reduceNamespaces = (filterState: RulesFilter) => {
return (namespaceAcc: CombinedRuleNamespace[], namespace: CombinedRuleNamespace) => {
const groups = namespace.groups
.filter((g) =>
filterStateFilters.groupName ? g.name.toLowerCase().includes(filterStateFilters.groupName.toLowerCase()) : true
)
.reduce(reduceGroups(filterStateFilters), [] as CombinedRuleGroup[]);
const groupNameFilter = filterState.groupName;
let filteredGroups = namespace.groups;
if (groups.length) {
if (groupNameFilter) {
const groupsHaystack = filteredGroups.map((g) => g.name);
const [idxs, info, order] = ufuzzy.search(groupsHaystack, groupNameFilter);
if (info && order) {
filteredGroups = order.map((idx) => filteredGroups[info.idx[idx]]);
} else {
filteredGroups = idxs.map((idx) => filteredGroups[idx]);
}
}
filteredGroups = filteredGroups.reduce(reduceGroups(filterState), [] as CombinedRuleGroup[]);
if (filteredGroups.length) {
namespaceAcc.push({
...namespace,
groups,
groups: filteredGroups,
});
}
@ -116,8 +147,22 @@ const reduceNamespaces = (filterStateFilters: RulesFilter) => {
// Reduces groups to only groups that have rules matching the filters
const reduceGroups = (filterState: RulesFilter) => {
const ruleNameQuery = filterState.ruleName ?? filterState.freeFormWords.join(' ');
return (groupAcc: CombinedRuleGroup[], group: CombinedRuleGroup) => {
const rules = group.rules.filter((rule) => {
let filteredRules = group.rules;
if (ruleNameQuery) {
const rulesHaystack = filteredRules.map((r) => r.name);
const [idxs, info, order] = ufuzzy.search(rulesHaystack, ruleNameQuery);
if (info && order) {
filteredRules = order.map((idx) => filteredRules[info.idx[idx]]);
} else {
filteredRules = idxs.map((idx) => filteredRules[idx]);
}
}
filteredRules = filteredRules.filter((rule) => {
if (filterState.ruleType && filterState.ruleType !== rule.promRule?.type) {
return false;
}
@ -127,19 +172,6 @@ const reduceGroups = (filterState: RulesFilter) => {
return false;
}
const ruleNameLc = rule.name?.toLocaleLowerCase();
// Free Form Query is used to filter by rule name
if (
filterState.freeFormWords.length > 0 &&
!filterState.freeFormWords.every((w) => ruleNameLc.includes(w.toLocaleLowerCase()))
) {
return false;
}
if (filterState.ruleName && !rule.name?.toLocaleLowerCase().includes(filterState.ruleName.toLocaleLowerCase())) {
return false;
}
if (filterState.ruleHealth && rule.promRule) {
const ruleHealth = getRuleHealth(rule.promRule.health);
return filterState.ruleHealth === ruleHealth;
@ -171,10 +203,10 @@ const reduceGroups = (filterState: RulesFilter) => {
return true;
});
// Add rules to the group that match the rule list filters
if (rules.length) {
if (filteredRules.length) {
groupAcc.push({
...group,
rules,
rules: filteredRules,
});
}
return groupAcc;