diff --git a/public/app/features/alerting/unified/AlertsFolderView.tsx b/public/app/features/alerting/unified/AlertsFolderView.tsx index 1beb52abf25..bfebca0c03a 100644 --- a/public/app/features/alerting/unified/AlertsFolderView.tsx +++ b/public/app/features/alerting/unified/AlertsFolderView.tsx @@ -14,8 +14,9 @@ import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces'; import { usePagination } from './hooks/usePagination'; import { useURLSearchParams } from './hooks/useURLSearchParams'; import { fetchPromRulesAction, fetchRulerRulesAction } from './state/actions'; -import { combineMatcherStrings, labelsMatchMatchers, parseMatchers } from './utils/alertmanager'; +import { combineMatcherStrings, labelsMatchMatchers } from './utils/alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { parsePromQLStyleMatcherLooseSafe } from './utils/matchers'; import { createViewLink } from './utils/misc'; interface Props { @@ -168,7 +169,7 @@ function filterAndSortRules( labelFilter: string, sortOrder: SortOrder ) { - const matchers = parseMatchers(labelFilter); + const matchers = parsePromQLStyleMatcherLooseSafe(labelFilter); let rules = originalRules.filter( (rule) => rule.name.toLowerCase().includes(nameFilter.toLowerCase()) && labelsMatchMatchers(rule.labels, matchers) ); diff --git a/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx b/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx index 8643ac99555..52b7ed0652b 100644 --- a/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/MatcherFilter.tsx @@ -6,7 +6,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Field, Icon, Input, Label, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { logInfo, LogMessages } from '../../Analytics'; -import { parseMatchers } from '../../utils/alertmanager'; +import { parsePromQLStyleMatcherLoose } from '../../utils/matchers'; interface Props { defaultQueryString?: string; @@ -28,13 +28,22 @@ export const MatcherFilter = ({ onFilterChange, defaultQueryString }: Props) => ); const searchIcon = ; - const inputInvalid = defaultQueryString ? parseMatchers(defaultQueryString).length === 0 : false; + let inputValid = Boolean(defaultQueryString && defaultQueryString.length >= 3); + try { + if (!defaultQueryString) { + inputValid = true; + } else { + parsePromQLStyleMatcherLoose(defaultQueryString); + } + } catch (err) { + inputValid = false; + } return ( diff --git a/public/app/features/alerting/unified/components/notification-policies/Filters.tsx b/public/app/features/alerting/unified/components/notification-policies/Filters.tsx index 1352563ed67..4989cf15a83 100644 --- a/public/app/features/alerting/unified/components/notification-policies/Filters.tsx +++ b/public/app/features/alerting/unified/components/notification-policies/Filters.tsx @@ -7,8 +7,12 @@ import { Button, Field, Icon, Input, Label, Select, Stack, Text, Tooltip, useSty import { ObjectMatcher, Receiver, RouteWithID } from 'app/plugins/datasource/alertmanager/types'; import { useURLSearchParams } from '../../hooks/useURLSearchParams'; -import { matcherToObjectMatcher, parseMatchers } from '../../utils/alertmanager'; -import { normalizeMatchers } from '../../utils/matchers'; +import { matcherToObjectMatcher } from '../../utils/alertmanager'; +import { + normalizeMatchers, + parsePromQLStyleMatcherLoose, + parsePromQLStyleMatcherLooseSafe, +} from '../../utils/matchers'; interface NotificationPoliciesFilterProps { receivers: Receiver[]; @@ -35,7 +39,7 @@ const NotificationPoliciesFilter = ({ }, [contactPoint, onChangeReceiver]); useEffect(() => { - const matchers = parseMatchers(queryString ?? '').map(matcherToObjectMatcher); + const matchers = parsePromQLStyleMatcherLooseSafe(queryString ?? '').map(matcherToObjectMatcher); handleChangeLabels()(matchers); }, [handleChangeLabels, queryString]); @@ -50,7 +54,17 @@ const NotificationPoliciesFilter = ({ const selectedContactPoint = receiverOptions.find((option) => option.value === contactPoint) ?? null; const hasFilters = queryString || contactPoint; - const inputInvalid = queryString && queryString.length > 3 ? parseMatchers(queryString).length === 0 : false; + + let inputValid = Boolean(queryString && queryString.length > 3); + try { + if (!queryString) { + inputValid = true; + } else { + parsePromQLStyleMatcherLoose(queryString); + } + } catch (err) { + inputValid = false; + } return ( @@ -73,8 +87,8 @@ const NotificationPoliciesFilter = ({ } - invalid={inputInvalid} - error={inputInvalid ? 'Query must use valid matcher syntax' : null} + invalid={!inputValid} + error={!inputValid ? 'Query must use valid matcher syntax' : null} > labelsMatchMatchers(labels, matchers)); } if (alertInstanceState) { diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx index 175c7a304fc..3474049887d 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/EventListSceneObject.tsx @@ -18,8 +18,9 @@ import { import { stateHistoryApi } from '../../../api/stateHistoryApi'; import { usePagination } from '../../../hooks/usePagination'; -import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager'; +import { labelsMatchMatchers } from '../../../utils/alertmanager'; import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource'; +import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; import { stringifyErrorLike } from '../../../utils/misc'; import { AlertLabels } from '../../AlertLabels'; import { CollapseToggle } from '../../CollapseToggle'; @@ -412,7 +413,7 @@ function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filter?: string) { return { historyRecords: [] }; } - const filterMatchers = filter ? parseMatchers(filter) : []; + const filterMatchers = filter ? parsePromQLStyleMatcherLooseSafe(filter) : []; const [tsValues, lines] = stateHistory.data.values; const timestamps = isNumbers(tsValues) ? tsValues : []; diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts b/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts index 02cd7a7dc8d..324849c6da5 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts +++ b/public/app/features/alerting/unified/components/rules/central-state-history/utils.ts @@ -3,7 +3,8 @@ import { groupBy } from 'lodash'; import { DataFrame, Field as DataFrameField, DataFrameJSON, Field, FieldType } from '@grafana/data'; import { fieldIndexComparer } from '@grafana/data/src/field/fieldComparers'; -import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager'; +import { labelsMatchMatchers } from '../../../utils/alertmanager'; +import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; import { LogRecord } from '../state-history/common'; import { isLine, isNumbers } from '../state-history/useRuleHistoryRecords'; @@ -61,7 +62,7 @@ function groupDataFramesByTimeAndFilterByLabels(dataFrames: DataFrame[]): DataFr const filterValue = getFilterInQueryParams(); const dataframesFiltered = dataFrames.filter((frame) => { const labels = JSON.parse(frame.name ?? ''); // in name we store the labels stringified - const matchers = Boolean(filterValue) ? parseMatchers(filterValue) : []; + const matchers = Boolean(filterValue) ? parsePromQLStyleMatcherLooseSafe(filterValue) : []; return labelsMatchMatchers(labels, matchers); }); // Extract time fields from filtered data frames diff --git a/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.tsx b/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.tsx index 67efa185b3a..8187fb06e8a 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/useRuleHistoryRecords.tsx @@ -13,7 +13,8 @@ import { fieldIndexComparer } from '@grafana/data/src/field/fieldComparers'; import { MappingType, ThresholdsMode } from '@grafana/schema'; import { useTheme2 } from '@grafana/ui'; -import { labelsMatchMatchers, parseMatchers } from '../../../utils/alertmanager'; +import { labelsMatchMatchers } from '../../../utils/alertmanager'; +import { parsePromQLStyleMatcherLooseSafe } from '../../../utils/matchers'; import { extractCommonLabels, Line, LogRecord, omitLabels } from './common'; @@ -50,7 +51,7 @@ export function useRuleHistoryRecords(stateHistory?: DataFrameJSON, filter?: str const commonLabels = extractCommonLabels(groupLabelsArray); - const filterMatchers = filter ? parseMatchers(filter) : []; + const filterMatchers = filter ? parsePromQLStyleMatcherLooseSafe(filter) : []; const filteredGroupedLines = Object.entries(logRecordsByInstance).filter(([key]) => { const labels = JSON.parse(key); return labelsMatchMatchers(labels, filterMatchers); diff --git a/public/app/features/alerting/unified/components/silences/SilencesFilter.tsx b/public/app/features/alerting/unified/components/silences/SilencesFilter.tsx index 7cb57c91055..a15698fc893 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesFilter.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesFilter.tsx @@ -6,7 +6,7 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Button, Field, Icon, Input, Label, Tooltip, useStyles2, Stack } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; -import { parseMatchers } from '../../utils/alertmanager'; +import { parsePromQLStyleMatcherLoose } from '../../utils/matchers'; import { getSilenceFiltersFromUrlParams } from '../../utils/misc'; const getQueryStringKey = () => uniqueId('query-string-'); @@ -30,7 +30,16 @@ export const SilencesFilter = () => { setTimeout(() => setQueryStringKey(getQueryStringKey())); }; - const inputInvalid = queryString && queryString.length > 3 ? parseMatchers(queryString).length === 0 : false; + let inputValid = queryString && queryString.length > 3; + try { + if (!queryString) { + inputValid = true; + } else { + parsePromQLStyleMatcherLoose(queryString); + } + } catch (err) { + inputValid = false; + } return (
@@ -53,8 +62,8 @@ export const SilencesFilter = () => { } - invalid={inputInvalid} - error={inputInvalid ? 'Query must use valid matcher syntax' : null} + invalid={!inputValid} + error={!inputValid ? 'Query must use valid matcher syntax' : null} > { } } if (queryString) { - const matchers = parseMatchers(queryString); + const matchers = parsePromQLStyleMatcherLooseSafe(queryString); const matchersMatch = matchers.every((matcher) => silence.matchers?.some( ({ name, value, isEqual, isRegex }) => diff --git a/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts b/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts index 3aa49042d93..3d88eafd006 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredAmGroups.ts @@ -3,19 +3,21 @@ import { useMemo } from 'react'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types'; -import { labelsMatchMatchers, parseMatchers } from '../utils/alertmanager'; +import { labelsMatchMatchers } from '../utils/alertmanager'; +import { parsePromQLStyleMatcherLooseSafe } from '../utils/matchers'; import { getFiltersFromUrlParams } from '../utils/misc'; export const useFilteredAmGroups = (groups: AlertmanagerGroup[]) => { const [queryParams] = useQueryParams(); - const filters = getFiltersFromUrlParams(queryParams); - const matchers = parseMatchers(filters.queryString || ''); + const { queryString, alertState } = getFiltersFromUrlParams(queryParams); return useMemo(() => { + const matchers = queryString ? parsePromQLStyleMatcherLooseSafe(queryString) : []; + return groups.reduce((filteredGroup: AlertmanagerGroup[], group) => { const alerts = group.alerts.filter(({ labels, status }) => { const labelsMatch = labelsMatchMatchers(labels, matchers); - const filtersMatch = filters.alertState ? status.state === filters.alertState : true; + const filtersMatch = alertState ? status.state === alertState : true; return labelsMatch && filtersMatch; }); if (alerts.length > 0) { @@ -28,5 +30,5 @@ export const useFilteredAmGroups = (groups: AlertmanagerGroup[]) => { } return filteredGroup; }, []); - }, [groups, filters, matchers]); + }, [queryString, groups, alertState]); }; diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index eaa13b43840..f7674c92500 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -9,10 +9,10 @@ import { CombinedRuleGroup, CombinedRuleNamespace, Rule } from 'app/types/unifie import { isPromAlertingRuleState, PromRuleType, RulerGrafanaRuleDTO } from 'app/types/unified-alerting-dto'; import { applySearchFilterToQuery, getSearchFilterFromQuery, RulesFilter } from '../search/rulesSearchParser'; -import { labelsMatchMatchers, matcherToMatcherField, parseMatchers } from '../utils/alertmanager'; +import { labelsMatchMatchers, matcherToMatcherField } from '../utils/alertmanager'; import { Annotation } from '../utils/constants'; import { isCloudRulesSource } from '../utils/datasource'; -import { parseMatcher } from '../utils/matchers'; +import { parseMatcher, parsePromQLStyleMatcherLoose } from '../utils/matchers'; import { getRuleHealth, isAlertingRule, @@ -71,7 +71,7 @@ export function useRulesFilter() { dataSource: queryParams.get('dataSource') ?? undefined, alertState: queryParams.get('alertState') ?? undefined, ruleType: queryParams.get('ruleType') ?? undefined, - labels: parseMatchers(queryParams.get('queryString') ?? '').map(matcherToMatcherField), + labels: parsePromQLStyleMatcherLoose(queryParams.get('queryString') ?? '').map(matcherToMatcherField), }; const hasLegacyFilters = Object.values(legacyFilters).some((legacyFilter) => !isEmpty(legacyFilter)); diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts index 34c1a5ead12..8af7b141bff 100644 --- a/public/app/features/alerting/unified/mocks.ts +++ b/public/app/features/alerting/unified/mocks.ts @@ -21,7 +21,6 @@ import { DataSourceSrv, GetDataSourceListFilters, config } from '@grafana/runtim import { defaultDashboard } from '@grafana/schema'; import { contextSrv } from 'app/core/services/context_srv'; import { MOCK_GRAFANA_ALERT_RULE_TITLE } from 'app/features/alerting/unified/mocks/server/handlers/alertRules'; -import { parseMatchers } from 'app/features/alerting/unified/utils/alertmanager'; import { DatasourceSrv } from 'app/features/plugins/datasource_srv'; import { AlertManagerCortexConfig, @@ -64,6 +63,8 @@ import { import { DashboardSearchItem, DashboardSearchItemType } from '../../search/types'; +import { parsePromQLStyleMatcherLooseSafe } from './utils/matchers'; + let nextDataSourceId = 1; export function mockDataSource( @@ -328,12 +329,12 @@ export const mockSilences = [ mockSilence({ id: MOCK_SILENCE_ID_EXISTING, comment: 'Happy path silence' }), mockSilence({ id: 'ce031625-61c7-47cd-9beb-8760bccf0ed7', - matchers: parseMatchers('foo!=bar'), + matchers: parsePromQLStyleMatcherLooseSafe('foo!=bar'), comment: 'Silence with negated matcher', }), mockSilence({ id: MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID, - matchers: parseMatchers(`__alert_rule_uid__=${MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID}`), + matchers: parsePromQLStyleMatcherLooseSafe(`__alert_rule_uid__=${MOCK_SILENCE_ID_EXISTING_ALERT_RULE_UID}`), comment: 'Silence with alert rule UID matcher', metadata: { rule_title: MOCK_GRAFANA_ALERT_RULE_TITLE, @@ -341,7 +342,7 @@ export const mockSilences = [ }), mockSilence({ id: MOCK_SILENCE_ID_LACKING_PERMISSIONS, - matchers: parseMatchers('something=else'), + matchers: parsePromQLStyleMatcherLooseSafe('something=else'), comment: 'Silence without permissions to edit', accessControl: {}, }), diff --git a/public/app/features/alerting/unified/utils/alertmanager.test.ts b/public/app/features/alerting/unified/utils/alertmanager.test.ts index d8f3cbe59c1..3c0293aef94 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.test.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.test.ts @@ -1,8 +1,8 @@ import { Matcher, MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; import { Labels } from 'app/types/unified-alerting-dto'; -import { parseMatchers, labelsMatchMatchers, removeMuteTimingFromRoute, matchersToString } from './alertmanager'; -import { parseMatcher } from './matchers'; +import { labelsMatchMatchers, removeMuteTimingFromRoute, matchersToString } from './alertmanager'; +import { parseMatcher, parsePromQLStyleMatcherLooseSafe } from './matchers'; describe('Alertmanager utils', () => { describe('parseMatcher', () => { @@ -64,57 +64,6 @@ describe('Alertmanager utils', () => { }); }); - describe('parseMatchers', () => { - it('should parse all operators', () => { - expect(parseMatchers('foo=bar, bar=~ba.+, severity!=warning, email!~@grafana.com')).toEqual([ - { name: 'foo', value: 'bar', isRegex: false, isEqual: true }, - { name: 'bar', value: 'ba.+', isEqual: true, isRegex: true }, - { name: 'severity', value: 'warning', isRegex: false, isEqual: false }, - { name: 'email', value: '@grafana.com', isRegex: true, isEqual: false }, - ]); - }); - - it('should parse with spaces and brackets', () => { - expect(parseMatchers('{ foo=bar }')).toEqual([ - { - name: 'foo', - value: 'bar', - isRegex: false, - isEqual: true, - }, - ]); - }); - - it('should parse with spaces in the value', () => { - expect(parseMatchers('foo=bar bazz')).toEqual([ - { - name: 'foo', - value: 'bar bazz', - isRegex: false, - isEqual: true, - }, - ]); - }); - - it('should return nothing for invalid operator', () => { - expect(parseMatchers('foo=!bar')).toEqual([]); - }); - - it('should parse matchers with or without quotes', () => { - expect(parseMatchers('foo="bar",bar=bazz')).toEqual([ - { name: 'foo', value: 'bar', isRegex: false, isEqual: true }, - { name: 'bar', value: 'bazz', isEqual: true, isRegex: false }, - ]); - }); - - it('should parse matchers for key with special characters', () => { - expect(parseMatchers('foo.bar-baz="bar",baz-bar.foo=bazz')).toEqual([ - { name: 'foo.bar-baz', value: 'bar', isRegex: false, isEqual: true }, - { name: 'baz-bar.foo', value: 'bazz', isEqual: true, isRegex: false }, - ]); - }); - }); - describe('labelsMatchMatchers', () => { it('should return true for matching labels', () => { const labels: Labels = { @@ -123,7 +72,7 @@ describe('Alertmanager utils', () => { bazz: 'buzz', }; - const matchers = parseMatchers('foo=bar,bar=bazz'); + const matchers = parsePromQLStyleMatcherLooseSafe('foo=bar,bar=bazz'); expect(labelsMatchMatchers(labels, matchers)).toBe(true); }); it('should return false for no matching labels', () => { @@ -131,7 +80,7 @@ describe('Alertmanager utils', () => { foo: 'bar', bar: 'bazz', }; - const matchers = parseMatchers('foo=buzz'); + const matchers = parsePromQLStyleMatcherLooseSafe('foo=buzz'); expect(labelsMatchMatchers(labels, matchers)).toBe(false); }); it('should match with different operators', () => { @@ -140,7 +89,7 @@ describe('Alertmanager utils', () => { bar: 'bazz', email: 'admin@grafana.com', }; - const matchers = parseMatchers('foo!=bazz,bar=~ba.+'); + const matchers = parsePromQLStyleMatcherLooseSafe('foo!=bazz,bar=~ba.+'); expect(labelsMatchMatchers(labels, matchers)).toBe(true); }); }); @@ -198,7 +147,7 @@ describe('Alertmanager utils', () => { const matchersString = matchersToString(matchers); - expect(matchersString).toBe('{severity="critical",resource=~"cpu",rule_uid!="2Otf8canzz",cluster!~"prom"}'); + expect(matchersString).toBe('{ severity="critical", resource=~"cpu", rule_uid!="2Otf8canzz", cluster!~"prom" }'); }); }); }); diff --git a/public/app/features/alerting/unified/utils/alertmanager.ts b/public/app/features/alerting/unified/utils/alertmanager.ts index 2738b703844..8a73dd2685c 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.ts @@ -16,7 +16,7 @@ import { MatcherFieldValue } from '../types/silence-form'; import { getAllDataSources } from './config'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './datasource'; -import { MatcherFormatter, unquoteWithUnescape } from './matchers'; +import { MatcherFormatter, parsePromQLStyleMatcherLooseSafe, unquoteWithUnescape } from './matchers'; export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig): AlertManagerCortexConfig { // add default receiver if it does not exist @@ -106,10 +106,10 @@ export function matchersToString(matchers: Matcher[]) { const combinedMatchers = matcherFields.reduce((acc, current) => { const currentMatcherString = `${current.name}${current.operator}"${current.value}"`; - return acc ? `${acc},${currentMatcherString}` : currentMatcherString; + return acc ? `${acc}, ${currentMatcherString}` : currentMatcherString; }, ''); - return `{${combinedMatchers}}`; + return `{ ${combinedMatchers} }`; } export const matcherFieldOptions: SelectableValue[] = [ @@ -124,35 +124,6 @@ export function matcherToObjectMatcher(matcher: Matcher): ObjectMatcher { return [matcher.name, operator, matcher.value]; } -export function parseMatchers(matcherQueryString: string): Matcher[] { - const matcherRegExp = /\b([\w.-]+)(=~|!=|!~|=(?="?\w))"?([^"\n,}]*)"?/g; - const matchers: Matcher[] = []; - - matcherQueryString.replace(matcherRegExp, (_, key, operator, value) => { - const isEqual = operator === MatcherOperator.equal || operator === MatcherOperator.regex; - const isRegex = operator === MatcherOperator.regex || operator === MatcherOperator.notRegex; - matchers.push({ - name: key, - value: isRegex ? getValidRegexString(value.trim()) : value.trim(), - isEqual, - isRegex, - }); - return ''; - }); - - return matchers; -} - -function getValidRegexString(regex: string): string { - // Regexes provided by users might be invalid, so we need to catch the error - try { - new RegExp(regex); - return regex; - } catch (error) { - return ''; - } -} - export function labelsMatchMatchers(labels: Labels, matchers: Matcher[]): boolean { return matchers.every(({ name, value, isRegex, isEqual }) => { return Object.entries(labels).some(([labelKey, labelValue]) => { @@ -177,7 +148,7 @@ export function labelsMatchMatchers(labels: Labels, matchers: Matcher[]): boolea } export function combineMatcherStrings(...matcherStrings: string[]): string { - const matchers = matcherStrings.map(parseMatchers).flat(); + const matchers = matcherStrings.map(parsePromQLStyleMatcherLooseSafe).flat(); const uniqueMatchers = uniqWith(matchers, isEqual); return matchersToString(uniqueMatchers); } diff --git a/public/app/features/alerting/unified/utils/matchers.test.ts b/public/app/features/alerting/unified/utils/matchers.test.ts index c6caad93097..69487604a8c 100644 --- a/public/app/features/alerting/unified/utils/matchers.test.ts +++ b/public/app/features/alerting/unified/utils/matchers.test.ts @@ -1,4 +1,4 @@ -import { MatcherOperator, Route } from '../../../../plugins/datasource/alertmanager/types'; +import { Matcher, MatcherOperator, Route } from '../../../../plugins/datasource/alertmanager/types'; import { encodeMatcher, @@ -8,6 +8,8 @@ import { normalizeMatchers, parseMatcher, parsePromQLStyleMatcher, + parsePromQLStyleMatcherLoose, + parsePromQLStyleMatcherLooseSafe, parseQueryParamMatchers, quoteWithEscape, quoteWithEscapeIfRequired, @@ -193,3 +195,86 @@ describe('parsePromQLStyleMatcher', () => { ); }); }); + +describe('parsePromQLStyleMatcherLooseSafe', () => { + it('should parse all operators', () => { + expect(parsePromQLStyleMatcherLooseSafe('foo=bar, bar=~ba.+, severity!=warning, email!~@grafana.com')).toEqual< + Matcher[] + >([ + { name: 'foo', value: 'bar', isRegex: false, isEqual: true }, + { name: 'bar', value: 'ba.+', isEqual: true, isRegex: true }, + { name: 'severity', value: 'warning', isRegex: false, isEqual: false }, + { name: 'email', value: '@grafana.com', isRegex: true, isEqual: false }, + ]); + }); + + it('should parse with spaces and brackets', () => { + expect(parsePromQLStyleMatcherLooseSafe('{ foo=bar }')).toEqual([ + { + name: 'foo', + value: 'bar', + isRegex: false, + isEqual: true, + }, + ]); + }); + + it('should parse with spaces in the value', () => { + expect(parsePromQLStyleMatcherLooseSafe('foo=bar bazz')).toEqual([ + { + name: 'foo', + value: 'bar bazz', + isRegex: false, + isEqual: true, + }, + ]); + }); + + it('should return nothing for invalid operator', () => { + expect(parsePromQLStyleMatcherLooseSafe('foo=!bar')).toEqual([ + { + name: 'foo', + value: '!bar', + isRegex: false, + isEqual: true, + }, + ]); + }); + + it('should parse matchers with or without quotes', () => { + expect(parsePromQLStyleMatcherLooseSafe('foo="bar",bar=bazz')).toEqual([ + { name: 'foo', value: 'bar', isRegex: false, isEqual: true }, + { name: 'bar', value: 'bazz', isEqual: true, isRegex: false }, + ]); + }); + + it('should parse matchers for key with special characters', () => { + expect(parsePromQLStyleMatcherLooseSafe('foo.bar-baz="bar",baz-bar.foo=bazz')).toEqual([ + { name: 'foo.bar-baz', value: 'bar', isRegex: false, isEqual: true }, + { name: 'baz-bar.foo', value: 'bazz', isEqual: true, isRegex: false }, + ]); + }); +}); + +describe('parsePromQLStyleMatcherLoose', () => { + it('should throw on invalid matcher', () => { + expect(() => { + parsePromQLStyleMatcherLoose('foo'); + }).toThrow(); + + expect(() => { + parsePromQLStyleMatcherLoose('foo;bar'); + }).toThrow(); + }); + + it('should return empty array for empty input', () => { + expect(parsePromQLStyleMatcherLoose('')).toStrictEqual([]); + }); + + it('should also accept { } syntax', () => { + expect(parsePromQLStyleMatcherLoose('{ foo=bar, bar=baz }')).toStrictEqual([ + { isEqual: true, isRegex: false, name: 'foo', value: 'bar' }, + { isEqual: true, isRegex: false, name: 'bar', value: 'baz' }, + ]); + }); +}); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index e5aea380b62..917e055e3bf 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -58,6 +58,8 @@ export function parseMatcher(matcher: string): Matcher { /** * This function combines parseMatcher and parsePromQLStyleMatcher, always returning an array of Matcher[] regardless of input syntax + * 1. { foo=bar, bar=baz } + * 2. foo=bar */ export function parseMatcherToArray(matcher: string): Matcher[] { return isPromQLStyleMatcher(matcher) ? parsePromQLStyleMatcher(matcher) : [parseMatcher(matcher)]; @@ -71,6 +73,15 @@ export function parsePromQLStyleMatcher(matcher: string): Matcher[] { throw new Error('not a PromQL style matcher'); } + return parsePromQLStyleMatcherLoose(matcher); +} + +/** + * This function behaves the same as "parsePromQLStyleMatcher" but does not check if the matcher is formatted with { } + * In other words; it accepts both "{ foo=bar, bar=baz }" and "foo=bar,bar=baz" + * @throws + */ +export function parsePromQLStyleMatcherLoose(matcher: string): Matcher[] { // split by `,` but not when it's used as a label value const commaUnlessQuoted = /,(?=(?:[^"]*"[^"]*")*[^"]*$)/; const parts = matcher.replace(/^\{/, '').replace(/\}$/, '').trim().split(commaUnlessQuoted); @@ -84,6 +95,18 @@ export function parsePromQLStyleMatcher(matcher: string): Matcher[] { })); } +/** + * This function behaves the same as "parsePromQLStyleMatcherLoose" but instead of throwing an error for incorrect syntax + * it returns an empty Array of matchers instead. + */ +export function parsePromQLStyleMatcherLooseSafe(matcher: string): Matcher[] { + try { + return parsePromQLStyleMatcherLoose(matcher); + } catch { + return []; + } +} + // Parses a list of entries like like "['foo=bar', 'baz=~bad*']" into SilenceMatcher[] export function parseQueryParamMatchers(matcherPairs: string[]): Matcher[] { const parsedMatchers = matcherPairs.filter((x) => !!x.trim()).map((x) => parseMatcher(x)); diff --git a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx index 0e0b3ce4fc6..2e615646324 100644 --- a/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx +++ b/public/app/plugins/panel/alertlist/UnifiedAlertList.tsx @@ -25,9 +25,9 @@ import { fetchAllPromAndRulerRulesAction, fetchPromAndRulerRulesAction, } from 'app/features/alerting/unified/state/actions'; -import { parseMatchers } from 'app/features/alerting/unified/utils/alertmanager'; import { Annotation } from 'app/features/alerting/unified/utils/constants'; import { GRAFANA_DATASOURCE_NAME, GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; +import { parsePromQLStyleMatcherLooseSafe } from 'app/features/alerting/unified/utils/matchers'; import { isAsyncRequestMapSlicePartiallyDispatched, isAsyncRequestMapSlicePartiallyFulfilled, @@ -132,7 +132,7 @@ function UnifiedAlertList(props: PanelProps) { }; const matcherList = useMemo( - () => parseMatchers(parsedOptions.alertInstanceLabelFilter), + () => parsePromQLStyleMatcherLooseSafe(parsedOptions.alertInstanceLabelFilter), [parsedOptions.alertInstanceLabelFilter] ); diff --git a/public/app/plugins/panel/alertlist/util.ts b/public/app/plugins/panel/alertlist/util.ts index 5acf905d27c..a7984904696 100644 --- a/public/app/plugins/panel/alertlist/util.ts +++ b/public/app/plugins/panel/alertlist/util.ts @@ -1,14 +1,15 @@ import { isEmpty } from 'lodash'; import { Labels } from '@grafana/data'; -import { labelsMatchMatchers, parseMatchers } from 'app/features/alerting/unified/utils/alertmanager'; +import { labelsMatchMatchers } from 'app/features/alerting/unified/utils/alertmanager'; +import { parsePromQLStyleMatcherLooseSafe } from 'app/features/alerting/unified/utils/matchers'; import { Alert, hasAlertState } from 'app/types/unified-alerting'; import { GrafanaAlertState, PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { UnifiedAlertListOptions } from './types'; function hasLabelFilter(alertInstanceLabelFilter: string, labels: Labels) { - const matchers = parseMatchers(alertInstanceLabelFilter); + const matchers = parsePromQLStyleMatcherLooseSafe(alertInstanceLabelFilter); return labelsMatchMatchers(labels, matchers); }