Alerting: New settings page (#84501)

This commit is contained in:
Gilles De Mey
2024-05-03 17:42:42 +02:00
committed by GitHub
parent 046eedaa4c
commit 5e25afe6e9
68 changed files with 1923 additions and 975 deletions

View File

@@ -1542,26 +1542,6 @@ exports[`better eslint`] = {
"public/app/features/alerting/unified/components/Well.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/features/alerting/unified/components/admin/AlertmanagerConfigSelector.tsx:5381": [
[0, 0, 0, "\'HorizontalGroup\' import from \'@grafana/ui\' is restricted from being used by a pattern. Use Stack component instead.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"]
],
"public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"]
],
"public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],

View File

@@ -394,7 +394,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
if c.SignedInUser.GetOrgRole() == org.RoleAdmin {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Admin", Id: "alerting-admin", Url: s.cfg.AppSubURL + "/alerting/admin",
Text: "Settings", Id: "alerting-admin", Url: s.cfg.AppSubURL + "/alerting/admin",
Icon: "cog",
})
}

View File

@@ -75,7 +75,7 @@ export function getNavTitle(navId: string | undefined) {
case 'groups':
return t('nav.alerting-groups.title', 'Groups');
case 'alerting-admin':
return t('nav.alerting-admin.title', 'Admin');
return t('nav.alerting-admin.title', 'Settings');
case 'cfg':
return t('nav.config.title', 'Administration');
case 'cfg/general':
@@ -213,6 +213,11 @@ export function getNavSubTitle(navId: string | undefined) {
'nav.alerting-upgrade.subtitle',
'Upgrade your existing legacy alerts and notification channels to the new Grafana Alerting'
);
case 'alerting-admin':
return t(
'nav.alerting-admin.subtitle',
'Manage Alertmanager configurations and configure where alert instances generated from Grafana managed alert rules are sent'
);
case 'alert-list':
return t('nav.alerting-list.subtitle', 'Rules that determine whether an alert will fire');
case 'receivers':
@@ -260,7 +265,7 @@ export function getNavSubTitle(navId: string | undefined) {
case 'admin':
return t(
'nav.admin.subtitle',
'Manage server-wide settings and access to resources such as organizations, users, and licenses'
'Manage Alertmanager configurations and configure where alert instances generated from Grafana managed alert rules are sent'
);
case 'cfg/general':
return t('nav.config-general.subtitle', 'Manage default preferences and settings across Grafana');

View File

@@ -217,7 +217,7 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
path: '/alerting/admin',
roles: () => ['Admin'],
component: importAlertingComponent(
() => import(/* webpackChunkName: "AlertingAdmin" */ 'app/features/alerting/unified/Admin')
() => import(/* webpackChunkName: "AlertingSettings" */ 'app/features/alerting/unified/Settings')
),
},
];

View File

@@ -1,27 +0,0 @@
import React from 'react';
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import AlertmanagerConfig from './components/admin/AlertmanagerConfig';
import { ExternalAlertmanagers } from './components/admin/ExternalAlertmanagers';
import { useAlertmanager } from './state/AlertmanagerContext';
import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
export default function Admin(): JSX.Element {
return (
<AlertmanagerPageWrapper navId="alerting-admin" accessType="notification">
<AdminPageContents />
</AlertmanagerPageWrapper>
);
}
function AdminPageContents() {
const { selectedAlertmanager } = useAlertmanager();
const isGrafanaAmSelected = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
return (
<>
<AlertmanagerConfig test-id="admin-alertmanagerconfig" />
{isGrafanaAmSelected && <ExternalAlertmanagers test-id="admin-externalalertmanagers" />}
</>
);
}

View File

@@ -21,14 +21,12 @@ import { getFiltersFromUrlParams } from './utils/misc';
import { initialAsyncRequestState } from './utils/redux';
const AlertGroups = () => {
const { useGetAlertmanagerChoiceStatusQuery } = alertmanagerApi;
const { selectedAlertmanager } = useAlertmanager();
const dispatch = useDispatch();
const [queryParams] = useQueryParams();
const { groupBy = [] } = getFiltersFromUrlParams(queryParams);
const { currentData: amConfigStatus } = useGetAlertmanagerChoiceStatusQuery();
const { currentData: amConfigStatus } = alertmanagerApi.endpoints.getGrafanaAlertingConfigurationStatus.useQuery();
const alertGroups = useUnifiedAlertingSelector((state) => state.amAlertGroups);
const { loading, error, result: results = [] } = alertGroups[selectedAlertmanager || ''] ?? initialAsyncRequestState;

View File

@@ -80,6 +80,18 @@ export function withPromRulesMetadataLogging<TFunc extends (...args: any[]) => P
};
}
type FormErrors = Record<string, Partial<{ message: string; type: string | number }>>;
export function reportFormErrors(errors: FormErrors) {
Object.entries(errors).forEach(([field, error]) => {
const message = error.message ?? 'unknown error';
const type = String(error.type) ?? 'unknown';
const errorObject = new Error(message);
logError(errorObject, { field, type });
});
}
function getPromRulesMetadata(promRules: RuleNamespace[]) {
const namespacesCount = promRules.length;
const groupsCount = promRules.flatMap((ns) => ns.groups).length;

View File

@@ -15,7 +15,7 @@ import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor
import { mockApi, mockFeatureDiscoveryApi, setupMswServer } from './mockApi';
import { grantUserPermissions, labelsPluginMetaMock, mockDataSource } from './mocks';
import {
defaultAlertmanagerChoiceResponse,
defaultGrafanaAlertingConfigurationStatusResponse,
emptyExternalAlertmanagersResponse,
mockAlertmanagerChoiceResponse,
mockAlertmanagersResponse,
@@ -55,7 +55,7 @@ setupDataSources(dataSources.default);
const server = setupMswServer();
mockFeatureDiscoveryApi(server).discoverDsFeatures(dataSources.default, buildInfoResponse.mimir);
mockAlertmanagerChoiceResponse(server, defaultAlertmanagerChoiceResponse);
mockAlertmanagerChoiceResponse(server, defaultGrafanaAlertingConfigurationStatusResponse);
mockAlertmanagersResponse(server, emptyExternalAlertmanagersResponse);
mockApi(server).eval({ results: {} });
mockApi(server).plugins.getPluginSettings({ ...labelsPluginMetaMock, enabled: false });

View File

@@ -10,7 +10,7 @@ import { setDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { mockApi, setupMswServer } from 'app/features/alerting/unified/mockApi';
import {
defaultAlertmanagerChoiceResponse,
defaultGrafanaAlertingConfigurationStatusResponse,
mockAlertmanagerChoiceResponse,
} from 'app/features/alerting/unified/mocks/alertmanagerApi';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
@@ -72,7 +72,7 @@ const server = setupMswServer();
describe('RuleEditor grafana managed rules', () => {
beforeEach(() => {
mockApi(server).eval({ results: {} });
mockAlertmanagerChoiceResponse(server, defaultAlertmanagerChoiceResponse);
mockAlertmanagerChoiceResponse(server, defaultGrafanaAlertingConfigurationStatusResponse);
jest.clearAllMocks();
contextSrv.isEditor = true;
contextSrv.hasEditPermissionInFolders = true;

View File

@@ -0,0 +1,123 @@
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { render } from 'test/test-utils';
import { byRole, byTestId, byText } from 'testing-library-selector';
import SettingsPage from './Settings';
import {
DataSourcesResponse,
setupGrafanaManagedServer,
withExternalOnlySetting,
} from './components/settings/__mocks__/server';
import { setupMswServer } from './mockApi';
import { grantUserRole } from './mocks';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
useReturnToPrevious: jest.fn(),
}));
const server = setupMswServer();
const ui = {
builtInAlertmanagerSection: byText('Built-in Alertmanager'),
otherAlertmanagerSection: byText('Other Alertmanagers'),
builtInAlertmanagerCard: byTestId('alertmanager-card-Grafana built-in'),
otherAlertmanagerCard: (name: string) => byTestId(`alertmanager-card-${name}`),
statusReceiving: byText(/receiving grafana-managed alerts/i),
statusNotReceiving: byText(/not receiving/i),
configurationDrawer: byRole('dialog', { name: 'Drawer title Internal Grafana Alertmanager' }),
editConfigurationButton: byRole('button', { name: /edit configuration/i }),
saveConfigurationButton: byRole('button', { name: /save/i }),
versionsTab: byRole('tab', { name: /versions/i }),
};
describe('Alerting settings', () => {
beforeEach(() => {
grantUserRole('ServerAdmin');
setupGrafanaManagedServer(server);
});
it('should render the page with Built-in only enabled, others disabled', async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(ui.builtInAlertmanagerSection.get()).toBeInTheDocument();
expect(ui.otherAlertmanagerSection.get()).toBeInTheDocument();
});
// check internal alertmanager configuration
expect(ui.builtInAlertmanagerCard.get()).toBeInTheDocument();
expect(ui.statusReceiving.get(ui.builtInAlertmanagerCard.get())).toBeInTheDocument();
// check external altermanagers
DataSourcesResponse.forEach((ds) => {
// get the card for datasource
const card = ui.otherAlertmanagerCard(ds.name).get();
// expect link to data source, provisioned badge, type, and status
expect(within(card).getByRole('link', { name: ds.name })).toBeInTheDocument();
});
});
it('should render the page with external only', async () => {
render(<SettingsPage />);
withExternalOnlySetting(server);
await waitFor(() => {
expect(ui.statusReceiving.query()).not.toBeInTheDocument();
});
});
it('should be able to view configuration', async () => {
render(<SettingsPage />);
// wait for loading to be done
await waitFor(() => expect(ui.builtInAlertmanagerSection.get()).toBeInTheDocument());
// open configuration drawer
const internalAMCard = ui.builtInAlertmanagerCard.get();
const editInternal = ui.editConfigurationButton.get(internalAMCard);
await userEvent.click(editInternal);
await waitFor(() => {
expect(ui.configurationDrawer.get()).toBeInTheDocument();
});
await userEvent.click(ui.saveConfigurationButton.get());
expect(ui.saveConfigurationButton.get()).toBeDisabled();
await waitFor(() => {
expect(ui.saveConfigurationButton.get()).not.toBeDisabled();
});
});
it('should be able to view versions', async () => {
render(<SettingsPage />);
// wait for loading to be done
await waitFor(() => expect(ui.builtInAlertmanagerSection.get()).toBeInTheDocument());
// open configuration drawer
const internalAMCard = ui.builtInAlertmanagerCard.get();
const editInternal = ui.editConfigurationButton.get(internalAMCard);
await userEvent.click(editInternal);
await waitFor(() => {
expect(ui.configurationDrawer.get()).toBeInTheDocument();
});
// click versions tab
await userEvent.click(ui.versionsTab.get());
await waitFor(() => {
expect(screen.getByText(/last applied/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { LinkButton, Stack, Text } from '@grafana/ui';
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { WithReturnButton } from './components/WithReturnButton';
import { useEditConfigurationDrawer } from './components/settings/ConfigurationDrawer';
import { ExternalAlertmanagers } from './components/settings/ExternalAlertmanagers';
import InternalAlertmanager from './components/settings/InternalAlertmanager';
import { SettingsProvider, useSettings } from './components/settings/SettingsContext';
export default function SettingsPage() {
return (
<SettingsProvider>
<SettingsContent />
</SettingsProvider>
);
}
function SettingsContent() {
const [configurationDrawer, showConfiguration] = useEditConfigurationDrawer();
const { isLoading } = useSettings();
return (
<AlertingPageWrapper
navId="alerting-admin"
isLoading={isLoading}
actions={[
<WithReturnButton
key="add-alertmanager"
title="Alerting settings"
component={
<LinkButton href="/connections/datasources/alertmanager" icon="plus" variant="primary">
Add new Alertmanager
</LinkButton>
}
/>,
]}
>
<Stack direction="column" gap={2}>
{/* Grafana built-in Alertmanager */}
<Text variant="h5">Built-in Alertmanager</Text>
<InternalAlertmanager onEditConfiguration={showConfiguration} />
{/* other (external Alertmanager data sources we have added to Grafana such as vanilla, Mimir, Cortex) */}
<Text variant="h5">Other Alertmanagers</Text>
<ExternalAlertmanagers onEditConfiguration={showConfiguration} />
</Stack>
{configurationDrawer}
</AlertingPageWrapper>
);
}

View File

@@ -33,8 +33,9 @@ export const alertingApi = createApi({
reducerPath: 'alertingApi',
baseQuery: backendSrvBaseQuery(),
tagTypes: [
'AlertmanagerChoice',
'AlertingConfiguration',
'AlertmanagerConfiguration',
'AlertmanagerConnectionStatus',
'AlertmanagerAlerts',
'AlertmanagerSilences',
'OnCallIntegrations',

View File

@@ -6,8 +6,6 @@ import {
AlertManagerCortexConfig,
AlertmanagerGroup,
AlertmanagerStatus,
ExternalAlertmanagerConfig,
ExternalAlertmanagersResponse,
Receiver,
TestReceiversAlert,
TestReceiversPayload,
@@ -164,40 +162,3 @@ function getReceiverResultError(receiversResult: TestReceiversResult) {
)
.join('; ');
}
export async function addAlertManagers(alertManagerConfig: ExternalAlertmanagerConfig): Promise<void> {
await lastValueFrom(
getBackendSrv().fetch({
method: 'POST',
data: alertManagerConfig,
url: '/api/v1/ngalert/admin_config',
showErrorAlert: false,
showSuccessAlert: false,
})
).then(() => {
fetchExternalAlertmanagerConfig();
});
}
export async function fetchExternalAlertmanagers(): Promise<ExternalAlertmanagersResponse> {
const result = await lastValueFrom(
getBackendSrv().fetch<ExternalAlertmanagersResponse>({
method: 'GET',
url: '/api/v1/ngalert/alertmanagers',
})
);
return result.data;
}
export async function fetchExternalAlertmanagerConfig(): Promise<ExternalAlertmanagerConfig> {
const result = await lastValueFrom(
getBackendSrv().fetch<ExternalAlertmanagerConfig>({
method: 'GET',
url: '/api/v1/ngalert/admin_config',
showErrorAlert: false,
})
);
return result.data;
}

View File

@@ -8,9 +8,9 @@ import {
AlertmanagerChoice,
AlertManagerCortexConfig,
AlertmanagerGroup,
ExternalAlertmanagerConfig,
ExternalAlertmanagers,
ExternalAlertmanagersResponse,
GrafanaAlertingConfiguration,
ExternalAlertmanagersConnectionStatus,
ExternalAlertmanagersStatusResponse,
GrafanaManagedContactPoint,
Matcher,
MuteTimeInterval,
@@ -30,10 +30,11 @@ import { alertingApi } from './alertingApi';
import { fetchAlertManagerConfig, fetchStatus } from './alertmanager';
import { featureDiscoveryApi } from './featureDiscoveryApi';
const LIMIT_TO_SUCCESSFULLY_APPLIED_AMS = 10;
// limits the number of previously applied Alertmanager configurations to be shown in the UI
const ALERTMANAGER_CONFIGURATION_CONFIGURATION_HISTORY_LIMIT = 30;
const FETCH_CONFIG_RETRY_TIMEOUT = 30 * 1000;
export interface AlertmanagersChoiceResponse {
export interface GrafanaAlertingConfigurationStatusResponse {
alertmanagersChoice: AlertmanagerChoice;
numExternalAlertmanagers: number;
}
@@ -91,36 +92,48 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
query: () => ({ url: '/api/alert-notifiers' }),
}),
getAlertmanagerChoiceStatus: build.query<AlertmanagersChoiceResponse, void>({
query: () => ({ url: '/api/v1/ngalert' }),
providesTags: ['AlertmanagerChoice'],
}),
getExternalAlertmanagerConfig: build.query<ExternalAlertmanagerConfig, void>({
// this endpoint requires administrator privileges
getGrafanaAlertingConfiguration: build.query<GrafanaAlertingConfiguration, void>({
query: () => ({ url: '/api/v1/ngalert/admin_config' }),
providesTags: ['AlertmanagerChoice'],
providesTags: ['AlertingConfiguration'],
}),
getExternalAlertmanagers: build.query<ExternalAlertmanagers, void>({
// this endpoint provides the current state of the requested configuration above (api/v1/ngalert/admin_config)
// this endpoint does not require administrator privileges
getGrafanaAlertingConfigurationStatus: build.query<GrafanaAlertingConfigurationStatusResponse, void>({
query: () => ({ url: '/api/v1/ngalert' }),
providesTags: ['AlertingConfiguration'],
}),
// this endpoints returns the current state of alertmanager data sources we want to forward alerts to
getExternalAlertmanagers: build.query<ExternalAlertmanagersConnectionStatus, void>({
query: () => ({ url: '/api/v1/ngalert/alertmanagers' }),
transformResponse: (response: ExternalAlertmanagersResponse) => response.data,
transformResponse: (response: ExternalAlertmanagersStatusResponse) => response.data,
providesTags: ['AlertmanagerConnectionStatus'],
}),
saveExternalAlertmanagersConfig: build.mutation<{ message: string }, ExternalAlertmanagerConfig>({
query: (config) => ({ url: '/api/v1/ngalert/admin_config', method: 'POST', data: config }),
invalidatesTags: ['AlertmanagerChoice'],
updateGrafanaAlertingConfiguration: build.mutation<{ message: string }, GrafanaAlertingConfiguration>({
query: (config) => ({
url: '/api/v1/ngalert/admin_config',
method: 'POST',
data: config,
showSuccessAlert: false,
}),
invalidatesTags: ['AlertingConfiguration', 'AlertmanagerConfiguration', 'AlertmanagerConnectionStatus'],
}),
getValidAlertManagersConfig: build.query<AlertManagerCortexConfig[], void>({
getAlertmanagerConfigurationHistory: build.query<AlertManagerCortexConfig[], void>({
//this is only available for the "grafana" alert manager
query: () => ({
url: `/api/alertmanager/${getDatasourceAPIUid(
GRAFANA_RULES_SOURCE_NAME
)}/config/history?limit=${LIMIT_TO_SUCCESSFULLY_APPLIED_AMS}`,
url: `/api/alertmanager/${getDatasourceAPIUid(GRAFANA_RULES_SOURCE_NAME)}/config/history`,
params: {
limit: ALERTMANAGER_CONFIGURATION_CONFIGURATION_HISTORY_LIMIT,
},
}),
providesTags: ['AlertmanagerConfiguration'],
}),
resetAlertManagerConfigToOldVersion: build.mutation<{ message: string }, { id: number }>({
resetAlertmanagerConfigurationToOldVersion: build.mutation<{ message: string }, { id: number }>({
//this is only available for the "grafana" alert manager
query: (config) => ({
url: `/api/alertmanager/${getDatasourceAPIUid(GRAFANA_RULES_SOURCE_NAME)}/config/history/${
@@ -128,6 +141,7 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
}/_activate`,
method: 'POST',
}),
invalidatesTags: ['AlertmanagerConfiguration'],
}),
// TODO we've sort of inherited the errors format here from the previous Redux actions, errors throw are of type "SerializedError"

View File

@@ -1,15 +1,30 @@
import { DataSourceJsonData, DataSourceSettings } from '@grafana/data';
import { DataSourceSettings } from '@grafana/data';
import { alertingApi } from './alertingApi';
export const dataSourcesApi = alertingApi.injectEndpoints({
endpoints: (build) => ({
getAllDataSourceSettings: build.query<Array<DataSourceSettings<DataSourceJsonData>>, void>({
getAllDataSourceSettings: build.query<DataSourceSettings[], void>({
query: () => ({ url: 'api/datasources' }),
// we'll create individual cache entries for each datasource UID
providesTags: (result) => {
return result ? result.map(({ uid }) => ({ type: 'DataSourceSettings', id: uid })) : ['DataSourceSettings'];
},
}),
getDataSourceSettingsForUID: build.query<DataSourceSettings, string>({
query: (uid) => ({ url: `api/datasources/uid/${uid}` }),
providesTags: (_result, _error, uid) => [{ type: 'DataSourceSettings', id: uid }],
}),
updateDataSourceSettingsForUID: build.mutation<unknown, { uid: string; settings: DataSourceSettings }>({
query: ({ uid, settings }) => ({
url: `api/datasources/uid/${uid}`,
method: 'PUT',
data: settings,
showSuccessAlert: false,
}),
// we need to invalidate the settings for a single Datasource because otherwise the backend will complain
// about it already having been edited by another user edits are tracked with a version number
invalidatesTags: (_result, _error, args) => [{ type: 'DataSourceSettings', id: args.uid }],
}),
}),
});

View File

@@ -16,9 +16,12 @@ export function GrafanaAlertmanagerDeliveryWarning({ currentAlertmanager }: Graf
const styles = useStyles2(getStyles);
const viewingInternalAM = currentAlertmanager === GRAFANA_RULES_SOURCE_NAME;
const { currentData: amChoiceStatus } = alertmanagerApi.endpoints.getAlertmanagerChoiceStatus.useQuery(undefined, {
skip: !viewingInternalAM,
});
const { currentData: amChoiceStatus } = alertmanagerApi.endpoints.getGrafanaAlertingConfigurationStatus.useQuery(
undefined,
{
skip: !viewingInternalAM,
}
);
const interactsWithExternalAMs =
amChoiceStatus?.alertmanagersChoice &&

View File

@@ -1,155 +0,0 @@
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { byRole, byTestId } from 'testing-library-selector';
import { selectors } from '@grafana/e2e-selectors';
import { locationService, setDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import store from 'app/core/store';
import {
AlertManagerCortexConfig,
AlertManagerDataSourceJsonData,
AlertManagerImplementation,
} from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import {
fetchAlertManagerConfig,
deleteAlertManagerConfig,
updateAlertManagerConfig,
fetchStatus,
} from '../../api/alertmanager';
import {
grantUserPermissions,
mockDataSource,
MockDataSourceSrv,
someCloudAlertManagerConfig,
someCloudAlertManagerStatus,
} from '../../mocks';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
import { getAllDataSources } from '../../utils/config';
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from '../../utils/constants';
import { DataSourceType } from '../../utils/datasource';
import AlertmanagerConfig from './AlertmanagerConfig';
jest.mock('../../api/alertmanager');
jest.mock('../../api/grafana');
jest.mock('../../utils/config');
const mocks = {
getAllDataSources: jest.mocked(getAllDataSources),
api: {
fetchConfig: jest.mocked(fetchAlertManagerConfig),
deleteAlertManagerConfig: jest.mocked(deleteAlertManagerConfig),
updateAlertManagerConfig: jest.mocked(updateAlertManagerConfig),
fetchStatus: jest.mocked(fetchStatus),
},
};
const renderAdminPage = (alertManagerSourceName?: string) => {
locationService.push(
'/alerting/notifications' +
(alertManagerSourceName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${alertManagerSourceName}` : '')
);
return render(
<TestProvider>
<AlertmanagerProvider accessType="instance">
<AlertmanagerConfig />
</AlertmanagerProvider>
</TestProvider>
);
};
const dataSources = {
alertManager: mockDataSource({
name: 'CloudManager',
type: DataSourceType.Alertmanager,
}),
promAlertManager: mockDataSource<AlertManagerDataSourceJsonData>({
name: 'PromManager',
type: DataSourceType.Alertmanager,
jsonData: {
implementation: AlertManagerImplementation.prometheus,
},
}),
};
const ui = {
confirmButton: byRole('button', { name: /Yes, reset configuration/ }),
resetButton: byRole('button', { name: /Reset configuration/ }),
saveButton: byRole('button', { name: /Save/ }),
configInput: byTestId(selectors.components.CodeEditor.container),
readOnlyConfig: byTestId('readonly-config'),
};
describe('Admin config', () => {
beforeEach(() => {
jest.clearAllMocks();
// FIXME: scope down
grantUserPermissions(Object.values(AccessControlAction));
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
setDataSourceSrv(new MockDataSourceSrv(dataSources));
contextSrv.isGrafanaAdmin = true;
store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY);
});
it('Reset alertmanager config', async () => {
mocks.api.fetchConfig.mockResolvedValue({
template_files: {
foo: 'bar',
},
alertmanager_config: {},
});
mocks.api.deleteAlertManagerConfig.mockResolvedValue();
renderAdminPage(dataSources.alertManager.name);
await userEvent.click(await ui.resetButton.find());
await userEvent.click(ui.confirmButton.get());
await waitFor(() => expect(mocks.api.deleteAlertManagerConfig).toHaveBeenCalled());
expect(ui.confirmButton.query()).not.toBeInTheDocument();
});
it('Editable alertmanager config', async () => {
let savedConfig: AlertManagerCortexConfig | undefined = undefined;
const defaultConfig = {
template_files: {},
alertmanager_config: {
route: {
receiver: 'old one',
},
},
};
mocks.api.fetchConfig.mockImplementation(() => Promise.resolve(savedConfig ?? defaultConfig));
mocks.api.updateAlertManagerConfig.mockResolvedValue();
renderAdminPage(dataSources.alertManager.name);
await ui.configInput.find();
await userEvent.click(ui.saveButton.get());
await waitFor(() => expect(mocks.api.updateAlertManagerConfig).toHaveBeenCalled());
expect(mocks.api.updateAlertManagerConfig.mock.lastCall).toMatchSnapshot();
await waitFor(() => expect(mocks.api.fetchConfig).toHaveBeenCalledTimes(2));
});
it('Read-only when using Prometheus Alertmanager', async () => {
mocks.api.fetchStatus.mockResolvedValue({
...someCloudAlertManagerStatus,
config: someCloudAlertManagerConfig.alertmanager_config,
});
renderAdminPage(dataSources.promAlertManager.name);
await ui.readOnlyConfig.find();
expect(ui.resetButton.query()).not.toBeInTheDocument();
expect(ui.saveButton.query()).not.toBeInTheDocument();
expect(mocks.api.fetchConfig).not.toHaveBeenCalled();
expect(mocks.api.fetchStatus).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,125 +0,0 @@
import { css } from '@emotion/css';
import React, { useState, useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, useStyles2 } from '@grafana/ui';
import { useDispatch } from 'app/types';
import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { deleteAlertManagerConfigAction, updateAlertManagerConfigAction } from '../../state/actions';
import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
import AlertmanagerConfigSelector, { ValidAmConfigOption } from './AlertmanagerConfigSelector';
import { ConfigEditor } from './ConfigEditor';
export interface FormValues {
configJSON: string;
}
export default function AlertmanagerConfig(): JSX.Element {
const dispatch = useDispatch();
const [showConfirmDeleteAMConfig, setShowConfirmDeleteAMConfig] = useState(false);
const { loading: isDeleting } = useUnifiedAlertingSelector((state) => state.deleteAMConfig);
const { loading: isSaving } = useUnifiedAlertingSelector((state) => state.saveAMConfig);
const { selectedAlertmanager } = useAlertmanager();
const readOnly = selectedAlertmanager ? isVanillaPrometheusAlertManagerDataSource(selectedAlertmanager) : false;
const styles = useStyles2(getStyles);
const [selectedAmConfig, setSelectedAmConfig] = useState<ValidAmConfigOption | undefined>();
const {
currentData: config,
error: loadingError,
isLoading: isLoadingConfig,
} = useAlertmanagerConfig(selectedAlertmanager);
const resetConfig = () => {
if (selectedAlertmanager) {
dispatch(deleteAlertManagerConfigAction(selectedAlertmanager));
}
setShowConfirmDeleteAMConfig(false);
};
const defaultValues = useMemo(
(): FormValues => ({
configJSON: config ? JSON.stringify(config, null, 2) : '',
}),
[config]
);
const defaultValidValues = useMemo(
(): FormValues => ({
configJSON: selectedAmConfig ? JSON.stringify(selectedAmConfig.value, null, 2) : '',
}),
[selectedAmConfig]
);
const loading = isDeleting || isLoadingConfig || isSaving;
const onSubmit = (values: FormValues) => {
if (selectedAlertmanager && config) {
dispatch(
updateAlertManagerConfigAction({
newConfig: JSON.parse(values.configJSON),
oldConfig: config,
alertManagerSourceName: selectedAlertmanager,
successMessage: 'Alertmanager configuration updated.',
})
);
}
};
return (
<div className={styles.container}>
{loadingError && !loading && (
<>
<Alert
severity="error"
title="Your Alertmanager configuration is incorrect. These are the details of the error:"
>
{loadingError.message || 'Unknown error.'}
</Alert>
{selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME && (
<AlertmanagerConfigSelector
onChange={setSelectedAmConfig}
selectedAmConfig={selectedAmConfig}
defaultValues={defaultValidValues}
readOnly={true}
loading={loading}
onSubmit={onSubmit}
/>
)}
</>
)}
{isDeleting && selectedAlertmanager !== GRAFANA_RULES_SOURCE_NAME && (
<Alert severity="info" title="Resetting Alertmanager configuration">
It might take a while...
</Alert>
)}
{selectedAlertmanager && config && (
<ConfigEditor
defaultValues={defaultValues}
onSubmit={(values) => onSubmit(values)}
readOnly={readOnly}
loading={loading}
alertManagerSourceName={selectedAlertmanager}
showConfirmDeleteAMConfig={showConfirmDeleteAMConfig}
onReset={() => setShowConfirmDeleteAMConfig(true)}
onConfirmReset={resetConfig}
onDismiss={() => setShowConfirmDeleteAMConfig(false)}
/>
)}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
margin-bottom: ${theme.spacing(4)};
`,
});

View File

@@ -1,110 +0,0 @@
import { css } from '@emotion/css';
import React, { useMemo } from 'react';
import { dateTime, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, HorizontalGroup, Select, useStyles2 } from '@grafana/ui';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { alertmanagerApi } from '../../api/alertmanagerApi';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { FormValues } from './AlertmanagerConfig';
import { ConfigEditor } from './ConfigEditor';
export interface ValidAmConfigOption {
label?: string;
value?: AlertManagerCortexConfig;
}
interface AlertmanagerConfigSelectorProps {
onChange: (selectedOption: ValidAmConfigOption) => void;
selectedAmConfig?: ValidAmConfigOption;
defaultValues: FormValues;
onSubmit: (values: FormValues, oldConfig?: AlertManagerCortexConfig) => void;
readOnly: boolean;
loading: boolean;
}
export default function AlertmanagerConfigSelector({
onChange,
selectedAmConfig,
defaultValues,
onSubmit,
readOnly,
loading,
}: AlertmanagerConfigSelectorProps): JSX.Element {
const { useGetValidAlertManagersConfigQuery, useResetAlertManagerConfigToOldVersionMutation } = alertmanagerApi;
const styles = useStyles2(getStyles);
const { currentData: validAmConfigs, isLoading: isFetchingValidAmConfigs } = useGetValidAlertManagersConfigQuery();
const [resetAlertManagerConfigToOldVersion] = useResetAlertManagerConfigToOldVersionMutation();
const validAmConfigsOptions = useMemo(() => {
if (!validAmConfigs?.length) {
return [];
}
const configs: ValidAmConfigOption[] = validAmConfigs.map((config) => {
const date = new Date(config.last_applied!);
return {
label: config.last_applied
? `Config from ${date.toLocaleString()} (${dateTime(date).locale('en').fromNow(true)} ago)`
: 'Previous config',
value: config,
};
});
onChange(configs[0]);
return configs;
}, [validAmConfigs, onChange]);
const onResetClick = async () => {
const id = selectedAmConfig?.value?.id;
if (id === undefined) {
return;
}
resetAlertManagerConfigToOldVersion({ id });
};
return (
<>
{!isFetchingValidAmConfigs && validAmConfigs && validAmConfigs.length > 0 ? (
<>
<div>Select a previous working configuration until you fix this error:</div>
<div className={styles.container}>
<HorizontalGroup align="flex-start" spacing="md">
<Select
options={validAmConfigsOptions}
value={selectedAmConfig}
onChange={(value: SelectableValue) => {
onChange(value);
}}
/>
<Button variant="primary" disabled={loading} onClick={onResetClick}>
Reset to selected configuration
</Button>
</HorizontalGroup>
</div>
<ConfigEditor
defaultValues={defaultValues}
onSubmit={(values) => onSubmit(values)}
readOnly={readOnly}
loading={loading}
alertManagerSourceName={GRAFANA_RULES_SOURCE_NAME}
/>
</>
) : null}
</>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
margin-top: ${theme.spacing(2)};
margin-bottom: ${theme.spacing(2)};
`,
});

View File

@@ -1,106 +0,0 @@
import React from 'react';
import { useForm } from 'react-hook-form';
import { Button, CodeEditor, ConfirmModal, Field, Stack } from '@grafana/ui';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { FormValues } from './AlertmanagerConfig';
interface ConfigEditorProps {
defaultValues: { configJSON: string };
readOnly: boolean;
loading: boolean;
alertManagerSourceName?: string;
onSubmit: (values: FormValues) => void;
showConfirmDeleteAMConfig?: boolean;
onReset?: () => void;
onConfirmReset?: () => void;
onDismiss?: () => void;
}
export const ConfigEditor = ({
defaultValues,
readOnly,
loading,
alertManagerSourceName,
showConfirmDeleteAMConfig,
onSubmit,
onReset,
onConfirmReset,
onDismiss,
}: ConfigEditorProps) => {
const {
handleSubmit,
formState: { errors },
setValue,
register,
} = useForm({ defaultValues });
register('configJSON', {
required: { value: true, message: 'Required' },
validate: (value: string) => {
try {
JSON.parse(value);
return true;
} catch (e) {
return e instanceof Error ? e.message : 'JSON is invalid';
}
},
});
return (
<form onSubmit={handleSubmit(onSubmit)} key={defaultValues.configJSON}>
<Field
disabled={loading}
label="Configuration"
invalid={!!errors.configJSON}
error={errors.configJSON?.message}
data-testid={readOnly ? 'readonly-config' : 'config'}
>
<CodeEditor
language="json"
width="100%"
height={500}
showLineNumbers={true}
value={defaultValues.configJSON}
showMiniMap={false}
onSave={(value) => {
setValue('configJSON', value);
}}
onBlur={(value) => {
setValue('configJSON', value);
}}
readOnly={readOnly}
/>
</Field>
{!readOnly && (
<Stack gap={1}>
<Button type="submit" variant="primary" disabled={loading}>
Save configuration
</Button>
{onReset && (
<Button type="button" disabled={loading} variant="destructive" onClick={onReset}>
Reset configuration
</Button>
)}
</Stack>
)}
{Boolean(showConfirmDeleteAMConfig) && onConfirmReset && onDismiss && (
<ConfirmModal
isOpen={true}
title="Reset Alertmanager configuration"
body={`Are you sure you want to reset configuration ${
alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME
? 'for the Grafana Alertmanager'
: `for "${alertManagerSourceName}"`
}? Contact points and notification policies will be reset to their defaults.`}
confirmText="Yes, reset configuration"
onConfirm={onConfirmReset}
onDismiss={onDismiss}
/>
)}
</form>
);
};

View File

@@ -1,124 +0,0 @@
import { css } from '@emotion/css';
import { capitalize } from 'lodash';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, CallToActionCard, Card, Icon, LinkButton, Tooltip, useStyles2 } from '@grafana/ui';
import { ExternalAlertmanagerDataSourceWithStatus } from '../../hooks/useExternalAmSelector';
import { makeDataSourceLink } from '../../utils/misc';
export interface ExternalAlertManagerDataSourcesProps {
alertmanagers: ExternalAlertmanagerDataSourceWithStatus[];
inactive: boolean;
}
export function ExternalAlertmanagerDataSources({ alertmanagers, inactive }: ExternalAlertManagerDataSourcesProps) {
const styles = useStyles2(getStyles);
return (
<>
<h5>Alertmanagers Receiving Grafana-managed alerts</h5>
<div className={styles.muted}>
Alertmanager data sources support a configuration setting that allows you to choose to send Grafana-managed
alerts to that Alertmanager. <br />
Below, you can see the list of all Alertmanager data sources that have this setting enabled.
</div>
{alertmanagers.length === 0 && (
<CallToActionCard
message={
<div>
There are no Alertmanager data sources configured to receive Grafana-managed alerts. <br />
You can change this by selecting Receive Grafana Alerts in a data source configuration.
</div>
}
callToActionElement={<LinkButton href="/datasources">Go to data sources</LinkButton>}
className={styles.externalDsCTA}
/>
)}
{alertmanagers.length > 0 && (
<div className={styles.externalDs}>
{alertmanagers.map((am) => (
<ExternalAMdataSourceCard key={am.dataSourceSettings.uid} alertmanager={am} inactive={inactive} />
))}
</div>
)}
</>
);
}
interface ExternalAMdataSourceCardProps {
alertmanager: ExternalAlertmanagerDataSourceWithStatus;
inactive: boolean;
}
export function ExternalAMdataSourceCard({ alertmanager, inactive }: ExternalAMdataSourceCardProps) {
const styles = useStyles2(getStyles);
const { dataSourceSettings, status } = alertmanager;
return (
<Card>
<Card.Heading className={styles.externalHeading}>
{dataSourceSettings.name}{' '}
{status === 'inconclusive' && (
<Tooltip content="Multiple Alertmanagers have the same URL configured. The state might be inconclusive.">
<Icon name="exclamation-triangle" size="md" className={styles.externalWarningIcon} />
</Tooltip>
)}
</Card.Heading>
<Card.Figure>
<img
src="public/app/plugins/datasource/alertmanager/img/logo.svg"
alt=""
height="40px"
width="40px"
style={{ objectFit: 'contain' }}
/>
</Card.Figure>
<Card.Tags>
{inactive ? (
<Badge
text="Inactive"
color="red"
tooltip="Grafana is configured to send alerts to the built-in internal Alertmanager only. External Alertmanagers do not receive any alerts."
/>
) : (
<Badge
text={capitalize(status)}
color={status === 'dropped' ? 'red' : status === 'active' ? 'green' : 'orange'}
/>
)}
</Card.Tags>
<Card.Meta>{dataSourceSettings.url}</Card.Meta>
<Card.Actions>
<LinkButton href={makeDataSourceLink(dataSourceSettings.uid)} size="sm" variant="secondary">
Go to datasource
</LinkButton>
</Card.Actions>
</Card>
);
}
export const getStyles = (theme: GrafanaTheme2) => ({
muted: css`
font-size: ${theme.typography.bodySmall.fontSize};
line-height: ${theme.typography.bodySmall.lineHeight};
color: ${theme.colors.text.secondary};
`,
externalHeading: css`
justify-content: flex-start;
`,
externalWarningIcon: css`
margin: ${theme.spacing(0, 1)};
fill: ${theme.colors.warning.main};
`,
externalDs: css`
display: grid;
gap: ${theme.spacing(1)};
padding: ${theme.spacing(2, 0)};
`,
externalDsCTA: css`
margin: ${theme.spacing(2, 0)};
`,
});

View File

@@ -1,99 +0,0 @@
import { css } from '@emotion/css';
import React, { useEffect } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Alert, Field, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { loadDataSources } from 'app/features/datasources/state/actions';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
import { alertmanagerApi } from '../../api/alertmanagerApi';
import { useExternalDataSourceAlertmanagers } from '../../hooks/useExternalAmSelector';
import { ExternalAlertmanagerDataSources } from './ExternalAlertmanagerDataSources';
const alertmanagerChoices: Array<SelectableValue<AlertmanagerChoice>> = [
{ value: AlertmanagerChoice.Internal, label: 'Only Internal' },
{ value: AlertmanagerChoice.External, label: 'Only External' },
{ value: AlertmanagerChoice.All, label: 'Both internal and external' },
];
export const ExternalAlertmanagers = () => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const externalDsAlertManagers = useExternalDataSourceAlertmanagers();
const gmaHandlingAlertmanagers = externalDsAlertManagers.filter(
(settings) => settings.dataSourceSettings.jsonData.handleGrafanaManagedAlerts === true
);
const {
useSaveExternalAlertmanagersConfigMutation,
useGetExternalAlertmanagerConfigQuery,
useGetExternalAlertmanagersQuery,
} = alertmanagerApi;
const [saveExternalAlertManagers] = useSaveExternalAlertmanagersConfigMutation();
const { currentData: externalAlertmanagerConfig } = useGetExternalAlertmanagerConfigQuery();
// Just to refresh the status periodically
useGetExternalAlertmanagersQuery(undefined, { pollingInterval: 5000 });
const alertmanagersChoice = externalAlertmanagerConfig?.alertmanagersChoice;
useEffect(() => {
dispatch(loadDataSources());
}, [dispatch]);
const onChangeAlertmanagerChoice = (alertmanagersChoice: AlertmanagerChoice) => {
saveExternalAlertManagers({ alertmanagersChoice });
};
return (
<div>
<h4>External Alertmanagers</h4>
<Alert title="External Alertmanager changes" severity="info">
The way you configure external Alertmanagers has changed.
<br />
You can now use configured Alertmanager data sources as receivers of your Grafana-managed alerts.
<br />
For more information, refer to our documentation.
</Alert>
<div className={styles.amChoice}>
<Field
label="Send alerts to"
description="Configures how the Grafana alert rule evaluation engine Alertmanager handles your alerts. Internal (Grafana built-in Alertmanager), External (All Alertmanagers configured below), or both."
>
<RadioButtonGroup
options={alertmanagerChoices}
value={alertmanagersChoice}
onChange={(value) => onChangeAlertmanagerChoice(value!)}
/>
</Field>
</div>
<ExternalAlertmanagerDataSources
alertmanagers={gmaHandlingAlertmanagers}
inactive={alertmanagersChoice === AlertmanagerChoice.Internal}
/>
</div>
);
};
export const getStyles = (theme: GrafanaTheme2) => ({
url: css`
margin-right: ${theme.spacing(1)};
`,
actions: css`
margin-top: ${theme.spacing(2)};
display: flex;
justify-content: flex-end;
`,
table: css`
margin-bottom: ${theme.spacing(2)};
`,
amChoice: css`
margin-bottom: ${theme.spacing(4)};
`,
});

View File

@@ -1,20 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Admin config Editable alertmanager config 1`] = `
[
"CloudManager",
{
"alertmanager_config": {
"receivers": [
{
"name": "default ",
},
],
"route": {
"receiver": "old one",
},
},
"template_files": {},
},
]
`;

View File

@@ -8,10 +8,11 @@ interface NeedHelpInfoProps {
contentText: string | JSX.Element;
externalLink?: string;
linkText?: string;
title: string;
title?: string;
}
export function NeedHelpInfo({ contentText, externalLink, linkText, title }: NeedHelpInfoProps) {
export function NeedHelpInfo({ contentText, externalLink, linkText, title = 'Need help?' }: NeedHelpInfoProps) {
const styles = useStyles2(getStyles);
return (
<Toggletip
content={<div className={styles.mutedText}>{contentText}</div>}

View File

@@ -1,13 +1,13 @@
import { http, HttpResponse, RequestHandler } from 'msw';
import { setupServer } from 'msw/node';
import { AlertmanagersChoiceResponse } from 'app/features/alerting/unified/api/alertmanagerApi';
import { GrafanaAlertingConfigurationStatusResponse } from 'app/features/alerting/unified/api/alertmanagerApi';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { alertmanagerChoiceHandler } from '../../../mocks/alertmanagerApi';
import { grafanaAlertingConfigurationStatusHandler } from '../../../mocks/alertmanagerApi';
const alertmanagerChoiceMockedResponse: AlertmanagersChoiceResponse = {
const grafanaAlertingConfigurationMockedResponse: GrafanaAlertingConfigurationStatusResponse = {
alertmanagersChoice: AlertmanagerChoice.Internal,
numExternalAlertmanagers: 0,
};
@@ -21,7 +21,7 @@ const folderAccess = {
export function createMockGrafanaServer(...handlers: RequestHandler[]) {
const folderHandler = mockFolderAccess(folderAccess);
const amChoiceHandler = alertmanagerChoiceHandler(alertmanagerChoiceMockedResponse);
const amChoiceHandler = grafanaAlertingConfigurationStatusHandler(grafanaAlertingConfigurationMockedResponse);
return setupServer(folderHandler, amChoiceHandler, ...handlers);
}

View File

@@ -13,7 +13,7 @@ import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { configureStore } from 'app/store/configureStore';
import { CombinedRule } from 'app/types/unified-alerting';
import { AlertmanagersChoiceResponse } from '../../api/alertmanagerApi';
import { GrafanaAlertingConfigurationStatusResponse } from '../../api/alertmanagerApi';
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
import { getCloudRule, getGrafanaRule } from '../../mocks';
import { mockAlertmanagerChoiceResponse } from '../../mocks/alertmanagerApi';
@@ -50,7 +50,7 @@ const server = setupServer(
})
);
const alertmanagerChoiceMockedResponse: AlertmanagersChoiceResponse = {
const alertmanagerChoiceMockedResponse: GrafanaAlertingConfigurationStatusResponse = {
alertmanagersChoice: AlertmanagerChoice.Internal,
numExternalAlertmanagers: 0,
};

View File

@@ -0,0 +1,161 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { render } from 'test/test-utils';
import { ConnectionStatus } from '../../hooks/useExternalAmSelector';
import { AlertmanagerCard } from './AlertmanagerCard';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
useReturnToPrevious: jest.fn(),
}));
describe('Alertmanager card', () => {
it('should show metadata', () => {
render(
<AlertmanagerCard
href="/datasource/foo"
url="http://alertmanager:9090/"
implementation="mimir"
logo="https://image.png"
receiving={true}
status="active"
name="External Alertmanager"
onEditConfiguration={jest.fn()}
onEnable={jest.fn()}
onDisable={jest.fn()}
/>
);
// check metadata
const link = screen.getByRole('link', { name: 'External Alertmanager' });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', '/datasource/foo');
expect(screen.getByText(/Receiving/i)).toBeInTheDocument();
expect(
screen.getByText((_, element) => element?.textContent === `Mimir|http://alertmanager:9090/`)
).toBeInTheDocument();
expect(screen.getByRole('img')).toHaveAttribute('src', 'https://image.png');
});
it('should show correct buttons for disabled alertmanager', async () => {
const onEditConfiguration = jest.fn();
const onEnable = jest.fn();
const onDisable = jest.fn();
render(
<AlertmanagerCard
name="Grafana built-in"
onEditConfiguration={onEditConfiguration}
onEnable={onEnable}
onDisable={onDisable}
/>
);
// check actions
const enableButton = screen.getByRole('button', { name: 'Enable' });
await userEvent.click(enableButton);
expect(onEnable).toHaveBeenCalled();
const editConfigurationButton = screen.getByRole('button', { name: 'Edit configuration' });
await userEvent.click(editConfigurationButton);
expect(onEditConfiguration).toHaveBeenCalled();
});
it('should show correct buttons for enabled alertmanager', async () => {
const onDisable = jest.fn();
render(
<AlertmanagerCard
name="Grafana built-in"
receiving={true}
onEditConfiguration={jest.fn()}
onEnable={jest.fn()}
onDisable={onDisable}
/>
);
// check actions
const disableButton = screen.getByRole('button', { name: 'Disable' });
await userEvent.click(disableButton);
expect(onDisable).toHaveBeenCalled();
});
it('should not show edit / enable buttons for provisioned alertmanager', () => {
render(
<AlertmanagerCard
name="External Alertmanager"
receiving={false}
provisioned={true}
onEditConfiguration={jest.fn()}
onEnable={jest.fn()}
onDisable={jest.fn()}
/>
);
// should not have "edit configuration"
const editConfigurationButton = screen.queryByRole('button', { name: 'Edit configuration' });
expect(editConfigurationButton).not.toBeInTheDocument();
// should have "view configuration"
const viewButton = screen.getByRole('button', { name: 'View configuration' });
expect(viewButton).toBeInTheDocument();
const enableButton = screen.queryByRole('button', { name: 'Enable' });
expect(enableButton).not.toBeInTheDocument();
});
it('should show correct buttons for read-only (vanilla) alertmanager', () => {
render(
<AlertmanagerCard
name="External Alertmanager"
receiving={false}
provisioned={false}
readOnly={true}
onEditConfiguration={jest.fn()}
onEnable={jest.fn()}
onDisable={jest.fn()}
/>
);
// should not have "edit configuration"
const editConfigurationButton = screen.queryByRole('button', { name: 'Edit configuration' });
expect(editConfigurationButton).not.toBeInTheDocument();
// should have "view configuration"
const viewButton = screen.getByRole('button', { name: 'View configuration' });
expect(viewButton).toBeInTheDocument();
// should be able to enable / disable
const enableButton = screen.getByRole('button', { name: 'Enable' });
expect(enableButton).toBeInTheDocument();
});
it('should render correct status', () => {
render(cardWithStatus('active'));
expect(screen.getByText(/Receiving/)).toBeInTheDocument();
render(cardWithStatus('dropped'));
expect(screen.getByText(/Failed to adopt/)).toBeInTheDocument();
render(cardWithStatus('pending'));
expect(screen.getByText(/Activation in progress/)).toBeInTheDocument();
render(cardWithStatus('inconclusive'));
expect(screen.getByText(/Inconclusive/)).toBeInTheDocument();
});
});
const cardWithStatus = (status: ConnectionStatus) => (
<AlertmanagerCard
name="External Alertmanager"
receiving={true}
status={status}
onEditConfiguration={jest.fn()}
onEnable={jest.fn()}
onDisable={jest.fn()}
/>
);

View File

@@ -0,0 +1,99 @@
import { capitalize } from 'lodash';
import React from 'react';
import { Card, Badge, Button, Stack, Text, TextLink } from '@grafana/ui';
import { ConnectionStatus } from '../../hooks/useExternalAmSelector';
import { ProvisioningBadge } from '../Provisioning';
import { WithReturnButton } from '../WithReturnButton';
interface Props {
name: string;
href?: string;
url?: string;
logo?: string;
provisioned?: boolean;
readOnly?: boolean;
implementation?: string;
receiving?: boolean;
status?: ConnectionStatus;
// functions
onEditConfiguration: () => void;
onDisable: () => void;
onEnable: () => void;
}
export function AlertmanagerCard({
name,
href,
url,
logo = 'public/app/plugins/datasource/alertmanager/img/logo.svg',
provisioned = false,
readOnly = provisioned,
implementation,
receiving = false,
status = 'unknown',
onEditConfiguration,
onEnable,
onDisable,
}: Props) {
return (
<Card data-testid={`alertmanager-card-${name}`}>
<Card.Heading>
<Stack alignItems="center" gap={1}>
{href ? (
<WithReturnButton title="Alerting settings" component={<TextLink href={href}>{name}</TextLink>} />
) : (
name
)}
{provisioned && <ProvisioningBadge />}
</Stack>
</Card.Heading>
<Card.Figure>
<img alt={`logo for ${name}`} src={logo} />
</Card.Figure>
{/* sadly we have to resort to "mimicking" the Card.Description in here because "<div>"s can not be child elements of "<p>" which is what the description element wrapper is */}
<Card.Meta>
<Stack direction="column" gap={1} alignItems="flex-start">
<Card.Meta>
{implementation && capitalize(implementation)}
{url && url}
</Card.Meta>
{!receiving ? (
<Text variant="bodySmall">Not receiving Grafana managed alerts</Text>
) : (
<>
{status === 'pending' && <Badge text="Activation in progress" color="orange" />}
{status === 'active' && <Badge text="Receiving Grafana-managed alerts" color="green" />}
{status === 'dropped' && <Badge text="Failed to adopt Alertmanager" color="red" />}
{status === 'inconclusive' && <Badge text="Inconclusive" color="orange" />}
</>
)}
</Stack>
</Card.Meta>
{/* we'll use the "tags" area to append buttons and actions */}
<Card.Tags>
<Stack direction="row" gap={1}>
<Button onClick={onEditConfiguration} icon={readOnly ? 'eye' : 'edit'} variant="secondary" fill="outline">
{readOnly ? 'View configuration' : 'Edit configuration'}
</Button>
{provisioned ? null : (
<>
{receiving ? (
<Button icon="times" variant="destructive" fill="outline" onClick={onDisable}>
Disable
</Button>
) : (
<Button icon="check" variant="secondary" fill="outline" onClick={onEnable}>
Enable
</Button>
)}
</>
)}
</Stack>
</Card.Tags>
</Card>
);
}

View File

@@ -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(
<AlertmanagerProvider accessType="instance">
<AlertmanagerConfig
alertmanagerName={alertManagerSourceName}
onDismiss={onDismiss}
onSave={onSave}
onReset={onReset}
/>
</AlertmanagerProvider>
);
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();
});
});

View File

@@ -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<FormValues>({
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 (
<Alert severity="error" title="Failed to load Alertmanager configuration">
{loadingError.message ?? 'An unknown error occurred.'}
</Alert>
);
}
/* resetting configuration state */
if (isDeleting) {
return (
<Alert severity="info" title="Resetting Alertmanager configuration">
Resetting configuration, this might take a while.
</Alert>
);
}
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 (
<div className={styles.container}>
{/* form error state */}
{errors.configJSON && (
<Alert severity="error" title="Oops, something went wrong">
{errors.configJSON.message || 'An unknown error occurred.'}
</Alert>
)}
{isLoadingSuccessful && (
<div className={styles.content}>
<AutoSizer>
{({ height, width }) => (
<CodeEditor
language="json"
width={width}
height={height}
showLineNumbers={true}
monacoOptions={{
scrollBeyondLastLine: false,
}}
value={defaultValues.configJSON}
showMiniMap={false}
onSave={(value) => setValue('configJSON', value)}
onBlur={(value) => setValue('configJSON', value)}
readOnly={isOperating}
/>
)}
</AutoSizer>
</div>
)}
<Stack justifyContent="flex-end">
{!readOnly && (
<Button variant="destructive" onClick={() => setShowResetConfirmation(true)} disabled={isOperating}>
Reset
</Button>
)}
<Spacer />
<Button variant="secondary" onClick={() => onDismiss()} disabled={isOperating}>
Cancel
</Button>
{!readOnly && (
<Button variant="primary" onClick={handleSave} disabled={isOperating}>
Save
</Button>
)}
</Stack>
<ConfirmModal
isOpen={showResetConfirmation}
title="Reset Alertmanager configuration"
body={confirmationText}
confirmText="Yes, reset configuration"
onConfirm={() => {
onReset(alertmanagerName);
setShowResetConfirmation(false);
}}
onDismiss={() => {
setShowResetConfirmation(false);
}}
/>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
display: 'flex',
flexDirection: 'column',
height: '100%',
gap: theme.spacing(2),
}),
content: css({
flex: '1 1 100%',
}),
});

View File

@@ -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<ActiveTab>('configuration');
const [dataSourceName, setDataSourceName] = useState<string | undefined>();
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 (
<Drawer
onClose={handleDismiss}
title={title}
subtitle="Edit the Alertmanager configuration"
tabs={
<TabsBar>
<Tab
label="JSON Model"
key="configuration"
icon="arrow"
active={activeTab === 'configuration'}
onChangeTab={() => setActiveTab('configuration')}
/>
<Tab
label="Versions"
key="versions"
icon="history"
active={activeTab === 'versions'}
onChangeTab={() => setActiveTab('versions')}
hidden={!isGrafanaAlertmanager}
/>
</TabsBar>
}
>
{activeTab === 'configuration' && dataSourceName && (
<AlertmanagerConfig
alertmanagerName={dataSourceName}
onDismiss={handleDismiss}
onSave={updateAlertmanagerSettings}
onReset={resetAlertmanagerSettings}
/>
)}
{activeTab === 'versions' && dataSourceName && (
<AlertmanagerConfigurationVersionManager alertmanagerName={dataSourceName} />
)}
</Drawer>
);
}, [open, dataSourceName, handleDismiss, activeTab, updateAlertmanagerSettings, resetAlertmanagerSettings]);
return [drawer, showConfiguration, handleDismiss] as const;
}

View File

@@ -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 (
<Stack direction="column" gap={0}>
{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 (
<AlertmanagerCard
key={uid}
name={name}
href={detailHref}
url={url}
provisioned={isProvisioned}
readOnly={isReadOnly}
implementation={jsonData.implementation ?? 'Prometheus'}
receiving={isReceiving}
status={status}
onEditConfiguration={handleEditConfiguration}
onDisable={() => disableAlertmanager(uid)}
onEnable={() => enableAlertmanager(uid)}
/>
);
})}
</Stack>
);
};

View File

@@ -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 (
<AlertmanagerCard
name="Grafana built-in"
logo="public/img/grafana_icon.svg"
status={status}
receiving={isReceiving}
onEditConfiguration={handleEditConfiguration}
onEnable={() => enableAlertmanager(GRAFANA_RULES_SOURCE_NAME)}
onDisable={() => disableAlertmanager(GRAFANA_RULES_SOURCE_NAME)}
/>
);
}

View File

@@ -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<Context | undefined>(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 <SettingsContext.Provider value={value}>{props.children}</SettingsContext.Provider>;
};
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;
}

View File

@@ -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<number | undefined>(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 <Alert title="Failed to load configuration history">{stringifyErrorLike(error)}</Alert>;
}
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<Column<VersionData>> = [
{
id: 'lastAppliedAt',
header: 'Last applied',
cell: LastAppliedCell,
},
{
id: 'diff',
disableGrow: true,
cell: ({ row, value }) => {
const isLatestConfiguration = row.index === 0;
if (isLatestConfiguration) {
return null;
}
return (
<Stack alignItems="baseline" gap={0.5}>
<Text color="success" variant="bodySmall">
+{value.added}
</Text>
<Text color="error" variant="bodySmall">
-{value.removed}
</Text>
</Stack>
);
},
},
{
id: 'actions',
disableGrow: true,
cell: ({ row }) => {
const isFirstItem = row.index === 0;
const versionID = Number(row.id);
return (
<Stack direction="row" alignItems="center" justifyContent="flex-end">
{isFirstItem ? (
<Badge text="Latest" color="blue" />
) : (
<>
<Button
variant="secondary"
size="sm"
icon="code-branch"
fill="outline"
onClick={() => {
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
</Button>
<Button
variant="secondary"
size="sm"
icon="history"
onClick={() => {
setActiveRestoreVersion(versionID);
showConfirmation();
}}
disabled={restoreVersionState.isLoading}
>
Restore
</Button>
</>
)}
</Stack>
);
},
},
];
if (restoreVersionState.isLoading) {
return (
<Alert severity="info" title="Restoring Alertmanager configuration">
This might take a while...
</Alert>
);
}
return (
<>
{activeComparison ? (
<CompareVersions
left={activeComparison[0]}
right={activeComparison[1]}
disabled={restoreVersionState.isLoading}
onCancel={() => {
setActiveRestoreVersion(undefined);
setActiveComparison(undefined);
hideConfirmation();
}}
onConfirm={() => {
showConfirmation();
}}
/>
) : (
<InteractiveTable pageSize={VERSIONS_PAGE_SIZE} columns={columns} data={rows} getRowId={(row) => row.id} />
)}
{/* TODO make this modal persist while restore is in progress */}
<ConfirmModal
isOpen={confirmRestore}
title={'Restore Version'}
body={'Are you sure you want to restore the configuration to this version? All unsaved changes will be lost.'}
confirmText={'Yes, restore configuration'}
onConfirm={() => {
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 (
<div className={styles.drawerWrapper}>
<div className={styles.diffWrapper}>
{/*
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
*/}
<DiffViewer newValue={left} oldValue={right} hideLineNumbers={true} />
</div>
<Stack direction="row" alignItems="center">
<Spacer />
<Button variant="secondary" onClick={onCancel} disabled={disabled}>
Return
</Button>
<Button icon="history" variant="primary" onClick={onConfirm} disabled={disabled}>
Restore
</Button>
</Stack>
</div>
);
}
const LastAppliedCell = ({ value }: CellProps<VersionData>) => {
const date = moment(value);
return (
<Stack direction="row" alignItems="center">
{date.toLocaleString()}
<Text variant="bodySmall" color="secondary">
{date.fromNow()}
</Text>
</Stack>
);
};
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 };

View File

@@ -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"
}
]
}
}

View File

@@ -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"
}
]

View File

@@ -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"
}
}

View File

@@ -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
}
]

View File

@@ -0,0 +1,3 @@
{
"alertmanagersChoice": "internal"
}

View File

@@ -0,0 +1,7 @@
{
"data": {
"activeAlertmanagers": [],
"droppedAlertmagers": []
},
"status": "success"
}

View File

@@ -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<AlertManagerDataSourceJsonData>({
uid: EXTERNAL_VANILLA_ALERTMANAGER_UID,
name: EXTERNAL_VANILLA_ALERTMANAGER_UID,
type: DataSourceType.Alertmanager,
jsonData: {
implementation: AlertManagerImplementation.prometheus,
},
}),
[PROVISIONED_VANILLA_ALERTMANAGER_UID]: mockDataSource<AlertManagerDataSourceJsonData>({
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());
};

View File

@@ -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;
};

View File

@@ -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 });

View File

@@ -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

View File

@@ -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,
});

View File

@@ -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<DataSourceSettings<AlertManagerDataSourceJsonData>>

View File

@@ -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<AlertManagerDataSourceJsonData>;
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<AlertManagerDataSourceJsonData>
): 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(/\/+$/, '');
}

View File

@@ -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<StoreState['unifiedAlerting']>) {
const defaultState = configureStore().getState();

View File

@@ -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)));
}

View File

@@ -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));
};

View File

@@ -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(),

View File

@@ -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<ExternalAlertmanagersResponse> => {
return withSerializedError(fetchExternalAlertmanagers());
}
);
export const fetchExternalAlertmanagersConfigAction = createAsyncThunk(
'unifiedAlerting/fetchExternAlertmanagersConfig',
(): Promise<ExternalAlertmanagerConfig> => {
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<void> => {
return withAppEvents(
withSerializedError(
(async () => {
await addAlertManagers(alertmanagerConfig);
thunkAPI.dispatch(fetchExternalAlertmanagersConfigAction());
})()
),
{
errorMessage: 'Failed adding alertmanagers',
successMessage: 'Alertmanagers updated',
}
);
}
);

View File

@@ -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,
});

View File

@@ -71,6 +71,12 @@ export function getExternalDsAlertManagers() {
return getAlertManagerDataSources().filter((ds) => ds.jsonData.handleGrafanaManagedAlerts);
}
export function isAlertmanagerDataSourceInterestedInAlerts(
dataSourceSettings: DataSourceSettings<AlertManagerDataSourceJsonData>
) {
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 {

View File

@@ -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;
}
};

View File

@@ -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',
},

View File

@@ -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}
/>
</div>
);

View File

@@ -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);

View File

@@ -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,

View File

@@ -87,7 +87,7 @@ export const ConfigEditor = (props: Props) => {
</div>
{options.jsonData.handleGrafanaManagedAlerts && (
<Text variant="bodySmall" color="secondary">
Make sure to enable the alert forwarding on the <Link to="/alerting/admin">admin page</Link>.
Make sure to enable the alert forwarding on the <Link to="/alerting/admin">settings page</Link>.
</Text>
)}
</div>

View File

@@ -278,7 +278,7 @@ export interface TestReceiversResult {
receivers: TestReceiversResultReceiver[];
}
export interface ExternalAlertmanagers {
export interface ExternalAlertmanagersConnectionStatus {
activeAlertManagers: AlertmanagerUrl[];
droppedAlertManagers: AlertmanagerUrl[];
}
@@ -287,8 +287,8 @@ export interface AlertmanagerUrl {
url: string;
}
export interface ExternalAlertmanagersResponse {
data: ExternalAlertmanagers;
export interface ExternalAlertmanagersStatusResponse {
data: ExternalAlertmanagersConnectionStatus;
}
export enum AlertmanagerChoice {
@@ -297,7 +297,7 @@ export enum AlertmanagerChoice {
All = 'all',
}
export interface ExternalAlertmanagerConfig {
export interface GrafanaAlertingConfiguration {
alertmanagersChoice: AlertmanagerChoice;
}

View File

@@ -887,7 +887,7 @@
"title": "Meldungen"
},
"alerting-admin": {
"title": "Administrator"
"title": "Einstellungen"
},
"alerting-am-routes": {
"subtitle": "Lege fest, wie Warnungen an Kontaktpunkte weitergeleitet werden",

View File

@@ -887,7 +887,8 @@
"title": "Alerting"
},
"alerting-admin": {
"title": "Admin"
"title": "Settings",
"subtitle": "Manage Alertmanager configurations and configure where alert instances generated from Grafana managed alert rules are sent"
},
"alerting-am-routes": {
"subtitle": "Determine how alerts are routed to contact points",

View File

@@ -887,7 +887,7 @@
"title": "Alertas"
},
"alerting-admin": {
"title": "Administrador"
"title": "Configuración"
},
"alerting-am-routes": {
"subtitle": "Determinar cómo se enrutan las alertas a los puntos de contacto",

View File

@@ -887,7 +887,7 @@
"title": "Alertes"
},
"alerting-admin": {
"title": "Administrateur"
"title": "Paramètres"
},
"alerting-am-routes": {
"subtitle": "Déterminer comment les alertes sont acheminées vers les points de contact",

View File

@@ -887,7 +887,7 @@
"title": "Åľęřŧįʼnģ"
},
"alerting-admin": {
"title": "Åđmįʼn"
"title": "Ŝęŧŧįʼnģş"
},
"alerting-am-routes": {
"subtitle": "Đęŧęřmįʼnę ĥőŵ äľęřŧş äřę řőūŧęđ ŧő čőʼnŧäčŧ pőįʼnŧş",

View File

@@ -881,7 +881,7 @@
"title": "警报"
},
"alerting-admin": {
"title": "管理员"
"title": "设置"
},
"alerting-am-routes": {
"subtitle": "确定警报如何路由到联络点",