From 5e25afe6e970ec8c89f663d3e36402f3b4c5fbaa Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Fri, 3 May 2024 17:42:42 +0200 Subject: [PATCH] Alerting: New settings page (#84501) --- .betterer.results | 20 -- pkg/services/navtree/navtreeimpl/navtree.go | 2 +- .../app/core/utils/navBarItem-translations.ts | 9 +- public/app/features/alerting/routes.tsx | 2 +- .../app/features/alerting/unified/Admin.tsx | 27 -- .../features/alerting/unified/AlertGroups.tsx | 4 +- .../features/alerting/unified/Analytics.ts | 12 + .../unified/RuleEditorCloudRules.test.tsx | 4 +- .../unified/RuleEditorGrafanaRules.test.tsx | 4 +- .../alerting/unified/Settings.test.tsx | 123 +++++++ .../features/alerting/unified/Settings.tsx | 51 +++ .../alerting/unified/api/alertingApi.ts | 3 +- .../alerting/unified/api/alertmanager.ts | 39 --- .../alerting/unified/api/alertmanagerApi.ts | 58 ++-- .../alerting/unified/api/dataSourcesApi.ts | 19 +- .../GrafanaAlertmanagerDeliveryWarning.tsx | 9 +- .../admin/AlertmanagerConfig.test.tsx | 155 --------- .../components/admin/AlertmanagerConfig.tsx | 125 ------- .../admin/AlertmanagerConfigSelector.tsx | 110 ------ .../unified/components/admin/ConfigEditor.tsx | 106 ------ .../admin/ExternalAlertmanagerDataSources.tsx | 124 ------- .../admin/ExternalAlertmanagers.tsx | 99 ------ .../AlertmanagerConfig.test.tsx.snap | 20 -- .../components/rule-editor/NeedHelpInfo.tsx | 5 +- .../rule-viewer/__mocks__/server.ts | 8 +- .../components/rules/RuleDetails.test.tsx | 4 +- .../settings/AlertmanagerCard.test.tsx | 161 +++++++++ .../components/settings/AlertmanagerCard.tsx | 99 ++++++ .../settings/AlertmanagerConfig.test.tsx | 106 ++++++ .../settings/AlertmanagerConfig.tsx | 199 +++++++++++ .../settings/ConfigurationDrawer.tsx | 79 +++++ .../settings/ExternalAlertmanagers.tsx | 73 ++++ .../settings/InternalAlertmanager.tsx | 32 ++ .../components/settings/SettingsContext.tsx | 182 ++++++++++ .../components/settings/VersionManager.tsx | 324 ++++++++++++++++++ .../grafana/config/api/v1/alerts.json | 30 ++ .../alertmanager/grafana/config/history.json | 16 + .../vanilla prometheus/api/v2/status.json | 23 ++ .../settings/__mocks__/api/datasources.json | 42 +++ .../api/v1/ngalert/admin_config.json | 3 + .../api/v1/ngalert/alertmanagers.json | 7 + .../components/settings/__mocks__/server.ts | 104 ++++++ .../unified/components/settings/hooks.tsx | 34 ++ .../unified/hooks/useAbilities.test.tsx | 4 +- .../alerting/unified/hooks/useAbilities.ts | 8 +- .../unified/hooks/useAlertmanagerConfig.ts | 2 + .../hooks/useExternalAMSelector.test.tsx | 34 +- .../unified/hooks/useExternalAmSelector.ts | 30 +- public/app/features/alerting/unified/mocks.ts | 4 + .../alerting/unified/mocks/alertmanagerApi.ts | 22 +- .../unified/mocks/server/configure.ts | 4 +- .../alerting/unified/mocks/server/handlers.ts | 4 +- .../alerting/unified/state/actions.ts | 37 -- .../alerting/unified/state/reducers.ts | 6 - .../alerting/unified/utils/datasource.ts | 12 +- .../alerting/unified/utils/settings.ts | 13 + .../__mocks__/store.navIndex.mock.ts | 4 +- .../settings/version-history/DiffViewer.tsx | 3 +- .../settings/version-history/utils.ts | 4 +- .../__mocks__/store.navIndex.mock.ts | 28 +- .../datasource/alertmanager/ConfigEditor.tsx | 2 +- .../plugins/datasource/alertmanager/types.ts | 8 +- public/locales/de-DE/grafana.json | 2 +- public/locales/en-US/grafana.json | 3 +- public/locales/es-ES/grafana.json | 2 +- public/locales/fr-FR/grafana.json | 2 +- public/locales/pseudo-LOCALE/grafana.json | 2 +- public/locales/zh-Hans/grafana.json | 2 +- 68 files changed, 1923 insertions(+), 975 deletions(-) delete mode 100644 public/app/features/alerting/unified/Admin.tsx create mode 100644 public/app/features/alerting/unified/Settings.test.tsx create mode 100644 public/app/features/alerting/unified/Settings.tsx delete mode 100644 public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx delete mode 100644 public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx delete mode 100644 public/app/features/alerting/unified/components/admin/AlertmanagerConfigSelector.tsx delete mode 100644 public/app/features/alerting/unified/components/admin/ConfigEditor.tsx delete mode 100644 public/app/features/alerting/unified/components/admin/ExternalAlertmanagerDataSources.tsx delete mode 100644 public/app/features/alerting/unified/components/admin/ExternalAlertmanagers.tsx delete mode 100644 public/app/features/alerting/unified/components/admin/__snapshots__/AlertmanagerConfig.test.tsx.snap create mode 100644 public/app/features/alerting/unified/components/settings/AlertmanagerCard.test.tsx create mode 100644 public/app/features/alerting/unified/components/settings/AlertmanagerCard.tsx create mode 100644 public/app/features/alerting/unified/components/settings/AlertmanagerConfig.test.tsx create mode 100644 public/app/features/alerting/unified/components/settings/AlertmanagerConfig.tsx create mode 100644 public/app/features/alerting/unified/components/settings/ConfigurationDrawer.tsx create mode 100644 public/app/features/alerting/unified/components/settings/ExternalAlertmanagers.tsx create mode 100644 public/app/features/alerting/unified/components/settings/InternalAlertmanager.tsx create mode 100644 public/app/features/alerting/unified/components/settings/SettingsContext.tsx create mode 100644 public/app/features/alerting/unified/components/settings/VersionManager.tsx create mode 100644 public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/grafana/config/api/v1/alerts.json create mode 100644 public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/grafana/config/history.json create mode 100644 public/app/features/alerting/unified/components/settings/__mocks__/api/alertmanager/vanilla prometheus/api/v2/status.json create mode 100644 public/app/features/alerting/unified/components/settings/__mocks__/api/datasources.json create mode 100644 public/app/features/alerting/unified/components/settings/__mocks__/api/v1/ngalert/admin_config.json create mode 100644 public/app/features/alerting/unified/components/settings/__mocks__/api/v1/ngalert/alertmanagers.json create mode 100644 public/app/features/alerting/unified/components/settings/__mocks__/server.ts create mode 100644 public/app/features/alerting/unified/components/settings/hooks.tsx create mode 100644 public/app/features/alerting/unified/utils/settings.ts diff --git a/.betterer.results b/.betterer.results index ca8e33efea0..e1ef6ce4716 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"], diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 504e5d3af31..4647808a04c 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -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", }) } diff --git a/public/app/core/utils/navBarItem-translations.ts b/public/app/core/utils/navBarItem-translations.ts index 61969eb5d03..5e010fb7a70 100644 --- a/public/app/core/utils/navBarItem-translations.ts +++ b/public/app/core/utils/navBarItem-translations.ts @@ -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'); diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 6272ab22e76..c34f680bc8e 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -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') ), }, ]; diff --git a/public/app/features/alerting/unified/Admin.tsx b/public/app/features/alerting/unified/Admin.tsx deleted file mode 100644 index d086bfabc13..00000000000 --- a/public/app/features/alerting/unified/Admin.tsx +++ /dev/null @@ -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 ( - - - - ); -} - -function AdminPageContents() { - const { selectedAlertmanager } = useAlertmanager(); - const isGrafanaAmSelected = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME; - - return ( - <> - - {isGrafanaAmSelected && } - - ); -} diff --git a/public/app/features/alerting/unified/AlertGroups.tsx b/public/app/features/alerting/unified/AlertGroups.tsx index 52632ce32e7..3aa683742eb 100644 --- a/public/app/features/alerting/unified/AlertGroups.tsx +++ b/public/app/features/alerting/unified/AlertGroups.tsx @@ -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; diff --git a/public/app/features/alerting/unified/Analytics.ts b/public/app/features/alerting/unified/Analytics.ts index ef58a579ec2..e7a6f1929be 100644 --- a/public/app/features/alerting/unified/Analytics.ts +++ b/public/app/features/alerting/unified/Analytics.ts @@ -80,6 +80,18 @@ export function withPromRulesMetadataLogging P }; } +type FormErrors = Record>; +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; diff --git a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx index e17a7c7a2bf..e0fac544479 100644 --- a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx @@ -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 }); diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx index ebfdcb91742..66d11fd9c22 100644 --- a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx @@ -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; diff --git a/public/app/features/alerting/unified/Settings.test.tsx b/public/app/features/alerting/unified/Settings.test.tsx new file mode 100644 index 00000000000..5fb3835d3e5 --- /dev/null +++ b/public/app/features/alerting/unified/Settings.test.tsx @@ -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(); + + 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(); + withExternalOnlySetting(server); + + await waitFor(() => { + expect(ui.statusReceiving.query()).not.toBeInTheDocument(); + }); + }); + + it('should be able to view configuration', async () => { + render(); + + // 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(); + + // 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(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/Settings.tsx b/public/app/features/alerting/unified/Settings.tsx new file mode 100644 index 00000000000..87a76606b6f --- /dev/null +++ b/public/app/features/alerting/unified/Settings.tsx @@ -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 ( + + + + ); +} + +function SettingsContent() { + const [configurationDrawer, showConfiguration] = useEditConfigurationDrawer(); + const { isLoading } = useSettings(); + + return ( + + Add new Alertmanager + + } + />, + ]} + > + + {/* Grafana built-in Alertmanager */} + Built-in Alertmanager + + {/* other (external Alertmanager data sources we have added to Grafana such as vanilla, Mimir, Cortex) */} + Other Alertmanagers + + + {configurationDrawer} + + ); +} diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts index bb1dd3071d4..e90fc21aa6d 100644 --- a/public/app/features/alerting/unified/api/alertingApi.ts +++ b/public/app/features/alerting/unified/api/alertingApi.ts @@ -33,8 +33,9 @@ export const alertingApi = createApi({ reducerPath: 'alertingApi', baseQuery: backendSrvBaseQuery(), tagTypes: [ - 'AlertmanagerChoice', + 'AlertingConfiguration', 'AlertmanagerConfiguration', + 'AlertmanagerConnectionStatus', 'AlertmanagerAlerts', 'AlertmanagerSilences', 'OnCallIntegrations', diff --git a/public/app/features/alerting/unified/api/alertmanager.ts b/public/app/features/alerting/unified/api/alertmanager.ts index 97fd5bfe321..60481bc568d 100644 --- a/public/app/features/alerting/unified/api/alertmanager.ts +++ b/public/app/features/alerting/unified/api/alertmanager.ts @@ -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 { - 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 { - const result = await lastValueFrom( - getBackendSrv().fetch({ - method: 'GET', - url: '/api/v1/ngalert/alertmanagers', - }) - ); - - return result.data; -} - -export async function fetchExternalAlertmanagerConfig(): Promise { - const result = await lastValueFrom( - getBackendSrv().fetch({ - method: 'GET', - url: '/api/v1/ngalert/admin_config', - showErrorAlert: false, - }) - ); - - return result.data; -} diff --git a/public/app/features/alerting/unified/api/alertmanagerApi.ts b/public/app/features/alerting/unified/api/alertmanagerApi.ts index dc605c0bd6f..660967d4725 100644 --- a/public/app/features/alerting/unified/api/alertmanagerApi.ts +++ b/public/app/features/alerting/unified/api/alertmanagerApi.ts @@ -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({ - query: () => ({ url: '/api/v1/ngalert' }), - providesTags: ['AlertmanagerChoice'], - }), - - getExternalAlertmanagerConfig: build.query({ + // this endpoint requires administrator privileges + getGrafanaAlertingConfiguration: build.query({ query: () => ({ url: '/api/v1/ngalert/admin_config' }), - providesTags: ['AlertmanagerChoice'], + providesTags: ['AlertingConfiguration'], }), - getExternalAlertmanagers: build.query({ + // 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({ + 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({ 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({ + getAlertmanagerConfigurationHistory: build.query({ //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" diff --git a/public/app/features/alerting/unified/api/dataSourcesApi.ts b/public/app/features/alerting/unified/api/dataSourcesApi.ts index 94bb2de0937..73a664b6c2a 100644 --- a/public/app/features/alerting/unified/api/dataSourcesApi.ts +++ b/public/app/features/alerting/unified/api/dataSourcesApi.ts @@ -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>, void>({ + getAllDataSourceSettings: build.query({ 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({ + query: (uid) => ({ url: `api/datasources/uid/${uid}` }), + providesTags: (_result, _error, uid) => [{ type: 'DataSourceSettings', id: uid }], + }), + updateDataSourceSettingsForUID: build.mutation({ + 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 }], + }), }), }); diff --git a/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.tsx b/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.tsx index 9be6b64f41a..80b90bfd166 100644 --- a/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.tsx +++ b/public/app/features/alerting/unified/components/GrafanaAlertmanagerDeliveryWarning.tsx @@ -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 && diff --git a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx deleted file mode 100644 index 598a95f867a..00000000000 --- a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.test.tsx +++ /dev/null @@ -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( - - - - - - ); -}; - -const dataSources = { - alertManager: mockDataSource({ - name: 'CloudManager', - type: DataSourceType.Alertmanager, - }), - promAlertManager: mockDataSource({ - 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); - }); -}); diff --git a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx b/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx deleted file mode 100644 index d4c4054fe97..00000000000 --- a/public/app/features/alerting/unified/components/admin/AlertmanagerConfig.tsx +++ /dev/null @@ -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(); - - 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 ( -
- {loadingError && !loading && ( - <> - - {loadingError.message || 'Unknown error.'} - - - {selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME && ( - - )} - - )} - {isDeleting && selectedAlertmanager !== GRAFANA_RULES_SOURCE_NAME && ( - - It might take a while... - - )} - {selectedAlertmanager && config && ( - onSubmit(values)} - readOnly={readOnly} - loading={loading} - alertManagerSourceName={selectedAlertmanager} - showConfirmDeleteAMConfig={showConfirmDeleteAMConfig} - onReset={() => setShowConfirmDeleteAMConfig(true)} - onConfirmReset={resetConfig} - onDismiss={() => setShowConfirmDeleteAMConfig(false)} - /> - )} -
- ); -} - -const getStyles = (theme: GrafanaTheme2) => ({ - container: css` - margin-bottom: ${theme.spacing(4)}; - `, -}); diff --git a/public/app/features/alerting/unified/components/admin/AlertmanagerConfigSelector.tsx b/public/app/features/alerting/unified/components/admin/AlertmanagerConfigSelector.tsx deleted file mode 100644 index fb500258f4a..00000000000 --- a/public/app/features/alerting/unified/components/admin/AlertmanagerConfigSelector.tsx +++ /dev/null @@ -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 ? ( - <> -
Select a previous working configuration until you fix this error:
- -
- -