fix(alerting): fix AlertmanagerContext error in TimeIntervalsPage

- Move useAlertmanager hook call inside AlertmanagerPageWrapper context
- Create TimeIntervalsPageContent component that uses the context
- Fixes 'useAlertmanager must be used within a AlertmanagerContext' error
- Revert incorrect changes to Templates.tsx (error was in TimeIntervalsPage)
This commit is contained in:
Alejandro Fraenkel
2026-01-08 15:56:36 +01:00
parent 2432756be8
commit 212bdb4400
7 changed files with 326 additions and 20 deletions
+21 -2
View File
@@ -559,12 +559,31 @@ func (s *ServiceImpl) buildAlertNavLinksV2(c *contextmodel.ReqContext) *navtree.
hasAccess := ac.HasAccess(s.accessControl, c)
var alertChildNavs []*navtree.NavLink
// 1. Alert activity (renamed from "Alerts")
// 1. Alert activity (parent with tabs: Alerts, Active notifications)
//nolint:staticcheck // not yet migrated to OpenFeature
var alertActivityChildren []*navtree.NavLink
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) {
// Alerts tab
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
alertActivityChildren = append(alertActivityChildren, &navtree.NavLink{
Text: "Alerts", SubTitle: "Visualize active and pending alerts", Id: "alert-activity-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell",
})
}
// Active notifications tab
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingInstanceRead), ac.EvalPermission(ac.ActionAlertingInstancesExternalRead))) {
alertActivityChildren = append(alertActivityChildren, &navtree.NavLink{
Text: "Active notifications", SubTitle: "See grouped alerts with active notifications", Id: "alert-activity-groups", Url: s.cfg.AppSubURL + "/alerting/groups", Icon: "layer-group",
})
}
if len(alertActivityChildren) > 0 {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert activity", SubTitle: "Visualize active and pending alerts", Id: "alert-activity", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true,
Text: "Alert activity",
SubTitle: "Visualize active and pending alerts",
Id: "alert-activity",
Url: s.cfg.AppSubURL + "/alerting/alerts",
Icon: "bell",
IsNew: true,
Children: alertActivityChildren,
})
}
}
@@ -35,11 +35,18 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte
if (shouldAddCrumb) {
const activeChildIndex = node.children?.findIndex((child) => child.active) ?? -1;
// Add tab to breadcrumbs if it's not the first active child
if (activeChildIndex > 0) {
// Add active tab to breadcrumbs if it exists and its URL is different from the node's URL
// This ensures tabs show in breadcrumbs (including the first tab) while preventing duplication
if (activeChildIndex >= 0) {
const activeChild = node.children?.[activeChildIndex];
if (activeChild) {
crumbs.unshift({ text: activeChild.text, href: activeChild.url ?? '' });
// Only add the active child if its URL doesn't match the node's URL
// This prevents duplication when the pageNav is the active tab
const nodeUrl = node.url?.split('?')[0] ?? '';
const childUrl = activeChild.url?.split('?')[0] ?? '';
if (nodeUrl !== childUrl) {
crumbs.unshift({ text: activeChild.text, href: activeChild.url ?? '' });
}
}
}
crumbs.unshift({ text: node.text, href: node.url ?? '' });
@@ -14,6 +14,7 @@ import { AlertGroupFilter } from './components/alert-groups/AlertGroupFilter';
import { useFilteredAmGroups } from './hooks/useFilteredAmGroups';
import { useGroupedAlerts } from './hooks/useGroupedAlerts';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { useAlertActivityNav } from './navigation/useAlertActivityNav';
import { useAlertmanager } from './state/AlertmanagerContext';
import { fetchAlertGroupsAction } from './state/actions';
import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants';
@@ -113,8 +114,9 @@ const AlertGroups = () => {
};
function AlertGroupsPage() {
const { navId, pageNav } = useAlertActivityNav();
return (
<AlertmanagerPageWrapper navId="groups" accessType="instance">
<AlertmanagerPageWrapper navId={navId || 'groups'} pageNav={pageNav} accessType="instance">
<AlertGroups />
</AlertmanagerPageWrapper>
);
@@ -6,17 +6,29 @@ import { useNotificationConfigNav } from './navigation/useNotificationConfigNav'
import { useAlertmanager } from './state/AlertmanagerContext';
import { withPageErrorBoundary } from './withPageErrorBoundary';
// Content component that uses AlertmanagerContext
// This must be rendered within AlertmanagerPageWrapper
function TimeIntervalsPageContent() {
const { selectedAlertmanager } = useAlertmanager();
return (
<>
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager!} />
<TimeIntervalsTable />
</>
);
}
function TimeIntervalsPage() {
const useV2Nav = shouldUseAlertingNavigationV2();
const { navId, pageNav } = useNotificationConfigNav();
const { selectedAlertmanager } = useAlertmanager();
// In V2 mode, wrap with page wrapper for proper navigation
// AlertmanagerPageWrapper provides AlertmanagerContext, so TimeIntervalsPageContent can use useAlertmanager
if (useV2Nav) {
return (
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager!} />
<TimeIntervalsTable />
<TimeIntervalsPageContent />
</AlertmanagerPageWrapper>
);
}
@@ -0,0 +1,181 @@
import { renderHook } from '@testing-library/react';
import { getWrapper } from 'test/test-utils';
import { config } from '@grafana/runtime';
import { useAlertActivityNav } from './useAlertActivityNav';
describe('useAlertActivityNav', () => {
const mockNavIndex = {
'alert-activity': {
id: 'alert-activity',
text: 'Alert activity',
url: '/alerting/alerts',
},
'alert-activity-alerts': {
id: 'alert-activity-alerts',
text: 'Alerts',
url: '/alerting/alerts',
},
'alert-activity-groups': {
id: 'alert-activity-groups',
text: 'Active notifications',
url: '/alerting/groups',
},
groups: {
id: 'groups',
text: 'Alert groups',
url: '/alerting/groups',
},
'alert-alerts': {
id: 'alert-alerts',
text: 'Alerts',
url: '/alerting/alerts',
},
};
const defaultPreloadedState = {
navIndex: mockNavIndex,
};
beforeEach(() => {
config.featureToggles.alertingNavigationV2 = false;
});
it('should return legacy navId when feature flag is off for /alerting/groups', () => {
const wrapper = getWrapper({
preloadedState: defaultPreloadedState,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/groups'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBe('groups');
expect(result.current.pageNav).toBeUndefined();
});
it('should return legacy navId when feature flag is off for /alerting/alerts', () => {
const wrapper = getWrapper({
preloadedState: defaultPreloadedState,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/alerts'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBe('alert-alerts');
expect(result.current.pageNav).toBeUndefined();
});
it('should return V2 navigation when feature flag is on for Alerts tab', () => {
config.featureToggles.alertingNavigationV2 = true;
const wrapper = getWrapper({
preloadedState: defaultPreloadedState,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/alerts'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBe('alert-activity');
expect(result.current.pageNav).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children).toBeDefined();
// The pageNav should represent Alert Activity (not the active tab) for consistent title
expect(result.current.pageNav?.text).toBe('Alert activity');
});
it('should return V2 navigation when feature flag is on for Active notifications tab', () => {
config.featureToggles.alertingNavigationV2 = true;
const wrapper = getWrapper({
preloadedState: defaultPreloadedState,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/groups'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBe('alert-activity');
expect(result.current.pageNav).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children).toBeDefined();
// The pageNav should represent Alert Activity (not the active tab) for consistent title
expect(result.current.pageNav?.text).toBe('Alert activity');
});
it('should set active tab based on current path', () => {
config.featureToggles.alertingNavigationV2 = true;
const wrapper = getWrapper({
preloadedState: defaultPreloadedState,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/groups'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
const activeNotificationsTab = result.current.pageNav?.children?.find((tab) => tab.id === 'alert-activity-groups');
expect(activeNotificationsTab?.active).toBe(true);
// eslint-disable-next-line testing-library/no-node-access
const alertsTab = result.current.pageNav?.children?.find((tab) => tab.id === 'alert-activity-alerts');
expect(alertsTab?.active).toBe(false);
});
it('should filter tabs based on permissions', () => {
config.featureToggles.alertingNavigationV2 = true;
const limitedNavIndex = {
'alert-activity': mockNavIndex['alert-activity'],
'alert-activity-alerts': mockNavIndex['alert-activity-alerts'],
// Missing 'alert-activity-groups' - user doesn't have permission
};
const wrapper = getWrapper({
preloadedState: {
navIndex: limitedNavIndex,
},
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/alerts'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.length).toBe(1);
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.[0].id).toBe('alert-activity-alerts');
});
it('should fallback to legacy when alert-activity nav is missing', () => {
config.featureToggles.alertingNavigationV2 = true;
const wrapper = getWrapper({
preloadedState: {
navIndex: {
groups: mockNavIndex.groups,
'alert-alerts': mockNavIndex['alert-alerts'],
},
},
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/groups'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBe('groups');
expect(result.current.pageNav).toBeUndefined();
});
});
@@ -0,0 +1,93 @@
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 { shouldUseAlertingNavigationV2 } from '../featureToggles';
export function useAlertActivityNav() {
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
const useV2Nav = shouldUseAlertingNavigationV2();
// If V2 navigation is not enabled, return legacy navId
if (!useV2Nav) {
if (location.pathname === '/alerting/groups') {
return {
navId: 'groups',
pageNav: undefined,
};
}
if (location.pathname === '/alerting/alerts') {
return {
navId: 'alert-alerts',
pageNav: undefined,
};
}
return {
navId: undefined,
pageNav: undefined,
};
}
const alertActivityNav = navIndex['alert-activity'];
if (!alertActivityNav) {
// Fallback to legacy
if (location.pathname === '/alerting/groups') {
return {
navId: 'groups',
pageNav: undefined,
};
}
if (location.pathname === '/alerting/alerts') {
return {
navId: 'alert-alerts',
pageNav: undefined,
};
}
return {
navId: undefined,
pageNav: undefined,
};
}
// All available tabs
const allTabs = [
{
id: 'alert-activity-alerts',
text: t('alerting.navigation.alerts', 'Alerts'),
url: '/alerting/alerts',
active: location.pathname === '/alerting/alerts',
icon: 'bell',
parentItem: alertActivityNav,
},
{
id: 'alert-activity-groups',
text: t('alerting.navigation.active-notifications', 'Active notifications'),
url: '/alerting/groups',
active: location.pathname === '/alerting/groups',
icon: 'layer-group',
parentItem: alertActivityNav,
},
].filter((tab) => {
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
const navItem = navIndex[tab.id];
return navItem !== undefined;
});
// Create pageNav structure following the same pattern as useNotificationConfigNav
// Keep "Alert Activity" as the pageNav (not the active tab) so the title and subtitle stay consistent
// The tabs are children, and the breadcrumb utility will add the active tab to breadcrumbs
// (including the first tab, after our fix to the breadcrumb utility)
const pageNav: NavModelItem = {
...alertActivityNav,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
children: allTabs as NavModelItem[],
};
return {
navId: 'alert-activity',
pageNav,
};
}
@@ -3,23 +3,15 @@ import { UrlSyncContextProvider } from '@grafana/scenes';
import { withErrorBoundary } from '@grafana/ui';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { shouldUseAlertingNavigationV2 } from '../featureToggles';
import { useAlertActivityNav } from '../navigation/useAlertActivityNav';
import { TriageScene, triageScene } from './scene/TriageScene';
export const TriagePage = () => {
const useV2Nav = shouldUseAlertingNavigationV2();
const navId = useV2Nav ? 'alert-activity' : 'alert-alerts';
const { navId, pageNav } = useAlertActivityNav();
return (
<AlertingPageWrapper
navId={navId}
subTitle={t(
'alerting.pages.triage.subtitle',
'See what is currently alerting and explore historical data to investigate current or past issues.'
)}
renderTitle={() => t('alerting.pages.triage.title', 'Alert Activity')}
>
<AlertingPageWrapper navId={navId || 'alert-alerts'} pageNav={pageNav}>
<UrlSyncContextProvider scene={triageScene} updateUrlOnInit={true} createBrowserHistorySteps={true}>
<TriageScene key={triageScene.state.key} />
</UrlSyncContextProvider>