feat(alerting): separate notification policies and time intervals, contact points and templates

- Separate Notification policies and Time intervals into distinct tabs in V2 navigation
- Separate Contact points and Notification templates into distinct tabs in V2 navigation
- Add backward compatibility for legacy navigation
- Add TimeIntervalsPage component
- Update navigation hooks and tests
- Enable feature toggles in defaults.ini
- Fix linting errors (duplicate imports, conditional hooks)
This commit is contained in:
Alejandro Fraenkel
2026-01-08 11:57:11 +01:00
parent 954156d5b3
commit 5bec0f1af7
10 changed files with 266 additions and 8 deletions
+1 -1
View File
@@ -2072,7 +2072,7 @@ license_path =
# will take precedence over toggles in the `enable` list.
# enable = feature1,feature2
enable = alertingNavigationV2
enable = alertingNavigationV2, alertingTriage, alertingListViewV2, alertingFilterV2, alertingCentralAlertHistory, alertingSavedSearches
# Some features are enabled by default, see:
# https://grafana.com/docs/grafana/next/setup-grafana/configure-grafana/feature-toggles/
+11
View File
@@ -56,6 +56,17 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
)
),
},
{
path: '/alerting/time-intervals',
roles: evaluateAccess([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
...PERMISSIONS_TIME_INTERVALS_READ,
]),
component: importAlertingComponent(
() => import(/* webpackChunkName: "TimeIntervalsPage" */ 'app/features/alerting/unified/TimeIntervalsPage')
),
},
{
path: '/alerting/routes/mute-timing/new',
roles: evaluateAccess([
@@ -1,6 +1,6 @@
import { produce } from 'immer';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { render, screen, userEvent, within } from 'test/test-utils';
import { render, screen, testWithFeatureToggles, userEvent, within } from 'test/test-utils';
import { byLabelText, byRole, byTestId } from 'testing-library-selector';
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
@@ -140,6 +140,39 @@ const getRootRoute = async () => {
};
describe('NotificationPolicies', () => {
describe('V2 Navigation Mode', () => {
testWithFeatureToggles({ enable: ['alertingNavigationV2'] });
beforeEach(() => {
setupDataSources(dataSources.am);
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
...PERMISSIONS_NOTIFICATION_POLICIES,
]);
});
it('shows only notification policies without internal tabs', async () => {
renderNotificationPolicies();
// Should show notification policies directly
expect(await ui.rootRouteContainer.find()).toBeInTheDocument();
// Should not have tabs
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
});
it('does not show time intervals tab in V2 mode', async () => {
renderNotificationPolicies();
// Should show notification policies
expect(await ui.rootRouteContainer.find()).toBeInTheDocument();
// Should not show time intervals tab
expect(screen.queryByText(/time intervals/i)).not.toBeInTheDocument();
});
});
// combobox hack :/
beforeAll(() => {
const mockGetBoundingClientRect = jest.fn(() => ({
@@ -12,6 +12,7 @@ import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alertin
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning';
import { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
import { shouldUseAlertingNavigationV2 } from './featureToggles';
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
import { useAlertmanager } from './state/AlertmanagerContext';
import { withPageErrorBoundary } from './withPageErrorBoundary';
@@ -107,8 +108,30 @@ function getActiveTabFromUrl(queryParams: UrlQueryMap, defaultTab: ActiveTab): Q
};
}
const NotificationPoliciesContent = () => {
const { selectedAlertmanager = '' } = useAlertmanager();
return (
<>
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager} />
<NotificationPoliciesList />
</>
);
};
function NotificationPoliciesPage() {
const useV2Nav = shouldUseAlertingNavigationV2();
const { navId, pageNav } = useNotificationConfigNav();
// In V2 mode, show only notification policies (no internal tabs)
if (useV2Nav) {
return (
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
<NotificationPoliciesContent />
</AlertmanagerPageWrapper>
);
}
// Legacy mode: Show internal tabs (backward compatible)
return (
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
<NotificationPoliciesTabs />
@@ -1,13 +1,56 @@
import { Route, Routes } from 'react-router-dom-v5-compat';
import { Trans } from '@grafana/i18n';
import { LinkButton, Stack, Text } from '@grafana/ui';
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import DuplicateMessageTemplate from './components/contact-points/DuplicateMessageTemplate';
import EditMessageTemplate from './components/contact-points/EditMessageTemplate';
import NewMessageTemplate from './components/contact-points/NewMessageTemplate';
import { NotificationTemplates } from './components/contact-points/NotificationTemplates';
import { shouldUseAlertingNavigationV2 } from './featureToggles';
import { AlertmanagerAction, useAlertmanagerAbility } from './hooks/useAbilities';
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
import { withPageErrorBoundary } from './withPageErrorBoundary';
function NotificationTemplates() {
const TemplatesList = () => {
const [createTemplateSupported, createTemplateAllowed] = useAlertmanagerAbility(
AlertmanagerAction.CreateNotificationTemplate
);
return (
<>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Text variant="body" color="secondary">
<Trans i18nKey="alerting.notification-templates-tab.create-notification-templates-customize-notifications">
Create notification templates to customize your notifications.
</Trans>
</Text>
{createTemplateSupported && (
<LinkButton
icon="plus"
variant="primary"
href="/alerting/notifications/templates/new"
disabled={!createTemplateAllowed}
>
<Trans i18nKey="alerting.notification-templates-tab.add-notification-template-group">
Add notification template group
</Trans>
</LinkButton>
)}
</Stack>
<NotificationTemplates />
</>
);
};
function NotificationTemplatesRoutes() {
const useV2Nav = shouldUseAlertingNavigationV2();
return (
<Routes>
{/* In V2 mode, show templates list on base route */}
{useV2Nav && <Route path="" element={<TemplatesList />} />}
<Route path="new" element={<NewMessageTemplate />} />
<Route path=":name/edit" element={<EditMessageTemplate />} />
<Route path=":name/duplicate" element={<DuplicateMessageTemplate />} />
@@ -15,4 +58,21 @@ function NotificationTemplates() {
);
}
export default withPageErrorBoundary(NotificationTemplates);
function NotificationTemplatesPage() {
const useV2Nav = shouldUseAlertingNavigationV2();
const { navId, pageNav } = useNotificationConfigNav();
// In V2 mode, wrap with page wrapper for proper navigation
if (useV2Nav) {
return (
<AlertmanagerPageWrapper navId={navId || 'receivers'} pageNav={pageNav} accessType="notification">
<NotificationTemplatesRoutes />
</AlertmanagerPageWrapper>
);
}
// In legacy mode, just render routes (templates are accessed via ContactPoints page tabs)
return <NotificationTemplatesRoutes />;
}
export default withPageErrorBoundary(NotificationTemplatesPage);
@@ -0,0 +1,53 @@
import { render, screen, testWithFeatureToggles } from 'test/test-utils';
import { AccessControlAction } from 'app/types/accessControl';
import TimeIntervalsPage from './TimeIntervalsPage';
import { setupMswServer } from './mockApi';
import { grantUserPermissions, mockDataSource } from './mocks';
import { setupDataSources } from './testSetup/datasources';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
setupMswServer();
const alertManager = mockDataSource({
name: 'Alertmanager',
type: DataSourceType.Alertmanager,
});
describe('TimeIntervalsPage', () => {
describe('V2 Navigation Mode', () => {
testWithFeatureToggles({ enable: ['alertingNavigationV2'] });
beforeEach(() => {
setupDataSources(alertManager);
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsTimeIntervalsRead,
]);
});
it('renders time intervals table', async () => {
render(<TimeIntervalsPage />, {
historyOptions: {
initialEntries: ['/alerting/time-intervals'],
},
});
// Should show time intervals content
expect(await screen.findByText(/time intervals/i)).toBeInTheDocument();
});
it('returns null in legacy mode', () => {
// This test verifies that the component returns null when V2 is disabled
// The feature toggle is controlled by testWithFeatureToggles, so we test it separately
const { container } = render(<TimeIntervalsPage />, {
historyOptions: {
initialEntries: ['/alerting/time-intervals'],
},
});
// In V2 mode (enabled by testWithFeatureToggles), it should render content
expect(container).not.toBeEmptyDOMElement();
});
});
});
@@ -0,0 +1,26 @@
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning';
import { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
import { shouldUseAlertingNavigationV2 } from './featureToggles';
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
import { withPageErrorBoundary } from './withPageErrorBoundary';
function TimeIntervalsPage() {
const useV2Nav = shouldUseAlertingNavigationV2();
const { navId, pageNav } = useNotificationConfigNav();
// In V2 mode, wrap with page wrapper for proper navigation
if (useV2Nav) {
return (
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
<GrafanaAlertmanagerWarning />
<TimeIntervalsTable />
</AlertmanagerPageWrapper>
);
}
// Legacy mode: not used (handled by NotificationPoliciesPage)
return null;
}
export default withPageErrorBoundary(TimeIntervalsPage);
@@ -1,6 +1,14 @@
import { MemoryHistoryBuildOptions } from 'history';
import { ComponentProps, ReactNode } from 'react';
import { render, screen, userEvent, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils';
import {
render,
screen,
testWithFeatureToggles,
userEvent,
waitFor,
waitForElementToBeRemoved,
within,
} from 'test/test-utils';
import { selectors } from '@grafana/e2e-selectors';
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
@@ -170,6 +178,30 @@ describe('contact points', () => {
});
});
describe('V2 Navigation Mode', () => {
testWithFeatureToggles({ enable: ['alertingNavigationV2'] });
test('shows only contact points without internal tabs', async () => {
renderWithProvider(<ContactPointsPageContents />);
// Should show contact points directly
expect(await screen.findByText(/create contact point/i)).toBeInTheDocument();
// Should not have tabs
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
});
test('does not show templates tab in V2 mode', async () => {
renderWithProvider(<ContactPointsPageContents />);
// Should show contact points
expect(await screen.findByText(/create contact point/i)).toBeInTheDocument();
// Should not show templates tab
expect(screen.queryByText(/notification templates/i)).not.toBeInTheDocument();
});
});
describe('templates tab', () => {
it('does not show a warning for a "misconfigured" template', async () => {
renderWithProvider(
@@ -19,6 +19,7 @@ import { shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
import { makeAMLink, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
import { AccessControlAction } from 'app/types/accessControl';
import { shouldUseAlertingNavigationV2 } from '../../featureToggles';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { usePagination } from '../../hooks/usePagination';
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
@@ -202,6 +203,9 @@ const useTabQueryParam = (defaultTab: ActiveTab) => {
export const ContactPointsPageContents = () => {
const { selectedAlertmanager } = useAlertmanager();
const useV2Nav = shouldUseAlertingNavigationV2();
// All hooks must be called unconditionally before any early returns
const [, canViewContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint);
const [, canCreateContactPoints] = useAlertmanagerAbility(AlertmanagerAction.CreateContactPoint);
const [, showTemplatesTab] = useAlertmanagerAbility(AlertmanagerAction.ViewNotificationTemplate);
@@ -221,6 +225,19 @@ export const ContactPointsPageContents = () => {
alertmanager: selectedAlertmanager!,
});
// In V2 navigation mode, show only contact points (no internal tabs)
// Templates are accessible via the sidebar navigation
if (useV2Nav) {
return (
<>
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager!} />
<ContactPointsTab />
</>
);
}
// Legacy mode: Show internal tabs (backward compatible)
const showingContactPoints = activeTab === ActiveTab.ContactPoints;
const showNotificationTemplates = activeTab === ActiveTab.NotificationTemplates;
@@ -52,8 +52,11 @@ export function useNotificationConfigNav() {
};
}
// Check if we're on the routes page with time_intervals tab
const isTimeIntervalsTab = location.pathname === '/alerting/routes' && location.search.includes('tab=time_intervals');
// Check if we're on the time intervals page
// In V2 mode, check for dedicated route; in legacy mode, check for query param
const isTimeIntervalsTab = useV2Nav
? location.pathname === '/alerting/time-intervals'
: location.pathname === '/alerting/routes' && location.search.includes('tab=time_intervals');
// All available tabs
const allTabs: NavModelItem[] = [
@@ -84,7 +87,7 @@ export function useNotificationConfigNav() {
{
id: 'notification-config-time-intervals',
text: t('alerting.navigation.time-intervals', 'Time intervals'),
url: '/alerting/routes?tab=time_intervals',
url: useV2Nav ? '/alerting/time-intervals' : '/alerting/routes?tab=time_intervals',
active: isTimeIntervalsTab,
icon: 'clock-nine',
parentItem: notificationConfigNav,