mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
e77b4abcc8
commit
825edddfb6
@ -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.
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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`
|
@ -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(
|
||||
|
@ -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<void> => {
|
||||
|
@ -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}
|
||||
/>
|
||||
<div className={styles.break} />
|
||||
<MuteTimingsTable alertManagerSourceName={alertManagerSourceName} />
|
||||
</>
|
||||
)}
|
||||
</AlertingPageWrapper>
|
||||
|
295
public/app/features/alerting/unified/MuteTimings.test.tsx
Normal file
295
public/app/features/alerting/unified/MuteTimings.test.tsx
Normal file
@ -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(
|
||||
<Provider store={store}>
|
||||
<Router history={locationService.getHistory()}>
|
||||
<MuteTimings />
|
||||
</Router>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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: {},
|
||||
});
|
||||
});
|
||||
});
|
70
public/app/features/alerting/unified/MuteTimings.tsx
Normal file
70
public/app/features/alerting/unified/MuteTimings.tsx
Normal file
@ -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 && <LoadingPlaceholder text="Loading mute timing" />}
|
||||
{error && !loading && (
|
||||
<Alert severity="error" title={`Error loading Alertmanager config for ${alertManagerSourceName}`}>
|
||||
{error.message || 'Unknown error.'}
|
||||
</Alert>
|
||||
)}
|
||||
{result && !error && (
|
||||
<Switch>
|
||||
<Route exact path="/alerting/routes/mute-timing/new">
|
||||
<MuteTimingForm />
|
||||
</Route>
|
||||
<Route exact path="/alerting/routes/mute-timing/edit">
|
||||
{() => {
|
||||
if (queryParams['muteName']) {
|
||||
const muteTiming = getMuteTimingByName(String(queryParams['muteName']));
|
||||
return <MuteTimingForm muteTiming={muteTiming} showError={!muteTiming} />;
|
||||
}
|
||||
return <Redirect to="/alerting/routes" />;
|
||||
}}
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MuteTimings;
|
@ -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<AmRoutesExpandedFormProps> = ({ onCancel,
|
||||
!!routes.groupWaitValue || !!routes.groupIntervalValue || !!routes.repeatIntervalValue
|
||||
);
|
||||
const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(routes.groupBy));
|
||||
const muteTimingOptions = useMuteTimingOptions();
|
||||
|
||||
return (
|
||||
<Form defaultValues={routes} onSubmit={onSave}>
|
||||
@ -310,6 +312,27 @@ export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel,
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field
|
||||
label="Mute timings"
|
||||
data-testid="am-mute-timing-select"
|
||||
description="Add mute timing to policy"
|
||||
invalid={!!errors.muteTimeIntervals}
|
||||
>
|
||||
<InputControl
|
||||
render={({ field: { onChange, ref, ...field } }) => (
|
||||
<MultiSelect
|
||||
aria-label="Mute timings"
|
||||
menuShouldPortal
|
||||
{...field}
|
||||
className={formStyles.input}
|
||||
onChange={(value) => onChange(mapMultiSelectValueToStrings(value))}
|
||||
options={muteTimingOptions}
|
||||
/>
|
||||
)}
|
||||
control={control}
|
||||
name="muteTimeIntervals"
|
||||
/>
|
||||
</Field>
|
||||
<div className={styles.buttonGroup}>
|
||||
<Button type="submit">Save policy</Button>
|
||||
<Button onClick={onCancel} fill="outline" type="button" variant="secondary">
|
||||
|
@ -6,6 +6,8 @@ import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes';
|
||||
import { emptyRoute } from '../../utils/amroutes';
|
||||
import { AmRoutesTable } from './AmRoutesTable';
|
||||
import { getGridStyles } from './gridStyles';
|
||||
import { MuteTimingsTable } from './MuteTimingsTable';
|
||||
import { useAlertManagerSourceName } from '../../hooks/useAlertManagerSourceName';
|
||||
|
||||
export interface AmRoutesExpandedReadProps {
|
||||
onChange: (routes: FormAmRoute) => void;
|
||||
@ -22,6 +24,7 @@ export const AmRoutesExpandedRead: FC<AmRoutesExpandedReadProps> = ({
|
||||
}) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const gridStyles = useStyles2(getGridStyles);
|
||||
const [alertManagerSourceName] = useAlertManagerSourceName();
|
||||
|
||||
const groupWait = routes.groupWaitValue ? `${routes.groupWaitValue}${routes.groupWaitValueType}` : '-';
|
||||
const groupInterval = routes.groupIntervalValue
|
||||
@ -87,6 +90,14 @@ export const AmRoutesExpandedRead: FC<AmRoutesExpandedReadProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={gridStyles.titleCell}>Mute timings</div>
|
||||
<div className={gridStyles.valueCell}>
|
||||
<MuteTimingsTable
|
||||
alertManagerSourceName={alertManagerSourceName!}
|
||||
muteTimingNames={routes.muteTimeIntervals}
|
||||
hideActions
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -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 (
|
||||
<AlertingPageWrapper pageId="am-routes">
|
||||
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} disabled />
|
||||
{result && !loading && (
|
||||
<FormProvider {...formApi}>
|
||||
<form onSubmit={formApi.handleSubmit(onSubmit)} data-testid="mute-timing-form">
|
||||
{showError && <Alert title="No matching mute timing found" />}
|
||||
<FieldSet label={'Create mute timing'}>
|
||||
<Field
|
||||
required
|
||||
label="Name"
|
||||
description="A unique name for the mute timing"
|
||||
invalid={!!formApi.formState.errors?.name}
|
||||
error={formApi.formState.errors.name?.message}
|
||||
>
|
||||
<Input
|
||||
{...formApi.register('name', {
|
||||
required: true,
|
||||
validate: (value) => {
|
||||
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'}
|
||||
/>
|
||||
</Field>
|
||||
<MuteTimingTimeInterval />
|
||||
<LinkButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
href={makeAMLink('/alerting/routes/', alertManagerSourceName)}
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
<Button type="submit" className={styles.submitButton}>
|
||||
{muteTiming ? 'Save' : 'Submit'}
|
||||
</Button>
|
||||
</FieldSet>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
input: css`
|
||||
width: 400px;
|
||||
`,
|
||||
submitButton: css`
|
||||
margin-left: ${theme.spacing(1)};
|
||||
`,
|
||||
});
|
||||
|
||||
export default MuteTimingForm;
|
@ -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<MuteTimingFields>({
|
||||
name: 'time_intervals',
|
||||
});
|
||||
|
||||
return (
|
||||
<FieldSet className={styles.timeIntervalLegend} label="Time intervals">
|
||||
<>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
{timeIntervals.map((timeInterval, timeIntervalIndex) => {
|
||||
const errors = formState.errors;
|
||||
return (
|
||||
<div key={timeInterval.id} className={styles.timeIntervalSection}>
|
||||
<MuteTimingTimeRange intervalIndex={timeIntervalIndex} />
|
||||
<Field
|
||||
label="Days of the week"
|
||||
error={errors.time_intervals?.[timeIntervalIndex]?.weekdays?.message ?? ''}
|
||||
invalid={!!errors.time_intervals?.[timeIntervalIndex]?.weekdays}
|
||||
>
|
||||
<Input
|
||||
{...register(`time_intervals.${timeIntervalIndex}.weekdays`, {
|
||||
validate: (value) =>
|
||||
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"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Days of the month"
|
||||
description="The days of the month, 1-31, of a month. Negative values can be used to represent days which begin at the end of the month"
|
||||
invalid={!!errors.time_intervals?.[timeIntervalIndex]?.days_of_month}
|
||||
error={errors.time_intervals?.[timeIntervalIndex]?.days_of_month?.message}
|
||||
>
|
||||
<Input
|
||||
{...register(`time_intervals.${timeIntervalIndex}.days_of_month`, {
|
||||
validate: (value) =>
|
||||
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"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Months"
|
||||
description="The months of the year in either numerical or the full calendar month"
|
||||
invalid={!!errors.time_intervals?.[timeIntervalIndex]?.months}
|
||||
error={errors.time_intervals?.[timeIntervalIndex]?.months?.message}
|
||||
>
|
||||
<Input
|
||||
{...register(`time_intervals.${timeIntervalIndex}.months`, {
|
||||
validate: (value) =>
|
||||
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"
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="Years"
|
||||
invalid={!!errors.time_intervals?.[timeIntervalIndex]?.years}
|
||||
error={errors.time_intervals?.[timeIntervalIndex]?.years?.message ?? ''}
|
||||
>
|
||||
<Input
|
||||
{...register(`time_intervals.${timeIntervalIndex}.years`, {
|
||||
validate: (value) => 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"
|
||||
/>
|
||||
</Field>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
icon="trash-alt"
|
||||
onClick={() => removeTimeInterval(timeIntervalIndex)}
|
||||
>
|
||||
Remove time interval
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className={styles.removeTimeIntervalButton}
|
||||
onClick={() => {
|
||||
addTimeInterval(defaultTimeInterval);
|
||||
}}
|
||||
icon="plus"
|
||||
>
|
||||
Add another time interval
|
||||
</Button>
|
||||
</>
|
||||
</FieldSet>
|
||||
);
|
||||
};
|
||||
|
||||
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)};
|
||||
`,
|
||||
});
|
@ -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<Props> = ({ intervalIndex }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { register, formState } = useFormContext<MuteTimingFields>();
|
||||
|
||||
const { fields: timeRanges, append: addTimeRange, remove: removeTimeRange } = useFieldArray<MuteTimingFields>({
|
||||
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 (
|
||||
<div>
|
||||
<Field
|
||||
className={styles.field}
|
||||
label="Time range"
|
||||
description="The time inclusive of the starting time and exclusive of the end time in UTC"
|
||||
invalid={timeRangeInvalid}
|
||||
error={timeRangeInvalid ? 'Times must be between 00:00 and 24:00 UTC' : ''}
|
||||
>
|
||||
<>
|
||||
{timeRanges.map((timeRange, index) => {
|
||||
return (
|
||||
<div className={styles.timeRange} key={timeRange.id}>
|
||||
<InlineFieldRow>
|
||||
<InlineField label="Start time" invalid={!!formErrors?.times?.[index]?.start_time}>
|
||||
<Input
|
||||
{...register(`time_intervals.${intervalIndex}.times.${index}.start_time`, {
|
||||
validate: validateTime,
|
||||
})}
|
||||
className={styles.timeRangeInput}
|
||||
// @ts-ignore react-hook-form doesn't handle nested field arrays well
|
||||
defaultValue={timeRange.start_time}
|
||||
placeholder="HH:MM"
|
||||
data-testid="mute-timing-starts-at"
|
||||
/>
|
||||
</InlineField>
|
||||
<InlineField label="End time" invalid={!!formErrors?.times?.[index]?.end_time}>
|
||||
<Input
|
||||
{...register(`time_intervals.${intervalIndex}.times.${index}.end_time`, {
|
||||
validate: validateTime,
|
||||
})}
|
||||
className={styles.timeRangeInput}
|
||||
// @ts-ignore react-hook-form doesn't handle nested field arrays well
|
||||
defaultValue={timeRange.end_time}
|
||||
placeholder="HH:MM"
|
||||
data-testid="mute-timing-ends-at"
|
||||
/>
|
||||
</InlineField>
|
||||
<IconButton
|
||||
className={styles.deleteTimeRange}
|
||||
title={'Remove'}
|
||||
name={'trash-alt'}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
removeTimeRange(index);
|
||||
}}
|
||||
/>
|
||||
</InlineFieldRow>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</Field>
|
||||
<Button
|
||||
className={styles.addTimeRange}
|
||||
variant="secondary"
|
||||
type="button"
|
||||
icon={'plus'}
|
||||
onClick={() => addTimeRange({ start_time: '', end_time: '' })}
|
||||
>
|
||||
Add another time range
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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)};
|
||||
`,
|
||||
});
|
@ -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<Props> = ({ alertManagerSourceName, muteTimingNames, hideActions }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const dispatch = useDispatch();
|
||||
const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs);
|
||||
const [muteTimingName, setMuteTimingName] = useState<string>('');
|
||||
const { result }: AsyncRequestState<AlertManagerCortexConfig> =
|
||||
(alertManagerSourceName && amConfigs[alertManagerSourceName]) || initialAsyncRequestState;
|
||||
|
||||
const items = useMemo((): Array<DynamicTableItemProps<MuteTimeInterval>> => {
|
||||
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 (
|
||||
<div>
|
||||
{!hideActions && <h5>Mute timings</h5>}
|
||||
{!hideActions && (
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
)}
|
||||
{items.length > 0 ? <DynamicTable items={items} cols={columns} /> : <p>No mute timings configured</p>}
|
||||
{!hideActions && (
|
||||
<ConfirmModal
|
||||
isOpen={!!muteTimingName}
|
||||
title="Delete mute timing"
|
||||
body={`Are you sure you would like to delete "${muteTimingName}"`}
|
||||
confirmText="Delete"
|
||||
onConfirm={() => dispatch(deleteMuteTimingAction(alertManagerSourceName, muteTimingName))}
|
||||
onDismiss={() => setMuteTimingName('')}
|
||||
/>
|
||||
)}
|
||||
{!hideActions && (
|
||||
<LinkButton
|
||||
className={styles.addMuteButton}
|
||||
variant="secondary"
|
||||
href={makeAMLink('alerting/routes/mute-timing/new', alertManagerSourceName)}
|
||||
>
|
||||
Add mute timing
|
||||
</LinkButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function useColumns(alertManagerSourceName: string, hideActions = false, setMuteTimingName: (name: string) => void) {
|
||||
return useMemo((): Array<DynamicTableColumnProps<MuteTimeInterval>> => {
|
||||
const columns: Array<DynamicTableColumnProps<MuteTimeInterval>> = [
|
||||
{
|
||||
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 (
|
||||
<div>
|
||||
<Link
|
||||
href={makeAMLink(`/alerting/routes/mute-timing/edit`, alertManagerSourceName, { muteName: data.name })}
|
||||
>
|
||||
<IconButton name="edit" title="Edit mute timing" />
|
||||
</Link>
|
||||
<IconButton name={'trash-alt'} title="Delete mute timing" onClick={() => setMuteTimingName(data.name)} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
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 (
|
||||
<React.Fragment key={JSON.stringify(interval) + index}>
|
||||
{`${timeString} ${weekdayString}`}
|
||||
<br />
|
||||
{[daysString, monthsString, yearsString].join(' | ')}
|
||||
<br />
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
addMuteButton: css`
|
||||
margin-top: ${theme.spacing(1)};
|
||||
`,
|
||||
});
|
@ -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<SelectableValue<string>> {
|
||||
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<SelectableValue<string>> =
|
||||
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]);
|
||||
}
|
@ -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<void> => {
|
||||
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[];
|
||||
|
@ -12,6 +12,7 @@ export interface FormAmRoute {
|
||||
groupIntervalValueType: string;
|
||||
repeatIntervalValue: string;
|
||||
repeatIntervalValueType: string;
|
||||
muteTimeIntervals: string[];
|
||||
routes: FormAmRoute[];
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
};
|
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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');
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -57,8 +57,12 @@ export function recordToArray(record: Record<string, string>): 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, string>): 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) {
|
||||
|
66
public/app/features/alerting/unified/utils/mute-timings.ts
Normal file
66
public/app/features/alerting/unified/utils/mute-timings.ts
Normal file
@ -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,
|
||||
};
|
||||
};
|
@ -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 };
|
||||
|
Loading…
Reference in New Issue
Block a user