diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index ced0d8d394f..e51fd7cc27e 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -314,8 +314,16 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { ), }, { + // This route is for backward compatibility + // Previously we had a single admin page containing only the alertmanager settings + // We now have a separate route for the alertmanager settings and other settings can be added as extensions path: '/alerting/admin', roles: () => ['Admin'], + component: () => , + }, + { + path: '/alerting/admin/alertmanager', + roles: () => ['Admin'], component: importAlertingComponent( () => import(/* webpackChunkName: "AlertingSettings" */ 'app/features/alerting/unified/Settings') ), diff --git a/public/app/features/alerting/unified/Settings.test.tsx b/public/app/features/alerting/unified/Settings.test.tsx index 0d362c74b45..cba6f0a0308 100644 --- a/public/app/features/alerting/unified/Settings.test.tsx +++ b/public/app/features/alerting/unified/Settings.test.tsx @@ -7,6 +7,7 @@ import DataSourcesResponse from './components/settings/mocks/api/datasources.jso import { setupGrafanaManagedServer, withExternalOnlySetting } from './components/settings/mocks/server'; import { setupMswServer } from './mockApi'; import { grantUserRole } from './mocks'; +import { addSettingsSection, clearSettingsExtensions } from './settings/extensions'; jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), @@ -35,12 +36,23 @@ const ui = { versionsTab: byRole('tab', { name: /versions/i }), provisionedBadge: byText(/^Provisioned$/), + + // New selectors for extension tabs + alertmanagerTab: byRole('tab', { name: 'Alert managers' }), + enrichmentTab: byRole('tab', { name: 'Enrichment' }), + notificationsTab: byRole('tab', { name: 'Notifications' }), + customTab: (name: string) => byRole('tab', { name }), }; describe('Alerting settings', () => { beforeEach(() => { grantUserRole('ServerAdmin'); setupGrafanaManagedServer(server); + clearSettingsExtensions(); + }); + + afterEach(() => { + clearSettingsExtensions(); }); it('should render the page with Built-in only enabled, others disabled', async () => { @@ -131,4 +143,118 @@ describe('Alerting settings', () => { expect(enableButton).not.toBeInTheDocument(); expect(disableButton).not.toBeInTheDocument(); }); + + it('should display additional tabs when settings extensions are registered', async () => { + // Register extensions before rendering + addSettingsSection({ + id: 'enrichment', + text: 'Enrichment', + url: '/alerting/admin/enrichment', + icon: 'star', + }); + + addSettingsSection({ + id: 'notifications', + text: 'Notifications', + url: '/alerting/admin/notifications', + icon: 'bell', + }); + + render(, { + historyOptions: { + initialEntries: ['/alerting/admin/alertmanager'], + }, + }); + + // Wait for the page to load + await waitFor(() => expect(ui.builtInAlertmanagerSection.get()).toBeInTheDocument()); + + // Check that the extension tabs are visible + expect(ui.enrichmentTab.get()).toBeInTheDocument(); + expect(ui.notificationsTab.get()).toBeInTheDocument(); + }); + + it('should correctly show active state for extension tabs based on route', async () => { + // Register an extension + addSettingsSection({ + id: 'enrichment', + text: 'Enrichment', + url: '/alerting/admin/enrichment', + icon: 'star', + }); + + // Render with the extension route as active + render(, { + historyOptions: { + initialEntries: ['/alerting/admin/enrichment'], + }, + }); + + // Wait for the page to load + await waitFor(() => expect(ui.builtInAlertmanagerSection.get()).toBeInTheDocument()); + + // Check that the extension tab is visible and active + const enrichmentTab = ui.enrichmentTab.get(); + expect(enrichmentTab).toBeInTheDocument(); + expect(enrichmentTab).toHaveAttribute('aria-selected', 'true'); + + // Check that the default alertmanager tab is not active + const alertmanagerTab = ui.alertmanagerTab.get(); + expect(alertmanagerTab).toHaveAttribute('aria-selected', 'false'); + }); + + it('should handle multiple extensions correctly', async () => { + // Register multiple extensions + addSettingsSection({ + id: 'enrichment', + text: 'Enrichment', + url: '/alerting/admin/enrichment', + icon: 'star', + }); + + addSettingsSection({ + id: 'notifications', + text: 'Notifications', + url: '/alerting/admin/notifications', + icon: 'bell', + }); + + addSettingsSection({ + id: 'custom-settings', + text: 'Custom Settings', + url: '/alerting/admin/custom', + icon: 'cog', + }); + + render(, { + historyOptions: { + initialEntries: ['/alerting/admin/alertmanager'], + }, + }); + + // Wait for the page to load + await waitFor(() => expect(ui.builtInAlertmanagerSection.get()).toBeInTheDocument()); + + // Check that all tabs are visible + expect(ui.alertmanagerTab.get()).toBeInTheDocument(); + expect(ui.enrichmentTab.get()).toBeInTheDocument(); + expect(ui.notificationsTab.get()).toBeInTheDocument(); + expect(ui.customTab('Custom Settings').get()).toBeInTheDocument(); + }); + + it('should not show extension tabs when no extensions are registered', async () => { + render(, { + historyOptions: { + initialEntries: ['/alerting/admin/alertmanager'], + }, + }); + + // Wait for the page to load + await waitFor(() => expect(ui.builtInAlertmanagerSection.get()).toBeInTheDocument()); + + // Check that only the default alertmanager tab is visible + expect(ui.alertmanagerTab.get()).toBeInTheDocument(); + expect(ui.enrichmentTab.query()).not.toBeInTheDocument(); + expect(ui.notificationsTab.query()).not.toBeInTheDocument(); + }); }); diff --git a/public/app/features/alerting/unified/Settings.tsx b/public/app/features/alerting/unified/Settings.tsx index e1a1eea1679..f09e9036fd3 100644 --- a/public/app/features/alerting/unified/Settings.tsx +++ b/public/app/features/alerting/unified/Settings.tsx @@ -7,24 +7,28 @@ import { useEditConfigurationDrawer } from './components/settings/ConfigurationD import { ExternalAlertmanagers } from './components/settings/ExternalAlertmanagers'; import InternalAlertmanager from './components/settings/InternalAlertmanager'; import { SettingsProvider, useSettings } from './components/settings/SettingsContext'; +import { useSettingsPageNav } from './settings/navigation'; import { withPageErrorBoundary } from './withPageErrorBoundary'; -function SettingsPage() { +function AlertmanagerSettingsPage() { return ( - + ); } -function SettingsContent() { +function AlertmanagerSettingsContent() { const [configurationDrawer, showConfiguration] = useEditConfigurationDrawer(); const { isLoading } = useSettings(); + const { navId, pageNav } = useSettingsPageNav(); + return ( & { + url: SettingsSectionUrl; +}; + +const settingsExtensions: Map = new Map(); + +/** + * Registers a new settings section that will appear as a tab in the alerting settings page. + * @param pageNav - The navigation configuration for the settings section + */ +export function addSettingsSection(pageNav: SettingsSectionNav) { + if (settingsExtensions.has(pageNav.url)) { + console.warn('Unable to add settings page, PageNav must have an unique url'); + return; + } + settingsExtensions.set(pageNav.url, { nav: pageNav }); +} + +/** + * Returns the navigation configuration for all settings extensions. + */ +export function useSettingsExtensionsNav(): NavModelItem[] { + const location = useLocation(); + + const navIndex = useSelector((state) => state.navIndex); + const settingsNav = navIndex['alerting-admin']; + + // Build extension tabs from settingsExtensions + const extensionTabs: NavModelItem[] = Array.from(settingsExtensions.entries()).map(([url, { nav }]) => ({ + ...nav, + active: location.pathname === url, + url: url, + parentItem: settingsNav, + })); + + return extensionTabs; +} + +/** + * ONLY USE FOR TESTING. Clears all settings extensions. + */ +export function clearSettingsExtensions() { + settingsExtensions.clear(); +} diff --git a/public/app/features/alerting/unified/settings/navigation.test.ts b/public/app/features/alerting/unified/settings/navigation.test.ts new file mode 100644 index 00000000000..c4b4c4678ef --- /dev/null +++ b/public/app/features/alerting/unified/settings/navigation.test.ts @@ -0,0 +1,111 @@ +import { renderHook } from '@testing-library/react'; +import { getWrapper } from 'test/test-utils'; + +import { addSettingsSection, clearSettingsExtensions } from './extensions'; +import { useSettingsPageNav } from './navigation'; + +describe('useSettingsPageNav', () => { + const mockNavIndex = { + 'alerting-admin': { + id: 'alerting-admin', + text: 'Settings', + url: '/alerting/admin', + }, + }; + + const defaultPreloadedState = { + navIndex: mockNavIndex, + }; + + it('should return settings page nav with alertmanager child when no extensions are present', () => { + const wrapper = getWrapper({ + preloadedState: defaultPreloadedState, + renderWithRouter: true, + historyOptions: { + initialEntries: ['/alerting/admin/alertmanager'], + }, + }); + + const { result } = renderHook(() => useSettingsPageNav(), { wrapper }); + + expect(result.current.navId).toBe('alerting'); + + // Check the structure + // eslint-disable-next-line testing-library/no-node-access + expect(result.current.pageNav.children).toHaveLength(1); + // eslint-disable-next-line testing-library/no-node-access + expect(result.current.pageNav.children).toEqual([ + expect.objectContaining({ + id: 'alertmanager', + text: 'Alert managers', + url: '/alerting/admin/alertmanager', + active: true, + icon: 'cloud', + parentItem: undefined, + }), + ]); + }); + + it('should include extensions when added via addSettingsSection', () => { + // Clear any existing extensions + clearSettingsExtensions(); + + // Add two extensions + addSettingsSection({ + id: 'enrichment', + text: 'Enrichment', + url: '/alerting/admin/enrichment', + icon: 'star', + }); + + addSettingsSection({ + id: 'notifications', + text: 'Notifications', + url: '/alerting/admin/notifications', + icon: 'bell', + }); + + const wrapper = getWrapper({ + preloadedState: defaultPreloadedState, + renderWithRouter: true, + historyOptions: { + initialEntries: ['/alerting/admin/enrichment'], + }, + }); + + const { result } = renderHook(() => useSettingsPageNav(), { wrapper }); + + expect(result.current.navId).toBe('alerting'); + + // Should have 3 children: alertmanager + 2 extensions + // eslint-disable-next-line testing-library/no-node-access + expect(result.current.pageNav.children).toHaveLength(3); + // eslint-disable-next-line testing-library/no-node-access + expect(result.current.pageNav.children).toEqual([ + expect.objectContaining({ + id: 'alertmanager', + text: 'Alert managers', + url: '/alerting/admin/alertmanager', + active: false, + icon: 'cloud', + parentItem: undefined, + }), + expect.objectContaining({ + id: 'enrichment', + text: 'Enrichment', + url: '/alerting/admin/enrichment', + active: true, + icon: 'star', + parentItem: undefined, + }), + expect.objectContaining({ + id: 'notifications', + text: 'Notifications', + url: '/alerting/admin/notifications', + active: false, + icon: 'bell', + parentItem: undefined, + }), + ]); + }); +}); diff --git a/public/app/features/alerting/unified/settings/navigation.ts b/public/app/features/alerting/unified/settings/navigation.ts new file mode 100644 index 00000000000..85513269e1f --- /dev/null +++ b/public/app/features/alerting/unified/settings/navigation.ts @@ -0,0 +1,40 @@ +import { useLocation } from 'react-router-dom-v5-compat'; + +import { NavModelItem } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { useSelector } from 'app/types/store'; + +import { useSettingsExtensionsNav } from './extensions'; + +export function useSettingsPageNav() { + const location = useLocation(); + + const navIndex = useSelector((state) => state.navIndex); + const settingsNav = navIndex['alerting-admin']; + + const extensionTabs = useSettingsExtensionsNav(); + + // All available tabs including the main alertmanager tab + const allTabs: NavModelItem[] = [ + { + id: 'alertmanager', + text: t('alerting.settings.tabs.alert-managers', 'Alert managers'), + url: '/alerting/admin/alertmanager', + active: location.pathname === '/alerting/admin/alertmanager', + icon: 'cloud', + parentItem: settingsNav, + }, + ...extensionTabs, + ]; + + // Create pageNav that represents the Settings page with tabs as children + const pageNav: NavModelItem = { + ...settingsNav, + children: allTabs, + }; + + return { + navId: 'alerting', + pageNav, + }; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index ff096665d92..deaf63f3127 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -2650,6 +2650,11 @@ "selecting-data-source-tooltip": { "tooltip-content": "Not finding the data source you want? Some data sources are not supported for alerting. Click on the icon for more information." }, + "settings": { + "tabs": { + "alert-managers": "Alert managers" + } + }, "settings-content": { "add-new-alertmanager": "Add new Alertmanager", "builtin-alertmanager": "Built-in Alertmanager",