Alerting: Extensible Settings module (#107831)

* Add extension points to the Settings page

* wip

* Update Settings page routing to catch unmatched routes and redirect to alertmanager

* Add settings page extension

* Refactor settings navigation

* Refactor settings navigation hook

* Update import paths

* Add Settings page tests for custom tabs

* Update translations

* Add a route explanation

---------

Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com>
This commit is contained in:
Konrad Lalik
2025-07-09 12:29:39 +02:00
committed by GitHub
co-authored by Sonia Aguilar
parent 92121a1030
commit 89c09a14b8
7 changed files with 349 additions and 5 deletions
+8
View File
@@ -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: () => <Navigate replace to="/alerting/admin/alertmanager" />,
},
{
path: '/alerting/admin/alertmanager',
roles: () => ['Admin'],
component: importAlertingComponent(
() => import(/* webpackChunkName: "AlertingSettings" */ 'app/features/alerting/unified/Settings')
),
@@ -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(<SettingsPage />, {
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(<SettingsPage />, {
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(<SettingsPage />, {
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(<SettingsPage />, {
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();
});
});
@@ -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 (
<SettingsProvider>
<SettingsContent />
<AlertmanagerSettingsContent />
</SettingsProvider>
);
}
function SettingsContent() {
function AlertmanagerSettingsContent() {
const [configurationDrawer, showConfiguration] = useEditConfigurationDrawer();
const { isLoading } = useSettings();
const { navId, pageNav } = useSettingsPageNav();
return (
<AlertingPageWrapper
navId="alerting-admin"
navId={navId}
isLoading={isLoading}
pageNav={pageNav}
actions={[
<WithReturnButton
key="add-alertmanager"
@@ -54,4 +58,4 @@ function SettingsContent() {
);
}
export default withPageErrorBoundary(SettingsPage);
export default withPageErrorBoundary(AlertmanagerSettingsPage);
@@ -0,0 +1,50 @@
import { useLocation } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { useSelector } from 'app/types/store';
type SettingsSectionUrl = `/alerting/admin/${string}`;
type SettingsSectionNav = Pick<NavModelItem, 'id' | 'text' | 'icon'> & {
url: SettingsSectionUrl;
};
const settingsExtensions: Map<SettingsSectionUrl, { nav: SettingsSectionNav }> = 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();
}
@@ -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,
}),
]);
});
});
@@ -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,
};
}
+5
View File
@@ -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",