diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index 66f6d760422..762c79bb662 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -1,10 +1,13 @@ import { + AlertmanagerAlert, AlertmanagerChoice, AlertManagerCortexConfig, ExternalAlertmanagerConfig, ExternalAlertmanagers, ExternalAlertmanagersResponse, + Matcher, } from '../../../../plugins/datasource/alertmanager/types'; +import { matcherToOperator } from '../utils/alertmanager'; import { getDatasourceAPIUid, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { alertingApi } from './alertingApi'; @@ -16,8 +19,34 @@ export interface AlertmanagersChoiceResponse { numExternalAlertmanagers: number; } +interface AlertmanagerAlertsFilter { + active?: boolean; + silenced?: boolean; + inhibited?: boolean; + unprocessed?: boolean; + matchers?: Matcher[]; +} + +// Based on https://github.com/prometheus/alertmanager/blob/main/api/v2/openapi.yaml export const alertmanagerApi = alertingApi.injectEndpoints({ endpoints: (build) => ({ + getAlertmanagerAlerts: build.query< + AlertmanagerAlert[], + { amSourceName: string; filter?: AlertmanagerAlertsFilter } + >({ + query: ({ amSourceName, filter }) => { + // TODO Add support for active, silenced, inhibited, unprocessed filters + const filterMatchers = filter?.matchers + ?.filter((matcher) => matcher.name && matcher.value) + .map((matcher) => `${matcher.name}${matcherToOperator(matcher)}${matcher.value}`); + + return { + url: `/api/alertmanager/${getDatasourceAPIUid(amSourceName)}/api/v2/alerts`, + params: { filter: filterMatchers }, + }; + }, + }), + getAlertmanagerChoiceStatus: build.query({ query: () => ({ url: '/api/v1/ngalert' }), providesTags: ['AlertmanagerChoice'], diff --git a/public/app/features/alerting/unified/components/DynamicTable.tsx b/public/app/features/alerting/unified/components/DynamicTable.tsx index deae6c1a7eb..29ee2eeac96 100644 --- a/public/app/features/alerting/unified/components/DynamicTable.tsx +++ b/public/app/features/alerting/unified/components/DynamicTable.tsx @@ -17,6 +17,7 @@ export interface DynamicTableColumnProps { renderCell: (item: DynamicTableItemProps, index: number) => ReactNode; size?: number | string; + className?: string; } export interface DynamicTableItemProps { @@ -134,7 +135,11 @@ export const DynamicTable = ({ )} {cols.map((col) => ( -
+
{col.renderCell(item, index)}
))} diff --git a/public/app/features/alerting/unified/components/silences/MatchedSilencedRules.tsx b/public/app/features/alerting/unified/components/silences/MatchedSilencedRules.tsx deleted file mode 100644 index 3042d066ec5..00000000000 --- a/public/app/features/alerting/unified/components/silences/MatchedSilencedRules.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { css } from '@emotion/css'; -import React, { useEffect, useState } from 'react'; -import { useFormContext } from 'react-hook-form'; -import { useDebounce } from 'react-use'; - -import { dateTime, GrafanaTheme2 } from '@grafana/data'; -import { Badge, useStyles2 } from '@grafana/ui'; -import { useDispatch } from 'app/types'; -import { Alert, AlertingRule } from 'app/types/unified-alerting'; - -import { useCombinedRuleNamespaces } from '../../hooks/useCombinedRuleNamespaces'; -import { fetchAllPromAndRulerRulesAction } from '../../state/actions'; -import { MatcherFieldValue, SilenceFormFields } from '../../types/silence-form'; -import { findAlertInstancesWithMatchers } from '../../utils/matchers'; -import { isAlertingRule } from '../../utils/rules'; -import { AlertLabels } from '../AlertLabels'; -import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; -import { AlertStateTag } from '../rules/AlertStateTag'; - -type MatchedRulesTableItemProps = DynamicTableItemProps<{ - matchedInstance: Alert; -}>; -type MatchedRulesTableColumnProps = DynamicTableColumnProps<{ matchedInstance: Alert }>; - -export const MatchedSilencedRules = () => { - const [matchedAlertRules, setMatchedAlertRules] = useState([]); - const formApi = useFormContext(); - const dispatch = useDispatch(); - const { watch } = formApi; - const matchers: MatcherFieldValue[] = watch('matchers'); - const styles = useStyles2(getStyles); - const columns = useColumns(); - - useEffect(() => { - dispatch(fetchAllPromAndRulerRulesAction()); - }, [dispatch]); - - const combinedNamespaces = useCombinedRuleNamespaces(); - useDebounce( - () => { - const matchedInstances = combinedNamespaces.flatMap((namespace) => { - return namespace.groups.flatMap((group) => { - return group.rules - .map((combinedRule) => combinedRule.promRule) - .filter((rule): rule is AlertingRule => isAlertingRule(rule)) - .flatMap((rule) => findAlertInstancesWithMatchers(rule.alerts ?? [], matchers)); - }); - }); - setMatchedAlertRules(matchedInstances); - }, - 500, - [combinedNamespaces, matchers] - ); - - return ( -
-

- Affected alert instances - {matchedAlertRules.length > 0 ? ( - - ) : null} -

-
- {matchers.every((matcher) => !matcher.value && !matcher.name) ? ( - Add a valid matcher to see affected alerts - ) : ( - - )} -
-
- ); -}; - -function useColumns(): MatchedRulesTableColumnProps[] { - return [ - { - id: 'state', - label: 'State', - renderCell: function renderStateTag({ data: { matchedInstance } }) { - return ; - }, - size: '160px', - }, - { - id: 'labels', - label: 'Labels', - renderCell: function renderName({ data: { matchedInstance } }) { - return ; - }, - size: 'auto', - }, - { - id: 'created', - label: 'Created', - renderCell: function renderSummary({ data: { matchedInstance } }) { - return ( - <> - {matchedInstance.activeAt.startsWith('0001') - ? '-' - : dateTime(matchedInstance.activeAt).format('YYYY-MM-DD HH:mm:ss')} - - ); - }, - size: '180px', - }, - ]; -} - -const getStyles = (theme: GrafanaTheme2) => ({ - table: css` - max-width: ${theme.breakpoints.values.lg}px; - `, - moreMatches: css` - margin-top: ${theme.spacing(1)}; - `, - title: css` - display: flex; - align-items: center; - `, - badge: css` - margin-left: ${theme.spacing(1)}; - `, -}); diff --git a/public/app/features/alerting/unified/components/silences/SilencedInstancesPreview.tsx b/public/app/features/alerting/unified/components/silences/SilencedInstancesPreview.tsx new file mode 100644 index 00000000000..8c1b4490b29 --- /dev/null +++ b/public/app/features/alerting/unified/components/silences/SilencedInstancesPreview.tsx @@ -0,0 +1,129 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { dateTime, GrafanaTheme2 } from '@grafana/data'; +import { Alert, Badge, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; +import { AlertmanagerAlert, Matcher } from 'app/plugins/datasource/alertmanager/types'; + +import { alertmanagerApi } from '../../api/alertmanagerApi'; +import { isNullDate } from '../../utils/time'; +import { AlertLabels } from '../AlertLabels'; +import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; + +import { AmAlertStateTag } from './AmAlertStateTag'; + +interface Props { + amSourceName: string; + matchers: Matcher[]; +} + +export const SilencedInstancesPreview = ({ amSourceName, matchers }: Props) => { + const { useGetAlertmanagerAlertsQuery } = alertmanagerApi; + const styles = useStyles2(getStyles); + const columns = useColumns(); + + // By default the form contains an empty matcher - with empty name and value and = operator + // We don't want to fetch previews for empty matchers as it results in all alerts returned + const hasValidMatchers = matchers.some((matcher) => matcher.value && matcher.name); + + const { + currentData: alerts = [], + isFetching, + isError, + } = useGetAlertmanagerAlertsQuery( + { amSourceName, filter: { matchers } }, + { skip: !hasValidMatchers, refetchOnMountOrArgChange: true } + ); + + const tableItemAlerts = alerts.map>((alert) => ({ + id: alert.fingerprint, + data: alert, + })); + + return ( +
+

+ Affected alert instances + {tableItemAlerts.length > 0 ? ( + + ) : null} +

+ {!hasValidMatchers && Add a valid matcher to see affected alerts} + {isError && ( + + Error occured when generating affected alerts preview. Are you matchers valid? + + )} + {isFetching && } + {!isFetching && !isError && hasValidMatchers && ( +
+ {tableItemAlerts.length > 0 ? ( + + ) : ( + No matching alert instances found + )} +
+ )} +
+ ); +}; + +function useColumns(): Array> { + const styles = useStyles2(getStyles); + + return [ + { + id: 'state', + label: 'State', + renderCell: function renderStateTag({ data }) { + return ; + }, + size: '120px', + className: styles.stateColumn, + }, + { + id: 'labels', + label: 'Labels', + renderCell: function renderName({ data }) { + return ; + }, + size: 'auto', + }, + { + id: 'created', + label: 'Created', + renderCell: function renderSummary({ data }) { + return <>{isNullDate(data.startsAt) ? '-' : dateTime(data.startsAt).format('YYYY-MM-DD HH:mm:ss')}; + }, + size: '180px', + }, + ]; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + table: css` + max-width: ${theme.breakpoints.values.lg}px; + `, + moreMatches: css` + margin-top: ${theme.spacing(1)}; + `, + title: css` + display: flex; + align-items: center; + `, + badge: css` + margin-left: ${theme.spacing(1)}; + `, + stateColumn: css` + display: flex; + align-items: center; + `, + alertLabels: css` + justify-content: flex-start; + `, +}); diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx index 3d1bb015b9d..7f6c3269146 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -1,5 +1,5 @@ import { css, cx } from '@emotion/css'; -import { pickBy } from 'lodash'; +import { isEqual, pickBy } from 'lodash'; import React, { useMemo, useState } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; import { useDebounce } from 'react-use'; @@ -16,7 +16,7 @@ import { import { config } from '@grafana/runtime'; import { Button, Field, FieldSet, Input, LinkButton, TextArea, useStyles2 } from '@grafana/ui'; import { useCleanup } from 'app/core/hooks/useCleanup'; -import { MatcherOperator, Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types'; +import { Matcher, MatcherOperator, Silence, SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types'; import { useDispatch } from 'app/types'; import { useURLSearchParams } from '../../hooks/useURLSearchParams'; @@ -28,9 +28,9 @@ import { parseQueryParamMatchers } from '../../utils/matchers'; import { makeAMLink } from '../../utils/misc'; import { initialAsyncRequestState } from '../../utils/redux'; -import { MatchedSilencedRules } from './MatchedSilencedRules'; import MatchersField from './MatchersField'; import { SilencePeriod } from './SilencePeriod'; +import { SilencedInstancesPreview } from './SilencedInstancesPreview'; interface Props { silence?: Silence; @@ -104,6 +104,9 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => { const formAPI = useForm({ defaultValues }); const dispatch = useDispatch(); const styles = useStyles2(getStyles); + const [matchersForPreview, setMatchersForPreview] = useState( + defaultValues.matchers.map(matcherFieldToMatcher) + ); const { loading } = useUnifiedAlertingSelector((state) => state.updateSilence); @@ -138,6 +141,7 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => { const duration = watch('duration'); const startsAt = watch('startsAt'); const endsAt = watch('endsAt'); + const matcherFields = watch('matchers'); // Keep duration and endsAt in sync const [prevDuration, setPrevDuration] = useState(duration); @@ -164,6 +168,19 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => { 700, [clearErrors, duration, endsAt, prevDuration, setValue, startsAt] ); + + useDebounce( + () => { + // React-hook-form watch does not return referentialy equal values so this trick is needed + const newMatchers = matcherFields.filter((m) => m.name && m.value).map(matcherFieldToMatcher); + if (!isEqual(matchersForPreview, newMatchers)) { + setMatchersForPreview(newMatchers); + } + }, + 700, + [matcherFields] + ); + const userLogged = Boolean(config.bootData.user.isSignedIn && config.bootData.user.name); return ( @@ -221,7 +238,7 @@ export const SilencesEditor = ({ silence, alertManagerSourceName }: Props) => { /> )} - +
{loading && ( diff --git a/public/app/features/alerting/unified/utils/matchers.test.ts b/public/app/features/alerting/unified/utils/matchers.test.ts index 09a54217fb7..ae5b64b105e 100644 --- a/public/app/features/alerting/unified/utils/matchers.test.ts +++ b/public/app/features/alerting/unified/utils/matchers.test.ts @@ -1,8 +1,4 @@ -import { MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; - -import { mockPromAlert } from '../mocks'; - -import { getMatcherQueryParams, findAlertInstancesWithMatchers, parseQueryParamMatchers } from './matchers'; +import { getMatcherQueryParams, parseQueryParamMatchers } from './matchers'; describe('Unified Alerting matchers', () => { describe('getMatcherQueryParams tests', () => { @@ -37,57 +33,4 @@ describe('Unified Alerting matchers', () => { expect(matchers[0].value).toBe('TestData 1'); }); }); - - describe('matchLabelsToMatchers', () => { - it('should match for equal', () => { - const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.equal }]; - const alerts = [mockPromAlert({ labels: { foo: 'bar' } }), mockPromAlert({ labels: { foo: 'baz' } })]; - const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers); - - expect(matchedAlerts).toHaveLength(1); - }); - - it('should match for not equal', () => { - const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.notEqual }]; - const alerts = [mockPromAlert({ labels: { foo: 'bar' } }), mockPromAlert({ labels: { foo: 'baz' } })]; - - const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers); - expect(matchedAlerts).toHaveLength(1); - }); - - it('should match for regex', () => { - const matchers = [{ name: 'foo', value: 'b{1}a.*', operator: MatcherOperator.regex }]; - const alerts = [ - mockPromAlert({ labels: { foo: 'bbr' } }), - mockPromAlert({ labels: { foo: 'aba' } }), // This does not match because the regex is implicitly anchored. - mockPromAlert({ labels: { foo: 'ba' } }), - mockPromAlert({ labels: { foo: 'bar' } }), - mockPromAlert({ labels: { foo: 'baz' } }), - mockPromAlert({ labels: { foo: 'bas' } }), - ]; - - const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers); - expect(matchedAlerts).toHaveLength(4); - expect(matchedAlerts.map((instance) => instance.data.matchedInstance.labels.foo)).toEqual([ - 'ba', - 'bar', - 'baz', - 'bas', - ]); - }); - - it('should not match regex', () => { - const matchers = [{ name: 'foo', value: 'ba{3}', operator: MatcherOperator.notRegex }]; - const alerts = [ - mockPromAlert({ labels: { foo: 'bar' } }), - mockPromAlert({ labels: { foo: 'baz' } }), - mockPromAlert({ labels: { foo: 'baaa' } }), - mockPromAlert({ labels: { foo: 'bas' } }), - ]; - - const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers); - expect(matchedAlerts).toHaveLength(3); - expect(matchedAlerts.map((instance) => instance.data.matchedInstance.labels.foo)).toEqual(['bar', 'baz', 'bas']); - }); - }); }); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index d8889638f58..7ca37819719 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -1,10 +1,7 @@ import { uniqBy } from 'lodash'; import { Labels } from '@grafana/data'; -import { Matcher, MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; -import { Alert } from 'app/types/unified-alerting'; - -import { MatcherFieldValue } from '../types/silence-form'; +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; import { parseMatcher } from './alertmanager'; @@ -29,61 +26,3 @@ export const getMatcherQueryParams = (labels: Labels) => { return matcherUrlParams; }; - -interface MatchedInstance { - id: string; - data: { - matchedInstance: Alert; - }; -} - -export const findAlertInstancesWithMatchers = ( - instances: Alert[], - matchers: MatcherFieldValue[] -): MatchedInstance[] => { - const anchorRegex = (regexpString: string): RegExp => { - // Silence matchers are always fully anchored in the Alertmanager: https://github.com/prometheus/alertmanager/pull/748 - if (!regexpString.startsWith('^')) { - regexpString = '^' + regexpString; - } - if (!regexpString.endsWith('$')) { - regexpString = regexpString + '$'; - } - return new RegExp(regexpString); - }; - - const matchesInstance = (instance: Alert, matcher: MatcherFieldValue) => { - return Object.entries(instance.labels).some(([key, value]) => { - if (!matcher.name || !matcher.value) { - return false; - } - if (matcher.name !== key) { - return false; - } - switch (matcher.operator) { - case MatcherOperator.equal: - return matcher.value === value; - case MatcherOperator.notEqual: - return matcher.value !== value; - case MatcherOperator.regex: - const regex = anchorRegex(matcher.value); - return regex.test(value); - case MatcherOperator.notRegex: - const negregex = anchorRegex(matcher.value); - return !negregex.test(value); - default: - return false; - } - }); - }; - - const filteredInstances = instances.filter((instance) => { - return matchers.every((matcher) => matchesInstance(instance, matcher)); - }); - const mappedInstances = filteredInstances.map((instance) => ({ - id: `${instance.activeAt}-${instance.value}`, - data: { matchedInstance: instance }, - })); - - return mappedInstances; -};