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
jackw/panel-extensions-ux
Konrad Lalik 2 years ago committed by GitHub
parent e8c131eb6f
commit 5179a830ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      .betterer.results
  2. 12
      public/app/features/alerting/unified/RuleList.test.tsx
  3. 6
      public/app/features/alerting/unified/RuleList.tsx
  4. 70
      public/app/features/alerting/unified/components/rules/RuleStats.tsx
  5. 85
      public/app/features/alerting/unified/components/rules/RulesFilter.tsx
  6. 78
      public/app/features/alerting/unified/hooks/useCombinedRuleNamespaces.ts
  7. 52
      public/app/features/alerting/unified/hooks/useFilteredRules.test.ts
  8. 110
      public/app/features/alerting/unified/hooks/useFilteredRules.ts

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

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

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

@ -1,5 +1,6 @@
import pluralize from 'pluralize'; 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 { Stack } from '@grafana/experimental';
import { Badge } from '@grafana/ui'; import { Badge } from '@grafana/ui';
@ -26,39 +27,48 @@ const emptyStats = {
export const RuleStats: FC<Props> = ({ group, namespaces, includeTotal }) => { export const RuleStats: FC<Props> = ({ group, namespaces, includeTotal }) => {
const evaluationInterval = group?.interval; 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.
const calcRule = (rule: CombinedRule) => { // Then redux store gets updated multiple times in a pretty short period, triggering calculating stats many times.
if (rule.promRule && isAlertingRule(rule.promRule)) { // debounce allows to skip calculations which results would be abandoned in milliseconds
if (isGrafanaRulerRulePaused(rule)) { useDebounce(
stats.paused += 1; () => {
const stats = { ...emptyStats };
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 (ruleHasError(rule)) { }
stats.error += 1; if (
} (rule.promRule && isRecordingRule(rule.promRule)) ||
if ( (rule.rulerRule && isRecordingRulerRule(rule.rulerRule))
(rule.promRule && isRecordingRule(rule.promRule)) || ) {
(rule.rulerRule && isRecordingRulerRule(rule.rulerRule)) stats.recording += 1;
) { }
stats.recording += 1; stats.total += 1;
} };
stats.total += 1;
};
if (group) { if (group) {
group.rules.forEach(calcRule); group.rules.forEach(calcRule);
} }
if (namespaces) { if (namespaces) {
namespaces.forEach((namespace) => namespace.groups.forEach((group) => group.rules.forEach(calcRule))); namespaces.forEach((namespace) => namespace.groups.forEach((group) => group.rules.forEach(calcRule)));
} }
return stats; setCalculated(stats);
}, [group, namespaces]); },
400,
[group, namespaces]
);
const statsComponents: React.ReactNode[] = []; const statsComponents: React.ReactNode[] = [];

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

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

@ -26,32 +26,37 @@ beforeAll(() => {
}); });
describe('filterRules', function () { 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 rules = [mockCombinedRule({ name: 'High CPU usage' }), mockCombinedRule({ name: 'Memory too low' })];
const ns = mockCombinedRuleNamespace({ const ns = mockCombinedRuleNamespace({
groups: [mockCombinedRuleGroup('Resources usage group', rules)], 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).toHaveLength(1);
expect(filtered[0].groups[0].rules[0].name).toBe('High CPU usage'); expect(filtered[0].groups[0].rules[0].name).toBe('High CPU usage');
}); });
it('should filter out rules by evaluation group name', function () { // Typos there are deliberate to test the fuzzy search
const ns = mockCombinedRuleNamespace({ it.each(['availability', 'avialability', 'avail group'])(
groups: [ 'should filter out rules by evaluation group name = "%s"',
mockCombinedRuleGroup('Performance group', [mockCombinedRule({ name: 'High CPU usage' })]), function (groupFilter) {
mockCombinedRuleGroup('Availability group', [mockCombinedRule({ name: 'Memory too low' })]), 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).toHaveLength(1);
expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low'); expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low');
}); }
);
it('should filter out rules by label filter', function () { it('should filter out rules by label filter', function () {
const rules = [ const rules = [
@ -160,4 +165,25 @@ describe('filterRules', function () {
expect(filtered[0].groups[0].rules).toHaveLength(1); expect(filtered[0].groups[0].rules).toHaveLength(1);
expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low'); 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');
});
}); });

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

Loading…
Cancel
Save