diff --git a/docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md b/docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md index e6a7d5207cc..6b46408c689 100644 --- a/docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md +++ b/docs/sources/alerting/unified-alerting/fundamentals/alertmanager.md @@ -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" >}} diff --git a/pkg/services/ngalert/api/api_admin.go b/pkg/services/ngalert/api/api_admin.go index e9113f54388..42c94562258 100644 --- a/pkg/services/ngalert/api/api_admin.go +++ b/pkg/services/ngalert/api/api_admin.go @@ -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" diff --git a/pkg/services/ngalert/models/admin_configuration.go b/pkg/services/ngalert/models/admin_configuration.go index a3323718150..80ae11d40a0 100644 --- a/pkg/services/ngalert/models/admin_configuration.go +++ b/pkg/services/ngalert/models/admin_configuration.go @@ -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 +} diff --git a/pkg/services/ngalert/models/admin_configuration_test.go b/pkg/services/ngalert/models/admin_configuration_test.go new file mode 100644 index 00000000000..ff932652cfc --- /dev/null +++ b/pkg/services/ngalert/models/admin_configuration_test.go @@ -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) + }) + } +} diff --git a/public/app/features/alerting/unified/Admin.tsx b/public/app/features/alerting/unified/Admin.tsx index 509674fc167..21a534cdca6 100644 --- a/public/app/features/alerting/unified/Admin.tsx +++ b/public/app/features/alerting/unified/Admin.tsx @@ -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 ( - - {loadingError && !loading && ( - - {loadingError.message || 'Unknown error.'} - - )} - {isDeleting && alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME && ( - - It might take a while... - - )} - {alertManagerSourceName && config && ( - - {({ register, errors }) => ( - <> - {!readOnly && ( - - { - try { - JSON.parse(v); - return true; - } catch (e) { - return e.message; - } - }, - })} - id="configuration" - rows={25} - /> - - )} - {readOnly && ( - - {defaultValues.configJSON} - - )} - {!readOnly && ( - - - Save - - setShowConfirmDeleteAMConfig(true)} - > - Reset configuration - - - )} - {!!showConfirmDeleteAMConfig && ( - setShowConfirmDeleteAMConfig(false)} - /> - )} - > - )} - - )} + + ); } diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts index 291de5fd49e..0ea54d1121b 100644 --- a/public/app/features/alerting/unified/api/alertmanager.ts +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -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 { + 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 { + const result = await lastValueFrom( + getBackendSrv().fetch({ + 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, '\\"'); } diff --git a/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx b/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx new file mode 100644 index 00000000000..82b258803bb --- /dev/null +++ b/public/app/features/alerting/unified/components/admin/AddAlertManagerModal.tsx @@ -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 = ({ alertmanagers, onClose }) => { + const styles = useStyles2(getStyles); + const dispatch = useDispatch(); + const defaultValues: Record = useMemo( + () => ({ + alertmanagers: alertmanagers, + }), + [alertmanagers] + ); + + const modalTitle = ( + + + Add Alertmanager + + ); + + const onSubmit = (values: Record) => { + dispatch(addExternalAlertmanagersAction(values.alertmanagers.map((am) => cleanAlertmanagerUrl(am.url)))); + onClose(); + }; + + return ( + + + We use a service discovery method to find existing Alertmanagers for a given URL. + + + {({ register, control, errors }) => ( + + + {({ fields, append, remove }) => ( + + Source url + + 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. + + {fields.map((field, index) => { + return ( + + remove(index)} + variant="destructive" + className={styles.destroyInputRow} + > + + + } + /> + + ); + })} + append({ url: '' })}> + Add URL + + + )} + + + onSubmit}>Add Alertmanagers + + + )} + + + ); +}; + +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)}; + `, + }; +}; diff --git a/public/app/features/alerting/unified/Admin.test.tsx b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx similarity index 90% rename from public/app/features/alerting/unified/Admin.test.tsx rename to public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx index 920d44ef36a..f38e41b6291 100644 --- a/public/app/features/alerting/unified/Admin.test.tsx +++ b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx @@ -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( - + ); @@ -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)); diff --git a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx new file mode 100644 index 00000000000..ec78d1c79c1 --- /dev/null +++ b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx @@ -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 ( + + + {loadingError && !loading && ( + + {loadingError.message || 'Unknown error.'} + + )} + {isDeleting && alertManagerSourceName !== GRAFANA_RULES_SOURCE_NAME && ( + + It might take a while... + + )} + {alertManagerSourceName && config && ( + + {({ register, errors }) => ( + <> + {!readOnly && ( + + { + try { + JSON.parse(v); + return true; + } catch (e) { + return e.message; + } + }, + })} + id="configuration" + rows={25} + /> + + )} + {readOnly && ( + + {defaultValues.configJSON} + + )} + {!readOnly && ( + + + Save + + setShowConfirmDeleteAMConfig(true)} + > + Reset configuration + + + )} + {!!showConfirmDeleteAMConfig && ( + setShowConfirmDeleteAMConfig(false)} + /> + )} + > + )} + + )} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css` + margin-bottom: ${theme.spacing(4)}; + `, +}); diff --git a/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx new file mode 100644 index 00000000000..9bb29cac46d --- /dev/null +++ b/public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx @@ -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 ( + + External Alertmanagers + + 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. + + + {!noAlertmanagers && ( + + Add Alertmanager + + )} + + {noAlertmanagers ? ( + + ) : ( + + + + Url + Status + Action + + + + {externalAlertManagers?.map((am, index) => { + return ( + + + {am.url} + {am.actualUrl ? ( + + + + ) : null} + + + + + + + + + + setDeleteModalState({ open: true, index })} + > + + + + + + ); + })} + + + )} + onDelete(deleteModalState.index)} + onDismiss={() => setDeleteModalState({ open: false, index: 0 })} + /> + {modalState.open && } + + ); +}; + +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``, +}); diff --git a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.ts b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.ts new file mode 100644 index 00000000000..823141fec9d --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.ts @@ -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', + }, + ]); + }); +}); diff --git a/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts b/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts new file mode 100644 index 00000000000..a4297f7b877 --- /dev/null +++ b/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts @@ -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]; +} diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts index 3e16ad34c7c..7a4ba99dd58 100644 --- a/public/app/features/alerting/unified/state/actions.ts +++ b/public/app/features/alerting/unified/state/actions.ts @@ -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 => { + 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 => { + return withAppEvents( + withSerializedError( + (async () => { + await addAlertManagers(alertManagerUrls); + thunkAPI.dispatch(fetchExternalAlertmanagersConfigAction()); + })() + ), + { + errorMessage: 'Failed adding alertmanagers', + successMessage: 'Alertmanagers updated', + } + ); + } +); diff --git a/public/app/features/alerting/unified/state/reducers.ts b/public/app/features/alerting/unified/state/reducers.ts index 25706cac90b..fd9636986ad 100644 --- a/public/app/features/alerting/unified/state/reducers.ts +++ b/public/app/features/alerting/unified/state/reducers.ts @@ -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; diff --git a/public/app/plugins/datasource/alertmanager/types.ts b/public/app/plugins/datasource/alertmanager/types.ts index 4a44690ebd1..9fe5511ee55 100644 --- a/public/app/plugins/datasource/alertmanager/types.ts +++ b/public/app/plugins/datasource/alertmanager/types.ts @@ -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',
{defaultValues.configJSON}