"s can not be child elements of "
" – which is what the description element wrapper is */}
+
+
+
+ {implementation && capitalize(implementation)}
+ {url && url}
+
+ {!receiving ? (
+ Not receiving Grafana managed alerts
+ ) : (
+ <>
+ {status === 'pending' && }
+ {status === 'active' && }
+ {status === 'dropped' && }
+ {status === 'inconclusive' && }
+ >
+ )}
+
+
+
+ {/* we'll use the "tags" area to append buttons and actions */}
+
+
+
+ {readOnly ? 'View configuration' : 'Edit configuration'}
+
+ {provisioned ? null : (
+ <>
+ {receiving ? (
+
+ Disable
+
+ ) : (
+
+ Enable
+
+ )}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.test.tsx b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.test.tsx
new file mode 100644
index 00000000000..26d3ce9a165
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.test.tsx
@@ -0,0 +1,106 @@
+import { waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { render } from 'test/test-utils';
+import { byRole, byTestId } from 'testing-library-selector';
+
+import { selectors } from '@grafana/e2e-selectors';
+import { AccessControlAction } from 'app/types';
+
+import { setupMswServer } from '../../mockApi';
+import { grantUserPermissions } from '../../mocks';
+import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
+
+import AlertmanagerConfig from './AlertmanagerConfig';
+import {
+ EXTERNAL_VANILLA_ALERTMANAGER_UID,
+ PROVISIONED_VANILLA_ALERTMANAGER_UID,
+ setupGrafanaManagedServer,
+ setupVanillaAlertmanagerServer,
+} from './__mocks__/server';
+
+const renderConfiguration = (
+ alertManagerSourceName: string,
+ { onDismiss = jest.fn(), onSave = jest.fn(), onReset = jest.fn() }
+) =>
+ render(
+
+
+
+ );
+
+const ui = {
+ resetButton: byRole('button', { name: /Reset/ }),
+ resetConfirmButton: byRole('button', { name: /Yes, reset configuration/ }),
+ saveButton: byRole('button', { name: /Save/ }),
+ cancelButton: byRole('button', { name: /Cancel/ }),
+ configInput: byTestId(selectors.components.CodeEditor.container),
+ readOnlyConfig: byTestId('readonly-config'),
+};
+
+describe('Alerting Settings', () => {
+ const server = setupMswServer();
+
+ beforeEach(() => {
+ setupGrafanaManagedServer(server);
+ grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingInstanceRead]);
+ });
+
+ it('should be able to reset alertmanager config', async () => {
+ const onReset = jest.fn();
+ renderConfiguration('grafana', { onReset });
+
+ await userEvent.click(await ui.resetButton.get());
+
+ await waitFor(() => {
+ expect(ui.resetConfirmButton.query()).toBeInTheDocument();
+ });
+
+ await userEvent.click(ui.resetConfirmButton.get());
+
+ await waitFor(() => expect(onReset).toHaveBeenCalled());
+ expect(onReset).toHaveBeenLastCalledWith('grafana');
+ });
+
+ it('should be able to cancel', async () => {
+ const onDismiss = jest.fn();
+ renderConfiguration('grafana', { onDismiss });
+
+ await userEvent.click(await ui.cancelButton.get());
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('vanilla Alertmanager', () => {
+ const server = setupMswServer();
+
+ beforeEach(() => {
+ setupVanillaAlertmanagerServer(server);
+ grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingInstanceRead]);
+ });
+
+ afterAll(() => {
+ jest.resetAllMocks();
+ });
+
+ it('should be read-only when using vanilla Prometheus Alertmanager', async () => {
+ renderConfiguration(EXTERNAL_VANILLA_ALERTMANAGER_UID, {});
+
+ expect(ui.cancelButton.get()).toBeInTheDocument();
+ expect(ui.saveButton.query()).not.toBeInTheDocument();
+ expect(ui.resetButton.query()).not.toBeInTheDocument();
+ });
+
+ it('should be read-only when provisioned Alertmanager', async () => {
+ renderConfiguration(PROVISIONED_VANILLA_ALERTMANAGER_UID, {});
+
+ expect(ui.cancelButton.get()).toBeInTheDocument();
+ expect(ui.saveButton.query()).not.toBeInTheDocument();
+ expect(ui.resetButton.query()).not.toBeInTheDocument();
+ });
+});
diff --git a/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx
new file mode 100644
index 00000000000..9177aff1c9e
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx
@@ -0,0 +1,199 @@
+import { css } from '@emotion/css';
+import React, { useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import AutoSizer from 'react-virtualized-auto-sizer';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import { Alert, Button, CodeEditor, ConfirmModal, Stack, useStyles2 } from '@grafana/ui';
+
+import { reportFormErrors } from '../../Analytics';
+import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig';
+import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
+import {
+ GRAFANA_RULES_SOURCE_NAME,
+ isProvisionedDataSource,
+ isVanillaPrometheusAlertManagerDataSource,
+} from '../../utils/datasource';
+import { Spacer } from '../Spacer';
+
+export interface FormValues {
+ configJSON: string;
+}
+
+interface Props {
+ alertmanagerName: string;
+ onDismiss: () => void;
+ onSave: (dataSourceName: string, oldConfig: string, newConfig: string) => void;
+ onReset: (dataSourceName: string) => void;
+}
+
+export default function AlertmanagerConfig({ alertmanagerName, onDismiss, onSave, onReset }: Props): JSX.Element {
+ const { loading: isDeleting, error: deletingError } = useUnifiedAlertingSelector((state) => state.deleteAMConfig);
+ const { loading: isSaving, error: savingError } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
+ const [showResetConfirmation, setShowResetConfirmation] = useState(false);
+
+ const immutableDataSource = alertmanagerName ? isVanillaPrometheusAlertManagerDataSource(alertmanagerName) : false;
+ const provisionedDataSource = isProvisionedDataSource(alertmanagerName);
+ const readOnly = immutableDataSource || provisionedDataSource;
+
+ const isGrafanaManagedAlertmanager = alertmanagerName === GRAFANA_RULES_SOURCE_NAME;
+ const styles = useStyles2(getStyles);
+
+ const {
+ currentData: config,
+ error: loadingError,
+ isSuccess: isLoadingSuccessful,
+ isLoading: isLoadingConfig,
+ } = useAlertmanagerConfig(alertmanagerName);
+
+ const defaultValues = {
+ configJSON: config ? JSON.stringify(config, null, 2) : '',
+ };
+
+ const {
+ register,
+ setValue,
+ setError,
+ handleSubmit,
+ formState: { errors },
+ } = useForm({
+ defaultValues,
+ });
+
+ // make sure we update the configJSON field when we receive a response from the `useAlertmanagerConfig` hook
+ useEffect(() => {
+ if (config) {
+ setValue('configJSON', JSON.stringify(config, null, 2));
+ }
+ }, [config, setValue]);
+
+ useEffect(() => {
+ if (savingError) {
+ setError('configJSON', { type: 'deps', message: savingError.message });
+ }
+ }, [savingError, setError]);
+
+ useEffect(() => {
+ if (deletingError) {
+ setError('configJSON', { type: 'deps', message: deletingError.message });
+ }
+ }, [deletingError, setError]);
+
+ // manually register the config field with validation
+ // @TODO sometimes the value doesn't get registered – find out why
+ register('configJSON', {
+ required: { value: true, message: 'Configuration cannot be empty' },
+ validate: (value: string) => {
+ try {
+ JSON.parse(value);
+ return true;
+ } catch (e) {
+ return e instanceof Error ? e.message : 'JSON is invalid';
+ }
+ },
+ });
+
+ const handleSave = handleSubmit((values: FormValues) => {
+ onSave(alertmanagerName, defaultValues.configJSON, values.configJSON);
+ }, reportFormErrors);
+
+ const isOperating = isLoadingConfig || isDeleting || isSaving;
+
+ /* loading error, if this fails don't bother rendering the form */
+ if (loadingError) {
+ return (
+
+ {loadingError.message ?? 'An unknown error occurred.'}
+
+ );
+ }
+
+ /* resetting configuration state */
+ if (isDeleting) {
+ return (
+
+ Resetting configuration, this might take a while.
+
+ );
+ }
+
+ const confirmationText = isGrafanaManagedAlertmanager
+ ? `Are you sure you want to reset configuration for the Grafana Alertmanager? Contact points and notification policies will be reset to their defaults.`
+ : `Are you sure you want to reset configuration for "${alertmanagerName}"? Contact points and notification policies will be reset to their defaults.`;
+
+ return (
+
+ {/* form error state */}
+ {errors.configJSON && (
+
+ {errors.configJSON.message || 'An unknown error occurred.'}
+
+ )}
+
+ {isLoadingSuccessful && (
+
+
+ {({ height, width }) => (
+ setValue('configJSON', value)}
+ onBlur={(value) => setValue('configJSON', value)}
+ readOnly={isOperating}
+ />
+ )}
+
+
+ )}
+
+
+ {!readOnly && (
+ setShowResetConfirmation(true)} disabled={isOperating}>
+ Reset
+
+ )}
+
+ onDismiss()} disabled={isOperating}>
+ Cancel
+
+ {!readOnly && (
+
+ Save
+
+ )}
+
+
{
+ onReset(alertmanagerName);
+ setShowResetConfirmation(false);
+ }}
+ onDismiss={() => {
+ setShowResetConfirmation(false);
+ }}
+ />
+
+ );
+}
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ container: css({
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ gap: theme.spacing(2),
+ }),
+ content: css({
+ flex: '1 1 100%',
+ }),
+});
diff --git a/public/app/features/alerting/unified/components/settings/ConfigurationDrawer.tsx b/public/app/features/alerting/unified/components/settings/ConfigurationDrawer.tsx
new file mode 100644
index 00000000000..92a8d587d3e
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/ConfigurationDrawer.tsx
@@ -0,0 +1,79 @@
+import React, { useCallback, useMemo, useState } from 'react';
+
+import { Drawer, Tab, TabsBar } from '@grafana/ui';
+
+import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
+
+import AlertmanagerConfig from './AlertmanagerConfig';
+import { useSettings } from './SettingsContext';
+import { AlertmanagerConfigurationVersionManager } from './VersionManager';
+
+type ActiveTab = 'configuration' | 'versions';
+
+export function useEditConfigurationDrawer() {
+ const [activeTab, setActiveTab] = useState('configuration');
+ const [dataSourceName, setDataSourceName] = useState();
+ const [open, setOpen] = useState(false);
+ const { updateAlertmanagerSettings, resetAlertmanagerSettings } = useSettings();
+
+ const showConfiguration = (dataSourceName: string) => {
+ setDataSourceName(dataSourceName);
+ setOpen(true);
+ };
+
+ const handleDismiss = useCallback(() => {
+ setActiveTab('configuration');
+ setOpen(false);
+ }, []);
+
+ const drawer = useMemo(() => {
+ if (!open) {
+ return null;
+ }
+
+ const isGrafanaAlertmanager = dataSourceName === GRAFANA_RULES_SOURCE_NAME;
+ const title = isGrafanaAlertmanager ? 'Internal Grafana Alertmanager' : dataSourceName;
+
+ // @todo check copy
+ return (
+
+ setActiveTab('configuration')}
+ />
+ setActiveTab('versions')}
+ hidden={!isGrafanaAlertmanager}
+ />
+
+ }
+ >
+ {activeTab === 'configuration' && dataSourceName && (
+
+ )}
+ {activeTab === 'versions' && dataSourceName && (
+
+ )}
+
+ );
+ }, [open, dataSourceName, handleDismiss, activeTab, updateAlertmanagerSettings, resetAlertmanagerSettings]);
+
+ return [drawer, showConfiguration, handleDismiss] as const;
+}
diff --git a/public/app/features/alerting/unified/components/settings/ExternalAlertmanagers.tsx b/public/app/features/alerting/unified/components/settings/ExternalAlertmanagers.tsx
new file mode 100644
index 00000000000..414cf4d1d95
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/ExternalAlertmanagers.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+
+import { Stack } from '@grafana/ui';
+import { DATASOURCES_ROUTES } from 'app/features/datasources/constants';
+import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
+
+import { ExternalAlertmanagerDataSourceWithStatus } from '../../hooks/useExternalAmSelector';
+import {
+ isAlertmanagerDataSourceInterestedInAlerts,
+ isVanillaPrometheusAlertManagerDataSource,
+} from '../../utils/datasource';
+import { createUrl } from '../../utils/url';
+
+import { AlertmanagerCard } from './AlertmanagerCard';
+import { useSettings } from './SettingsContext';
+
+interface Props {
+ onEditConfiguration: (dataSourceName: string) => void;
+}
+
+export const ExternalAlertmanagers = ({ onEditConfiguration }: Props) => {
+ const { externalAlertmanagerDataSourcesWithStatus, configuration, enableAlertmanager, disableAlertmanager } =
+ useSettings();
+
+ // determine if the alertmanger is receiving alerts
+ // this is true if Grafana is configured to send to either "both" or "external" and the Alertmanager datasource _wants_ to receive alerts.
+ const isReceivingGrafanaAlerts = (
+ externalDataSourceAlertmanager: ExternalAlertmanagerDataSourceWithStatus
+ ): boolean => {
+ const sendingToExternal = [AlertmanagerChoice.All, AlertmanagerChoice.External].some(
+ (choice) => configuration?.alertmanagersChoice === choice
+ );
+ const wantsAlertsReceived = isAlertmanagerDataSourceInterestedInAlerts(
+ externalDataSourceAlertmanager.dataSourceSettings
+ );
+
+ return sendingToExternal && wantsAlertsReceived;
+ };
+
+ return (
+
+ {externalAlertmanagerDataSourcesWithStatus.map((alertmanager) => {
+ const { uid, name, jsonData, url } = alertmanager.dataSourceSettings;
+ const { status } = alertmanager;
+
+ const isReceiving = isReceivingGrafanaAlerts(alertmanager);
+ const isProvisioned = alertmanager.dataSourceSettings.readOnly === true;
+ const isReadOnly =
+ isProvisioned || isVanillaPrometheusAlertManagerDataSource(alertmanager.dataSourceSettings.name);
+ const detailHref = createUrl(DATASOURCES_ROUTES.Edit.replace(/:uid/gi, uid));
+
+ const handleEditConfiguration = () => onEditConfiguration(name);
+
+ return (
+ disableAlertmanager(uid)}
+ onEnable={() => enableAlertmanager(uid)}
+ />
+ );
+ })}
+
+ );
+};
diff --git a/public/app/features/alerting/unified/components/settings/InternalAlertmanager.tsx b/public/app/features/alerting/unified/components/settings/InternalAlertmanager.tsx
new file mode 100644
index 00000000000..9c0275df305
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/InternalAlertmanager.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+
+import { ConnectionStatus } from '../../hooks/useExternalAmSelector';
+import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
+import { isInternalAlertmanagerInterestedInAlerts } from '../../utils/settings';
+
+import { AlertmanagerCard } from './AlertmanagerCard';
+import { useSettings } from './SettingsContext';
+
+interface Props {
+ onEditConfiguration: (dataSourceName: string) => void;
+}
+
+export default function InternalAlertmanager({ onEditConfiguration }: Props) {
+ const { configuration, enableAlertmanager, disableAlertmanager } = useSettings();
+
+ const isReceiving = isInternalAlertmanagerInterestedInAlerts(configuration);
+ const status: ConnectionStatus = isReceiving ? 'active' : 'uninterested';
+ const handleEditConfiguration = () => onEditConfiguration(GRAFANA_RULES_SOURCE_NAME);
+
+ return (
+ enableAlertmanager(GRAFANA_RULES_SOURCE_NAME)}
+ onDisable={() => disableAlertmanager(GRAFANA_RULES_SOURCE_NAME)}
+ />
+ );
+}
diff --git a/public/app/features/alerting/unified/components/settings/SettingsContext.tsx b/public/app/features/alerting/unified/components/settings/SettingsContext.tsx
new file mode 100644
index 00000000000..e69a6c71316
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/SettingsContext.tsx
@@ -0,0 +1,182 @@
+import { union, without, debounce } from 'lodash';
+import React, { PropsWithChildren, useEffect, useRef } from 'react';
+
+import { AppEvents } from '@grafana/data';
+import { getAppEvents } from '@grafana/runtime';
+import { AlertmanagerChoice, GrafanaAlertingConfiguration } from 'app/plugins/datasource/alertmanager/types';
+import { dispatch } from 'app/store/store';
+
+import { alertmanagerApi } from '../../api/alertmanagerApi';
+import { dataSourcesApi } from '../../api/dataSourcesApi';
+import {
+ ExternalAlertmanagerDataSourceWithStatus,
+ useExternalDataSourceAlertmanagers,
+} from '../../hooks/useExternalAmSelector';
+import { deleteAlertManagerConfigAction, updateAlertManagerConfigAction } from '../../state/actions';
+import { GRAFANA_RULES_SOURCE_NAME, isAlertmanagerDataSourceInterestedInAlerts } from '../../utils/datasource';
+import { isInternalAlertmanagerInterestedInAlerts } from '../../utils/settings';
+
+import { useEnableOrDisableHandlingGrafanaManagedAlerts } from './hooks';
+
+const appEvents = getAppEvents();
+
+interface Context {
+ configuration?: GrafanaAlertingConfiguration;
+ externalAlertmanagerDataSourcesWithStatus: ExternalAlertmanagerDataSourceWithStatus[];
+
+ isLoading: boolean;
+ isUpdating: boolean;
+
+ // for enabling / disabling Alertmanager datasources as additional receivers
+ enableAlertmanager: (uid: string) => void;
+ disableAlertmanager: (uid: string) => void;
+
+ // for updating or resetting the configuration for an Alertmanager
+ updateAlertmanagerSettings: (name: string, oldConfig: string, newConfig: string) => void;
+ resetAlertmanagerSettings: (name: string) => void;
+}
+
+const SettingsContext = React.createContext(undefined);
+const isInternalAlertmanager = (uid: string) => uid === GRAFANA_RULES_SOURCE_NAME;
+
+export const SettingsProvider = (props: PropsWithChildren) => {
+ // this list will keep track of Alertmanager UIDs (including internal) that are interested in receiving alert instances
+ // this will be used to infer the correct "delivery mode" and update the correct list of datasources with "wantsAlertsReceived"
+ let interestedAlertmanagers: string[] = [];
+
+ const { currentData: configuration, isLoading: isLoadingConfiguration } =
+ alertmanagerApi.endpoints.getGrafanaAlertingConfiguration.useQuery();
+
+ const [updateConfiguration, updateConfigurationState] =
+ alertmanagerApi.endpoints.updateGrafanaAlertingConfiguration.useMutation();
+ const [enableGrafanaManagedAlerts, disableGrafanaManagedAlerts, enableOrDisableHandlingGrafanaManagedAlertsState] =
+ useEnableOrDisableHandlingGrafanaManagedAlerts();
+
+ // we will alwayw refetch because a user could edit a data source and come back to this page
+ const externalAlertmanagersWithStatus = useExternalDataSourceAlertmanagers({ refetchOnMountOrArgChange: true });
+
+ const interestedInternal = isInternalAlertmanagerInterestedInAlerts(configuration);
+ if (interestedInternal) {
+ interestedAlertmanagers.push(GRAFANA_RULES_SOURCE_NAME);
+ }
+
+ externalAlertmanagersWithStatus
+ .filter((dataSource) => isAlertmanagerDataSourceInterestedInAlerts(dataSource.dataSourceSettings))
+ .forEach((alertmanager) => {
+ interestedAlertmanagers.push(alertmanager.dataSourceSettings.uid);
+ });
+
+ const enableAlertmanager = (uid: string) => {
+ const updatedInterestedAlertmanagers = union([uid], interestedAlertmanagers); // union will give us a unique array of uids
+ const newDeliveryMode = determineDeliveryMode(updatedInterestedAlertmanagers);
+ if (newDeliveryMode === null) {
+ return;
+ }
+
+ if (newDeliveryMode !== configuration?.alertmanagersChoice) {
+ updateConfiguration({ alertmanagersChoice: newDeliveryMode });
+ }
+
+ if (!isInternalAlertmanager(uid)) {
+ enableGrafanaManagedAlerts(uid);
+ }
+ };
+
+ const disableAlertmanager = (uid: string) => {
+ const updatedInterestedAlertmanagers = without(interestedAlertmanagers, uid);
+ const newDeliveryMode = determineDeliveryMode(updatedInterestedAlertmanagers);
+ if (newDeliveryMode === null) {
+ return;
+ }
+
+ if (newDeliveryMode !== configuration?.alertmanagersChoice) {
+ updateConfiguration({ alertmanagersChoice: newDeliveryMode });
+ }
+
+ if (!isInternalAlertmanager(uid)) {
+ disableGrafanaManagedAlerts(uid);
+ }
+ };
+
+ const updateAlertmanagerSettings = (alertManagerName: string, oldConfig: string, newConfig: string): void => {
+ dispatch(
+ updateAlertManagerConfigAction({
+ newConfig: JSON.parse(newConfig),
+ oldConfig: JSON.parse(oldConfig),
+ alertManagerSourceName: alertManagerName,
+ successMessage: 'Alertmanager configuration updated.',
+ })
+ );
+ };
+
+ const resetAlertmanagerSettings = (alertmanagerName: string) => {
+ dispatch(deleteAlertManagerConfigAction(alertmanagerName));
+ };
+
+ const value: Context = {
+ configuration,
+ externalAlertmanagerDataSourcesWithStatus: externalAlertmanagersWithStatus,
+ enableAlertmanager,
+ disableAlertmanager,
+ isLoading: isLoadingConfiguration,
+ isUpdating: updateConfigurationState.isLoading || enableOrDisableHandlingGrafanaManagedAlertsState.isLoading,
+
+ // CRUD for Alertmanager settings
+ updateAlertmanagerSettings,
+ resetAlertmanagerSettings,
+ };
+
+ return {props.children} ;
+};
+
+function determineDeliveryMode(interestedAlertmanagers: string[]): AlertmanagerChoice | null {
+ const containsInternalAlertmanager = interestedAlertmanagers.some((uid) => uid === GRAFANA_RULES_SOURCE_NAME);
+ const containsExternalAlertmanager = interestedAlertmanagers.some((uid) => uid !== GRAFANA_RULES_SOURCE_NAME);
+
+ if (containsInternalAlertmanager && containsExternalAlertmanager) {
+ return AlertmanagerChoice.All;
+ }
+
+ if (!containsInternalAlertmanager && containsExternalAlertmanager) {
+ return AlertmanagerChoice.External;
+ }
+
+ if (containsInternalAlertmanager && !containsExternalAlertmanager) {
+ return AlertmanagerChoice.Internal;
+ }
+
+ // if we get here we probably have no targets at all and that's not supposed to be possible.
+ appEvents.publish({
+ type: AppEvents.alertError.name,
+ payload: ['You need to have at least one Alertmanager to receive alerts.'],
+ });
+
+ return null;
+}
+
+export function useSettings() {
+ const context = React.useContext(SettingsContext);
+
+ if (context === undefined) {
+ throw new Error('useSettings must be used within a SettingsContext');
+ }
+
+ // we'll automatically re-fetch the Alertmanager connection status while any Alertmanagers are pending by invalidating the cache entry
+ const debouncedUpdateStatus = debounce(() => {
+ dispatch(dataSourcesApi.util.invalidateTags(['AlertmanagerConnectionStatus']));
+ }, 3000);
+ const refetchAlertmanagerConnectionStatus = useRef(debouncedUpdateStatus);
+
+ const hasPendingAlertmanagers = context.externalAlertmanagerDataSourcesWithStatus.some(
+ ({ status }) => status === 'pending'
+ );
+ if (hasPendingAlertmanagers) {
+ refetchAlertmanagerConnectionStatus.current();
+ }
+
+ useEffect(() => {
+ debouncedUpdateStatus.cancel();
+ }, [debouncedUpdateStatus]);
+
+ return context;
+}
diff --git a/public/app/features/alerting/unified/components/settings/VersionManager.tsx b/public/app/features/alerting/unified/components/settings/VersionManager.tsx
new file mode 100644
index 00000000000..d84b00185b8
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/VersionManager.tsx
@@ -0,0 +1,324 @@
+import { css } from '@emotion/css';
+import { chain, omit } from 'lodash';
+import moment from 'moment';
+import React, { useState } from 'react';
+
+import { GrafanaTheme2 } from '@grafana/data';
+import {
+ Alert,
+ Badge,
+ Button,
+ CellProps,
+ Column,
+ ConfirmModal,
+ InteractiveTable,
+ Stack,
+ Text,
+ useStyles2,
+} from '@grafana/ui';
+import { DiffViewer } from 'app/features/dashboard-scene/settings/version-history/DiffViewer';
+import { jsonDiff } from 'app/features/dashboard-scene/settings/version-history/utils';
+import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
+
+import { alertmanagerApi } from '../../api/alertmanagerApi';
+import { stringifyErrorLike } from '../../utils/misc';
+import { Spacer } from '../Spacer';
+
+const VERSIONS_PAGE_SIZE = 30;
+
+interface AlertmanagerConfigurationVersionManagerProps {
+ alertmanagerName: string;
+}
+
+type Diff = {
+ added: number;
+ removed: number;
+};
+
+type VersionData = {
+ id: string;
+ lastAppliedAt: string;
+ diff: Diff;
+};
+
+interface ConfigWithDiff extends AlertManagerCortexConfig {
+ diff: Diff;
+}
+
+const AlertmanagerConfigurationVersionManager = ({
+ alertmanagerName,
+}: AlertmanagerConfigurationVersionManagerProps) => {
+ // we'll track the ID of the version we want to restore
+ const [activeRestoreVersion, setActiveRestoreVersion] = useState(undefined);
+ const [confirmRestore, setConfirmRestore] = useState(false);
+
+ // in here we'll track the configs we are comparing
+ const [activeComparison, setActiveComparison] = useState<[left: string, right: string] | undefined>(undefined);
+
+ const {
+ currentData: historicalConfigs = [],
+ isLoading,
+ error,
+ } = alertmanagerApi.endpoints.getAlertmanagerConfigurationHistory.useQuery(undefined);
+
+ const [resetAlertManagerConfigToOldVersion, restoreVersionState] =
+ alertmanagerApi.endpoints.resetAlertmanagerConfigurationToOldVersion.useMutation();
+
+ const showConfirmation = () => {
+ setConfirmRestore(true);
+ };
+
+ const hideConfirmation = () => {
+ setConfirmRestore(false);
+ };
+
+ const restoreVersion = (id: number) => {
+ setActiveComparison(undefined);
+ setActiveRestoreVersion(undefined);
+
+ resetAlertManagerConfigToOldVersion({ id });
+ };
+
+ if (error) {
+ return {stringifyErrorLike(error)} ;
+ }
+
+ if (isLoading) {
+ return 'Loading...';
+ }
+
+ if (!historicalConfigs.length) {
+ return 'No previous configurations';
+ }
+
+ // with this function we'll compute the diff with the previous version; that way the user can get some idea of how many lines where changed in each update that was applied
+ const previousVersions: ConfigWithDiff[] = historicalConfigs.map((config, index) => {
+ const latestConfig = historicalConfigs[0];
+ const priorConfig = historicalConfigs[index];
+
+ return {
+ ...config,
+ diff: priorConfig ? computeConfigDiff(config, latestConfig) : { added: 0, removed: 0 },
+ };
+ });
+
+ const rows: VersionData[] = previousVersions.map((version) => ({
+ id: String(version.id ?? 0),
+ lastAppliedAt: version.last_applied ?? 'unknown',
+ diff: version.diff,
+ }));
+
+ const columns: Array> = [
+ {
+ id: 'lastAppliedAt',
+ header: 'Last applied',
+ cell: LastAppliedCell,
+ },
+ {
+ id: 'diff',
+ disableGrow: true,
+ cell: ({ row, value }) => {
+ const isLatestConfiguration = row.index === 0;
+ if (isLatestConfiguration) {
+ return null;
+ }
+
+ return (
+
+
+ +{value.added}
+
+
+ -{value.removed}
+
+
+ );
+ },
+ },
+ {
+ id: 'actions',
+ disableGrow: true,
+ cell: ({ row }) => {
+ const isFirstItem = row.index === 0;
+ const versionID = Number(row.id);
+
+ return (
+
+ {isFirstItem ? (
+
+ ) : (
+ <>
+ {
+ const latestConfiguration = historicalConfigs[0];
+ const historicalConfiguration = historicalConfigs[row.index];
+
+ const left = normalizeConfig(latestConfiguration);
+ const right = normalizeConfig(historicalConfiguration);
+
+ setActiveRestoreVersion(versionID);
+ setActiveComparison([JSON.stringify(left, null, 2), JSON.stringify(right, null, 2)]);
+ }}
+ >
+ Compare
+
+ {
+ setActiveRestoreVersion(versionID);
+ showConfirmation();
+ }}
+ disabled={restoreVersionState.isLoading}
+ >
+ Restore
+
+ >
+ )}
+
+ );
+ },
+ },
+ ];
+
+ if (restoreVersionState.isLoading) {
+ return (
+
+ This might take a while...
+
+ );
+ }
+
+ return (
+ <>
+ {activeComparison ? (
+ {
+ setActiveRestoreVersion(undefined);
+ setActiveComparison(undefined);
+ hideConfirmation();
+ }}
+ onConfirm={() => {
+ showConfirmation();
+ }}
+ />
+ ) : (
+ row.id} />
+ )}
+ {/* TODO make this modal persist while restore is in progress */}
+ {
+ if (activeRestoreVersion) {
+ restoreVersion(activeRestoreVersion);
+ }
+
+ hideConfirmation();
+ }}
+ onDismiss={() => hideConfirmation()}
+ />
+ >
+ );
+};
+
+interface CompareVersionsProps {
+ left: string;
+ right: string;
+
+ disabled?: boolean;
+ onCancel: () => void;
+ onConfirm: () => void;
+}
+
+function CompareVersions({ left, right, disabled = false, onCancel, onConfirm }: CompareVersionsProps) {
+ const styles = useStyles2(getStyles);
+
+ return (
+
+
+ {/*
+ we're hiding the line numbers because the historical snapshots will have certain parts of the config hidden (ex. auto-generated policies)
+ so the line numbers will not match up with what you can see in the JSON modal tab
+ */}
+
+
+
+
+
+ Return
+
+
+ Restore
+
+
+
+ );
+}
+
+const LastAppliedCell = ({ value }: CellProps) => {
+ const date = moment(value);
+
+ return (
+
+ {date.toLocaleString()}
+
+ {date.fromNow()}
+
+
+ );
+};
+
+const getStyles = (theme: GrafanaTheme2) => ({
+ drawerWrapper: css({
+ maxHeight: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(1),
+ }),
+ diffWrapper: css({
+ overflowY: 'auto',
+ }),
+});
+
+// these props are part of the historical config response but not the current config, so we remove them for fair comparison
+function normalizeConfig(config: AlertManagerCortexConfig) {
+ return omit(config, ['id', 'last_applied']);
+}
+
+function computeConfigDiff(json1: AlertManagerCortexConfig, json2: AlertManagerCortexConfig): Diff {
+ const cleanedJson1 = normalizeConfig(json1);
+ const cleanedJson2 = normalizeConfig(json2);
+
+ const diff = jsonDiff(cleanedJson1, cleanedJson2);
+ const added = chain(diff)
+ .values()
+ .flatMap()
+ .filter((operation) => operation.op === 'add' || operation.op === 'replace' || operation.op === 'move')
+ .sumBy((operation) => operation.endLineNumber - operation.startLineNumber + 1)
+ .value();
+
+ const removed = chain(diff)
+ .values()
+ .flatMap()
+ .filter((operation) => operation.op === 'remove' || operation.op === 'replace')
+ .sumBy((operation) => operation.endLineNumber - operation.startLineNumber + 1)
+ .value();
+
+ return {
+ added,
+ removed,
+ };
+}
+
+export { AlertmanagerConfigurationVersionManager };
diff --git a/public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/grafana/config/api/v1/alerts.json b/public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/grafana/config/api/v1/alerts.json
new file mode 100644
index 00000000000..9e805ce4971
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/grafana/config/api/v1/alerts.json
@@ -0,0 +1,30 @@
+{
+ "template_files": {
+ "foobar": "{{ define \"foobar\" }}\n some content blabla \n{{ end }}",
+ "foobar (carbon copy)": "{{ define \"foobar_NEW_1688861410267\" }}\n some content blabla \n{{ end }}",
+ "palantir": "{{ define \"palantir\" -}}\n{{- range .Alerts }}[{{.Status}}] {{ .Labels.alertname }}\n\nLabels:\n{{- range .Labels.SortedPairs }}\n {{ .Name }}: {{ .Value }}\n{{- end }}\n\n{{- if gt (len .Annotations) 0 }}\nAnnotations:\n{{- range .Annotations.SortedPairs }}\n {{ .Name }}: {{ .Value }}\n{{- end }}\n{{- end }} \n\nClick here to go to the detail view:\nhttp://localhost:3000/alerting/grafana-cloud/{{ urlquery .Labels.alertname }}/find?group={{ urlquery .Labels.group }}\u0026namespace={{ urlquery .Labels.namespace }}\n{{- end }}\n{{ end }}\n\n{{ define \"another one\" -}}\n This is another template, because notification templates can have... multiple templates :)\n{{- end -}}",
+ "palantir (broken)": "{{ define \"palantir (broken)\" }}\n {{- range .Alerts }}[{{.Status}}] {{ .Labels.alertname }}\n \n Labels:\n {{- range .Labels.SortedPairs }}\n {{ .Name }}: {{ .Value }}\n {{- end }}\n \n {{- if gt (len .Annotations) 0 }}\n Annotations:\n {{- range .Annotations.SortedPairs }}\n {{ .Name }}: {{ .Value }}\n {{- end }}\n {{- end }} \n \n Click here to go to the detail view:\n http://localhost:3000/alerting/grafana-cloud/{{ urlquery .Labels.alertname }}/find?group={{ urlquery .Labels.group }}\u0026namespace={{ urlquery .Labels.namespace }}\n {{- end }}\n{{ end }}\n\n{{ define \"test (broken)\" }}hello, world! {{ end }}"
+ },
+ "template_file_provenances": { "tmpl-2t3CCncOiC22VslgYhNsS4FFWQODEXsu": "api" },
+ "alertmanager_config": {
+ "route": {
+ "receiver": "grafana-default-email",
+ "group_by": ["grafana_folder", "alertname"],
+ "routes": [
+ {
+ "receiver": "grafana-default-email",
+ "object_matchers": [["__grafana_autogenerated__", "=", "true"]],
+ "routes": []
+ }
+ ]
+ },
+ "mute_time_intervals": [],
+ "templates": [],
+ "muteTimeProvenances": {},
+ "receivers": [
+ {
+ "name": "grafana-default-email"
+ }
+ ]
+ }
+}
diff --git a/public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/grafana/config/history.json b/public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/grafana/config/history.json
new file mode 100644
index 00000000000..868b2c6bc4d
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/grafana/config/history.json
@@ -0,0 +1,16 @@
+[
+ {
+ "id": 13648072,
+ "template_files": {},
+ "template_file_provenances": {},
+ "alertmanager_config": {},
+ "last_applied": "2024-04-25T15:31:48.000Z"
+ },
+ {
+ "id": 13648071,
+ "template_files": {},
+ "template_file_provenances": {},
+ "alertmanager_config": {},
+ "last_applied": "2024-04-25T15:27:25.000Z"
+ }
+]
diff --git a/public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/vanilla prometheus/api/v2/status.json b/public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/vanilla prometheus/api/v2/status.json
new file mode 100644
index 00000000000..72a43850683
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/vanilla prometheus/api/v2/status.json
@@ -0,0 +1,23 @@
+{
+ "cluster": {
+ "name": "01HWZ568JJWWJJNAME4MKKQ987",
+ "peers": [{ "address": "172.18.0.7:9094", "name": "01HWZ568JJWWJJNAME4MKKQ987" }],
+ "status": "ready"
+ },
+ "config": {
+ "global": {},
+ "route": {},
+ "inhibit_rules": [],
+ "templates": [],
+ "receivers": []
+ },
+ "uptime": "2024-05-03T11:59:46.775Z",
+ "versionInfo": {
+ "branch": "HEAD",
+ "buildDate": "20240228-11:47:50",
+ "buildUser": "root@2024b1e0f6e3",
+ "goVersion": "go1.21.7",
+ "revision": "0aa3c2aad14cff039931923ab16b26b7481783b5",
+ "version": "0.27.0"
+ }
+}
diff --git a/public/app/features/alerting/unified/components/settings/__mocks__/api/datasources.json b/public/app/features/alerting/unified/components/settings/__mocks__/api/datasources.json
new file mode 100644
index 00000000000..a31e1fdc10e
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/__mocks__/api/datasources.json
@@ -0,0 +1,42 @@
+[
+ {
+ "id": 183,
+ "uid": "xPVD2XISz",
+ "orgId": 1,
+ "name": "Mimir-based Alertmanager",
+ "type": "alertmanager",
+ "typeName": "Alertmanager",
+ "typeLogoUrl": "public/app/plugins/datasource/prometheus/img/prometheus_logo.svg",
+ "access": "proxy",
+ "url": "http://foo.bar:9090/",
+ "user": "",
+ "database": "",
+ "basicAuth": false,
+ "isDefault": false,
+ "jsonData": {
+ "httpMethod": "POST",
+ "implementation": "mimir"
+ },
+ "readOnly": false
+ },
+ {
+ "id": 160,
+ "uid": "iETbvsT4z",
+ "orgId": 1,
+ "name": "Vanilla Alertmanager",
+ "type": "alertmanager",
+ "typeName": "Alertmanager",
+ "typeLogoUrl": "public/app/plugins/datasource/alertmanager/img/logo.svg",
+ "access": "proxy",
+ "url": "http://localhost:9093",
+ "user": "",
+ "database": "",
+ "basicAuth": false,
+ "isDefault": false,
+ "jsonData": {
+ "handleGrafanaManagedAlerts": false,
+ "implementation": "prometheus"
+ },
+ "readOnly": false
+ }
+]
diff --git a/public/app/features/alerting/unified/components/settings/__mocks__/api/v1/ngalert/admin_config.json b/public/app/features/alerting/unified/components/settings/__mocks__/api/v1/ngalert/admin_config.json
new file mode 100644
index 00000000000..4d71563da99
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/__mocks__/api/v1/ngalert/admin_config.json
@@ -0,0 +1,3 @@
+{
+ "alertmanagersChoice": "internal"
+}
diff --git a/public/app/features/alerting/unified/components/settings/__mocks__/api/v1/ngalert/alertmanagers.json b/public/app/features/alerting/unified/components/settings/__mocks__/api/v1/ngalert/alertmanagers.json
new file mode 100644
index 00000000000..fb2053e98eb
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/__mocks__/api/v1/ngalert/alertmanagers.json
@@ -0,0 +1,7 @@
+{
+ "data": {
+ "activeAlertmanagers": [],
+ "droppedAlertmagers": []
+ },
+ "status": "success"
+}
diff --git a/public/app/features/alerting/unified/components/settings/__mocks__/server.ts b/public/app/features/alerting/unified/components/settings/__mocks__/server.ts
new file mode 100644
index 00000000000..0a64f290eac
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/__mocks__/server.ts
@@ -0,0 +1,104 @@
+import { delay, http, HttpResponse } from 'msw';
+import { SetupServerApi } from 'msw/lib/node';
+
+import { setDataSourceSrv } from '@grafana/runtime';
+import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
+
+import { mockDataSource, MockDataSourceSrv } from '../../../mocks';
+import * as config from '../../../utils/config';
+import { DataSourceType } from '../../../utils/datasource';
+
+import internalAlertmanagerConfig from './api/alertmanager/grafana/config/api/v1/alerts.json';
+import history from './api/alertmanager/grafana/config/history.json';
+import vanillaAlertmanagerConfig from './api/alertmanager/vanilla prometheus/api/v2/status.json';
+import datasources from './api/datasources.json';
+import admin_config from './api/v1/ngalert/admin_config.json';
+import alertmanagers from './api/v1/ngalert/alertmanagers.json';
+
+export { datasources as DataSourcesResponse };
+export { admin_config as AdminConfigResponse };
+export { alertmanagers as AlertmanagersResponse };
+export { internalAlertmanagerConfig as InternalAlertmanagerConfiguration };
+export { vanillaAlertmanagerConfig as VanillaAlertmanagerConfiguration };
+export { history as alertmanagerConfigurationHistory };
+
+export const EXTERNAL_VANILLA_ALERTMANAGER_UID = 'vanilla-alertmanager';
+export const PROVISIONED_VANILLA_ALERTMANAGER_UID = 'provisioned-alertmanager';
+
+jest.spyOn(config, 'getAllDataSources');
+
+const mocks = {
+ getAllDataSources: jest.mocked(config.getAllDataSources),
+};
+
+const mockDataSources = {
+ [EXTERNAL_VANILLA_ALERTMANAGER_UID]: mockDataSource({
+ uid: EXTERNAL_VANILLA_ALERTMANAGER_UID,
+ name: EXTERNAL_VANILLA_ALERTMANAGER_UID,
+ type: DataSourceType.Alertmanager,
+ jsonData: {
+ implementation: AlertManagerImplementation.prometheus,
+ },
+ }),
+ [PROVISIONED_VANILLA_ALERTMANAGER_UID]: mockDataSource({
+ uid: PROVISIONED_VANILLA_ALERTMANAGER_UID,
+ name: PROVISIONED_VANILLA_ALERTMANAGER_UID,
+ type: DataSourceType.Alertmanager,
+ jsonData: {
+ // this is a mutable data source type but we're making it readOnly
+ implementation: AlertManagerImplementation.mimir,
+ },
+ readOnly: true,
+ }),
+};
+
+export function setupGrafanaManagedServer(server: SetupServerApi) {
+ server.use(
+ createAdminConfigHandler(),
+ createExternalAlertmanagersHandler(),
+ createAlertmanagerDataSourcesHandler(),
+ ...createAlertmanagerConfigurationHandlers(),
+ createAlertmanagerHistoryHandler()
+ );
+
+ return server;
+}
+
+export function setupVanillaAlertmanagerServer(server: SetupServerApi) {
+ mocks.getAllDataSources.mockReturnValue(Object.values(mockDataSources));
+ setDataSourceSrv(new MockDataSourceSrv(mockDataSources));
+
+ server.use(
+ createVanillaAlertmanagerConfigurationHandler(EXTERNAL_VANILLA_ALERTMANAGER_UID),
+ ...createAlertmanagerConfigurationHandlers(PROVISIONED_VANILLA_ALERTMANAGER_UID)
+ );
+
+ return server;
+}
+
+const createAdminConfigHandler = () => http.get('/api/v1/ngalert/admin_config', () => HttpResponse.json(admin_config));
+
+const createExternalAlertmanagersHandler = () => {
+ return http.get('/api/v1/ngalert/alertmanagers', () => HttpResponse.json(alertmanagers));
+};
+
+const createAlertmanagerConfigurationHandlers = (name = 'grafana') => {
+ return [
+ http.get(`/api/alertmanager/${name}/config/api/v1/alerts`, () => HttpResponse.json(internalAlertmanagerConfig)),
+ http.post(`/api/alertmanager/${name}/config/api/v1/alerts`, async () => {
+ await delay(1000); // simulate some time
+ return HttpResponse.json({ message: 'configuration created' });
+ }),
+ ];
+};
+
+const createAlertmanagerDataSourcesHandler = () => http.get('/api/datasources', () => HttpResponse.json(datasources));
+const createAlertmanagerHistoryHandler = (name = 'grafana') =>
+ http.get(`/api/alertmanager/${name}/config/history`, () => HttpResponse.json(history));
+
+const createVanillaAlertmanagerConfigurationHandler = (dataSourceUID: string) =>
+ http.get(`/api/alertmanager/${dataSourceUID}/api/v2/status`, () => HttpResponse.json(vanillaAlertmanagerConfig));
+
+export const withExternalOnlySetting = (server: SetupServerApi) => {
+ server.use(createAdminConfigHandler());
+};
diff --git a/public/app/features/alerting/unified/components/settings/hooks.tsx b/public/app/features/alerting/unified/components/settings/hooks.tsx
new file mode 100644
index 00000000000..764074dd99d
--- /dev/null
+++ b/public/app/features/alerting/unified/components/settings/hooks.tsx
@@ -0,0 +1,34 @@
+import { produce } from 'immer';
+
+import { dataSourcesApi } from '../../api/dataSourcesApi';
+import { isAlertmanagerDataSource } from '../../utils/datasource';
+
+export const useEnableOrDisableHandlingGrafanaManagedAlerts = () => {
+ const [getSettings, getSettingsState] = dataSourcesApi.endpoints.getDataSourceSettingsForUID.useLazyQuery();
+ const [updateSettings, updateSettingsState] = dataSourcesApi.endpoints.updateDataSourceSettingsForUID.useMutation();
+
+ const enableOrDisable = async (uid: string, handleGrafanaManagedAlerts: boolean) => {
+ const existingSettings = await getSettings(uid).unwrap();
+ if (!isAlertmanagerDataSource(existingSettings)) {
+ throw new Error(`Data source with UID ${uid} is not an Alertmanager data source`);
+ }
+
+ const newSettings = produce(existingSettings, (draft) => {
+ draft.jsonData.handleGrafanaManagedAlerts = handleGrafanaManagedAlerts;
+ });
+
+ updateSettings({ uid, settings: newSettings });
+ };
+
+ const enable = (uid: string) => enableOrDisable(uid, true);
+ const disable = (uid: string) => enableOrDisable(uid, false);
+
+ const loadingState = {
+ isLoading: getSettingsState.isLoading || updateSettingsState.isLoading,
+ isError: getSettingsState.isError || updateSettingsState.isError,
+ error: getSettingsState.error || updateSettingsState.error,
+ data: updateSettingsState.data,
+ };
+
+ return [enable, disable, loadingState] as const;
+};
diff --git a/public/app/features/alerting/unified/hooks/useAbilities.test.tsx b/public/app/features/alerting/unified/hooks/useAbilities.test.tsx
index 83b076bc2c6..6b4158a275b 100644
--- a/public/app/features/alerting/unified/hooks/useAbilities.test.tsx
+++ b/public/app/features/alerting/unified/hooks/useAbilities.test.tsx
@@ -6,7 +6,7 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { mockFolderApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
import {
- defaultAlertmanagerChoiceResponse,
+ defaultGrafanaAlertingConfigurationStatusResponse,
mockAlertmanagerChoiceResponse,
} from 'app/features/alerting/unified/mocks/alertmanagerApi';
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
@@ -154,7 +154,7 @@ describe('AlertRule abilities', () => {
accessControl: { [AccessControlAction.AlertingRuleUpdate]: false },
})
);
- mockAlertmanagerChoiceResponse(server, defaultAlertmanagerChoiceResponse);
+ mockAlertmanagerChoiceResponse(server, defaultGrafanaAlertingConfigurationStatusResponse);
const abilities = renderHook(() => useAllAlertRuleAbilities(rule), { wrapper: TestProvider });
diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts
index 9ba6999239c..93ce5d66c72 100644
--- a/public/app/features/alerting/unified/hooks/useAbilities.ts
+++ b/public/app/features/alerting/unified/hooks/useAbilities.ts
@@ -277,10 +277,10 @@ export function useAlertmanagerAbilities(actions: AlertmanagerAction[]): Ability
function useCanSilence(rulesSource: RulesSource): [boolean, boolean] {
const isGrafanaManagedRule = rulesSource === GRAFANA_RULES_SOURCE_NAME;
- const { useGetAlertmanagerChoiceStatusQuery } = alertmanagerApi;
- const { currentData: amConfigStatus, isLoading } = useGetAlertmanagerChoiceStatusQuery(undefined, {
- skip: !isGrafanaManagedRule,
- });
+ const { currentData: amConfigStatus, isLoading } =
+ alertmanagerApi.endpoints.getGrafanaAlertingConfigurationStatus.useQuery(undefined, {
+ skip: !isGrafanaManagedRule,
+ });
// we don't support silencing when the rule is not a Grafana managed rule
// we simply don't know what Alertmanager the ruler is sending alerts to
diff --git a/public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts b/public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts
index 05067599dde..c926c4649fa 100644
--- a/public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts
+++ b/public/app/features/alerting/unified/hooks/useAlertmanagerConfig.ts
@@ -11,6 +11,8 @@ type Options = {
// and remove this hook since it adds little value
export function useAlertmanagerConfig(amSourceName?: string, options?: Options) {
const fetchConfig = alertmanagerApi.endpoints.getAlertmanagerConfiguration.useQuery(amSourceName ?? '', {
+ // we'll disable cache by default to prevent overwriting other changes made since last fetch
+ refetchOnMountOrArgChange: true,
...options,
skip: !amSourceName,
});
diff --git a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx
index d4d892c0ed2..c0d687a0883 100644
--- a/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx
+++ b/public/app/features/alerting/unified/hooks/useExternalAMSelector.test.tsx
@@ -10,7 +10,7 @@ import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmana
import { mockAlertmanagersResponse } from '../mocks/alertmanagerApi';
-import { useExternalDataSourceAlertmanagers } from './useExternalAmSelector';
+import { normalizeDataSourceURL, useExternalDataSourceAlertmanagers } from './useExternalAmSelector';
const server = setupServer();
@@ -196,6 +196,38 @@ describe('useExternalDataSourceAlertmanagers', () => {
});
});
+describe('normalizeDataSourceURL', () => {
+ it('should add "http://" protocol if missing', () => {
+ const url = 'example.com';
+ const normalizedURL = normalizeDataSourceURL(url);
+ expect(normalizedURL).toBe('http://example.com');
+ });
+
+ it('should not modify the URL if it already has a protocol', () => {
+ const url = 'https://example.com';
+ const normalizedURL = normalizeDataSourceURL(url);
+ expect(normalizedURL).toBe(url);
+ });
+
+ it('should remove trailing slashes from the URL', () => {
+ const url = 'http://example.com/';
+ const normalizedURL = normalizeDataSourceURL(url);
+ expect(normalizedURL).toBe('http://example.com');
+ });
+
+ it('should remove multiple trailing slashes from the URL', () => {
+ const url = 'http://example.com///';
+ const normalizedURL = normalizeDataSourceURL(url);
+ expect(normalizedURL).toBe('http://example.com');
+ });
+
+ it('should keep paths from the URL', () => {
+ const url = 'http://example.com/foo//';
+ const normalizedURL = normalizeDataSourceURL(url);
+ expect(normalizedURL).toBe('http://example.com/foo');
+ });
+});
+
function setupAlertmanagerDataSource(
server: SetupServer,
partialDsSettings?: Partial>
diff --git a/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts b/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts
index 8cbe7228698..df70d9eba82 100644
--- a/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts
+++ b/public/app/features/alerting/unified/hooks/useExternalAmSelector.ts
@@ -1,24 +1,36 @@
import { DataSourceSettings } from '@grafana/data';
-import { AlertManagerDataSourceJsonData, ExternalAlertmanagers } from 'app/plugins/datasource/alertmanager/types';
+import {
+ AlertManagerDataSourceJsonData,
+ ExternalAlertmanagersConnectionStatus,
+} from 'app/plugins/datasource/alertmanager/types';
import { alertmanagerApi } from '../api/alertmanagerApi';
import { dataSourcesApi } from '../api/dataSourcesApi';
import { isAlertmanagerDataSource } from '../utils/datasource';
-type ConnectionStatus = 'active' | 'pending' | 'dropped' | 'inconclusive' | 'uninterested' | 'unknown';
+export type ConnectionStatus = 'active' | 'pending' | 'dropped' | 'inconclusive' | 'uninterested' | 'unknown';
export interface ExternalAlertmanagerDataSourceWithStatus {
dataSourceSettings: DataSourceSettings;
status: ConnectionStatus;
}
+interface UseExternalDataSourceAlertmanagersProps {
+ refetchOnMountOrArgChange?: boolean;
+}
+
/**
* Returns all configured Alertmanager data sources and their connection status with the internal ruler
*/
-export function useExternalDataSourceAlertmanagers(): ExternalAlertmanagerDataSourceWithStatus[] {
+export function useExternalDataSourceAlertmanagers({
+ refetchOnMountOrArgChange = false,
+}: UseExternalDataSourceAlertmanagersProps = {}): ExternalAlertmanagerDataSourceWithStatus[] {
// firstly we'll fetch the settings for all datasources and filter for "alertmanager" type
const { alertmanagerDataSources } = dataSourcesApi.endpoints.getAllDataSourceSettings.useQuery(undefined, {
refetchOnReconnect: true,
+ // we will refetch the list of data sources every time the component is rendered so we always show fresh data after a user
+ // may have made changes to a data source and came back to the list
+ refetchOnMountOrArgChange,
selectFromResult: (result) => {
const alertmanagerDataSources = result.currentData?.filter(isAlertmanagerDataSource) ?? [];
return { ...result, alertmanagerDataSources };
@@ -26,10 +38,9 @@ export function useExternalDataSourceAlertmanagers(): ExternalAlertmanagerDataSo
});
// we'll also fetch the configuration for which Alertmanagers we are forwarding Grafana-managed alerts too
- // @TODO use polling when we have one or more alertmanagers in pending state
const { currentData: externalAlertmanagers } = alertmanagerApi.endpoints.getExternalAlertmanagers.useQuery(
undefined,
- { refetchOnReconnect: true }
+ { refetchOnReconnect: true, refetchOnMountOrArgChange }
);
if (!alertmanagerDataSources) {
@@ -50,7 +61,7 @@ export function useExternalDataSourceAlertmanagers(): ExternalAlertmanagerDataSo
// using the information from /api/v1/ngalert/alertmanagers we should derive the connection status of a single data source
function determineAlertmanagerConnectionStatus(
- externalAlertmanagers: ExternalAlertmanagers,
+ externalAlertmanagers: ExternalAlertmanagersConnectionStatus,
dataSourceSettings: DataSourceSettings
): ConnectionStatus {
const isInterestedInAlerts = dataSourceSettings.jsonData.handleGrafanaManagedAlerts;
@@ -108,7 +119,10 @@ function isAlertmanagerMatchByURL(dataSourceUrl: string, alertmanagerUrl: string
}
// Grafana prepends the http protocol if there isn't one, but it doesn't store that in the datasource settings
-function normalizeDataSourceURL(url: string) {
+export function normalizeDataSourceURL(url: string) {
const hasProtocol = new RegExp('^[^:]*://').test(url);
- return hasProtocol ? url : `http://${url}`;
+ const urlWithProtocol = hasProtocol ? url : `http://${url}`;
+
+ // replace trailing slashes
+ return urlWithProtocol.replace(/\/+$/, '');
}
diff --git a/public/app/features/alerting/unified/mocks.ts b/public/app/features/alerting/unified/mocks.ts
index 22b239e2bd1..7991ed9d3a2 100644
--- a/public/app/features/alerting/unified/mocks.ts
+++ b/public/app/features/alerting/unified/mocks.ts
@@ -632,6 +632,10 @@ export const grantUserPermissions = (permissions: AccessControlAction[]) => {
.mockImplementation((action) => permissions.includes(action as AccessControlAction));
};
+export const grantUserRole = (role: string) => {
+ jest.spyOn(contextSrv, 'hasRole').mockReturnValue(true);
+};
+
export function mockUnifiedAlertingStore(unifiedAlerting?: Partial) {
const defaultState = configureStore().getState();
diff --git a/public/app/features/alerting/unified/mocks/alertmanagerApi.ts b/public/app/features/alerting/unified/mocks/alertmanagerApi.ts
index 9083fd30a89..f519161dbfd 100644
--- a/public/app/features/alerting/unified/mocks/alertmanagerApi.ts
+++ b/public/app/features/alerting/unified/mocks/alertmanagerApi.ts
@@ -8,30 +8,34 @@ import {
AlertmanagerChoice,
AlertManagerCortexConfig,
AlertState,
- ExternalAlertmanagersResponse,
+ ExternalAlertmanagersStatusResponse,
} from '../../../../plugins/datasource/alertmanager/types';
-import { AlertmanagersChoiceResponse } from '../api/alertmanagerApi';
+import { GrafanaAlertingConfigurationStatusResponse } from '../api/alertmanagerApi';
import { getDatasourceAPIUid } from '../utils/datasource';
-export const defaultAlertmanagerChoiceResponse: AlertmanagersChoiceResponse = {
+export const defaultGrafanaAlertingConfigurationStatusResponse: GrafanaAlertingConfigurationStatusResponse = {
alertmanagersChoice: AlertmanagerChoice.Internal,
numExternalAlertmanagers: 0,
};
-export const alertmanagerChoiceHandler = (response = defaultAlertmanagerChoiceResponse) =>
- http.get('/api/v1/ngalert', () => HttpResponse.json(response));
+export const grafanaAlertingConfigurationStatusHandler = (
+ response = defaultGrafanaAlertingConfigurationStatusResponse
+) => http.get('/api/v1/ngalert', () => HttpResponse.json(response));
-export function mockAlertmanagerChoiceResponse(server: SetupServer, response: AlertmanagersChoiceResponse) {
- server.use(alertmanagerChoiceHandler(response));
+export function mockAlertmanagerChoiceResponse(
+ server: SetupServer,
+ response: GrafanaAlertingConfigurationStatusResponse
+) {
+ server.use(grafanaAlertingConfigurationStatusHandler(response));
}
-export const emptyExternalAlertmanagersResponse: ExternalAlertmanagersResponse = {
+export const emptyExternalAlertmanagersResponse: ExternalAlertmanagersStatusResponse = {
data: {
droppedAlertManagers: [],
activeAlertManagers: [],
},
};
-export function mockAlertmanagersResponse(server: SetupServer, response: ExternalAlertmanagersResponse) {
+export function mockAlertmanagersResponse(server: SetupServer, response: ExternalAlertmanagersStatusResponse) {
server.use(http.get('/api/v1/ngalert/alertmanagers', () => HttpResponse.json(response)));
}
diff --git a/public/app/features/alerting/unified/mocks/server/configure.ts b/public/app/features/alerting/unified/mocks/server/configure.ts
index 04b17f571ac..1dda45e266b 100644
--- a/public/app/features/alerting/unified/mocks/server/configure.ts
+++ b/public/app/features/alerting/unified/mocks/server/configure.ts
@@ -1,5 +1,5 @@
import server from 'app/features/alerting/unified/mockApi';
-import { alertmanagerChoiceHandler } from 'app/features/alerting/unified/mocks/alertmanagerApi';
+import { grafanaAlertingConfigurationStatusHandler } from 'app/features/alerting/unified/mocks/alertmanagerApi';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
/**
@@ -11,5 +11,5 @@ export const setAlertmanagerChoices = (alertmanagersChoice: AlertmanagerChoice,
alertmanagersChoice,
numExternalAlertmanagers,
};
- server.use(alertmanagerChoiceHandler(response));
+ server.use(grafanaAlertingConfigurationStatusHandler(response));
};
diff --git a/public/app/features/alerting/unified/mocks/server/handlers.ts b/public/app/features/alerting/unified/mocks/server/handlers.ts
index 99871367855..9e1d459e668 100644
--- a/public/app/features/alerting/unified/mocks/server/handlers.ts
+++ b/public/app/features/alerting/unified/mocks/server/handlers.ts
@@ -4,7 +4,7 @@
import {
alertmanagerAlertsListHandler,
- alertmanagerChoiceHandler,
+ grafanaAlertingConfigurationStatusHandler,
} from 'app/features/alerting/unified/mocks/alertmanagerApi';
import { datasourceBuildInfoHandler } from 'app/features/alerting/unified/mocks/datasources';
import { folderHandler } from 'app/features/alerting/unified/mocks/folders';
@@ -19,7 +19,7 @@ import {
* All mock handlers that are required across Alerting tests
*/
const allHandlers = [
- alertmanagerChoiceHandler(),
+ grafanaAlertingConfigurationStatusHandler(),
alertmanagerAlertsListHandler(),
folderHandler(),
diff --git a/public/app/features/alerting/unified/state/actions.ts b/public/app/features/alerting/unified/state/actions.ts
index d362b7282b9..9583b8656d5 100644
--- a/public/app/features/alerting/unified/state/actions.ts
+++ b/public/app/features/alerting/unified/state/actions.ts
@@ -6,8 +6,6 @@ import { logMeasurement } from '@grafana/runtime/src/utils/logging';
import {
AlertManagerCortexConfig,
AlertmanagerGroup,
- ExternalAlertmanagerConfig,
- ExternalAlertmanagersResponse,
Matcher,
Receiver,
TestReceiversAlert,
@@ -41,11 +39,8 @@ import {
withRulerRulesMetadataLogging,
} from '../Analytics';
import {
- addAlertManagers,
deleteAlertManagerConfig,
fetchAlertGroups,
- fetchExternalAlertmanagerConfig,
- fetchExternalAlertmanagers,
testReceivers,
updateAlertManagerConfig,
} from '../api/alertmanager';
@@ -129,20 +124,6 @@ export const fetchPromRulesAction = createAsyncThunk(
}
);
-export const fetchExternalAlertmanagersAction = createAsyncThunk(
- 'unifiedAlerting/fetchExternalAlertmanagers',
- (): Promise => {
- return withSerializedError(fetchExternalAlertmanagers());
- }
-);
-
-export const fetchExternalAlertmanagersConfigAction = createAsyncThunk(
- 'unifiedAlerting/fetchExternAlertmanagersConfig',
- (): Promise => {
- return withSerializedError(fetchExternalAlertmanagerConfig());
- }
-);
-
export const fetchRulerRulesAction = createAsyncThunk(
'unifiedalerting/fetchRulerRules',
async (
@@ -889,21 +870,3 @@ export const updateRulesOrder = createAsyncThunk(
);
}
);
-
-export const addExternalAlertmanagersAction = createAsyncThunk(
- 'unifiedAlerting/addExternalAlertmanagers',
- async (alertmanagerConfig: ExternalAlertmanagerConfig, thunkAPI): Promise => {
- return withAppEvents(
- withSerializedError(
- (async () => {
- await addAlertManagers(alertmanagerConfig);
- 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 8783f479877..47d08ec1f1b 100644
--- a/public/app/features/alerting/unified/state/reducers.ts
+++ b/public/app/features/alerting/unified/state/reducers.ts
@@ -6,8 +6,6 @@ import {
deleteAlertManagerConfigAction,
fetchAlertGroupsAction,
fetchEditableRuleAction,
- fetchExternalAlertmanagersAction,
- fetchExternalAlertmanagersConfigAction,
fetchFolderAction,
fetchGrafanaAnnotationsAction,
fetchGrafanaNotifiersAction,
@@ -45,10 +43,6 @@ 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,
- }),
managedAlertStateHistory: createAsyncSlice('managedAlertStateHistory', fetchGrafanaAnnotationsAction).reducer,
});
diff --git a/public/app/features/alerting/unified/utils/datasource.ts b/public/app/features/alerting/unified/utils/datasource.ts
index e2f70cb8586..bb5fb463c8d 100644
--- a/public/app/features/alerting/unified/utils/datasource.ts
+++ b/public/app/features/alerting/unified/utils/datasource.ts
@@ -71,6 +71,12 @@ export function getExternalDsAlertManagers() {
return getAlertManagerDataSources().filter((ds) => ds.jsonData.handleGrafanaManagedAlerts);
}
+export function isAlertmanagerDataSourceInterestedInAlerts(
+ dataSourceSettings: DataSourceSettings
+) {
+ return dataSourceSettings.jsonData.handleGrafanaManagedAlerts === true;
+}
+
const grafanaAlertManagerDataSource: AlertManagerDataSource = {
name: GRAFANA_RULES_SOURCE_NAME,
imgUrl: 'public/img/grafana_icon.svg',
@@ -105,7 +111,7 @@ export function useGetAlertManagerDataSourcesByPermissionAndConfig(
const internalDSAlertManagers = allAlertManagersByPermission.availableInternalDataSources;
//get current alerting configuration
- const { currentData: amConfigStatus } = alertmanagerApi.useGetAlertmanagerChoiceStatusQuery(undefined);
+ const { currentData: amConfigStatus } = alertmanagerApi.endpoints.getGrafanaAlertingConfigurationStatus.useQuery();
const alertmanagerChoice = amConfigStatus?.alertmanagersChoice;
@@ -204,6 +210,10 @@ export function isVanillaPrometheusAlertManagerDataSource(name: string): boolean
);
}
+export function isProvisionedDataSource(name: string): boolean {
+ return getAlertmanagerDataSourceByName(name)?.readOnly === true;
+}
+
export function isGrafanaRulesSource(
rulesSource: RulesSource | string
): rulesSource is typeof GRAFANA_RULES_SOURCE_NAME {
diff --git a/public/app/features/alerting/unified/utils/settings.ts b/public/app/features/alerting/unified/utils/settings.ts
new file mode 100644
index 00000000000..64c15794342
--- /dev/null
+++ b/public/app/features/alerting/unified/utils/settings.ts
@@ -0,0 +1,13 @@
+import { AlertmanagerChoice, GrafanaAlertingConfiguration } from 'app/plugins/datasource/alertmanager/types';
+
+// if we have either "internal" or "both" configured this means the internal Alertmanager is receiving Grafana-managed alerts
+export const isInternalAlertmanagerInterestedInAlerts = (config?: GrafanaAlertingConfiguration): boolean => {
+ switch (config?.alertmanagersChoice) {
+ case AlertmanagerChoice.Internal:
+ case AlertmanagerChoice.All:
+ return true;
+ case AlertmanagerChoice.External:
+ default:
+ return false;
+ }
+};
diff --git a/public/app/features/connections/__mocks__/store.navIndex.mock.ts b/public/app/features/connections/__mocks__/store.navIndex.mock.ts
index 1deead6feb3..217f9891655 100644
--- a/public/app/features/connections/__mocks__/store.navIndex.mock.ts
+++ b/public/app/features/connections/__mocks__/store.navIndex.mock.ts
@@ -151,7 +151,7 @@ export const navIndex: NavIndex = {
},
{
id: 'alerting-admin',
- text: 'Admin',
+ text: 'Settings',
icon: 'cog',
url: '/alerting/admin',
},
@@ -209,7 +209,7 @@ export const navIndex: NavIndex = {
},
'alerting-admin': {
id: 'alerting-admin',
- text: 'Admin',
+ text: 'Settings',
icon: 'cog',
url: '/alerting/admin',
},
diff --git a/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx b/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx
index 27ce79b8c82..9422a8ec1a9 100644
--- a/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx
+++ b/public/app/features/dashboard-scene/settings/version-history/DiffViewer.tsx
@@ -5,7 +5,7 @@ import tinycolor from 'tinycolor2';
import { useTheme2 } from '@grafana/ui';
-export const DiffViewer = ({ oldValue, newValue }: ReactDiffViewerProps) => {
+export const DiffViewer = ({ oldValue, newValue, ...diffProps }: ReactDiffViewerProps) => {
const theme = useTheme2();
const styles = {
@@ -67,6 +67,7 @@ export const DiffViewer = ({ oldValue, newValue }: ReactDiffViewerProps) => {
splitView={false}
compareMethod={DiffMethod.CSS}
useDarkTheme={theme.isDark}
+ {...diffProps}
/>
);
diff --git a/public/app/features/dashboard-scene/settings/version-history/utils.ts b/public/app/features/dashboard-scene/settings/version-history/utils.ts
index 7c57af5488a..ef5d3a21a98 100644
--- a/public/app/features/dashboard-scene/settings/version-history/utils.ts
+++ b/public/app/features/dashboard-scene/settings/version-history/utils.ts
@@ -3,8 +3,6 @@ import { compare, Operation } from 'fast-json-patch';
import jsonMap from 'json-source-map';
import { flow, get, isArray, isEmpty, last, sortBy, tail, toNumber, isNaN } from 'lodash';
-import { Dashboard } from '@grafana/schema';
-
export type Diff = {
op: 'add' | 'replace' | 'remove' | 'copy' | 'test' | '_get' | 'move';
value: unknown;
@@ -18,7 +16,7 @@ export type Diffs = {
[key: string]: Diff[];
};
-export type JSONValue = string | Dashboard;
+type JSONValue = string | Object;
export const jsonDiff = (lhs: JSONValue, rhs: JSONValue): Diffs => {
const diffs = compare(lhs, rhs);
diff --git a/public/app/features/datasources/__mocks__/store.navIndex.mock.ts b/public/app/features/datasources/__mocks__/store.navIndex.mock.ts
index 19c3cfc5310..ceddcdb1c43 100644
--- a/public/app/features/datasources/__mocks__/store.navIndex.mock.ts
+++ b/public/app/features/datasources/__mocks__/store.navIndex.mock.ts
@@ -557,7 +557,7 @@ export const navIndex: NavIndex = {
},
{
id: 'alerting-admin',
- text: 'Admin',
+ text: 'Settings',
icon: 'cog',
url: '/alerting/admin',
},
@@ -616,7 +616,7 @@ export const navIndex: NavIndex = {
},
{
id: 'alerting-admin',
- text: 'Admin',
+ text: 'Settings',
icon: 'cog',
url: '/alerting/admin',
},
@@ -676,7 +676,7 @@ export const navIndex: NavIndex = {
},
{
id: 'alerting-admin',
- text: 'Admin',
+ text: 'Settings',
icon: 'cog',
url: '/alerting/admin',
},
@@ -736,7 +736,7 @@ export const navIndex: NavIndex = {
},
{
id: 'alerting-admin',
- text: 'Admin',
+ text: 'Settings',
icon: 'cog',
url: '/alerting/admin',
},
@@ -796,7 +796,7 @@ export const navIndex: NavIndex = {
},
{
id: 'alerting-admin',
- text: 'Admin',
+ text: 'Settings',
icon: 'cog',
url: '/alerting/admin',
},
@@ -856,7 +856,7 @@ export const navIndex: NavIndex = {
},
{
id: 'alerting-admin',
- text: 'Admin',
+ text: 'Settings',
icon: 'cog',
url: '/alerting/admin',
},
@@ -873,7 +873,7 @@ export const navIndex: NavIndex = {
},
'alerting-admin': {
id: 'alerting-admin',
- text: 'Admin',
+ text: 'Settings',
icon: 'cog',
url: '/alerting/admin',
parentItem: {
@@ -916,7 +916,7 @@ export const navIndex: NavIndex = {
},
{
id: 'alerting-admin',
- text: 'Admin',
+ text: 'Settings',
icon: 'cog',
url: '/alerting/admin',
},
@@ -978,7 +978,7 @@ export const navIndex: NavIndex = {
},
{
id: 'alerting-admin',
- text: 'Admin',
+ text: 'Settings',
icon: 'cog',
url: '/alerting/admin',
},
@@ -2174,7 +2174,7 @@ export const navIndex: NavIndex = {
},
profile: {
id: 'profile',
- text: 'admin',
+ text: 'Settings',
img: '/avatar/46d229b033af06a191ff2267bca9ae56',
url: '/profile',
sortWeight: -1100,
@@ -2214,7 +2214,7 @@ export const navIndex: NavIndex = {
url: '/profile',
parentItem: {
id: 'profile',
- text: 'admin',
+ text: 'Settings',
img: '/avatar/46d229b033af06a191ff2267bca9ae56',
url: '/profile',
sortWeight: -1100,
@@ -2255,7 +2255,7 @@ export const navIndex: NavIndex = {
url: '/notifications',
parentItem: {
id: 'profile',
- text: 'admin',
+ text: 'Settings',
img: '/avatar/46d229b033af06a191ff2267bca9ae56',
url: '/profile',
sortWeight: -1100,
@@ -2296,7 +2296,7 @@ export const navIndex: NavIndex = {
url: '/profile/password',
parentItem: {
id: 'profile',
- text: 'admin',
+ text: 'Settings',
img: '/avatar/46d229b033af06a191ff2267bca9ae56',
url: '/profile',
sortWeight: -1100,
@@ -2339,7 +2339,7 @@ export const navIndex: NavIndex = {
hideFromTabs: true,
parentItem: {
id: 'profile',
- text: 'admin',
+ text: 'Settings',
img: '/avatar/46d229b033af06a191ff2267bca9ae56',
url: '/profile',
sortWeight: -1100,
diff --git a/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx b/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx
index 817324ebf1b..f65cd16dd6b 100644
--- a/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx
+++ b/public/app/plugins/datasource/alertmanager/ConfigEditor.tsx
@@ -87,7 +87,7 @@ export const ConfigEditor = (props: Props) => {
{options.jsonData.handleGrafanaManagedAlerts && (