From 825edddfb66e61b3e9b79bc541d3651c30448ed7 Mon Sep 17 00:00:00 2001 From: Nathan Rodman Date: Wed, 5 Jan 2022 10:16:43 -0800 Subject: [PATCH] Alerting: UI for mute timings (#41578) * wip: add form inputs for creating mute timing * form for mute timings * add action for submitting config * fix bug in payload * add table for viewing mute timings * remove mute timing from routes when deleted * attach mute timing to route * edit a mute timing * use field array for multiple intervals * Add confirmation modal for deleting mute timing * add default values to form inputs * fetch am config prior to renderring form * validation for mute timing fields * fix tests * tests for mute timing form * small ui fixes for the form and table * pass mute name as query param * make time fields inline * fix validation for an existing alert and overwrite on edit * rename mute timing in routes on edit * fix validation for time inputs * make time interval its own component * add descriptions for mute timings * refactor time interval parsing functions * fix linting and tests * refactor makeAmLink * docs for mute timings * reorganize docs and add intro for mute timings * doc review edits Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> * run prettier Co-authored-by: achatterjee-grafana <70489351+achatterjee-grafana@users.noreply.github.com> --- .../alerting/unified-alerting/_index.md | 2 +- .../alerting/unified-alerting/alert-groups.md | 2 +- .../unified-alerting/contact-points.md | 2 +- .../_index.md} | 0 .../notifications/mute-timings.md | 59 ++++ public/app/features/alerting/routes.tsx | 16 +- .../alerting/unified/AmRoutes.test.tsx | 88 ++++++ .../features/alerting/unified/AmRoutes.tsx | 3 + .../alerting/unified/MuteTimings.test.tsx | 295 ++++++++++++++++++ .../features/alerting/unified/MuteTimings.tsx | 70 +++++ .../amroutes/AmRoutesExpandedForm.tsx | 23 ++ .../amroutes/AmRoutesExpandedRead.tsx | 11 + .../components/amroutes/MuteTimingForm.tsx | 157 ++++++++++ .../amroutes/MuteTimingTimeInterval.tsx | 163 ++++++++++ .../amroutes/MuteTimingTimeRange.tsx | 118 +++++++ .../components/amroutes/MuteTimingsTable.tsx | 145 +++++++++ .../unified/hooks/useMuteTimingOptions.ts | 26 ++ .../alerting/unified/state/actions.ts | 37 ++- .../alerting/unified/types/amroutes.ts | 1 + .../unified/types/mute-timing-form.ts | 14 + .../unified/utils/alertmanager.test.ts | 52 ++- .../alerting/unified/utils/alertmanager.ts | 80 ++++- .../alerting/unified/utils/amroutes.ts | 3 + .../features/alerting/unified/utils/misc.ts | 8 +- .../alerting/unified/utils/mute-timings.ts | 66 ++++ .../plugins/datasource/alertmanager/types.ts | 20 ++ 26 files changed, 1451 insertions(+), 10 deletions(-) rename docs/sources/alerting/unified-alerting/{notification-policies.md => notifications/_index.md} (100%) create mode 100644 docs/sources/alerting/unified-alerting/notifications/mute-timings.md create mode 100644 public/app/features/alerting/unified/MuteTimings.test.tsx create mode 100644 public/app/features/alerting/unified/MuteTimings.tsx create mode 100644 public/app/features/alerting/unified/components/amroutes/MuteTimingForm.tsx create mode 100644 public/app/features/alerting/unified/components/amroutes/MuteTimingTimeInterval.tsx create mode 100644 public/app/features/alerting/unified/components/amroutes/MuteTimingTimeRange.tsx create mode 100644 public/app/features/alerting/unified/components/amroutes/MuteTimingsTable.tsx create mode 100644 public/app/features/alerting/unified/hooks/useMuteTimingOptions.ts create mode 100644 public/app/features/alerting/unified/types/mute-timing-form.ts create mode 100644 public/app/features/alerting/unified/utils/mute-timings.ts diff --git a/docs/sources/alerting/unified-alerting/_index.md b/docs/sources/alerting/unified-alerting/_index.md index 55deca2585a..c5664fd3bbe 100644 --- a/docs/sources/alerting/unified-alerting/_index.md +++ b/docs/sources/alerting/unified-alerting/_index.md @@ -15,7 +15,7 @@ When Grafana alerting is enabled, you can: - [View existing alerting rules and manage their current state]({{< relref "alerting-rules/rule-list.md" >}}) - [View the state and health of alerting rules]({{< relref "./fundamentals/state-and-health.md" >}}) - [Add or edit an alert contact point]({{< relref "./contact-points.md" >}}) -- [Add or edit notification policies]({{< relref "./notification-policies.md" >}}) +- [Add or edit notification policies]({{< relref "./notifications/_index.md" >}}) - [Add or edit silences]({{< relref "./silences.md" >}}) Before you begin using Grafana alerting, we recommend that you familiarize yourself with some [basic concepts]({{< relref "./fundamentals/_index.md" >}}) of Grafana alerting. diff --git a/docs/sources/alerting/unified-alerting/alert-groups.md b/docs/sources/alerting/unified-alerting/alert-groups.md index 2a947bf212e..8160174c3d1 100644 --- a/docs/sources/alerting/unified-alerting/alert-groups.md +++ b/docs/sources/alerting/unified-alerting/alert-groups.md @@ -7,7 +7,7 @@ weight = 400 # Alert groups -Alert groups show grouped alerts from an Alertmanager instance. By default, the alerts are grouped by the label keys for the root policy in [notification policies]({{< relref "./notification-policies.md" >}}). Grouping common alerts into a single alert group prevents duplicate alerts from being fired. +Alert groups show grouped alerts from an Alertmanager instance. By default, the alerts are grouped by the label keys for the root policy in [notification policies]({{< relref "./notifications/_index.md" >}}). Grouping common alerts into a single alert group prevents duplicate alerts from being fired. ## View alert groupings diff --git a/docs/sources/alerting/unified-alerting/contact-points.md b/docs/sources/alerting/unified-alerting/contact-points.md index 97ccd5cb2dd..d0172af876f 100644 --- a/docs/sources/alerting/unified-alerting/contact-points.md +++ b/docs/sources/alerting/unified-alerting/contact-points.md @@ -48,7 +48,7 @@ To send a test notification: 1. Find the contact point to delete, then click **Delete** (trash icon). 1. In the confirmation dialog, click **Yes, delete**. -> **Note:** You cannot delete contact points that are in use by a notification policy. You will have to either delete the [notification policy]({{< relref "./notification-policies.md" >}}) or update it to use another contact point. +> **Note:** You cannot delete contact points that are in use by a notification policy. You will have to either delete the [notification policy]({{< relref "./notifications/_index.md" >}}) or update it to use another contact point. ## Edit Alertmanager global config diff --git a/docs/sources/alerting/unified-alerting/notification-policies.md b/docs/sources/alerting/unified-alerting/notifications/_index.md similarity index 100% rename from docs/sources/alerting/unified-alerting/notification-policies.md rename to docs/sources/alerting/unified-alerting/notifications/_index.md diff --git a/docs/sources/alerting/unified-alerting/notifications/mute-timings.md b/docs/sources/alerting/unified-alerting/notifications/mute-timings.md new file mode 100644 index 00000000000..a41915e1660 --- /dev/null +++ b/docs/sources/alerting/unified-alerting/notifications/mute-timings.md @@ -0,0 +1,59 @@ ++++ +title = "Mute timings" +description = "Mute timings" +keywords = ["grafana", "alerting", "guide", "mute", "mute timings", "mute time interval"] +weight = 450 ++++ + +# Mute timings + +A mute timing is a recurring interval of time when no new notifications for a policy are generated or sent. are sent for a policy. Use them to prevent alerts from firing a specific and reoccurring period, for example, a regular maintenance period. + +Similar to silences, mute timings do not prevent alert rules from being evaluated, nor do they stop alert instances from being shown in the user interface. They only prevent notifications from being created. + +You can configure Grafana managed mute timings as well as mute timings for an [external Alertmanager data source]({{< relref "../../datasources/alertmanager.md" >}}). For more information, see [Alertmanager]({{< relref "./fundamentals/alertmanager.md" >}}). + +## Mute timings vs silences + +The following table highlights the key differences between mute timings and silences. + +| Mute timing | Silence | +| -------------------------------------------------- | ---------------------------------------------------------------------------- | +| Uses time interval definitions that can reoccur | Has a fixed start and end time | +| Is created and then added to notification policies | Uses labels to match against an alert to determine whether to silence or not | + +## Create a mute timing + +1. In the Grafana menu, click the **Alerting** (bell) icon to open the Alerting page listing existing alerts. +1. Click **Notification policies**. +1. From the **Alertmanager** dropdown, select an external Alertmanager. By default, the Grafana Alertmanager is selected. +1. At the bottom of the page there will be a section titled **Mute timings**. Click the **Add mute timing** button. +1. You will be redirected to a form to create a [time interval](#time-intervals) to match against for your mute timing. +1. Click **Submit** to create the mute timing. + +## Add mute timing to a notification policy + +1. Identify the notification policy you would like to add the mute timing to and click the **Edit** button for that policy. +1. From the Mute Timings dropdown select the mute timings you would like to add to the route. +1. Click the **Save policy** button to save. + +## Time intervals + +A time interval is a definition for a moment in time. If an alert fires during this interval it will be suppressed. All fields are lists, and at least one list element must be satisfied to match the field. Fields also support ranges using `:` (ex: `monday:thursday`). The fields available for a time interval are: mute timing can contain multiple time intervals. A time interval is a specific duration when alerts are suppressed from firing. The duration typically consists of a specific time range along with days of a week, month, or year. + + All properties for the time interval are lists, and at least one list element must be satisfied to match the field. The fields support ranges using `:` (ex: `monday:thursday`). If you leave a field blank, it will match with any moment of time. + +Supported time interval options are: + +- Time range: The time inclusive of the starting time and exclusive of the end time in UTC. - Days of the week: The day or range of days of the week. Example: `monday:thursday`. - Days of the month: The date 1-31 of a month. Negative values can also be used to represent days that begin at the end of the month. For example: `-1` for the last day of the month. - Months: The months of the year in either numerical or the full calendar month. For example: `1, may:august`. - Years: The year or years for the interval. For example: `2021:2024`. + +If a field is left blank, any moment of time will match the field. For an instant of time to match a complete time interval, all fields must match. A mute timing can contain multiple time intervals. + +If you want to specify an exact duration, specify all the options. For example, if you wanted to create a time interval for the first Monday of the month, for March, June, September, and December, between the hours of 12:00 and 24:00 UTC your time interval specification would be: + +- Time range: + - Start time: `12:00` + - End time: `24:00` +- Days of the week: `monday` +- Months: `3, 6, 9, 12` +- Days of the month: `1:7` diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index b767df008c5..9254e343dc5 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -4,7 +4,7 @@ import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynami import { config } from 'app/core/config'; import { RouteDescriptor } from 'app/core/navigation/types'; -const alertingRoutes = [ +const alertingRoutes: RouteDescriptor[] = [ { path: '/alerting', // eslint-disable-next-line react/display-name @@ -29,6 +29,20 @@ const alertingRoutes = [ () => import(/* webpackChunkName: "AlertAmRoutes" */ 'app/features/alerting/unified/AmRoutes') ), }, + { + path: '/alerting/routes/mute-timing/new', + roles: () => ['Admin', 'Editor'], + component: SafeDynamicImport( + () => import(/* webpackChunkName: "MuteTimings" */ 'app/features/alerting/unified/MuteTimings') + ), + }, + { + path: '/alerting/routes/mute-timing/edit', + roles: () => ['Admin', 'Editor'], + component: SafeDynamicImport( + () => import(/* webpackChunkName: "MuteTimings" */ 'app/features/alerting/unified/MuteTimings') + ), + }, { path: '/alerting/silences', component: SafeDynamicImport( diff --git a/public/app/features/alerting/unified/AmRoutes.test.tsx b/public/app/features/alerting/unified/AmRoutes.test.tsx index 12cdb66ccb4..3542859ee36 100644 --- a/public/app/features/alerting/unified/AmRoutes.test.tsx +++ b/public/app/features/alerting/unified/AmRoutes.test.tsx @@ -7,6 +7,7 @@ import { AlertManagerCortexConfig, AlertManagerDataSourceJsonData, AlertManagerImplementation, + MuteTimeInterval, Route, } from 'app/plugins/datasource/alertmanager/types'; import { configureStore } from 'app/store/configureStore'; @@ -80,9 +81,11 @@ const ui = { deleteRouteButton: byLabelText('Delete route'), newPolicyButton: byRole('button', { name: /New policy/ }), newPolicyCTAButton: byRole('button', { name: /New specific policy/ }), + savePolicyButton: byRole('button', { name: /save policy/i }), receiverSelect: byTestId('am-receiver-select'), groupSelect: byTestId('am-group-select'), + muteTimingSelect: byTestId('am-mute-timing-select'), groupWaitContainer: byTestId('am-group-wait'), groupIntervalContainer: byTestId('am-group-interval'), @@ -160,6 +163,19 @@ describe('AmRoutes', () => { routes: subroutes, }; + const muteInterval: MuteTimeInterval = { + name: 'default-mute', + time_intervals: [ + { + times: [{ start_time: '12:00', end_time: '24:00' }], + weekdays: ['monday:friday'], + days_of_month: ['1:7', '-1:-7'], + months: ['january:june'], + years: ['2020:2022'], + }, + ], + }; + beforeEach(() => { mocks.getAllDataSourcesMock.mockReturnValue(Object.values(dataSources)); setDataSourceSrv(new MockDataSourceSrv(dataSources)); @@ -287,6 +303,7 @@ describe('AmRoutes', () => { group_interval: '4m', group_wait: '1m', repeat_interval: '5h', + mute_time_intervals: [], }, templates: [], }, @@ -336,6 +353,7 @@ describe('AmRoutes', () => { group_by: ['severity', 'namespace'], receiver: 'default', routes: [], + mute_time_intervals: [], }, }, template_files: {}, @@ -406,6 +424,7 @@ describe('AmRoutes', () => { group_wait: '1m', receiver: 'default', repeat_interval: '5h', + mute_time_intervals: [], routes: [ { continue: false, @@ -415,6 +434,7 @@ describe('AmRoutes', () => { ['foo', '!=', 'bar'], ], receiver: 'simple-receiver', + mute_time_intervals: [], routes: [], }, ], @@ -476,6 +496,7 @@ describe('AmRoutes', () => { matchers: [], receiver: 'default', repeat_interval: '5h', + mute_time_intervals: [], routes: [ { continue: false, @@ -483,6 +504,7 @@ describe('AmRoutes', () => { matchers: ['hello=world', 'foo!=bar'], receiver: 'simple-receiver', routes: [], + mute_time_intervals: [], }, ], }, @@ -528,6 +550,72 @@ describe('AmRoutes', () => { expect(mocks.api.fetchAlertManagerConfig).not.toHaveBeenCalled(); expect(mocks.api.fetchStatus).toHaveBeenCalledTimes(1); }); + + it('Can add a mute timing to a route', async () => { + const defaultConfig: AlertManagerCortexConfig = { + alertmanager_config: { + receivers: [{ name: 'default' }, { name: 'critical' }], + route: { + continue: false, + receiver: 'default', + group_by: ['alertname'], + routes: [simpleRoute], + group_interval: '4m', + group_wait: '1m', + repeat_interval: '5h', + }, + templates: [], + mute_time_intervals: [muteInterval], + }, + template_files: {}, + }; + + const currentConfig = { current: defaultConfig }; + mocks.api.updateAlertManagerConfig.mockImplementation((amSourceName, newConfig) => { + currentConfig.current = newConfig; + return Promise.resolve(); + }); + + mocks.api.fetchAlertManagerConfig.mockResolvedValue(defaultConfig); + + await renderAmRoutes(dataSources.am.name); + const rows = await ui.row.findAll(); + expect(rows).toHaveLength(1); + userEvent.click(ui.editRouteButton.get(rows[0])); + + const muteTimingSelect = ui.muteTimingSelect.get(); + await clickSelectOption(muteTimingSelect, 'default-mute'); + expect(muteTimingSelect).toHaveTextContent('default-mute'); + + const savePolicyButton = ui.savePolicyButton.get(); + expect(savePolicyButton).toBeInTheDocument(); + + userEvent.click(savePolicyButton); + + await waitFor(() => expect(savePolicyButton).not.toBeInTheDocument()); + + expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled(); + expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith(dataSources.am.name, { + ...defaultConfig, + alertmanager_config: { + ...defaultConfig.alertmanager_config, + route: { + ...defaultConfig.alertmanager_config.route, + mute_time_intervals: [], + matchers: [], + routes: [ + { + ...simpleRoute, + mute_time_intervals: [muteInterval.name], + routes: [], + continue: false, + group_by: [], + }, + ], + }, + }, + }); + }); }); const clickSelectOption = async (selectElement: HTMLElement, optionText: string): Promise => { diff --git a/public/app/features/alerting/unified/AmRoutes.tsx b/public/app/features/alerting/unified/AmRoutes.tsx index bb237b25834..b757fed3097 100644 --- a/public/app/features/alerting/unified/AmRoutes.tsx +++ b/public/app/features/alerting/unified/AmRoutes.tsx @@ -17,6 +17,7 @@ import { AmRouteReceiver, FormAmRoute } from './types/amroutes'; import { amRouteToFormAmRoute, formAmRouteToAmRoute, stringsToSelectableValues } from './utils/amroutes'; import { initialAsyncRequestState } from './utils/redux'; import { isVanillaPrometheusAlertManagerDataSource } from './utils/datasource'; +import { MuteTimingsTable } from './components/amroutes/MuteTimingsTable'; const AmRoutes: FC = () => { const dispatch = useDispatch(); @@ -124,6 +125,8 @@ const AmRoutes: FC = () => { receivers={receivers} routes={rootRoute} /> +
+ )} diff --git a/public/app/features/alerting/unified/MuteTimings.test.tsx b/public/app/features/alerting/unified/MuteTimings.test.tsx new file mode 100644 index 00000000000..da69dc6674b --- /dev/null +++ b/public/app/features/alerting/unified/MuteTimings.test.tsx @@ -0,0 +1,295 @@ +import React from 'react'; +import { render, waitFor, fireEvent } from '@testing-library/react'; +import { locationService, setDataSourceSrv } from '@grafana/runtime'; + +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; +import { fetchAlertManagerConfig, updateAlertManagerConfig } from './api/alertmanager'; +import { typeAsJestMock } from 'test/helpers/typeAsJestMock'; +import { configureStore } from 'app/store/configureStore'; + +import { mockDataSource, MockDataSourceSrv } from './mocks'; +import { DataSourceType } from './utils/datasource'; +import { AlertManagerCortexConfig, MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types'; +import { byRole, byTestId, byText } from 'testing-library-selector'; +import userEvent from '@testing-library/user-event'; +import MuteTimings from './MuteTimings'; + +jest.mock('./api/alertmanager'); + +const mocks = { + api: { + fetchAlertManagerConfig: typeAsJestMock(fetchAlertManagerConfig), + updateAlertManagerConfig: typeAsJestMock(updateAlertManagerConfig), + }, +}; + +const renderMuteTimings = (location = '/alerting/routes/mute-timing/new') => { + const store = configureStore(); + locationService.push(location); + + return render( + + + + + + ); +}; + +const dataSources = { + am: mockDataSource({ + name: 'Alertmanager', + type: DataSourceType.Alertmanager, + }), +}; + +const ui = { + form: byTestId('mute-timing-form'), + nameField: byTestId('mute-timing-name'), + + startsAt: byTestId('mute-timing-starts-at'), + endsAt: byTestId('mute-timing-ends-at'), + addTimeRange: byRole('button', { name: /add another time range/i }), + + weekdays: byTestId('mute-timing-weekdays'), + days: byTestId('mute-timing-days'), + months: byTestId('mute-timing-months'), + years: byTestId('mute-timing-years'), + + addInterval: byRole('button', { name: /add another time interval/i }), + submitButton: byText(/submit/i), +}; + +const muteTimeInterval: MuteTimeInterval = { + name: 'default-mute', + time_intervals: [ + { + times: [ + { + start_time: '12:00', + end_time: '24:00', + }, + ], + days_of_month: ['15', '-1'], + months: ['august:december', 'march'], + }, + ], +}; + +const defaultConfig: AlertManagerCortexConfig = { + alertmanager_config: { + receivers: [{ name: 'default' }, { name: 'critical' }], + route: { + receiver: 'default', + group_by: ['alertname'], + routes: [ + { + matchers: ['env=prod', 'region!=EU'], + mute_time_intervals: [muteTimeInterval.name], + }, + ], + }, + templates: [], + mute_time_intervals: [muteTimeInterval], + }, + template_files: {}, +}; + +const resetMocks = () => { + jest.resetAllMocks(); + + mocks.api.fetchAlertManagerConfig.mockImplementation(() => { + return Promise.resolve({ ...defaultConfig }); + }); + mocks.api.updateAlertManagerConfig.mockImplementation(() => { + return Promise.resolve(); + }); +}; + +describe('Mute timings', () => { + beforeEach(() => { + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + resetMocks(); + }); + + it('creates a new mute timing', async () => { + await renderMuteTimings(); + + await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled()); + expect(ui.nameField.get()).toBeInTheDocument(); + + userEvent.type(ui.nameField.get(), 'maintenance period'); + userEvent.type(ui.startsAt.get(), '22:00'); + userEvent.type(ui.endsAt.get(), '24:00'); + userEvent.type(ui.days.get(), '-1'); + userEvent.type(ui.months.get(), 'january, july'); + + fireEvent.submit(ui.form.get()); + + await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled()); + expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith('grafana', { + ...defaultConfig, + alertmanager_config: { + ...defaultConfig.alertmanager_config, + mute_time_intervals: [ + muteTimeInterval, + { + name: 'maintenance period', + time_intervals: [ + { + days_of_month: ['-1'], + months: ['january', 'july'], + times: [ + { + start_time: '22:00', + end_time: '24:00', + }, + ], + }, + ], + }, + ], + }, + }); + }); + + it('prepoluates the form when editing a mute timing', async () => { + await renderMuteTimings( + '/alerting/routes/mute-timing/edit' + `?muteName=${encodeURIComponent(muteTimeInterval.name)}` + ); + + await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled()); + expect(ui.nameField.get()).toBeInTheDocument(); + expect(ui.nameField.get()).toHaveValue(muteTimeInterval.name); + expect(ui.months.get()).toHaveValue(muteTimeInterval.time_intervals[0].months?.join(', ')); + + userEvent.clear(ui.startsAt.getAll()?.[0]); + userEvent.clear(ui.endsAt.getAll()?.[0]); + userEvent.clear(ui.weekdays.get()); + userEvent.clear(ui.days.get()); + userEvent.clear(ui.months.get()); + userEvent.clear(ui.years.get()); + + userEvent.type(ui.weekdays.get(), 'monday'); + userEvent.type(ui.days.get(), '-7:-1'); + userEvent.type(ui.months.get(), '3, 6, 9, 12'); + userEvent.type(ui.years.get(), '2021:2024'); + + fireEvent.submit(ui.form.get()); + + await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled()); + expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith('grafana', { + alertmanager_config: { + receivers: [ + { + name: 'default', + }, + { + name: 'critical', + }, + ], + route: { + receiver: 'default', + group_by: ['alertname'], + routes: [ + { + matchers: ['env=prod', 'region!=EU'], + mute_time_intervals: ['default-mute'], + }, + ], + }, + templates: [], + mute_time_intervals: [ + { + name: 'default-mute', + time_intervals: [ + { + times: [], + weekdays: ['monday'], + days_of_month: ['-7:-1'], + months: ['3', '6', '9', '12'], + years: ['2021:2024'], + }, + ], + }, + ], + }, + template_files: {}, + }); + }); + + it('form is invalid with duplicate mute timing name', async () => { + await renderMuteTimings(); + + await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled()); + await waitFor(() => expect(ui.nameField.get()).toBeInTheDocument()); + await userEvent.type(ui.nameField.get(), 'default-mute'); + await userEvent.type(ui.days.get(), '1'); + await waitFor(() => expect(ui.nameField.get()).toHaveValue('default-mute')); + + fireEvent.submit(ui.form.get()); + + // Form state should be invalid and prevent firing of update action + await waitFor(() => expect(byRole('alert').get()).toBeInTheDocument()); + expect(mocks.api.updateAlertManagerConfig).not.toHaveBeenCalled(); + }); + + it('replaces mute timings in routes when the mute timing name is changed', async () => { + await renderMuteTimings( + '/alerting/routes/mute-timing/edit' + `?muteName=${encodeURIComponent(muteTimeInterval.name)}` + ); + + await waitFor(() => expect(mocks.api.fetchAlertManagerConfig).toHaveBeenCalled()); + expect(ui.nameField.get()).toBeInTheDocument(); + expect(ui.nameField.get()).toHaveValue(muteTimeInterval.name); + + userEvent.clear(ui.nameField.get()); + userEvent.type(ui.nameField.get(), 'Lunch breaks'); + + fireEvent.submit(ui.form.get()); + + await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled()); + expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalledWith('grafana', { + alertmanager_config: { + receivers: [ + { + name: 'default', + }, + { + name: 'critical', + }, + ], + route: { + receiver: 'default', + group_by: ['alertname'], + routes: [ + { + matchers: ['env=prod', 'region!=EU'], + mute_time_intervals: ['Lunch breaks'], + }, + ], + }, + templates: [], + mute_time_intervals: [ + { + name: 'Lunch breaks', + time_intervals: [ + { + times: [ + { + start_time: '12:00', + end_time: '24:00', + }, + ], + days_of_month: ['15', '-1'], + months: ['august:december', 'march'], + }, + ], + }, + ], + }, + template_files: {}, + }); + }); +}); diff --git a/public/app/features/alerting/unified/MuteTimings.tsx b/public/app/features/alerting/unified/MuteTimings.tsx new file mode 100644 index 00000000000..0087a3900d6 --- /dev/null +++ b/public/app/features/alerting/unified/MuteTimings.tsx @@ -0,0 +1,70 @@ +import React, { useCallback, useEffect } from 'react'; +import { Route, Redirect, Switch } from 'react-router-dom'; +import MuteTimingForm from './components/amroutes/MuteTimingForm'; +import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName'; +import { useDispatch } from 'react-redux'; +import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector'; +import { fetchAlertManagerConfigAction } from './state/actions'; +import { initialAsyncRequestState } from './utils/redux'; +import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types'; +import { Alert, LoadingPlaceholder } from '@grafana/ui'; +import { useQueryParams } from 'app/core/hooks/useQueryParams'; + +const MuteTimings = () => { + const [queryParams] = useQueryParams(); + const dispatch = useDispatch(); + const [alertManagerSourceName] = useAlertManagerSourceName(); + + const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs); + + const fetchConfig = useCallback(() => { + if (alertManagerSourceName) { + dispatch(fetchAlertManagerConfigAction(alertManagerSourceName)); + } + }, [alertManagerSourceName, dispatch]); + + useEffect(() => { + fetchConfig(); + }, [fetchConfig]); + + const { result, error, loading } = + (alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState; + + const config = result?.alertmanager_config; + + const getMuteTimingByName = useCallback( + (id: string): MuteTimeInterval | undefined => { + return config?.mute_time_intervals?.find(({ name }: MuteTimeInterval) => name === id); + }, + [config] + ); + + return ( + <> + {loading && } + {error && !loading && ( + + {error.message || 'Unknown error.'} + + )} + {result && !error && ( + + + + + + {() => { + if (queryParams['muteName']) { + const muteTiming = getMuteTimingByName(String(queryParams['muteName'])); + return ; + } + return ; + }} + + + )} + + ); +}; + +export default MuteTimings; diff --git a/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx b/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx index 7c913f346d2..567a9488759 100644 --- a/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx +++ b/public/app/features/alerting/unified/components/amroutes/AmRoutesExpandedForm.tsx @@ -27,6 +27,7 @@ import { import { timeOptions } from '../../utils/time'; import { getFormStyles } from './formStyles'; import { matcherFieldOptions } from '../../utils/alertmanager'; +import { useMuteTimingOptions } from '../../hooks/useMuteTimingOptions'; export interface AmRoutesExpandedFormProps { onCancel: () => void; @@ -43,6 +44,7 @@ export const AmRoutesExpandedForm: FC = ({ onCancel, !!routes.groupWaitValue || !!routes.groupIntervalValue || !!routes.repeatIntervalValue ); const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(routes.groupBy)); + const muteTimingOptions = useMuteTimingOptions(); return (
@@ -310,6 +312,27 @@ export const AmRoutesExpandedForm: FC = ({ onCancel, )} + + ( + onChange(mapMultiSelectValueToStrings(value))} + options={muteTimingOptions} + /> + )} + control={control} + name="muteTimeIntervals" + /> +
)}
+
Mute timings
+
+ +
); }; diff --git a/public/app/features/alerting/unified/components/amroutes/MuteTimingForm.tsx b/public/app/features/alerting/unified/components/amroutes/MuteTimingForm.tsx new file mode 100644 index 00000000000..99f5a6eaf48 --- /dev/null +++ b/public/app/features/alerting/unified/components/amroutes/MuteTimingForm.tsx @@ -0,0 +1,157 @@ +import React, { useMemo } from 'react'; +import { AlertingPageWrapper } from '../AlertingPageWrapper'; +import { Alert, Field, FieldSet, Input, Button, LinkButton, useStyles2 } from '@grafana/ui'; +import { FormProvider, useForm } from 'react-hook-form'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useDispatch } from 'react-redux'; +import { css } from '@emotion/css'; +import { + AlertmanagerConfig, + AlertManagerCortexConfig, + MuteTimeInterval, +} from 'app/plugins/datasource/alertmanager/types'; +import { AlertManagerPicker } from '../AlertManagerPicker'; +import { useAlertManagerSourceName } from '../../hooks/useAlertManagerSourceName'; +import { updateAlertManagerConfigAction } from '../../state/actions'; +import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; +import { initialAsyncRequestState } from '../../utils/redux'; +import { MuteTimingFields } from '../../types/mute-timing-form'; +import { createMuteTiming, defaultTimeInterval } from '../../utils/mute-timings'; +import { makeAMLink } from '../../utils/misc'; +import { renameMuteTimings } from '../../utils/alertmanager'; +import { MuteTimingTimeInterval } from './MuteTimingTimeInterval'; + +interface Props { + muteTiming?: MuteTimeInterval; + showError?: boolean; +} + +const useDefaultValues = (muteTiming?: MuteTimeInterval): MuteTimingFields => { + return useMemo(() => { + const defaultValues = { + name: '', + time_intervals: [defaultTimeInterval], + }; + + if (!muteTiming) { + return defaultValues; + } + + const intervals = muteTiming.time_intervals.map((interval) => ({ + times: interval.times ?? defaultTimeInterval.times, + weekdays: interval?.weekdays?.join(', ') ?? defaultTimeInterval.weekdays, + days_of_month: interval?.days_of_month?.join(', ') ?? defaultTimeInterval.days_of_month, + months: interval?.months?.join(', ') ?? defaultTimeInterval.months, + years: interval?.years?.join(', ') ?? defaultTimeInterval.years, + })); + + return { + name: muteTiming.name, + time_intervals: intervals, + }; + }, [muteTiming]); +}; + +const MuteTimingForm = ({ muteTiming, showError }: Props) => { + const dispatch = useDispatch(); + const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(); + const styles = useStyles2(getStyles); + + const defaultAmCortexConfig = { alertmanager_config: {}, template_files: {} }; + const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs); + const { result = defaultAmCortexConfig, loading } = + (alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState; + + const config: AlertmanagerConfig = result?.alertmanager_config ?? {}; + const defaultValues = useDefaultValues(muteTiming); + const formApi = useForm({ defaultValues }); + + const onSubmit = (values: MuteTimingFields) => { + const newMuteTiming = createMuteTiming(values); + + const muteTimings = muteTiming + ? config?.mute_time_intervals?.filter(({ name }) => name !== muteTiming.name) + : config.mute_time_intervals; + + const newConfig: AlertManagerCortexConfig = { + ...result, + alertmanager_config: { + ...config, + route: + muteTiming && newMuteTiming.name !== muteTiming.name + ? renameMuteTimings(newMuteTiming.name, muteTiming.name, config.route ?? {}) + : config.route, + mute_time_intervals: [...(muteTimings || []), newMuteTiming], + }, + }; + + dispatch( + updateAlertManagerConfigAction({ + newConfig, + oldConfig: result, + alertManagerSourceName: alertManagerSourceName!, + successMessage: 'Mute timing saved', + redirectPath: '/alerting/routes/', + }) + ); + }; + + return ( + + + {result && !loading && ( + + + {showError && } +
+ + { + if (!muteTiming) { + const existingMuteTiming = config?.mute_time_intervals?.find(({ name }) => value === name); + return existingMuteTiming ? `Mute timing already exists for "${value}"` : true; + } + return value.length > 0 || 'Name is required'; + }, + })} + className={styles.input} + data-testid={'mute-timing-name'} + /> + + + + Cancel + + +
+ +
+ )} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + input: css` + width: 400px; + `, + submitButton: css` + margin-left: ${theme.spacing(1)}; + `, +}); + +export default MuteTimingForm; diff --git a/public/app/features/alerting/unified/components/amroutes/MuteTimingTimeInterval.tsx b/public/app/features/alerting/unified/components/amroutes/MuteTimingTimeInterval.tsx new file mode 100644 index 00000000000..6d23d6be091 --- /dev/null +++ b/public/app/features/alerting/unified/components/amroutes/MuteTimingTimeInterval.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, Input, Field, FieldSet, useStyles2 } from '@grafana/ui'; +import { css } from '@emotion/css'; +import { useFormContext, useFieldArray } from 'react-hook-form'; +import { DAYS_OF_THE_WEEK, MONTHS, validateArrayField, defaultTimeInterval } from '../../utils/mute-timings'; +import { MuteTimingFields } from '../../types/mute-timing-form'; +import { MuteTimingTimeRange } from './MuteTimingTimeRange'; + +export const MuteTimingTimeInterval = () => { + const styles = useStyles2(getStyles); + const { formState, register } = useFormContext(); + const { + fields: timeIntervals, + append: addTimeInterval, + remove: removeTimeInterval, + } = useFieldArray({ + name: 'time_intervals', + }); + + return ( +
+ <> +

+ A time interval is a definition for a moment in time. All fields are lists, and at least one list element must + be satisfied to match the field. If a field is left blank, any moment of time will match the field. For an + instant of time to match a complete time interval, all fields must match. A mute timing can contain multiple + time intervals. +

+ {timeIntervals.map((timeInterval, timeIntervalIndex) => { + const errors = formState.errors; + return ( +
+ + + + validateArrayField( + value, + (day) => DAYS_OF_THE_WEEK.includes(day.toLowerCase()), + 'Invalid day of the week' + ), + })} + className={styles.input} + data-testid="mute-timing-weekdays" + // @ts-ignore react-hook-form doesn't handle nested field arrays well + defaultValue={timeInterval.weekdays} + placeholder="Example: monday, tuesday:thursday" + /> + + + + validateArrayField( + value, + (day) => { + const parsedDay = parseInt(day, 10); + return (parsedDay > -31 && parsedDay < 0) || (parsedDay > 0 && parsedDay < 32); + }, + 'Invalid day' + ), + })} + className={styles.input} + // @ts-ignore react-hook-form doesn't handle nested field arrays well + defaultValue={timeInterval.days_of_month} + placeholder="Example: 1, 14:16, -1" + data-testid="mute-timing-days" + /> + + + + validateArrayField( + value, + (month) => MONTHS.includes(month) || (parseInt(month, 10) < 13 && parseInt(month, 10) > 0), + 'Invalid month' + ), + })} + className={styles.input} + placeholder="Example: 1:3, may:august, december" + // @ts-ignore react-hook-form doesn't handle nested field arrays well + defaultValue={timeInterval.months} + data-testid="mute-timing-months" + /> + + + validateArrayField(value, (year) => /^\d{4}$/.test(year), 'Invalid year'), + })} + className={styles.input} + placeholder="Example: 2021:2022, 2030" + // @ts-ignore react-hook-form doesn't handle nested field arrays well + defaultValue={timeInterval.years} + data-testid="mute-timing-years" + /> + + +
+ ); + })} + + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + input: css` + width: 400px; + `, + timeIntervalLegend: css` + legend { + font-size: 1.25rem; + } + `, + timeIntervalSection: css` + background-color: ${theme.colors.background.secondary}; + padding: ${theme.spacing(1)}; + margin-bottom: ${theme.spacing(1)}; + `, + removeTimeIntervalButton: css` + margin-top: ${theme.spacing(1)}; + `, +}); diff --git a/public/app/features/alerting/unified/components/amroutes/MuteTimingTimeRange.tsx b/public/app/features/alerting/unified/components/amroutes/MuteTimingTimeRange.tsx new file mode 100644 index 00000000000..dfb1b91e64f --- /dev/null +++ b/public/app/features/alerting/unified/components/amroutes/MuteTimingTimeRange.tsx @@ -0,0 +1,118 @@ +import React, { FC } from 'react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; +import { MuteTimingFields } from '../../types/mute-timing-form'; +import { Field, InlineFieldRow, InlineField, Input, Button, IconButton, useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; +import { css } from '@emotion/css'; + +interface Props { + intervalIndex: number; +} + +export const MuteTimingTimeRange: FC = ({ intervalIndex }) => { + const styles = useStyles2(getStyles); + const { register, formState } = useFormContext(); + + const { fields: timeRanges, append: addTimeRange, remove: removeTimeRange } = useFieldArray({ + name: `time_intervals.${intervalIndex}.times`, + }); + + const validateTime = (timeString: string) => { + if (!timeString) { + return true; + } + const [hour, minutes] = timeString.split(':').map((x) => parseInt(x, 10)); + const isHourValid = hour > 0 && hour < 25; + const isMinuteValid = minutes > -1 && minutes < 60; + const isTimeValid = hour === 24 ? minutes === 0 : isHourValid && isMinuteValid; + + return isTimeValid || 'Time is invalid'; + }; + + const formErrors = formState.errors.time_intervals?.[intervalIndex]; + const timeRangeInvalid = formErrors?.times?.some((value) => value?.start_time || value?.end_time) ?? false; + + return ( +
+ + <> + {timeRanges.map((timeRange, index) => { + return ( +
+ + + + + + + + { + e.preventDefault(); + removeTimeRange(index); + }} + /> + +
+ ); + })} + +
+ +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + field: css` + margin-bottom: 0; + `, + timeRange: css` + margin-bottom: ${theme.spacing(1)}; + `, + timeRangeInput: css` + width: 120px; + `, + deleteTimeRange: css` + margin: ${theme.spacing(1)} 0 0 ${theme.spacing(0.5)}; + `, + addTimeRange: css` + margin-bottom: ${theme.spacing(2)}; + `, +}); diff --git a/public/app/features/alerting/unified/components/amroutes/MuteTimingsTable.tsx b/public/app/features/alerting/unified/components/amroutes/MuteTimingsTable.tsx new file mode 100644 index 00000000000..2c32e5138ce --- /dev/null +++ b/public/app/features/alerting/unified/components/amroutes/MuteTimingsTable.tsx @@ -0,0 +1,145 @@ +import React, { FC, useMemo, useState } from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { IconButton, LinkButton, Link, useStyles2, ConfirmModal } from '@grafana/ui'; +import { AlertManagerCortexConfig, MuteTimeInterval, TimeInterval } from 'app/plugins/datasource/alertmanager/types'; +import { useDispatch } from 'react-redux'; +import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; +import { deleteMuteTimingAction } from '../../state/actions'; +import { makeAMLink } from '../../utils/misc'; +import { AsyncRequestState, initialAsyncRequestState } from '../../utils/redux'; +import { DynamicTable, DynamicTableItemProps, DynamicTableColumnProps } from '../DynamicTable'; +import { + getTimeString, + getWeekdayString, + getDaysOfMonthString, + getMonthsString, + getYearsString, +} from '../../utils/alertmanager'; + +interface Props { + alertManagerSourceName: string; + muteTimingNames?: string[]; + hideActions?: boolean; +} + +export const MuteTimingsTable: FC = ({ alertManagerSourceName, muteTimingNames, hideActions }) => { + const styles = useStyles2(getStyles); + const dispatch = useDispatch(); + const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs); + const [muteTimingName, setMuteTimingName] = useState(''); + const { result }: AsyncRequestState = + (alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState; + + const items = useMemo((): Array> => { + const muteTimings = result?.alertmanager_config?.mute_time_intervals ?? []; + return muteTimings + .filter(({ name }) => (muteTimingNames ? muteTimingNames.includes(name) : true)) + .map((mute) => { + return { + id: mute.name, + data: mute, + }; + }); + }, [result?.alertmanager_config?.mute_time_intervals, muteTimingNames]); + + const columns = useColumns(alertManagerSourceName, hideActions, setMuteTimingName); + + return ( +
+ {!hideActions &&
Mute timings
} + {!hideActions && ( +

+ Mute timings are a named interval of time that may be referenced in the notification policy tree to mute + particular notification policies for specific times of the day. +

+ )} + {items.length > 0 ? :

No mute timings configured

} + {!hideActions && ( + dispatch(deleteMuteTimingAction(alertManagerSourceName, muteTimingName))} + onDismiss={() => setMuteTimingName('')} + /> + )} + {!hideActions && ( + + Add mute timing + + )} +
+ ); +}; + +function useColumns(alertManagerSourceName: string, hideActions = false, setMuteTimingName: (name: string) => void) { + return useMemo((): Array> => { + const columns: Array> = [ + { + id: 'name', + label: 'Name', + renderCell: function renderName({ data }) { + return data.name; + }, + size: '250px', + }, + { + id: 'timeRange', + label: 'Time range', + renderCell: ({ data }) => renderTimeIntervals(data.time_intervals), + }, + ]; + if (!hideActions) { + columns.push({ + id: 'actions', + label: 'Actions', + renderCell: function renderActions({ data }) { + return ( +
+ + + + setMuteTimingName(data.name)} /> +
+ ); + }, + size: '100px', + }); + } + return columns; + }, [alertManagerSourceName, hideActions, setMuteTimingName]); +} + +function renderTimeIntervals(timeIntervals: TimeInterval[]) { + return timeIntervals.map((interval, index) => { + const { times, weekdays, days_of_month, months, years } = interval; + const timeString = getTimeString(times); + const weekdayString = getWeekdayString(weekdays); + const daysString = getDaysOfMonthString(days_of_month); + const monthsString = getMonthsString(months); + const yearsString = getYearsString(years); + + return ( + + {`${timeString} ${weekdayString}`} +
+ {[daysString, monthsString, yearsString].join(' | ')} +
+
+ ); + }); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + addMuteButton: css` + margin-top: ${theme.spacing(1)}; + `, +}); diff --git a/public/app/features/alerting/unified/hooks/useMuteTimingOptions.ts b/public/app/features/alerting/unified/hooks/useMuteTimingOptions.ts new file mode 100644 index 00000000000..650ee65ef72 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useMuteTimingOptions.ts @@ -0,0 +1,26 @@ +import { SelectableValue } from '@grafana/data'; +import { AlertmanagerConfig } from 'app/plugins/datasource/alertmanager/types'; +import { useMemo } from 'react'; +import { timeIntervalToString } from '../utils/alertmanager'; +import { initialAsyncRequestState } from '../utils/redux'; +import { useAlertManagerSourceName } from './useAlertManagerSourceName'; +import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; + +export function useMuteTimingOptions(): Array> { + const [alertManagerSourceName] = useAlertManagerSourceName(); + const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs); + + return useMemo(() => { + const { result } = (alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState; + const config: AlertmanagerConfig = result?.alertmanager_config ?? {}; + + const muteTimingsOptions: Array> = + config?.mute_time_intervals?.map((value) => ({ + value: value.name, + label: value.name, + description: value.time_intervals.map((interval) => timeIntervalToString(interval)).join(', AND '), + })) ?? []; + + return muteTimingsOptions; + }, [alertManagerSourceName, amConfigs]); +} diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index d7a10fa2b16..ab87c0557ff 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -62,7 +62,7 @@ import { isPrometheusRuleIdentifier, isRulerNotSupportedResponse, } from '../utils/rules'; -import { addDefaultsToAlertmanagerConfig } from '../utils/alertmanager'; +import { addDefaultsToAlertmanagerConfig, removeMuteTimingFromRoute } from '../utils/alertmanager'; import * as ruleId from '../utils/rule-id'; import { isEmpty } from 'lodash'; import messageFromError from 'app/plugins/datasource/grafana-azure-monitor-datasource/utils/messageFromError'; @@ -655,6 +655,41 @@ export const deleteAlertManagerConfigAction = createAsyncThunk( } ); +export const deleteMuteTimingAction = (alertManagerSourceName: string, muteTimingName: string): ThunkResult => { + return async (dispatch, getState) => { + const config = getState().unifiedAlerting.amConfigs[alertManagerSourceName].result; + + const muteIntervals = + config?.alertmanager_config?.mute_time_intervals?.filter(({ name }) => name !== muteTimingName) ?? []; + + if (config) { + withAppEvents( + dispatch( + updateAlertManagerConfigAction({ + alertManagerSourceName, + oldConfig: config, + newConfig: { + ...config, + alertmanager_config: { + ...config.alertmanager_config, + route: config.alertmanager_config.route + ? removeMuteTimingFromRoute(muteTimingName, config.alertmanager_config?.route) + : undefined, + mute_time_intervals: muteIntervals, + }, + }, + refetch: true, + }) + ), + { + successMessage: `Deleted "${muteTimingName}" from Alertmanager configuration`, + errorMessage: 'Failed to delete mute timing', + } + ); + } + }; +}; + interface TestReceiversOptions { alertManagerSourceName: string; receivers: Receiver[]; diff --git a/public/app/features/alerting/unified/types/amroutes.ts b/public/app/features/alerting/unified/types/amroutes.ts index 24d301ad769..1bb95ed5e6f 100644 --- a/public/app/features/alerting/unified/types/amroutes.ts +++ b/public/app/features/alerting/unified/types/amroutes.ts @@ -12,6 +12,7 @@ export interface FormAmRoute { groupIntervalValueType: string; repeatIntervalValue: string; repeatIntervalValueType: string; + muteTimeIntervals: string[]; routes: FormAmRoute[]; } diff --git a/public/app/features/alerting/unified/types/mute-timing-form.ts b/public/app/features/alerting/unified/types/mute-timing-form.ts new file mode 100644 index 00000000000..3c759231c6b --- /dev/null +++ b/public/app/features/alerting/unified/types/mute-timing-form.ts @@ -0,0 +1,14 @@ +import { TimeRange } from 'app/plugins/datasource/alertmanager/types'; + +export type MuteTimingFields = { + name: string; + time_intervals: MuteTimingIntervalFields[]; +}; + +export type MuteTimingIntervalFields = { + times: TimeRange[]; + weekdays: string; + days_of_month: string; + months: string; + years: string; +}; diff --git a/public/app/features/alerting/unified/utils/alertmanager.test.ts b/public/app/features/alerting/unified/utils/alertmanager.test.ts index c318fdb7eea..408764ce083 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.test.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.test.ts @@ -1,6 +1,12 @@ -import { Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { Matcher, MatcherOperator, Route } from 'app/plugins/datasource/alertmanager/types'; import { Labels } from 'app/types/unified-alerting-dto'; -import { parseMatcher, parseMatchers, stringifyMatcher, labelsMatchMatchers } from './alertmanager'; +import { + parseMatcher, + parseMatchers, + stringifyMatcher, + labelsMatchMatchers, + removeMuteTimingFromRoute, +} from './alertmanager'; describe('Alertmanager utils', () => { describe('parseMatcher', () => { @@ -126,4 +132,46 @@ describe('Alertmanager utils', () => { expect(labelsMatchMatchers(labels, matchers)).toBe(true); }); }); + + describe('removeMuteTimingFromRoute', () => { + const route: Route = { + receiver: 'gmail', + object_matchers: [['foo', MatcherOperator.equal, 'bar']], + mute_time_intervals: ['test1', 'test2'], + routes: [ + { + receiver: 'slack', + object_matchers: [['env', MatcherOperator.equal, 'prod']], + mute_time_intervals: ['test2'], + }, + { + receiver: 'pagerduty', + object_matchers: [['env', MatcherOperator.equal, 'eu']], + mute_time_intervals: ['test1'], + }, + ], + }; + + it('should remove mute timings from routes', () => { + expect(removeMuteTimingFromRoute('test1', route)).toEqual({ + mute_time_intervals: ['test2'], + object_matchers: [['foo', '=', 'bar']], + receiver: 'gmail', + routes: [ + { + mute_time_intervals: ['test2'], + object_matchers: [['env', '=', 'prod']], + receiver: 'slack', + routes: undefined, + }, + { + mute_time_intervals: [], + object_matchers: [['env', '=', 'eu']], + receiver: 'pagerduty', + routes: undefined, + }, + ], + }); + }); + }); }); diff --git a/public/app/features/alerting/unified/utils/alertmanager.ts b/public/app/features/alerting/unified/utils/alertmanager.ts index 917a77654ef..236b5366bc2 100644 --- a/public/app/features/alerting/unified/utils/alertmanager.ts +++ b/public/app/features/alerting/unified/utils/alertmanager.ts @@ -1,4 +1,11 @@ -import { AlertManagerCortexConfig, MatcherOperator, Route, Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { + AlertManagerCortexConfig, + MatcherOperator, + Route, + Matcher, + TimeInterval, + TimeRange, +} from 'app/plugins/datasource/alertmanager/types'; import { Labels } from 'app/types/unified-alerting-dto'; import { MatcherFieldValue } from '../types/silence-form'; import { SelectableValue } from '@grafana/data'; @@ -22,6 +29,25 @@ export function addDefaultsToAlertmanagerConfig(config: AlertManagerCortexConfig return config; } +export function removeMuteTimingFromRoute(muteTiming: string, route: Route): Route { + const newRoute: Route = { + ...route, + mute_time_intervals: route.mute_time_intervals?.filter((muteName) => muteName !== muteTiming) ?? [], + routes: route.routes?.map((subRoute) => removeMuteTimingFromRoute(muteTiming, subRoute)), + }; + return newRoute; +} + +export function renameMuteTimings(newMuteTimingName: string, oldMuteTimingName: string, route: Route): Route { + return { + ...route, + mute_time_intervals: route.mute_time_intervals?.map((name) => + name === oldMuteTimingName ? newMuteTimingName : name + ), + routes: route.routes?.map((subRoute) => renameMuteTimings(newMuteTimingName, oldMuteTimingName, subRoute)), + }; +} + function isReceiverUsedInRoute(receiver: string, route: Route): boolean { return ( (route.receiver === receiver || route.routes?.some((route) => isReceiverUsedInRoute(receiver, route))) ?? false @@ -184,3 +210,55 @@ export function getAllAlertmanagerDataSources() { export function getAlertmanagerByUid(uid?: string) { return getAllAlertmanagerDataSources().find((ds) => uid === ds.uid); } + +export function timeIntervalToString(timeInterval: TimeInterval): string { + const { times, weekdays, days_of_month, months, years } = timeInterval; + const timeString = getTimeString(times); + const weekdayString = getWeekdayString(weekdays); + const daysString = getDaysOfMonthString(days_of_month); + const monthsString = getMonthsString(months); + const yearsString = getYearsString(years); + + return [timeString, weekdayString, daysString, monthsString, yearsString].join(', '); +} + +export function getTimeString(times?: TimeRange[]): string { + return ( + 'Times: ' + + (times ? times?.map(({ start_time, end_time }) => `${start_time} - ${end_time} UTC`).join(' and ') : 'All') + ); +} + +export function getWeekdayString(weekdays?: string[]): string { + return ( + 'Weekdays: ' + + (weekdays + ?.map((day) => { + if (day.includes(':')) { + return day + .split(':') + .map((d) => { + const abbreviated = d.slice(0, 3); + return abbreviated[0].toLocaleUpperCase() + abbreviated.substr(1); + }) + .join('-'); + } else { + const abbreviated = day.slice(0, 3); + return abbreviated[0].toLocaleUpperCase() + abbreviated.substr(1); + } + }) + .join(', ') ?? 'All') + ); +} + +export function getDaysOfMonthString(daysOfMonth?: string[]): string { + return 'Days of the month: ' + (daysOfMonth?.join(', ') ?? 'All'); +} + +export function getMonthsString(months?: string[]): string { + return 'Months: ' + (months?.join(', ') ?? 'All'); +} + +export function getYearsString(years?: string[]): string { + return 'Years: ' + (years?.join(', ') ?? 'All'); +} diff --git a/public/app/features/alerting/unified/utils/amroutes.ts b/public/app/features/alerting/unified/utils/amroutes.ts index 62f82be19ba..8609d24c906 100644 --- a/public/app/features/alerting/unified/utils/amroutes.ts +++ b/public/app/features/alerting/unified/utils/amroutes.ts @@ -66,6 +66,7 @@ export const emptyRoute: FormAmRoute = { groupIntervalValueType: timeOptions[0].value, repeatIntervalValue: '', repeatIntervalValueType: timeOptions[0].value, + muteTimeIntervals: [], }; //returns route, and a record mapping id to existing route route @@ -114,6 +115,7 @@ export const amRouteToFormAmRoute = (route: Route | undefined): [FormAmRoute, Re repeatIntervalValue, repeatIntervalValueType, routes: formRoutes, + muteTimeIntervals: route.mute_time_intervals ?? [], }, id2route, ]; @@ -146,6 +148,7 @@ export const formAmRouteToAmRoute = ( routes: formAmRoute.routes.map((subRoute) => formAmRouteToAmRoute(alertManagerSourceName, subRoute, id2ExistingRoute) ), + mute_time_intervals: formAmRoute.muteTimeIntervals, }; if (alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME) { diff --git a/public/app/features/alerting/unified/utils/misc.ts b/public/app/features/alerting/unified/utils/misc.ts index 5aecdce9140..33741bad6e6 100644 --- a/public/app/features/alerting/unified/utils/misc.ts +++ b/public/app/features/alerting/unified/utils/misc.ts @@ -57,8 +57,12 @@ export function recordToArray(record: Record): Array<{ key: stri return Object.entries(record).map(([key, value]) => ({ key, value })); } -export function makeAMLink(path: string, alertManagerName?: string): string { - return `${path}${alertManagerName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${encodeURIComponent(alertManagerName)}` : ''}`; +export function makeAMLink(path: string, alertManagerName?: string, options?: Record): string { + const search = new URLSearchParams(options); + if (alertManagerName) { + search.append(ALERTMANAGER_NAME_QUERY_KEY, alertManagerName); + } + return `${path}?${search.toString()}`; } export function makeSilenceLink(alertmanagerSourceName: string, rule: CombinedRule) { diff --git a/public/app/features/alerting/unified/utils/mute-timings.ts b/public/app/features/alerting/unified/utils/mute-timings.ts new file mode 100644 index 00000000000..c5afe46d326 --- /dev/null +++ b/public/app/features/alerting/unified/utils/mute-timings.ts @@ -0,0 +1,66 @@ +import { MuteTimeInterval, TimeInterval } from 'app/plugins/datasource/alertmanager/types'; +import { omitBy, isUndefined } from 'lodash'; +import { MuteTimingFields, MuteTimingIntervalFields } from '../types/mute-timing-form'; + +export const DAYS_OF_THE_WEEK = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; + +export const MONTHS = [ + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december', +]; + +export const defaultTimeInterval: MuteTimingIntervalFields = { + times: [{ start_time: '', end_time: '' }], + weekdays: '', + days_of_month: '', + months: '', + years: '', +}; + +export const validateArrayField = (value: string, validateValue: (input: string) => boolean, invalidText: string) => { + if (value) { + return ( + value + .split(',') + .map((x) => x.trim()) + .every((entry) => entry.split(':').every(validateValue)) || invalidText + ); + } else { + return true; + } +}; + +const convertStringToArray = (str: string) => { + return str ? str.split(',').map((s) => s.trim()) : undefined; +}; + +export const createMuteTiming = (fields: MuteTimingFields): MuteTimeInterval => { + const timeIntervals: TimeInterval[] = fields.time_intervals.map( + ({ times, weekdays, days_of_month, months, years }) => { + const interval = { + times: times.filter(({ start_time, end_time }) => !!start_time && !!end_time), + weekdays: convertStringToArray(weekdays)?.map((v) => v.toLowerCase()), + days_of_month: convertStringToArray(days_of_month), + months: convertStringToArray(months), + years: convertStringToArray(years), + }; + + return omitBy(interval, isUndefined); + } + ); + + return { + name: fields.name, + time_intervals: timeIntervals, + }; +}; diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index 9fe5511ee55..1ef41d32b79 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -105,6 +105,7 @@ export type Route = { group_interval?: string; repeat_interval?: string; routes?: Route[]; + mute_time_intervals?: string[]; }; export type InhibitRule = { @@ -141,6 +142,7 @@ export type AlertmanagerConfig = { route?: Route; inhibit_rules?: InhibitRule[]; receivers?: Receiver[]; + mute_time_intervals?: MuteTimeInterval[]; }; export type Matcher = { @@ -275,4 +277,22 @@ export enum AlertManagerImplementation { prometheus = 'prometheus', } +export interface TimeRange { + /** Times are in format `HH:MM` in UTC */ + start_time: string; + end_time: string; +} +export interface TimeInterval { + times?: TimeRange[]; + weekdays?: string[]; + days_of_month?: string[]; + months?: string[]; + years?: string[]; +} + +export type MuteTimeInterval = { + name: string; + time_intervals: TimeInterval[]; +}; + export type AlertManagerDataSourceJsonData = DataSourceJsonData & { implementation?: AlertManagerImplementation };