Alerting: Add external Alertmanagers (#39183)

* building ui

* saving alertmanager urls

* add actions and api call to get external ams

* add list to add modal

* add validation and edit/delete

* work on merging results

* merging results

* get color for status heart

* adding tests

* tests added

* rename

* add pollin and status

* fix list sync

* fix polling

* add info icon with actual tooltip

* fix test

* Accessibility things

* fix strict error

* delete public/dist files

* Add API tests for invalid URL

* start redo admin test

* Fix for empty configuration and test

* remove admin test

* text updates after review

* suppress appevent error

* fix tests

* update description

Co-authored-by: gotjosh <josue@grafana.com>

* fix text plus go lint

* updates after pr review

* Adding docs

* Update docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md

Co-authored-by: gotjosh <josue@grafana.com>

* Update docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md

Co-authored-by: gotjosh <josue@grafana.com>

* Update docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md

Co-authored-by: gotjosh <josue@grafana.com>

* Update docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md

Co-authored-by: gotjosh <josue@grafana.com>

* Update docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md

Co-authored-by: gotjosh <josue@grafana.com>

* Update docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md

Co-authored-by: gotjosh <josue@grafana.com>

* Update docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md

Co-authored-by: gotjosh <josue@grafana.com>

* prettier

* updates after docs feedback

Co-authored-by: gotjosh <josue.abreu@gmail.com>
Co-authored-by: gotjosh <josue@grafana.com>
This commit is contained in:
Peter Holmberg 2021-11-12 22:19:16 +01:00 committed by GitHub
parent 862054918d
commit b2d7162168
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 873 additions and 153 deletions

View File

@ -15,3 +15,25 @@ Grafana includes built-in support for Prometheus Alertmanager. By default, notif
Grafana 8 alerting added support for external Alertmanager configuration. When you add an [Alertmanager data source]({{< relref "../../../datasources/alertmanager.md" >}}), the Alertmanager drop-down shows a list of available external Alertmanager data sources. Select a data source to create and manage alerting for standalone Cortex or Loki data sources.
{{< figure max-width="40%" src="/static/img/docs/alerting/unified/contact-points-select-am-8-0.gif" max-width="250px" caption="Select Alertmanager" >}}
You can configure one or several external Alertmanagers to receive alerts from Grafana. Once configured, both the embedded Alertmanager **and** any configured external Alertmanagers will receive _all_ alerts.
You can do the setup in the "Admin" tab within the Grafana v8 Alerts UI.
### Add a new external Alertmanager
1. In the Grafana menu, click the Alerting (bell) icon to open the Alerting page listing existing alerts.
2. Click **Admin** and then scroll down to the External Alertmanager section.
3. Click **Add Alertmanager** and a modal opens.
4. Add the URL and the port for the external Alertmanager. You do not need to specify the path suffix, for example, `/api/v(1|2)/alerts`. Grafana automatically adds this.
The external URL is listed in the table with a pending status. Once Grafana verifies that the Alertmanager is discovered, the status changes to active. No requests are made to the external Alertmanager at this point; the verification signals that alerts are ready to be sent.
### Edit an external Alertmanager
1. Click the pen symbol to the right of the Alertmanager row in the table.
2. When the edit modal opens, you can view all the URLs that were added.
The edited URL will be pending until Grafana verifies it again.
{{< figure max-width="40%" src="/static/img/docs/alerting/unified/ext-alertmanager-active.png" max-width="650px" caption="External Alertmanagers" >}}

View File

@ -70,6 +70,12 @@ func (srv AdminSrv) RoutePostNGalertConfig(c *models.ReqContext, body apimodels.
OrgID: c.OrgId,
}
if err := cfg.Validate(); err != nil {
msg := "failed to validate admin configuration"
srv.log.Error(msg, "err", err)
return ErrResp(http.StatusBadRequest, err, msg)
}
cmd := store.UpdateAdminConfigurationCmd{AdminConfiguration: cfg}
if err := srv.store.UpdateAdminConfiguration(cmd); err != nil {
msg := "failed to save the admin configuration to the database"

View File

@ -3,6 +3,7 @@ package models
import (
"crypto/sha256"
"fmt"
"net/url"
)
// AdminConfiguration represents the ngalert administration configuration settings.
@ -22,3 +23,14 @@ func (ac *AdminConfiguration) AsSHA256() string {
_, _ = h.Write([]byte(fmt.Sprintf("%v", ac.Alertmanagers)))
return fmt.Sprintf("%x", h.Sum(nil))
}
func (ac *AdminConfiguration) Validate() error {
for _, u := range ac.Alertmanagers {
_, err := url.Parse(u)
if err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,58 @@
package models
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
func TestAdminConfiguration_AsSHA256(t *testing.T) {
tc := []struct {
name string
ac *AdminConfiguration
ciphertext string
}{
{
name: "AsSHA256",
ac: &AdminConfiguration{Alertmanagers: []string{"http://localhost:9093"}},
ciphertext: "3ec9db375a5ba12f7c7b704922cf4b8e21a31e30d85be2386803829f0ee24410",
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.ciphertext, tt.ac.AsSHA256())
})
}
}
func TestAdminConfiguration_Validate(t *testing.T) {
tc := []struct {
name string
ac *AdminConfiguration
err error
}{
{
name: "should return the first error if any of the Alertmanagers URL is invalid",
ac: &AdminConfiguration{Alertmanagers: []string{"http://localhost:9093", "http://›∂-)Æÿ ñ"}},
err: fmt.Errorf("parse \"http://›∂-)Æÿ ñ\": invalid character \" \" in host name"),
},
{
name: "should not return any errors if all URLs are valid",
ac: &AdminConfiguration{Alertmanagers: []string{"http://localhost:9093"}},
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
err := tt.ac.Validate()
if tt.err != nil {
require.EqualError(t, err, tt.err.Error())
return
}
require.NoError(t, err)
})
}
}

View File

@ -1,150 +1,13 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Alert, Button, ConfirmModal, TextArea, HorizontalGroup, Field, Form } from '@grafana/ui';
import { useAlertManagerSourceName } from './hooks/useAlertManagerSourceName';
import React from 'react';
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { AlertManagerPicker } from './components/AlertManagerPicker';
import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from './utils/datasource';
import { useDispatch } from 'react-redux';
import {
deleteAlertManagerConfigAction,
fetchAlertManagerConfigAction,
updateAlertManagerConfigAction,
} from './state/actions';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { initialAsyncRequestState } from './utils/redux';
interface FormValues {
configJSON: string;
}
import AlertmanagerConfig from './components/admin/AlertmanagerConfig';
import { ExternalAlertmanagers } from './components/admin/ExternalAlertmanagers';
export default function Admin(): JSX.Element {
const dispatch = useDispatch();
const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName();
const [showConfirmDeleteAMConfig, setShowConfirmDeleteAMConfig] = useState(false);
const { loading: isDeleting } = useUnifiedAlertingSelector((state) => state.deleteAMConfig);
const { loading: isSaving } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
const readOnly = alertManagerSourceName ? isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName) : false;
const configRequests = useUnifiedAlertingSelector((state) => state.amConfigs);
const { result: config, loading: isLoadingConfig, error: loadingError } =
(alertManagerSourceName && configRequests[alertManagerSourceName]) || initialAsyncRequestState;
useEffect(() => {
if (alertManagerSourceName) {
dispatch(fetchAlertManagerConfigAction(alertManagerSourceName));
}
}, [alertManagerSourceName, dispatch]);
const resetConfig = () => {
if (alertManagerSourceName) {
dispatch(deleteAlertManagerConfigAction(alertManagerSourceName));
}
setShowConfirmDeleteAMConfig(false);
};
const defaultValues = useMemo(
(): FormValues => ({
configJSON: config ? JSON.stringify(config, null, 2) : '',
}),
[config]
);
const loading = isDeleting || isLoadingConfig || isSaving;
const onSubmit = (values: FormValues) => {
if (alertManagerSourceName) {
dispatch(
updateAlertManagerConfigAction({
newConfig: JSON.parse(values.configJSON),
oldConfig: config,
alertManagerSourceName,
successMessage: 'Alertmanager configuration updated.',
refetch: true,
})
);
}
};
return (
<AlertingPageWrapper pageId="alerting-admin">
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
{loadingError && !loading && (
<Alert severity="error" title="Error loading Alertmanager configuration">
{loadingError.message || 'Unknown error.'}
</Alert>
)}
{isDeleting && alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME && (
<Alert severity="info" title="Resetting Alertmanager configuration">
It might take a while...
</Alert>
)}
{alertManagerSourceName && config && (
<Form defaultValues={defaultValues} onSubmit={onSubmit} key={defaultValues.configJSON}>
{({ register, errors }) => (
<>
{!readOnly && (
<Field
disabled={loading}
label="Configuration"
invalid={!!errors.configJSON}
error={errors.configJSON?.message}
>
<TextArea
{...register('configJSON', {
required: { value: true, message: 'Required.' },
validate: (v) => {
try {
JSON.parse(v);
return true;
} catch (e) {
return e.message;
}
},
})}
id="configuration"
rows={25}
/>
</Field>
)}
{readOnly && (
<Field label="Configuration">
<pre data-testid="readonly-config">{defaultValues.configJSON}</pre>
</Field>
)}
{!readOnly && (
<HorizontalGroup>
<Button type="submit" variant="primary" disabled={loading}>
Save
</Button>
<Button
type="button"
disabled={loading}
variant="destructive"
onClick={() => setShowConfirmDeleteAMConfig(true)}
>
Reset configuration
</Button>
</HorizontalGroup>
)}
{!!showConfirmDeleteAMConfig && (
<ConfirmModal
isOpen={true}
title="Reset Alertmanager configuration"
body={`Are you sure you want to reset configuration ${
alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME
? 'for the Grafana Alertmanager'
: `for "${alertManagerSourceName}"`
}? Contact points and notification policies will be reset to their defaults.`}
confirmText="Yes, reset configuration"
onConfirm={resetConfig}
onDismiss={() => setShowConfirmDeleteAMConfig(false)}
/>
)}
</>
)}
</Form>
)}
<AlertmanagerConfig test-id="admin-alertmanagerconfig" />
<ExternalAlertmanagers test-id="admin-externalalertmanagers" />
</AlertingPageWrapper>
);
}

View File

@ -14,6 +14,7 @@ import {
TestReceiversPayload,
TestReceiversResult,
TestReceiversAlert,
ExternalAlertmanagersResponse,
} from 'app/plugins/datasource/alertmanager/types';
import { getDatasourceAPIId, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
@ -194,6 +195,43 @@ export async function testReceivers(
}
}
export async function addAlertManagers(alertManagers: string[]): Promise<void> {
await lastValueFrom(
getBackendSrv().fetch({
method: 'POST',
data: { alertmanagers: alertManagers },
url: '/api/v1/ngalert/admin_config',
showErrorAlert: false,
showSuccessAlert: false,
})
).then(() => {
fetchExternalAlertmanagerConfig();
});
}
export async function fetchExternalAlertmanagers(): Promise<ExternalAlertmanagersResponse> {
const result = await lastValueFrom(
getBackendSrv().fetch<ExternalAlertmanagersResponse>({
method: 'GET',
url: '/api/v1/ngalert/alertmanagers',
})
);
return result.data;
}
export async function fetchExternalAlertmanagerConfig(): Promise<{ alertmanagers: string[] }> {
const result = await lastValueFrom(
getBackendSrv().fetch<{ alertmanagers: string[] }>({
method: 'GET',
url: '/api/v1/ngalert/admin_config',
showErrorAlert: false,
})
);
return result.data;
}
function escapeQuotes(value: string): string {
return value.replace(/"/g, '\\"');
}

View File

@ -0,0 +1,138 @@
import React, { FC, useMemo } from 'react';
import { css, cx } from '@emotion/css';
import { useDispatch } from 'react-redux';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Field, FieldArray, Form, Icon, Input, Modal, useStyles2 } from '@grafana/ui';
import { addExternalAlertmanagersAction } from '../../state/actions';
import { AlertmanagerUrl } from 'app/plugins/datasource/alertmanager/types';
interface Props {
onClose: () => void;
alertmanagers: AlertmanagerUrl[];
}
export const AddAlertManagerModal: FC<Props> = ({ alertmanagers, onClose }) => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const defaultValues: Record<string, AlertmanagerUrl[]> = useMemo(
() => ({
alertmanagers: alertmanagers,
}),
[alertmanagers]
);
const modalTitle = (
<div className={styles.modalTitle}>
<Icon name="bell" className={styles.modalIcon} />
<h3>Add Alertmanager</h3>
</div>
);
const onSubmit = (values: Record<string, AlertmanagerUrl[]>) => {
dispatch(addExternalAlertmanagersAction(values.alertmanagers.map((am) => cleanAlertmanagerUrl(am.url))));
onClose();
};
return (
<Modal title={modalTitle} isOpen={true} onDismiss={onClose} className={styles.modal}>
<div className={styles.description}>
We use a service discovery method to find existing Alertmanagers for a given URL.
</div>
<Form onSubmit={onSubmit} defaultValues={defaultValues}>
{({ register, control, errors }) => (
<div>
<FieldArray control={control} name="alertmanagers">
{({ fields, append, remove }) => (
<div className={styles.fieldArray}>
<div className={styles.bold}>Source url</div>
<div className={styles.muted}>
Authentication can be done via URL (e.g. user:password@myalertmanager.com) and only the Alertmanager
v2 API is supported. The suffix is added internally, there is no need to specify it.
</div>
{fields.map((field, index) => {
return (
<Field
invalid={!!errors?.alertmanagers?.[index]}
error="Field is required"
key={`${field.id}-${index}`}
>
<Input
className={styles.input}
defaultValue={field.url}
{...register(`alertmanagers.${index}.url`, { required: true })}
placeholder="http://localhost:9093"
addonAfter={
<Button
aria-label="Remove alertmanager"
type="button"
onClick={() => remove(index)}
variant="destructive"
className={styles.destroyInputRow}
>
<Icon name="trash-alt" />
</Button>
}
/>
</Field>
);
})}
<Button type="button" variant="secondary" onClick={() => append({ url: '' })}>
Add URL
</Button>
</div>
)}
</FieldArray>
<div>
<Button onSubmit={() => onSubmit}>Add Alertmanagers</Button>
</div>
</div>
)}
</Form>
</Modal>
);
};
function cleanAlertmanagerUrl(url: string): string {
return url.replace(/\/$/, '').replace(/\/api\/v[1|2]\/alerts/i, '');
}
const getStyles = (theme: GrafanaTheme2) => {
const muted = css`
color: ${theme.colors.text.secondary};
`;
return {
description: cx(
css`
margin-bottom: ${theme.spacing(2)};
`,
muted
),
muted: muted,
bold: css`
font-weight: ${theme.typography.fontWeightBold};
`,
modal: css``,
modalIcon: cx(
muted,
css`
margin-right: ${theme.spacing(1)};
`
),
modalTitle: css`
display: flex;
`,
input: css`
margin-bottom: ${theme.spacing(1)};
margin-right: ${theme.spacing(1)};
`,
inputRow: css`
display: flex;
`,
destroyInputRow: css`
padding: ${theme.spacing(1)};
`,
fieldArray: css`
margin-bottom: ${theme.spacing(4)};
`,
};
};

View File

@ -1,22 +1,27 @@
import React from 'react';
import { typeAsJestMock } from 'test/helpers/typeAsJestMock';
import { getAllDataSources } from './utils/config';
import { getAllDataSources } from '../../utils/config';
import {
fetchAlertManagerConfig,
deleteAlertManagerConfig,
updateAlertManagerConfig,
fetchStatus,
} from './api/alertmanager';
} from '../../api/alertmanager';
import { configureStore } from 'app/store/configureStore';
import { locationService, setDataSourceSrv } from '@grafana/runtime';
import Admin from './Admin';
import AlertmanagerConfig from './AlertmanagerConfig';
import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from '../../utils/constants';
import { render, waitFor } from '@testing-library/react';
import { byLabelText, byRole, byTestId } from 'testing-library-selector';
import { mockDataSource, MockDataSourceSrv, someCloudAlertManagerConfig, someCloudAlertManagerStatus } from './mocks';
import { DataSourceType } from './utils/datasource';
import {
mockDataSource,
MockDataSourceSrv,
someCloudAlertManagerConfig,
someCloudAlertManagerStatus,
} from '../../mocks';
import { DataSourceType } from '../../utils/datasource';
import { contextSrv } from 'app/core/services/context_srv';
import store from 'app/core/store';
import userEvent from '@testing-library/user-event';
@ -26,9 +31,9 @@ import {
AlertManagerImplementation,
} from 'app/plugins/datasource/alertmanager/types';
jest.mock('./api/alertmanager');
jest.mock('./api/grafana');
jest.mock('./utils/config');
jest.mock('../../api/alertmanager');
jest.mock('../../api/grafana');
jest.mock('../../utils/config');
const mocks = {
getAllDataSources: typeAsJestMock(getAllDataSources),
@ -52,7 +57,7 @@ const renderAdminPage = (alertManagerSourceName?: string) => {
return render(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<Admin />
<AlertmanagerConfig />
</Router>
</Provider>
);
@ -80,7 +85,7 @@ const ui = {
readOnlyConfig: byTestId('readonly-config'),
};
describe('Alerting Admin', () => {
describe('Admin config', () => {
beforeEach(() => {
jest.resetAllMocks();
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));

View File

@ -0,0 +1,158 @@
import React, { useEffect, useState, useMemo } from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Button, ConfirmModal, TextArea, HorizontalGroup, Field, Form, useStyles2 } from '@grafana/ui';
import { useAlertManagerSourceName } from '../../hooks/useAlertManagerSourceName';
import { AlertManagerPicker } from '../AlertManagerPicker';
import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
import { useDispatch } from 'react-redux';
import {
deleteAlertManagerConfigAction,
fetchAlertManagerConfigAction,
updateAlertManagerConfigAction,
} from '../../state/actions';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { initialAsyncRequestState } from '../../utils/redux';
interface FormValues {
configJSON: string;
}
export default function AlertmanagerConfig(): JSX.Element {
const dispatch = useDispatch();
const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName();
const [showConfirmDeleteAMConfig, setShowConfirmDeleteAMConfig] = useState(false);
const { loading: isDeleting } = useUnifiedAlertingSelector((state) => state.deleteAMConfig);
const { loading: isSaving } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
const readOnly = alertManagerSourceName ? isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName) : false;
const styles = useStyles2(getStyles);
const configRequests = useUnifiedAlertingSelector((state) => state.amConfigs);
const { result: config, loading: isLoadingConfig, error: loadingError } =
(alertManagerSourceName && configRequests[alertManagerSourceName]) || initialAsyncRequestState;
useEffect(() => {
if (alertManagerSourceName) {
dispatch(fetchAlertManagerConfigAction(alertManagerSourceName));
}
}, [alertManagerSourceName, dispatch]);
const resetConfig = () => {
if (alertManagerSourceName) {
dispatch(deleteAlertManagerConfigAction(alertManagerSourceName));
}
setShowConfirmDeleteAMConfig(false);
};
const defaultValues = useMemo(
(): FormValues => ({
configJSON: config ? JSON.stringify(config, null, 2) : '',
}),
[config]
);
const loading = isDeleting || isLoadingConfig || isSaving;
const onSubmit = (values: FormValues) => {
if (alertManagerSourceName) {
dispatch(
updateAlertManagerConfigAction({
newConfig: JSON.parse(values.configJSON),
oldConfig: config,
alertManagerSourceName,
successMessage: 'Alertmanager configuration updated.',
refetch: true,
})
);
}
};
return (
<div className={styles.container}>
<AlertManagerPicker current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
{loadingError && !loading && (
<Alert severity="error" title="Error loading Alertmanager configuration">
{loadingError.message || 'Unknown error.'}
</Alert>
)}
{isDeleting && alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME && (
<Alert severity="info" title="Resetting Alertmanager configuration">
It might take a while...
</Alert>
)}
{alertManagerSourceName && config && (
<Form defaultValues={defaultValues} onSubmit={onSubmit} key={defaultValues.configJSON}>
{({ register, errors }) => (
<>
{!readOnly && (
<Field
disabled={loading}
label="Configuration"
invalid={!!errors.configJSON}
error={errors.configJSON?.message}
>
<TextArea
{...register('configJSON', {
required: { value: true, message: 'Required.' },
validate: (v) => {
try {
JSON.parse(v);
return true;
} catch (e) {
return e.message;
}
},
})}
id="configuration"
rows={25}
/>
</Field>
)}
{readOnly && (
<Field label="Configuration">
<pre data-testid="readonly-config">{defaultValues.configJSON}</pre>
</Field>
)}
{!readOnly && (
<HorizontalGroup>
<Button type="submit" variant="primary" disabled={loading}>
Save
</Button>
<Button
type="button"
disabled={loading}
variant="destructive"
onClick={() => setShowConfirmDeleteAMConfig(true)}
>
Reset configuration
</Button>
</HorizontalGroup>
)}
{!!showConfirmDeleteAMConfig && (
<ConfirmModal
isOpen={true}
title="Reset Alertmanager configuration"
body={`Are you sure you want to reset configuration ${
alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME
? 'for the Grafana Alertmanager'
: `for "${alertManagerSourceName}"`
}? Contact points and notification policies will be reset to their defaults.`}
confirmText="Yes, reset configuration"
onConfirm={resetConfig}
onDismiss={() => setShowConfirmDeleteAMConfig(false)}
/>
)}
</>
)}
</Form>
)}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
margin-bottom: ${theme.spacing(4)};
`,
});

View File

@ -0,0 +1,180 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, ConfirmModal, HorizontalGroup, Icon, Tooltip, useStyles2 } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { AddAlertManagerModal } from './AddAlertManagerModal';
import {
addExternalAlertmanagersAction,
fetchExternalAlertmanagersAction,
fetchExternalAlertmanagersConfigAction,
} from '../../state/actions';
import { useExternalAmSelector } from '../../hooks/useExternalAmSelector';
export const ExternalAlertmanagers = () => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const [modalState, setModalState] = useState({ open: false, payload: [{ url: '' }] });
const [deleteModalState, setDeleteModalState] = useState({ open: false, index: 0 });
const externalAlertManagers = useExternalAmSelector();
useEffect(() => {
dispatch(fetchExternalAlertmanagersAction());
dispatch(fetchExternalAlertmanagersConfigAction());
const interval = setInterval(() => dispatch(fetchExternalAlertmanagersAction()), 5000);
return () => {
clearInterval(interval);
};
}, [dispatch]);
const onDelete = useCallback(
(index: number) => {
// to delete we need to filter the alertmanager from the list and repost
const newList = (externalAlertManagers ?? [])
.filter((am, i) => i !== index)
.map((am) => {
return am.url;
});
dispatch(addExternalAlertmanagersAction(newList));
setDeleteModalState({ open: false, index: 0 });
},
[externalAlertManagers, dispatch]
);
const onEdit = useCallback(() => {
const ams = externalAlertManagers ? [...externalAlertManagers] : [{ url: '' }];
setModalState((state) => ({
...state,
open: true,
payload: ams,
}));
}, [setModalState, externalAlertManagers]);
const onOpenModal = useCallback(() => {
setModalState((state) => {
const ams = externalAlertManagers ? [...externalAlertManagers, { url: '' }] : [{ url: '' }];
return {
...state,
open: true,
payload: ams,
};
});
}, [externalAlertManagers]);
const onCloseModal = useCallback(() => {
setModalState((state) => ({
...state,
open: false,
}));
}, [setModalState]);
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'green';
case 'pending':
return 'yellow';
default:
return 'red';
}
};
const noAlertmanagers = externalAlertManagers?.length === 0;
return (
<div>
<h4>External Alertmanagers</h4>
<div className={styles.muted}>
You can have your Grafana managed alerts be delivered to one or many external Alertmanager(s) in addition to the
internal Alertmanager by specifying their URLs below.
</div>
<div className={styles.actions}>
{!noAlertmanagers && (
<Button type="button" onClick={onOpenModal}>
Add Alertmanager
</Button>
)}
</div>
{noAlertmanagers ? (
<EmptyListCTA
title="You have not added any external alertmanagers"
onClick={onOpenModal}
buttonTitle="Add Alertmanager"
buttonIcon="bell-slash"
/>
) : (
<table className="filter-table form-inline filter-table--hover">
<thead>
<tr>
<th>Url</th>
<th>Status</th>
<th style={{ width: '2%' }}>Action</th>
</tr>
</thead>
<tbody>
{externalAlertManagers?.map((am, index) => {
return (
<tr key={index}>
<td>
<span className={styles.url}>{am.url}</span>
{am.actualUrl ? (
<Tooltip content={`Discovered ${am.actualUrl} from ${am.url}`} theme="info">
<Icon name="info-circle" />
</Tooltip>
) : null}
</td>
<td>
<Icon name="heart" style={{ color: getStatusColor(am.status) }} title={am.status} />
</td>
<td>
<HorizontalGroup>
<Button variant="secondary" type="button" onClick={onEdit} aria-label="Edit alertmanager">
<Icon name="pen" />
</Button>
<Button
variant="destructive"
aria-label="Remove alertmanager"
type="button"
onClick={() => setDeleteModalState({ open: true, index })}
>
<Icon name="trash-alt" />
</Button>
</HorizontalGroup>
</td>
</tr>
);
})}
</tbody>
</table>
)}
<ConfirmModal
isOpen={deleteModalState.open}
title="Remove Alertmanager"
body="Are you sure you want to remove this Alertmanager"
confirmText="Remove"
onConfirm={() => onDelete(deleteModalState.index)}
onDismiss={() => setDeleteModalState({ open: false, index: 0 })}
/>
{modalState.open && <AddAlertManagerModal onClose={onCloseModal} alertmanagers={modalState.payload} />}
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
url: css`
margin-right: ${theme.spacing(1)};
`,
muted: css`
color: ${theme.colors.text.secondary};
`,
actions: css`
margin-top: ${theme.spacing(2)};
display: flex;
justify-content: flex-end;
`,
table: css``,
});

View File

@ -0,0 +1,129 @@
import * as reactRedux from 'react-redux';
import { useExternalAmSelector } from './useExternalAmSelector';
const createMockStoreState = (
activeAlertmanagers: Array<{ url: string }>,
droppedAlertmanagers: Array<{ url: string }>,
alertmanagerConfig: string[]
) => ({
unifiedAlerting: {
externalAlertmanagers: {
discoveredAlertmanagers: {
result: {
data: {
activeAlertManagers: activeAlertmanagers,
droppedAlertManagers: droppedAlertmanagers,
},
},
},
alertmanagerConfig: {
result: {
alertmanagers: alertmanagerConfig,
},
},
},
},
});
describe('useExternalAmSelector', () => {
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
beforeEach(() => {
useSelectorMock.mockClear();
});
it('should have one in pending', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(createMockStoreState([], [], ['some/url/to/am']));
});
const alertmanagers = useExternalAmSelector();
expect(alertmanagers).toEqual([
{
url: 'some/url/to/am',
status: 'pending',
actualUrl: '',
},
]);
});
it('should have one active, one pending', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(
createMockStoreState([{ url: 'some/url/to/am/api/v2/alerts' }], [], ['some/url/to/am', 'some/url/to/am1'])
);
});
const alertmanagers = useExternalAmSelector();
expect(alertmanagers).toEqual([
{
url: 'some/url/to/am',
actualUrl: 'some/url/to/am/api/v2/alerts',
status: 'active',
},
{
url: 'some/url/to/am1',
actualUrl: '',
status: 'pending',
},
]);
});
it('should have two active', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(
createMockStoreState(
[{ url: 'some/url/to/am/api/v2/alerts' }, { url: 'some/url/to/am1/api/v2/alerts' }],
[],
['some/url/to/am', 'some/url/to/am1']
)
);
});
const alertmanagers = useExternalAmSelector();
expect(alertmanagers).toEqual([
{
url: 'some/url/to/am',
actualUrl: 'some/url/to/am/api/v2/alerts',
status: 'active',
},
{
url: 'some/url/to/am1',
actualUrl: 'some/url/to/am1/api/v2/alerts',
status: 'active',
},
]);
});
it('should have one active, one dropped, one pending', () => {
useSelectorMock.mockImplementation((callback) => {
return callback(
createMockStoreState(
[{ url: 'some/url/to/am/api/v2/alerts' }],
[{ url: 'some/dropped/url/api/v2/alerts' }],
['some/url/to/am', 'some/url/to/am1']
)
);
});
const alertmanagers = useExternalAmSelector();
expect(alertmanagers).toEqual([
{
url: 'some/url/to/am',
actualUrl: 'some/url/to/am/api/v2/alerts',
status: 'active',
},
{
url: 'some/url/to/am1',
actualUrl: '',
status: 'pending',
},
{
url: 'some/dropped/url',
actualUrl: 'some/dropped/url/api/v2/alerts',
status: 'dropped',
},
]);
});
});

View File

@ -0,0 +1,56 @@
import { useSelector } from 'react-redux';
import { StoreState } from '../../../../types';
const SUFFIX_REGEX = /\/api\/v[1|2]\/alerts/i;
type AlertmanagerConfig = { url: string; status: string; actualUrl: string };
export function useExternalAmSelector(): AlertmanagerConfig[] | undefined {
const discoveredAlertmanagers = useSelector(
(state: StoreState) => state.unifiedAlerting.externalAlertmanagers.discoveredAlertmanagers.result?.data
);
const alertmanagerConfig = useSelector(
(state: StoreState) => state.unifiedAlerting.externalAlertmanagers.alertmanagerConfig.result?.alertmanagers
);
if (!discoveredAlertmanagers || !alertmanagerConfig) {
return [];
}
const enabledAlertmanagers: AlertmanagerConfig[] = [];
const droppedAlertmanagers: AlertmanagerConfig[] = discoveredAlertmanagers?.droppedAlertManagers.map((am) => ({
url: am.url.replace(SUFFIX_REGEX, ''),
status: 'dropped',
actualUrl: am.url,
}));
for (const url of alertmanagerConfig) {
if (discoveredAlertmanagers.activeAlertManagers.length === 0) {
enabledAlertmanagers.push({
url: url,
status: 'pending',
actualUrl: '',
});
} else {
let found = false;
for (const activeAM of discoveredAlertmanagers.activeAlertManagers) {
if (activeAM.url === `${url}/api/v2/alerts`) {
found = true;
enabledAlertmanagers.push({
url: activeAM.url.replace(SUFFIX_REGEX, ''),
status: 'active',
actualUrl: activeAM.url,
});
}
}
if (!found) {
enabledAlertmanagers.push({
url: url,
status: 'pending',
actualUrl: '',
});
}
}
}
return [...enabledAlertmanagers, ...droppedAlertmanagers];
}

View File

@ -4,6 +4,7 @@ import {
AlertmanagerAlert,
AlertManagerCortexConfig,
AlertmanagerGroup,
ExternalAlertmanagersResponse,
Receiver,
Silence,
SilenceCreatePayload,
@ -29,6 +30,9 @@ import {
fetchStatus,
deleteAlertManagerConfig,
testReceivers,
addAlertManagers,
fetchExternalAlertmanagers,
fetchExternalAlertmanagerConfig,
} from '../api/alertmanager';
import { FetchPromRulesFilter, fetchRules } from '../api/prometheus';
import {
@ -107,6 +111,20 @@ export const fetchAlertManagerConfigAction = createAsyncThunk(
)
);
export const fetchExternalAlertmanagersAction = createAsyncThunk(
'unifiedAlerting/fetchExternalAlertmanagers',
(): Promise<ExternalAlertmanagersResponse> => {
return withSerializedError(fetchExternalAlertmanagers());
}
);
export const fetchExternalAlertmanagersConfigAction = createAsyncThunk(
'unifiedAlerting/fetchExternAlertmanagersConfig',
(): Promise<{ alertmanagers: string[] }> => {
return withSerializedError(fetchExternalAlertmanagerConfig());
}
);
export const fetchRulerRulesAction = createAsyncThunk(
'unifiedalerting/fetchRulerRules',
({
@ -732,3 +750,21 @@ export const updateLotexNamespaceAndGroupAction = createAsyncThunk(
);
}
);
export const addExternalAlertmanagersAction = createAsyncThunk(
'unifiedAlerting/addExternalAlertmanagers',
async (alertManagerUrls: string[], thunkAPI): Promise<void> => {
return withAppEvents(
withSerializedError(
(async () => {
await addAlertManagers(alertManagerUrls);
thunkAPI.dispatch(fetchExternalAlertmanagersConfigAction());
})()
),
{
errorMessage: 'Failed adding alertmanagers',
successMessage: 'Alertmanagers updated',
}
);
}
);

View File

@ -17,6 +17,8 @@ import {
deleteAlertManagerConfigAction,
testReceiversAction,
updateLotexNamespaceAndGroupAction,
fetchExternalAlertmanagersAction,
fetchExternalAlertmanagersConfigAction,
} from './actions';
export const reducer = combineReducers({
@ -54,6 +56,10 @@ export const reducer = combineReducers({
testReceivers: createAsyncSlice('testReceivers', testReceiversAction).reducer,
updateLotexNamespaceAndGroup: createAsyncSlice('updateLotexNamespaceAndGroup', updateLotexNamespaceAndGroupAction)
.reducer,
externalAlertmanagers: combineReducers({
alertmanagerConfig: createAsyncSlice('alertmanagerConfig', fetchExternalAlertmanagersConfigAction).reducer,
discoveredAlertmanagers: createAsyncSlice('discoveredAlertmanagers', fetchExternalAlertmanagersAction).reducer,
}),
});
export type UnifiedAlertingState = ReturnType<typeof reducer>;

View File

@ -257,6 +257,19 @@ export interface TestReceiversResult {
receivers: TestReceiversResultReceiver[];
}
export interface ExternalAlertmanagers {
activeAlertManagers: AlertmanagerUrl[];
droppedAlertManagers: AlertmanagerUrl[];
}
export interface AlertmanagerUrl {
url: string;
}
export interface ExternalAlertmanagersResponse {
data: ExternalAlertmanagers;
status: 'string';
}
export enum AlertManagerImplementation {
cortex = 'cortex',
prometheus = 'prometheus',