diff --git a/.betterer.results b/.betterer.results index 84e8e04d72f..e0b69715f9d 100644 --- a/.betterer.results +++ b/.betterer.results @@ -3000,8 +3000,7 @@ exports[`better eslint`] = { [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"], - [0, 0, 0, "Do not use any type assertions.", "1"] + [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"] diff --git a/public/app/features/alerting/unified/RuleList.test.tsx b/public/app/features/alerting/unified/RuleList.test.tsx index 534aa43628f..44a8508b672 100644 --- a/public/app/features/alerting/unified/RuleList.test.tsx +++ b/public/app/features/alerting/unified/RuleList.test.tsx @@ -498,10 +498,10 @@ describe('RuleList', () => { expect(groups).toHaveLength(2); const filterInput = ui.rulesFilterInput.get(); - await userEvent.type(filterInput, '{{foo="bar"}'); + await userEvent.type(filterInput, 'label:foo=bar'); // Input is debounced so wait for it to be visible - await waitFor(() => expect(filterInput).toHaveValue('{foo="bar"}')); + await waitFor(() => expect(filterInput).toHaveValue('label:foo=bar')); // Group doesn't contain matching labels await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1)); @@ -517,17 +517,17 @@ describe('RuleList', () => { // Check for different label matchers await userEvent.clear(filterInput); - await userEvent.type(filterInput, '{{foo!="bar",foo!="baz"}'); + await userEvent.type(filterInput, 'label:foo!=bar label:foo!=baz'); // 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, '{{foo=~"b.+"}'); + await userEvent.type(filterInput, 'label:"foo=~b.+"'); await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(2)); await userEvent.clear(filterInput); - await userEvent.type(filterInput, '{{region="US"}'); + await userEvent.type(filterInput, 'label:region=US'); await waitFor(() => expect(ui.ruleGroup.queryAll()).toHaveLength(1)); await waitFor(() => expect(ui.ruleGroup.get()).toHaveTextContent('group-2')); }); diff --git a/public/app/features/alerting/unified/RuleList.tsx b/public/app/features/alerting/unified/RuleList.tsx index a46ddfc9523..c819a0b81bc 100644 --- a/public/app/features/alerting/unified/RuleList.tsx +++ b/public/app/features/alerting/unified/RuleList.tsx @@ -20,13 +20,12 @@ import { RuleListStateView } from './components/rules/RuleListStateView'; import { RuleStats } from './components/rules/RuleStats'; import RulesFilter from './components/rules/RulesFilter'; import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces'; -import { useFilteredRules } from './hooks/useFilteredRules'; +import { useFilteredRules, useRulesFilter } from './hooks/useFilteredRules'; import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; import { fetchAllPromAndRulerRulesAction } from './state/actions'; import { useRulesAccess } from './utils/accessControlHooks'; import { RULE_LIST_POLL_INTERVAL_MS } from './utils/constants'; import { getAllRulesSourceNames } from './utils/datasource'; -import { getFiltersFromUrlParams } from './utils/misc'; const VIEWS = { groups: RuleListGroupView, @@ -42,8 +41,7 @@ const RuleList = withErrorBoundary( const [expandAll, setExpandAll] = useState(false); const [queryParams] = useQueryParams(); - const filters = getFiltersFromUrlParams(queryParams); - const filtersActive = Object.values(filters).some((filter) => filter !== undefined); + const { filterState, hasActiveFilters } = useRulesFilter(); const { canCreateGrafanaRules, canCreateCloudRules } = useRulesAccess(); @@ -83,19 +81,20 @@ const RuleList = withErrorBoundary( const hasNoAlertRulesCreatedYet = allPromLoaded && allPromEmpty && promRequests.length > 0; const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces(); - const filteredNamespaces = useFilteredRules(combinedNamespaces); + const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState); + return ( // We don't want to show the Loading... indicator for the whole page. // We show separate indicators for Grafana-managed and Cloud rules - + setExpandAll(false)} /> {!hasNoAlertRulesCreatedYet && ( <>
- {view === 'groups' && filtersActive && ( + {view === 'groups' && hasActiveFilters && ( -
- )} -
+ + +
+ + +
+ + {hasActiveFilters && ( +
+ +
+ )} + +
); }; @@ -181,32 +194,65 @@ const RulesFilter = () => { const getStyles = (theme: GrafanaTheme2) => { return { container: css` - display: flex; - flex-direction: column; - padding-bottom: ${theme.spacing(1)}; margin-bottom: ${theme.spacing(1)}; `, - inputWidth: css` - width: 340px; + dsPickerContainer: css` + width: 250px; flex-grow: 0; + margin: 0; `, - flexRow: css` - display: flex; - flex-direction: row; - align-items: flex-end; - width: 100%; - flex-wrap: wrap; - `, - spaceBetween: css` - justify-content: space-between; - `, - rowChild: css` - margin: ${theme.spacing(0, 1, 0, 0)}; - `, - clearButton: css` - margin-top: ${theme.spacing(1)}; + searchInput: css` + flex: 1; + margin: 0; `, }; }; +function SearchQueryHelp() { + const styles = useStyles2(helpStyles); + + return ( +
+
Search syntax allows to query alert rules by the parameters defined below.
+
+
+
Filter type
+
Expression
+ + + + + + + + +
+
+ ); +} + +function HelpRow({ title, expr }: { title: string; expr: string }) { + const styles = useStyles2(helpStyles); + + return ( + <> +
{title}
+ {expr} + + ); +} + +const helpStyles = (theme: GrafanaTheme2) => ({ + grid: css` + display: grid; + grid-template-columns: max-content auto; + gap: ${theme.spacing(1)}; + align-items: center; + `, + code: css` + display: block; + text-align: center; + `, +}); + export default RulesFilter; diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts new file mode 100644 index 00000000000..a809e148a61 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts @@ -0,0 +1,163 @@ +import { setDataSourceSrv } from '@grafana/runtime'; + +import { PromAlertingRuleState } from '../../../../types/unified-alerting-dto'; +import { + mockAlertQuery, + mockCombinedRule, + mockCombinedRuleGroup, + mockCombinedRuleNamespace, + mockDataSource, + MockDataSourceSrv, + mockPromAlert, + mockPromAlertingRule, + mockRulerGrafanaRule, +} from '../mocks'; +import { RuleHealth } from '../search/rulesSearchParser'; +import { getFilter } from '../utils/search'; + +import { filterRules } from './useFilteredRules'; + +const dataSources = { + prometheus: mockDataSource({ uid: 'prom-1', name: 'prometheus' }), + loki: mockDataSource({ uid: 'loki-1', name: 'loki' }), +}; +beforeAll(() => { + setDataSourceSrv(new MockDataSourceSrv(dataSources)); +}); + +describe('filterRules', function () { + it('should filter out rules by name filter', function () { + 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' })); + + 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' })]), + ], + }); + + const filtered = filterRules([ns], getFilter({ groupName: 'availability' })); + + 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 = [ + mockCombinedRule({ name: 'High CPU usage', labels: { severity: 'warning' } }), + mockCombinedRule({ name: 'Memory too low', labels: { severity: 'critical' } }), + ]; + + const ns = mockCombinedRuleNamespace({ + groups: [mockCombinedRuleGroup('Resources usage group', rules)], + }); + + const filtered = filterRules([ns], getFilter({ labels: ['severity=critical'] })); + + expect(filtered[0].groups[0].rules).toHaveLength(1); + expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low'); + }); + + it('should filter out rules by alert instance labels', function () { + const rules = [ + mockCombinedRule({ + name: 'High CPU usage', + promRule: mockPromAlertingRule({ alerts: [mockPromAlert({ labels: { severity: 'warning' } })] }), + }), + mockCombinedRule({ + name: 'Memory too low', + promRule: mockPromAlertingRule({ labels: { severity: 'critical' }, alerts: [] }), + }), + ]; + + const ns = mockCombinedRuleNamespace({ + groups: [mockCombinedRuleGroup('Resources usage group', rules)], + }); + + const filtered = filterRules([ns], getFilter({ labels: ['severity=warning'] })); + + 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 state filter', function () { + const rules = [ + mockCombinedRule({ + name: 'High CPU usage', + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }), + mockCombinedRule({ + name: 'Memory too low', + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Firing }), + }), + ]; + + const ns = mockCombinedRuleNamespace({ + groups: [mockCombinedRuleGroup('Resources usage group', rules)], + }); + + const filtered = filterRules([ns], getFilter({ ruleState: PromAlertingRuleState.Firing })); + + expect(filtered[0].groups[0].rules).toHaveLength(1); + expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low'); + }); + + it('should filter out rules by health filter', function () { + const rules = [ + mockCombinedRule({ + name: 'High CPU usage', + promRule: mockPromAlertingRule({ health: RuleHealth.Ok }), + }), + mockCombinedRule({ + name: 'Memory too low', + promRule: mockPromAlertingRule({ health: RuleHealth.Error }), + }), + ]; + + const ns = mockCombinedRuleNamespace({ + groups: [mockCombinedRuleGroup('Resources usage group', rules)], + }); + + const filtered = filterRules([ns], getFilter({ ruleHealth: RuleHealth.Error })); + + expect(filtered[0].groups[0].rules).toHaveLength(1); + expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low'); + }); + + it('should filter out rules by datasource', function () { + const rules = [ + mockCombinedRule({ + name: 'High CPU usage', + rulerRule: mockRulerGrafanaRule(undefined, { + data: [mockAlertQuery({ datasourceUid: dataSources.prometheus.uid })], + }), + }), + mockCombinedRule({ + name: 'Memory too low', + rulerRule: mockRulerGrafanaRule(undefined, { + data: [mockAlertQuery({ datasourceUid: dataSources.loki.uid })], + }), + }), + ]; + + const ns = mockCombinedRuleNamespace({ + groups: [mockCombinedRuleGroup('Resources usage group', rules)], + }); + + const filtered = filterRules([ns], getFilter({ dataSourceName: 'loki' })); + + expect(filtered[0].groups[0].rules).toHaveLength(1); + expect(filtered[0].groups[0].rules[0].name).toBe('Memory too low'); + }); +}); diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index 20cf18caf17..061c90a7ead 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -1,35 +1,107 @@ -import { useMemo } from 'react'; +import produce from 'immer'; +import { compact, isEmpty } from 'lodash'; +import { useCallback, useEffect, useMemo } from 'react'; import { getDataSourceSrv } from '@grafana/runtime'; -import { useQueryParams } from 'app/core/hooks/useQueryParams'; -import { CombinedRuleGroup, CombinedRuleNamespace, FilterState } from 'app/types/unified-alerting'; -import { PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; +import { isPromAlertingRuleState, PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; -import { labelsMatchMatchers, parseMatchers } from '../utils/alertmanager'; +import { getSearchFilterFromQuery, RulesFilter, applySearchFilterToQuery } from '../search/rulesSearchParser'; +import { labelsMatchMatchers, matcherToMatcherField, parseMatcher, parseMatchers } from '../utils/alertmanager'; import { isCloudRulesSource } from '../utils/datasource'; -import { getFiltersFromUrlParams } from '../utils/misc'; -import { isAlertingRule, isGrafanaRulerRule } from '../utils/rules'; +import { getRuleHealth, isAlertingRule, isGrafanaRulerRule, isPromRuleType } from '../utils/rules'; -export const useFilteredRules = (namespaces: CombinedRuleNamespace[]) => { - const [queryParams] = useQueryParams(); - const filters = getFiltersFromUrlParams(queryParams); +import { useURLSearchParams } from './useURLSearchParams'; - return useMemo(() => { - const filteredNamespaces = namespaces - // Filter by data source - // TODO: filter by multiple data sources for grafana-managed alerts - .filter(({ rulesSource }) => - filters.dataSource && isCloudRulesSource(rulesSource) ? rulesSource.name === filters.dataSource : true - ) - // If a namespace and group have rules that match the rules filters then keep them. - .reduce(reduceNamespaces(filters), [] as CombinedRuleNamespace[]); - return filteredNamespaces; - }, [namespaces, filters]); +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 updateFilters = useCallback( + (newFilter: RulesFilter) => { + const newSearchQuery = applySearchFilterToQuery(searchQuery, newFilter); + updateQueryParams({ search: newSearchQuery }); + }, + [searchQuery, updateQueryParams] + ); + + const setSearchQuery = useCallback( + (newSearchQuery: string | undefined) => { + updateQueryParams({ search: newSearchQuery }); + }, + [updateQueryParams] + ); + + // Handle legacy filters + useEffect(() => { + const legacyFilters = { + dataSource: queryParams.get('dataSource') ?? undefined, + alertState: queryParams.get('alertState') ?? undefined, + ruleType: queryParams.get('ruleType') ?? undefined, + labels: parseMatchers(queryParams.get('queryString') ?? '').map(matcherToMatcherField), + }; + + const hasLegacyFilters = Object.values(legacyFilters).some((legacyFilter) => !isEmpty(legacyFilter)); + if (hasLegacyFilters) { + updateQueryParams({ dataSource: undefined, alertState: undefined, ruleType: undefined, queryString: undefined }); + // Existing query filters takes precedence over legacy ones + updateFilters( + produce(filterState, (draft) => { + draft.dataSourceName ??= legacyFilters.dataSource; + if (legacyFilters.alertState && isPromAlertingRuleState(legacyFilters.alertState)) { + draft.ruleState ??= legacyFilters.alertState; + } + if (legacyFilters.ruleType && isPromRuleType(legacyFilters.ruleType)) { + draft.ruleType ??= legacyFilters.ruleType; + } + if (draft.labels.length === 0 && legacyFilters.labels.length > 0) { + const legacyLabelsAsStrings = legacyFilters.labels.map( + ({ name, operator, value }) => `${name}${operator}${value}` + ); + draft.labels.push(...legacyLabelsAsStrings); + } + }) + ); + } + }, [queryParams, updateFilters, filterState, updateQueryParams]); + + return { filterState, hasActiveFilters, searchQuery, setSearchQuery, updateFilters }; +} + +export const useFilteredRules = (namespaces: CombinedRuleNamespace[], filterState: RulesFilter) => { + return useMemo(() => filterRules(namespaces, filterState), [namespaces, filterState]); }; -const reduceNamespaces = (filters: FilterState) => { +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[]) + ); +}; + +const reduceNamespaces = (filterStateFilters: RulesFilter) => { return (namespaceAcc: CombinedRuleNamespace[], namespace: CombinedRuleNamespace) => { - const groups = namespace.groups.reduce(reduceGroups(filters), [] as CombinedRuleGroup[]); + const groups = namespace.groups + .filter((g) => + filterStateFilters.groupName ? g.name.toLowerCase().includes(filterStateFilters.groupName.toLowerCase()) : true + ) + .reduce(reduceGroups(filterStateFilters), [] as CombinedRuleGroup[]); if (groups.length) { namespaceAcc.push({ @@ -43,35 +115,56 @@ const reduceNamespaces = (filters: FilterState) => { }; // Reduces groups to only groups that have rules matching the filters -const reduceGroups = (filters: FilterState) => { +const reduceGroups = (filterState: RulesFilter) => { return (groupAcc: CombinedRuleGroup[], group: CombinedRuleGroup) => { const rules = group.rules.filter((rule) => { - if (filters.ruleType && filters.ruleType !== rule.promRule?.type) { + if (filterState.ruleType && filterState.ruleType !== rule.promRule?.type) { return false; } - if (filters.dataSource && isGrafanaRulerRule(rule.rulerRule) && !isQueryingDataSource(rule.rulerRule, filters)) { - return false; - } - // Query strings can match alert name, label keys, and label values - if (filters.queryString) { - const normalizedQueryString = filters.queryString.toLocaleLowerCase(); - const doesNameContainsQueryString = rule.name?.toLocaleLowerCase().includes(normalizedQueryString); - const matchers = parseMatchers(filters.queryString); - const doRuleLabelsMatchQuery = labelsMatchMatchers(rule.labels, matchers); + const doesNotQueryDs = isGrafanaRulerRule(rule.rulerRule) && !isQueryingDataSource(rule.rulerRule, filterState); + if (filterState.dataSourceName && doesNotQueryDs) { + 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; + } + + // Query strings can match alert name, label keys, and label values + if (filterState.labels.length > 0) { + // const matchers = parseMatchers(filters.queryString); + const matchers = compact(filterState.labels.map(looseParseMatcher)); + + const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(rule.labels, matchers); const doAlertsContainMatchingLabels = + matchers.length > 0 && rule.promRule && rule.promRule.type === PromRuleType.Alerting && rule.promRule.alerts && rule.promRule.alerts.some((alert) => labelsMatchMatchers(alert.labels, matchers)); - if (!(doesNameContainsQueryString || doRuleLabelsMatchQuery || doAlertsContainMatchingLabels)) { + if (!(doRuleLabelsMatchQuery || doAlertsContainMatchingLabels)) { return false; } } if ( - filters.alertState && - !(rule.promRule && isAlertingRule(rule.promRule) && rule.promRule.state === filters.alertState) + filterState.ruleState && + !(rule.promRule && isAlertingRule(rule.promRule) && rule.promRule.state === filterState.ruleState) ) { return false; } @@ -88,8 +181,17 @@ const reduceGroups = (filters: FilterState) => { }; }; -const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filter: FilterState): boolean => { - if (!filter.dataSource) { +function looseParseMatcher(matcherQuery: string): Matcher | undefined { + try { + return parseMatcher(matcherQuery); + } catch { + // Try to createa a matcher than matches all values for a given key + return { name: matcherQuery, value: '', isRegex: true, isEqual: true }; + } +} + +const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filterState: RulesFilter): boolean => { + if (!filterState.dataSourceName) { return true; } @@ -98,6 +200,6 @@ const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filter: FilterStat return false; } const ds = getDataSourceSrv().getInstanceSettings(query.datasourceUid); - return ds?.name === filter.dataSource; + return ds?.name === filterState.dataSourceName; }); }; diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index e0f524eb91e..2148209f3f7 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -23,8 +23,18 @@ import { } from 'app/plugins/datasource/alertmanager/types'; import { configureStore } from 'app/store/configureStore'; import { AccessControlAction, FolderDTO, StoreState } from 'app/types'; -import { Alert, AlertingRule, CombinedRule, RecordingRule, RuleGroup, RuleNamespace } from 'app/types/unified-alerting'; import { + Alert, + AlertingRule, + CombinedRule, + CombinedRuleGroup, + CombinedRuleNamespace, + RecordingRule, + RuleGroup, + RuleNamespace, +} from 'app/types/unified-alerting'; +import { + AlertQuery, GrafanaAlertStateDecision, GrafanaRuleDefinition, PromAlertingRuleState, @@ -517,6 +527,29 @@ export function mockStore(recipe: (state: StoreState) => void) { return configureStore(produce(defaultState, recipe)); } +export function mockAlertQuery(query: Partial): AlertQuery { + return { + datasourceUid: '--uid--', + refId: 'A', + queryType: '', + model: { refId: 'A' }, + ...query, + }; +} + +export function mockCombinedRuleGroup(name: string, rules: CombinedRule[]): CombinedRuleGroup { + return { name, rules }; +} + +export function mockCombinedRuleNamespace(namespace: Partial): CombinedRuleNamespace { + return { + name: 'Grafana', + groups: [], + rulesSource: 'grafana', + ...namespace, + }; +} + export function getGrafanaRule(override?: Partial) { return mockCombinedRule({ namespace: { diff --git a/public/app/features/alerting/unified/search/README.md b/public/app/features/alerting/unified/search/README.md new file mode 100644 index 00000000000..c550edffe6c --- /dev/null +++ b/public/app/features/alerting/unified/search/README.md @@ -0,0 +1,27 @@ +# Alerting search syntax + +## Lezer grammar + +Alerting uses the [Lezer](https://lezer.codemirror.net/) parser system to create a search syntax grammar. + +File [search.grammar](search.grammar) describes the search grammar. + +`@lezer/generator` package is used to generate [search.js](search.js) and [search.terms.js](search.terms.js) files which include a JS grammar parser. + +## Changing the grammar + +After making changes in the `search.grammar` file, a new version of the parser needs to be generated. +To do that, the following command needs to be run in the `public/app/features/alerting/unified/search` directory + +```sh +yarn dlx @lezer/generator search.grammar -o search.js +``` + +The command will re-create [search.js](search.js) and [search.terms.js](search.terms.js) files which are the files containing grammar parser. + +## Extensibility + +The `search.grammar` uses the [dialects feature](https://lezer.codemirror.net/docs/guide/#dialects) of Lezer to enable parsing of each filter term separately. + +This will allow us to have a single grammar file for handling filter expressions for all of our filters (e.g. Rules, Silences, Notification policies). +Then we can configure the required set of filters dynamically in the JS code using the parser. diff --git a/public/app/features/alerting/unified/search/rulesSearchParser.test.ts b/public/app/features/alerting/unified/search/rulesSearchParser.test.ts new file mode 100644 index 00000000000..4520655095c --- /dev/null +++ b/public/app/features/alerting/unified/search/rulesSearchParser.test.ts @@ -0,0 +1,183 @@ +import { PromAlertingRuleState, PromRuleType } from '../../../../types/unified-alerting-dto'; +import { getFilter } from '../utils/search'; + +import { applySearchFilterToQuery, getSearchFilterFromQuery, RuleHealth } from './rulesSearchParser'; + +describe('Alert rules searchParser', () => { + describe('getSearchFilterFromQuery', () => { + it.each(['datasource:prometheus'])('should parse data source filter from "%s" query', (query) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.dataSourceName).toBe('prometheus'); + }); + + it.each(['namespace:integrations-node'])('should parse namespace filter from "%s" query', (query) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.namespace).toBe('integrations-node'); + }); + + it.each(['label:team label:region=emea'])('should parse label filter from "%s" query', (query) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.labels).toHaveLength(2); + expect(filter.labels).toContain('team'); + expect(filter.labels).toContain('region=emea'); + }); + + it.each(['group:cpu-utilization'])('should parse group filter from "%s" query', (query) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.groupName).toBe('cpu-utilization'); + }); + + it.each(['rule:cpu-80%-alert'])('should parse rule name filter from "%s" query', (query) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.ruleName).toBe('cpu-80%-alert'); + }); + + it.each([ + { query: 'state:firing', expectedFilter: PromAlertingRuleState.Firing }, + { query: 'state:inactive', expectedFilter: PromAlertingRuleState.Inactive }, + { query: 'state:pending', expectedFilter: PromAlertingRuleState.Pending }, + ])('should parse $expectedFilter rule state filter from "$query" query', ({ query, expectedFilter }) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.ruleState).toBe(expectedFilter); + }); + + it.each([ + { query: 'type:alerting', expectedFilter: PromRuleType.Alerting }, + { query: 'type:recording', expectedFilter: PromRuleType.Recording }, + ])('should parse $expectedFilter rule type filter from "$query" input', ({ query, expectedFilter }) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.ruleType).toBe(expectedFilter); + }); + + it.each([ + { query: 'health:ok', expectedFilter: RuleHealth.Ok }, + { query: 'health:nodata', expectedFilter: RuleHealth.NoData }, + { query: 'health:error', expectedFilter: RuleHealth.Error }, + ])('should parse RuleHealth $expectedFilter filter from "$query" query', ({ query, expectedFilter }) => { + const filter = getSearchFilterFromQuery(query); + expect(filter.ruleHealth).toBe(expectedFilter); + }); + + it('should parse non-filtering words as free form query', () => { + const filter = getSearchFilterFromQuery('cpu usage rule'); + expect(filter.freeFormWords).toHaveLength(3); + expect(filter.freeFormWords).toContain('cpu'); + expect(filter.freeFormWords).toContain('usage'); + expect(filter.freeFormWords).toContain('rule'); + }); + + it('should parse free words with quotes', () => { + const query = '"hello world" hello world'; + const filter = getSearchFilterFromQuery(query); + + expect(filter.freeFormWords).toEqual(['hello world', 'hello', 'world']); + }); + + it('should parse filter values with whitespaces when in quotes', () => { + const query = + 'datasource:"prom dev" namespace:"node one" label:"team=frontend us" group:"cpu alerts" rule:"cpu failure"'; + const filter = getSearchFilterFromQuery(query); + + expect(filter.dataSourceName).toBe('prom dev'); + expect(filter.namespace).toBe('node one'); + expect(filter.labels).toContain('team=frontend us'); + expect(filter.groupName).toContain('cpu alerts'); + expect(filter.ruleName).toContain('cpu failure'); + }); + + it('should parse filter values with special characters', () => { + const query = + 'datasource:prom::dev/linux>>; namespace:"[{node}] (#20+)" label:_region=apac|emea\\nasa group:$20.00%$ rule:"cpu!! & memory.,?"'; + const filter = getSearchFilterFromQuery(query); + + expect(filter.dataSourceName).toBe('prom::dev/linux>>;'); + expect(filter.namespace).toBe('[{node}] (#20+)'); + expect(filter.labels).toContain('_region=apac|emea\\nasa'); + expect(filter.groupName).toContain('$20.00%$'); + expect(filter.ruleName).toContain('cpu!! & memory.,?'); + }); + + it('should parse non-filter terms with colon as free form words', () => { + const query = 'cpu:high-utilization memory:overload'; + const filter = getSearchFilterFromQuery(query); + + expect(filter.freeFormWords).toContain('cpu:high-utilization'); + expect(filter.freeFormWords).toContain('memory:overload'); + }); + + it('should parse mixed free form words and filters', () => { + const query = 'datasource:prometheus utilization label:team cpu'; + const filter = getSearchFilterFromQuery(query); + + expect(filter.dataSourceName).toBe('prometheus'); + expect(filter.labels).toContain('team'); + expect(filter.freeFormWords).toContain('utilization'); + expect(filter.freeFormWords).toContain('cpu'); + }); + + it('should parse labels containing matchers', () => { + const query = 'label:region!=US label:"team=~fe.*devs" label:cluster!~ba.+'; + const filter = getSearchFilterFromQuery(query); + + expect(filter.labels).toContain('region!=US'); + expect(filter.labels).toContain('team=~fe.*devs'); + expect(filter.labels).toContain('cluster!~ba.+'); + }); + }); + + describe('applySearchFilterToQuery', () => { + it('should apply filters to an empty query', () => { + const filter = getFilter({ + freeFormWords: ['cpu', 'eighty'], + dataSourceName: 'Mimir Dev', + namespace: '/etc/prometheus', + labels: ['team', 'region=apac'], + groupName: 'cpu-usage', + ruleName: 'cpu > 80%', + ruleType: PromRuleType.Alerting, + ruleState: PromAlertingRuleState.Firing, + ruleHealth: RuleHealth.Ok, + }); + + const query = applySearchFilterToQuery('', filter); + + expect(query).toBe( + 'datasource:"Mimir Dev" namespace:/etc/prometheus group:cpu-usage rule:"cpu > 80%" state:firing type:alerting health:ok label:team label:region=apac cpu eighty' + ); + }); + + it('should update filters in existing query', () => { + const filter = getFilter({ + dataSourceName: 'Mimir Dev', + namespace: '/etc/prometheus', + labels: ['team', 'region=apac'], + groupName: 'cpu-usage', + ruleName: 'cpu > 80%', + }); + + const baseQuery = 'datasource:prometheus namespace:mimir-global group:memory rule:"mem > 90% label:severity"'; + const query = applySearchFilterToQuery(baseQuery, filter); + + expect(query).toBe( + 'datasource:"Mimir Dev" namespace:/etc/prometheus group:cpu-usage rule:"cpu > 80%" label:team label:region=apac' + ); + }); + + it('should preserve the order of parameters when updating', () => { + const filter = getFilter({ + dataSourceName: 'Mimir Dev', + namespace: '/etc/prometheus', + labels: ['region=emea'], + groupName: 'cpu-usage', + ruleName: 'cpu > 80%', + }); + + const baseQuery = 'label:region=apac rule:"mem > 90%" group:memory namespace:mimir-global datasource:prometheus'; + const query = applySearchFilterToQuery(baseQuery, filter); + + expect(query).toBe( + 'label:region=emea rule:"cpu > 80%" group:cpu-usage namespace:/etc/prometheus datasource:"Mimir Dev"' + ); + }); + }); +}); diff --git a/public/app/features/alerting/unified/search/rulesSearchParser.ts b/public/app/features/alerting/unified/search/rulesSearchParser.ts new file mode 100644 index 00000000000..c74bd0871bf --- /dev/null +++ b/public/app/features/alerting/unified/search/rulesSearchParser.ts @@ -0,0 +1,100 @@ +import { isPromAlertingRuleState, PromAlertingRuleState, PromRuleType } from '../../../../types/unified-alerting-dto'; +import { getRuleHealth, isPromRuleType } from '../utils/rules'; + +import * as terms from './search.terms'; +import { + applyFiltersToQuery, + FilterExpr, + FilterSupportedTerm, + parseQueryToFilter, + QueryFilterMapper, +} from './searchParser'; + +export interface RulesFilter { + freeFormWords: string[]; + namespace?: string; + groupName?: string; + ruleName?: string; + ruleState?: PromAlertingRuleState; + ruleType?: PromRuleType; + dataSourceName?: string; + labels: string[]; + ruleHealth?: RuleHealth; +} + +const filterSupportedTerms: FilterSupportedTerm[] = [ + FilterSupportedTerm.dataSource, + FilterSupportedTerm.nameSpace, + FilterSupportedTerm.label, + FilterSupportedTerm.group, + FilterSupportedTerm.rule, + FilterSupportedTerm.state, + FilterSupportedTerm.type, + FilterSupportedTerm.health, +]; + +export enum RuleHealth { + Ok = 'ok', + Error = 'error', + NoData = 'nodata', + Unknown = 'unknown', +} + +// Define how to map parsed tokens into the filter object +export function getSearchFilterFromQuery(query: string): RulesFilter { + const filter: RulesFilter = { labels: [], freeFormWords: [] }; + + const tokenToFilterMap: QueryFilterMapper = { + [terms.DataSourceToken]: (value) => (filter.dataSourceName = value), + [terms.NameSpaceToken]: (value) => (filter.namespace = value), + [terms.GroupToken]: (value) => (filter.groupName = value), + [terms.RuleToken]: (value) => (filter.ruleName = value), + [terms.LabelToken]: (value) => filter.labels.push(value), + [terms.StateToken]: (value) => (isPromAlertingRuleState(value) ? (filter.ruleState = value) : undefined), + [terms.TypeToken]: (value) => (isPromRuleType(value) ? (filter.ruleType = value) : undefined), + [terms.HealthToken]: (value) => (filter.ruleHealth = getRuleHealth(value)), + [terms.FreeFormExpression]: (value) => filter.freeFormWords.push(value), + }; + + parseQueryToFilter(query, filterSupportedTerms, tokenToFilterMap); + + return filter; +} + +// Reverse of the previous function +// Describes how to map the object into an array of tokens and values +export function applySearchFilterToQuery(query: string, filter: RulesFilter): string { + const filterStateArray: FilterExpr[] = []; + + // Convert filter object into an array + // It allows to pick filters from the array in the same order as they were applied in the original query + if (filter.dataSourceName) { + filterStateArray.push({ type: terms.DataSourceToken, value: filter.dataSourceName }); + } + if (filter.namespace) { + filterStateArray.push({ type: terms.NameSpaceToken, value: filter.namespace }); + } + if (filter.groupName) { + filterStateArray.push({ type: terms.GroupToken, value: filter.groupName }); + } + if (filter.ruleName) { + filterStateArray.push({ type: terms.RuleToken, value: filter.ruleName }); + } + if (filter.ruleState) { + filterStateArray.push({ type: terms.StateToken, value: filter.ruleState }); + } + if (filter.ruleType) { + filterStateArray.push({ type: terms.TypeToken, value: filter.ruleType }); + } + if (filter.ruleHealth) { + filterStateArray.push({ type: terms.HealthToken, value: filter.ruleHealth }); + } + if (filter.labels) { + filterStateArray.push(...filter.labels.map((l) => ({ type: terms.LabelToken, value: l }))); + } + if (filter.freeFormWords) { + filterStateArray.push(...filter.freeFormWords.map((word) => ({ type: terms.FreeFormExpression, value: word }))); + } + + return applyFiltersToQuery(query, filterSupportedTerms, filterStateArray); +} diff --git a/public/app/features/alerting/unified/search/search.grammar b/public/app/features/alerting/unified/search/search.grammar new file mode 100644 index 00000000000..a8d64b6dceb --- /dev/null +++ b/public/app/features/alerting/unified/search/search.grammar @@ -0,0 +1,54 @@ +@top AlertRuleSearch { expression+ } + +@dialects { dataSourceFilter, nameSpaceFilter, labelFilter, groupFilter, ruleFilter, stateFilter, typeFilter, healthFilter } + +expression { (FilterExpression | FreeFormExpression) expression } + +FreeFormExpression { word (colon word)* | stringWithQuotes } + +FilterExpression { + filter | + filter | + filter | + filter | + filter | + filter | + filter | + filter +} + +filter { token FilterValue } + +@tokens { + colon { ":" } + + // Special characters (except colon, quotes and space), Latin characters, extended latin and emoji + allowedInputChar { $[!#$%&'()*+,-./] | $[\u{0030}-\u{0039}] | $[\u{003b}-\u{1eff}] | $[\u{2030}-\u{1faff}] } + word { allowedInputChar+ } + + allowedInputCharOrColon { allowedInputChar | colon } + allowedInputCharOrColonOrWhitespace { allowedInputCharOrColon | @whitespace } + stringWithQuotes { ("\"" allowedInputCharOrColonOrWhitespace+ "\"") } + + FilterValue { allowedInputCharOrColon+ | stringWithQuotes } + filterToken { type colon } + + DataSourceToken[@dialect=dataSourceFilter] { filterToken<"datasource"> } + NameSpaceToken[@dialect=nameSpaceFilter] { filterToken<"namespace"> } + LabelToken[@dialect=labelFilter] { filterToken<"label"> } + GroupToken[@dialect=groupFilter] { filterToken<"group"> } + RuleToken[@dialect=ruleFilter] { filterToken<"rule"> } + StateToken[@dialect=stateFilter] { filterToken<"state"> } + TypeToken[@dialect=typeFilter] { filterToken<"type"> } + HealthToken[@dialect=healthFilter] { filterToken<"health"> } + + @precedence { DataSourceToken, word } + @precedence { NameSpaceToken, word } + @precedence { LabelToken, word } + @precedence { GroupToken, word } + @precedence { RuleToken, word } + @precedence { StateToken, word } + @precedence { TypeToken, word } + @precedence { HealthToken, word } +} + diff --git a/public/app/features/alerting/unified/search/search.js b/public/app/features/alerting/unified/search/search.js new file mode 100644 index 00000000000..8ee40ac1d7c --- /dev/null +++ b/public/app/features/alerting/unified/search/search.js @@ -0,0 +1,30 @@ +// This file was generated by lezer-generator. You probably shouldn't edit it. +import { LRParser } from '@lezer/lr'; +export const parser = LRParser.deserialize({ + version: 14, + states: + "!vOQOPOOOrOPO'#ChOOOO'#Ch'#ChOQOPO'#ClOOOO'#Ci'#CiQQOPOOO!gOQO'#C^O!lOPO'#CjO!qOPO,59SOOOO,59W,59WOOOO-E6g-E6gOOOO,58x,58xOOOO,59U,59UOOOO-E6h-E6h", + stateData: + '$O~ORUOTUOUUOVUOWUOXUOYUOZUOaPOcQO~ObVOR[XT[XU[XV[XW[XX[XY[XZ[Xa[Xc[X~OSZO~Oa[O~ObVOR[aT[aU[aV[aW[aX[aY[aZ[aa[ac[a~OR~T~U~V~W~Y~Z~RZYXWVUTa~', + goto: 'zaPPbPPPPPPPPPbgmPsVRORTQTORYTQWPR]WSSOTRXR', + nodeNames: + '⚠ AlertRuleSearch FilterExpression DataSourceToken FilterValue NameSpaceToken LabelToken GroupToken RuleToken StateToken TypeToken HealthToken FreeFormExpression', + maxTerm: 19, + skippedNodes: [0], + repeatNodeCount: 2, + tokenData: + "#$QRRqqr#Yrs&fst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]+h!]#W#Y#W#X,z#X#Z#Y#Z#[=b#[#]F[#]#`#Y#`#a!!n#a#b#Y#b#c!+h#c#f#Y#f#g!:f#g#h!Av#h#i!Jp#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR#acSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YQ$qcSQqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lQ&PP;=`<%l$lQ&VP;=`;NQ$lR&]P;=`<%l#YR&cP;=`;NQ#YR&irX^(spq(sqr(sst(stu(suv(svw(swx(sxy(syz(sz{(s{|(s|!P(s!P!Q(s!Q![(s![!](s!]#y(s#y#z(s#z$f(s$f$g(s$g#BY(s#BY#BZ(s#BZ$Ch(s$IS$I_(s$I|$JO(s$JT$JU(s$JU$KV(s$KV$KW(s$KW&FU(s&FU&FV(s&FV;'S(s;'S;(d+[;(d;(e+b<%lO(sR(vsX^(spq(sqr(srs+Tst(stu(suv(svw(swx(sxy(syz(sz{(s{|(s|!P(s!P!Q(s!Q![(s![!](s!]#y(s#y#z(s#z$f(s$f$g(s$g#BY(s#BY#BZ(s#BZ$Ch(s$IS$I_(s$I|$JO(s$JT$JU(s$JU$KV(s$KV$KW(s$KW&FU(s&FU&FV(s&FV;'S(s;'S;(d+[;(d;(e+b<%lO(sR+[OSQcPR+_P;=`<%l(sR+eP;=`;NQ(sR+ocSQbPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR-ReSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U.d#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR.keSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#h#Y#h#i/|#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR0TeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U1f#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR1meSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#g#Y#g#h3O#h$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR3VeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#c#Y#c#d4h#d$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR4oeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#i#Y#i#j6Q#j$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR6XeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#f#Y#f#g7j#g$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR7qeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#V#Y#V#W9S#W$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR9ZeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y:l#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR:scSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]z#g$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR?ReSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#c#Y#c#d@d#d$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR@keSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#i#Y#i#jA|#j$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRBTeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#d#Y#d#eCf#e$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRCmcSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]Dx!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YREPcSQVPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lRFceSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#YGt#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRG{eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#UI^#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRIeeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#`#Y#`#aJv#a$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRJ}eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#h#Y#h#iL`#i$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRLgeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#[#Y#[#]Mx#]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YRNPcSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]! [!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR! ccSQZPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!!ueSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U!$W#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!$_eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#U#Y#U#V!%p#V$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!%weSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!'Y#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!'aeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#`#Y#`#a!(r#a$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!(ycSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]!*U!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!*]cSQUPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!+oeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U!-Q#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!-XeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#a#Y#a#b!.j#b$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!.qeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!0S#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!0ZeSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#g#Y#g#h!1l#h$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!1seSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#d#Y#d#e!3U#e$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!3]eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#T#Y#T#U!4n#U$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!4ueSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#V#Y#V#W!6W#W$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!6_eSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#X#Y#X#Y!7p#Y$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!7wcSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]!9S!]$Ch#Y$JU;'S#Y;'S;(d&Y;(d;(e&`<%lO#YR!9ZcSQTPqr$lst$ltu$luv$lvw$lwx$lxy$lyz$lz{$l{|$l|!P$l!P!Q$l!Q![$l![!]$l!]$Ch$l$JU;'S$l;'S;(d%|;(d;(e&S<%lO$lR!:meSQaPqr#Yst#Ytu#Yuv#Yvw#Ywx#Yxy#Yyz#Yz{#Y{|#Y|!P#Y!P!Q#Y!Q![#Y![!]$l!]#i#Y#i#j! = { + [terms.DataSourceToken]: 'datasource', + [terms.NameSpaceToken]: 'namespace', + [terms.LabelToken]: 'label', + [terms.RuleToken]: 'rule', + [terms.GroupToken]: 'group', + [terms.StateToken]: 'state', + [terms.TypeToken]: 'type', + [terms.HealthToken]: 'health', +}; + +// This enum allows to configure parser behavior +// Depending on our needs we can enable and disable only selected filters +// Thanks to that we can create multiple different filters having the same search grammar +export enum FilterSupportedTerm { + dataSource = 'dataSourceFilter', + nameSpace = 'nameSpaceFilter', + label = 'labelFilter', + group = 'groupFilter', + rule = 'ruleFilter', + state = 'stateFilter', + type = 'typeFilter', + health = 'healthFilter', +} + +export type QueryFilterMapper = Record void>; + +export interface FilterExpr { + type: number; + value: string; +} + +export function parseQueryToFilter( + query: string, + supportedTerms: FilterSupportedTerm[], + filterMapper: QueryFilterMapper +) { + traverseNodeTree(query, supportedTerms, (node) => { + if (node.type.id === terms.FilterExpression) { + const filter = getFilterFromSyntaxNode(query, node); + + if (filter.type && filter.value) { + const filterHandler = filterMapper[filter.type]; + if (filterHandler) { + filterHandler(filter.value); + } + } + } else if (node.type.id === terms.FreeFormExpression) { + const filterHandler = filterMapper[terms.FreeFormExpression]; + if (filterHandler) { + filterHandler(getNodeContent(query, node)); + } + } + }); +} + +function getFilterFromSyntaxNode(query: string, filterExpressionNode: SyntaxNode): { type?: number; value?: string } { + if (filterExpressionNode.type.id !== terms.FilterExpression) { + throw new Error('Invalid node provided. Only FilterExpression nodes are supported'); + } + + const filterTokenNode = filterExpressionNode.firstChild; + if (!filterTokenNode) { + return { type: undefined, value: undefined }; + } + + const filterValueNode = filterExpressionNode.getChild(terms.FilterValue); + const filterValue = filterValueNode ? trim(getNodeContent(query, filterValueNode), '"') : undefined; + + return { type: filterTokenNode.type.id, value: filterValue }; +} + +function getNodeContent(query: string, node: SyntaxNode) { + return query.slice(node.from, node.to).trim().replace(/\"/g, ''); +} + +export function applyFiltersToQuery( + query: string, + supportedTerms: FilterSupportedTerm[], + filters: FilterExpr[] +): string { + const existingFilterNodes: SyntaxNode[] = []; + traverseNodeTree(query, supportedTerms, (node) => { + if (node.type.id === terms.FilterExpression && node.firstChild) { + existingFilterNodes.push(node.firstChild); + } + if (node.type.id === terms.FreeFormExpression) { + existingFilterNodes.push(node); + } + }); + + let newQueryExpressions: string[] = []; + + // Apply filters from filterState in the same order as they appear in the search query + // This allows to remain the order of filters in the search input during changes + existingFilterNodes.forEach((filterNode) => { + const matchingFilterIdx = filters.findIndex((f) => f.type === filterNode.type.id); + if (matchingFilterIdx === -1) { + return; + } + + if (filterNode.parent?.type.is(terms.FilterExpression)) { + const filterToken = filterTokenToTypeMap[filterNode.type.id]; + const filterItem = filters.splice(matchingFilterIdx, 1)[0]; + newQueryExpressions.push(`${filterToken}:${getSafeFilterValue(filterItem.value)}`); + } + + if (filterNode.type.is(terms.FreeFormExpression)) { + const freeFormWordNode = filters.splice(matchingFilterIdx, 1)[0]; + newQueryExpressions.push(freeFormWordNode.value); + } + }); + + // Apply new filters that hasn't been in the query yet + filters.forEach((fs) => { + if (fs.type === terms.FreeFormExpression) { + newQueryExpressions.push(fs.value); + } else { + newQueryExpressions.push(`${filterTokenToTypeMap[fs.type]}:${getSafeFilterValue(fs.value)}`); + } + }); + + return newQueryExpressions.join(' '); +} + +function traverseNodeTree(query: string, supportedTerms: FilterSupportedTerm[], visit: (node: SyntaxNode) => void) { + const dialect = supportedTerms.join(' '); + const parsed = parser.configure({ dialect }).parse(query); + let cursor = parsed.cursor(); + do { + visit(cursor.node); + } while (cursor.next()); +} + +function getSafeFilterValue(filterValue: string) { + const containsWhiteSpaces = /\s/.test(filterValue); + return containsWhiteSpaces ? `\"${filterValue}\"` : filterValue; +} diff --git a/public/app/features/alerting/unified/utils/rules.ts b/public/app/features/alerting/unified/utils/rules.ts index 6386541891a..c44ba0869d4 100644 --- a/public/app/features/alerting/unified/utils/rules.ts +++ b/public/app/features/alerting/unified/utils/rules.ts @@ -27,6 +27,7 @@ import { } from 'app/types/unified-alerting-dto'; import { State } from '../components/StateTag'; +import { RuleHealth } from '../search/rulesSearchParser'; import { RULER_NOT_SUPPORTED_MSG } from './constants'; import { AsyncRequestState } from './redux'; @@ -67,10 +68,30 @@ export function isCloudRuleIdentifier(identifier: RuleIdentifier): identifier is return 'rulerRuleHash' in identifier; } +export function isPromRuleType(ruleType: string): ruleType is PromRuleType { + return Object.values(PromRuleType).includes(ruleType); +} + export function isPrometheusRuleIdentifier(identifier: RuleIdentifier): identifier is PrometheusRuleIdentifier { return 'ruleHash' in identifier; } +export function getRuleHealth(health: string): RuleHealth | undefined { + switch (health) { + case 'ok': + return RuleHealth.Ok; + case 'nodata': + return RuleHealth.NoData; + case 'error': + case 'err': // Prometheus-compat data sources + return RuleHealth.Error; + case 'unknown': + return RuleHealth.Unknown; + default: + return undefined; + } +} + export function alertStateToReadable(state: PromAlertingRuleState | GrafanaAlertStateWithReason | AlertState): string { if (state === PromAlertingRuleState.Inactive) { return 'Normal'; diff --git a/public/app/features/alerting/unified/utils/search.ts b/public/app/features/alerting/unified/utils/search.ts new file mode 100644 index 00000000000..846da556b01 --- /dev/null +++ b/public/app/features/alerting/unified/utils/search.ts @@ -0,0 +1,9 @@ +import { RulesFilter } from '../search/rulesSearchParser'; + +export function getFilter(filter: Partial): RulesFilter { + return { + freeFormWords: [], + labels: [], + ...filter, + }; +} diff --git a/public/app/types/unified-alerting-dto.ts b/public/app/types/unified-alerting-dto.ts index 9c07deff217..4d00880782e 100644 --- a/public/app/types/unified-alerting-dto.ts +++ b/public/app/types/unified-alerting-dto.ts @@ -23,6 +23,10 @@ type GrafanaAlertStateReason = ` (${string})` | ''; export type GrafanaAlertStateWithReason = `${GrafanaAlertState}${GrafanaAlertStateReason}`; +export function isPromAlertingRuleState(state: string): state is PromAlertingRuleState { + return Object.values(PromAlertingRuleState).includes(state); +} + export function isGrafanaAlertState(state: string): state is GrafanaAlertState { return Object.values(GrafanaAlertState).some((promState) => promState === state); }