mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: New settings page (#84501)
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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')
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
123
public/app/features/alerting/unified/Settings.test.tsx
Normal file
123
public/app/features/alerting/unified/Settings.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
51
public/app/features/alerting/unified/Settings.tsx
Normal file
51
public/app/features/alerting/unified/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -33,8 +33,9 @@ export const alertingApi = createApi({
|
||||
reducerPath: 'alertingApi',
|
||||
baseQuery: backendSrvBaseQuery(),
|
||||
tagTypes: [
|
||||
'AlertmanagerChoice',
|
||||
'AlertingConfiguration',
|
||||
'AlertmanagerConfiguration',
|
||||
'AlertmanagerConnectionStatus',
|
||||
'AlertmanagerAlerts',
|
||||
'AlertmanagerSilences',
|
||||
'OnCallIntegrations',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)};
|
||||
`,
|
||||
});
|
||||
@@ -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)};
|
||||
`,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)};
|
||||
`,
|
||||
});
|
||||
@@ -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)};
|
||||
`,
|
||||
});
|
||||
@@ -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": {},
|
||||
},
|
||||
]
|
||||
`;
|
||||
@@ -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>}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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%',
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"alertmanagersChoice": "internal"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"data": {
|
||||
"activeAlertmanagers": [],
|
||||
"droppedAlertmagers": []
|
||||
},
|
||||
"status": "success"
|
||||
}
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>>
|
||||
|
||||
@@ -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(/\/+$/, '');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
13
public/app/features/alerting/unified/utils/settings.ts
Normal file
13
public/app/features/alerting/unified/utils/settings.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -887,7 +887,7 @@
|
||||
"title": "Meldungen"
|
||||
},
|
||||
"alerting-admin": {
|
||||
"title": "Administrator"
|
||||
"title": "Einstellungen"
|
||||
},
|
||||
"alerting-am-routes": {
|
||||
"subtitle": "Lege fest, wie Warnungen an Kontaktpunkte weitergeleitet werden",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -887,7 +887,7 @@
|
||||
"title": "Åľęřŧįʼnģ"
|
||||
},
|
||||
"alerting-admin": {
|
||||
"title": "Åđmįʼn"
|
||||
"title": "Ŝęŧŧįʼnģş"
|
||||
},
|
||||
"alerting-am-routes": {
|
||||
"subtitle": "Đęŧęřmįʼnę ĥőŵ äľęřŧş äřę řőūŧęđ ŧő čőʼnŧäčŧ pőįʼnŧş",
|
||||
|
||||
@@ -881,7 +881,7 @@
|
||||
"title": "警报"
|
||||
},
|
||||
"alerting-admin": {
|
||||
"title": "管理员"
|
||||
"title": "设置"
|
||||
},
|
||||
"alerting-am-routes": {
|
||||
"subtitle": "确定警报如何路由到联络点",
|
||||
|
||||
Reference in New Issue
Block a user