From 954156d5b39d3443abbeb7968c54f0bcb2e047f2 Mon Sep 17 00:00:00 2001 From: Alejandro Fraenkel Date: Thu, 8 Jan 2026 00:34:41 +0100 Subject: [PATCH] feat(alerting): implement grouped navigation structure with feature flag - Add alertingNavigationV2 feature flag - Refactor backend navigation to support legacy and V2 structures - Create frontend navigation hooks (useAlertRulesNav, useNotificationConfigNav, useInsightsNav) - Extract Insights component and create InsightsPage - Update all page components to use new navigation hooks - Add comprehensive backend and frontend tests - Support grouped navigation with parent items and tabs --- conf/defaults.ini | 2 +- .../src/types/featureToggles.gen.ts | 5 + pkg/services/featuremgmt/registry.go | 8 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 13 + pkg/services/navtree/navtreeimpl/navtree.go | 191 +++++++++ .../navtreeimpl/navtree_alerting_test.go | 393 ++++++++++++++++++ public/app/features/alerting/routes.tsx | 7 + .../unified/NotificationPoliciesPage.tsx | 4 +- .../contact-points/ContactPoints.tsx | 4 +- .../CentralAlertHistoryPage.tsx | 4 +- .../rules/deleted-rules/DeletedRulesPage.tsx | 4 +- .../alerting/unified/featureToggles.ts | 5 + .../unified/insights/InsightsPage.tsx | 41 ++ .../navigation/useAlertRulesNav.test.tsx | 119 ++++++ .../unified/navigation/useAlertRulesNav.ts | 66 +++ .../navigation/useInsightsNav.test.tsx | 114 +++++ .../unified/navigation/useInsightsNav.ts | 78 ++++ .../useNotificationConfigNav.test.tsx | 131 ++++++ .../navigation/useNotificationConfigNav.ts | 108 +++++ .../unified/rule-list/RuleList.v1.tsx | 6 +- .../unified/rule-list/RuleList.v2.tsx | 5 +- 23 files changed, 1306 insertions(+), 7 deletions(-) create mode 100644 pkg/services/navtree/navtreeimpl/navtree_alerting_test.go create mode 100644 public/app/features/alerting/unified/insights/InsightsPage.tsx create mode 100644 public/app/features/alerting/unified/navigation/useAlertRulesNav.test.tsx create mode 100644 public/app/features/alerting/unified/navigation/useAlertRulesNav.ts create mode 100644 public/app/features/alerting/unified/navigation/useInsightsNav.test.tsx create mode 100644 public/app/features/alerting/unified/navigation/useInsightsNav.ts create mode 100644 public/app/features/alerting/unified/navigation/useNotificationConfigNav.test.tsx create mode 100644 public/app/features/alerting/unified/navigation/useNotificationConfigNav.ts diff --git a/conf/defaults.ini b/conf/defaults.ini index 363ca39d0c4..cfc5c43ef61 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -2072,7 +2072,7 @@ license_path = # will take precedence over toggles in the `enable` list. # enable = feature1,feature2 -enable = +enable = alertingNavigationV2 # Some features are enabled by default, see: # https://grafana.com/docs/grafana/next/setup-grafana/configure-grafana/feature-toggles/ diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 5d9ad02dbc7..7d6174ecc81 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -547,6 +547,11 @@ export interface FeatureToggles { */ alertingCentralAlertHistory?: boolean; /** + * Enable new grouped navigation structure for Alerting + * @default false + */ + alertingNavigationV2?: boolean; + /** * Preserve plugin proxy trailing slash. * @default false */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 5ec4bfb880b..094d8fefed0 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -907,6 +907,14 @@ var ( Owner: grafanaAlertingSquad, FrontendOnly: false, // changes navtree from backend }, + { + Name: "alertingNavigationV2", + Description: "Enable new grouped navigation structure for Alerting", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + FrontendOnly: false, // changes navtree from backend + Expression: "false", // Off by default + }, { Name: "pluginProxyPreserveTrailingSlash", Description: "Preserve plugin proxy trailing slash.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 20009d3f30b..175258423bf 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -125,6 +125,7 @@ alertingSavedSearches,experimental,@grafana/alerting-squad,false,false,true alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,false +alertingNavigationV2,experimental,@grafana/alerting-squad,false,false,false pluginProxyPreserveTrailingSlash,GA,@grafana/plugins-platform-backend,false,false,false azureMonitorPrometheusExemplars,GA,@grafana/partner-datasources,false,false,false authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 062748b95df..f66898d31b6 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -379,6 +379,10 @@ const ( // Enables the new central alert history. FlagAlertingCentralAlertHistory = "alertingCentralAlertHistory" + // FlagAlertingNavigationV2 + // Enable new grouped navigation structure for Alerting + FlagAlertingNavigationV2 = "alertingNavigationV2" + // FlagPluginProxyPreserveTrailingSlash // Preserve plugin proxy trailing slash. FlagPluginProxyPreserveTrailingSlash = "pluginProxyPreserveTrailingSlash" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index ddd6d3bbc0d..13c46e3b514 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -348,6 +348,19 @@ "expression": "true" } }, + { + "metadata": { + "name": "alertingNavigationV2", + "resourceVersion": "1767827323622", + "creationTimestamp": "2026-01-07T23:08:43Z" + }, + "spec": { + "description": "Enable new grouped navigation structure for Alerting", + "stage": "experimental", + "codeowner": "@grafana/alerting-squad", + "expression": "false" + } + }, { "metadata": { "name": "alertingNotificationHistory", diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 5b9eb985bd8..a5c57cbbe93 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -433,6 +433,14 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt } func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.NavLink { + //nolint:staticcheck // not yet migrated to OpenFeature + if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingNavigationV2) { + return s.buildAlertNavLinksV2(c) + } + return s.buildAlertNavLinksLegacy(c) +} + +func (s *ServiceImpl) buildAlertNavLinksLegacy(c *contextmodel.ReqContext) *navtree.NavLink { hasAccess := ac.HasAccess(s.accessControl, c) var alertChildNavs []*navtree.NavLink @@ -547,6 +555,189 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na return nil } +func (s *ServiceImpl) buildAlertNavLinksV2(c *contextmodel.ReqContext) *navtree.NavLink { + hasAccess := ac.HasAccess(s.accessControl, c) + var alertChildNavs []*navtree.NavLink + + // 1. Alert activity (renamed from "Alerts") + //nolint:staticcheck // not yet migrated to OpenFeature + if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingTriage) { + if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) { + 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, + }) + } + } + + // 2. Alert rules (parent with tabs: Alert rules, Recently deleted) + var alertRulesChildren []*navtree.NavLink + if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) { + alertRulesChildren = append(alertRulesChildren, &navtree.NavLink{ + Text: "Alert rules", SubTitle: "Rules that determine whether an alert will fire", Id: "alert-rules-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul", + }) + } + //nolint:staticcheck // not yet migrated to OpenFeature + if c.GetOrgRole() == org.RoleAdmin && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertRuleRestore) && s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingRuleRecoverDeleted) { + alertRulesChildren = append(alertRulesChildren, &navtree.NavLink{ + Text: "Recently deleted", + SubTitle: "Any items listed here for more than 30 days will be automatically deleted.", + Id: "alert-rules-recently-deleted", + Url: s.cfg.AppSubURL + "/alerting/recently-deleted", + }) + } + if len(alertRulesChildren) > 0 { + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Alert rules", + SubTitle: "Manage alert and recording rules", + Id: "alert-rules", + Url: s.cfg.AppSubURL + "/alerting/list", + Icon: "list-ul", + Children: alertRulesChildren, + }) + } + + // 3. Notification configuration (parent with tabs: Contact points, Notification policies, Templates, Time intervals) + var notificationConfigChildren []*navtree.NavLink + + contactPointsPerms := []ac.Evaluator{ + ac.EvalPermission(ac.ActionAlertingNotificationsRead), + ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead), + ac.EvalPermission(ac.ActionAlertingReceiversRead), + ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets), + ac.EvalPermission(ac.ActionAlertingReceiversCreate), + ac.EvalPermission(ac.ActionAlertingNotificationsTemplatesRead), + ac.EvalPermission(ac.ActionAlertingNotificationsTemplatesWrite), + ac.EvalPermission(ac.ActionAlertingNotificationsTemplatesDelete), + } + + if hasAccess(ac.EvalAny(contactPointsPerms...)) { + notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{ + Text: "Contact points", SubTitle: "Choose how to notify your contact points when an alert instance fires", Id: "notification-config-contact-points", Url: s.cfg.AppSubURL + "/alerting/notifications", Icon: "comment-alt-share", + }) + } + + if hasAccess(ac.EvalAny( + ac.EvalPermission(ac.ActionAlertingNotificationsRead), + ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead), + ac.EvalPermission(ac.ActionAlertingRoutesRead), + ac.EvalPermission(ac.ActionAlertingRoutesWrite), + ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsRead), + ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsWrite), + )) { + notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{ + Text: "Notification policies", SubTitle: "Determine how alerts are routed to contact points", Id: "notification-config-policies", Url: s.cfg.AppSubURL + "/alerting/routes", Icon: "sitemap", + }) + } + + // Templates + if hasAccess(ac.EvalAny(contactPointsPerms...)) { + notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{ + Text: "Notification templates", SubTitle: "Manage notification templates", Id: "notification-config-templates", Url: s.cfg.AppSubURL + "/alerting/notifications/templates", Icon: "file-alt", + }) + } + + // Time intervals + if hasAccess(ac.EvalAny( + ac.EvalPermission(ac.ActionAlertingNotificationsRead), + ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead), + ac.EvalPermission(ac.ActionAlertingRoutesRead), + ac.EvalPermission(ac.ActionAlertingRoutesWrite), + ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsRead), + ac.EvalPermission(ac.ActionAlertingNotificationsTimeIntervalsWrite), + )) { + notificationConfigChildren = append(notificationConfigChildren, &navtree.NavLink{ + Text: "Time intervals", SubTitle: "Configure time intervals for notification policies", Id: "notification-config-time-intervals", Url: s.cfg.AppSubURL + "/alerting/routes?tab=time_intervals", Icon: "clock-nine", + }) + } + + if len(notificationConfigChildren) > 0 { + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Notification configuration", + SubTitle: "Configure how alerts are notified", + Id: "notification-config", + Url: s.cfg.AppSubURL + "/alerting/notifications", + Icon: "cog", + Children: notificationConfigChildren, + }) + } + + // 4. Insights (parent with tabs: System Insights, Alert state history) + var insightsChildren []*navtree.NavLink + + // System Insights + if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) { + insightsChildren = append(insightsChildren, &navtree.NavLink{ + Text: "System Insights", SubTitle: "View system insights and analytics", Id: "insights-system", Url: s.cfg.AppSubURL + "/alerting/insights", Icon: "chart-line", + }) + } + + // Alert state history + //nolint:staticcheck // not yet migrated to OpenFeature + if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingCentralAlertHistory) { + if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead))) { + insightsChildren = append(insightsChildren, &navtree.NavLink{ + Text: "Alert state history", + SubTitle: "View a history of all alert events generated by your Grafana-managed alert rules. All alert events are displayed regardless of whether silences or mute timings are set.", + Id: "insights-history", + Url: s.cfg.AppSubURL + "/alerting/history", + Icon: "history", + }) + } + } + + if len(insightsChildren) > 0 { + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Insights", + SubTitle: "Analytics and history for alerting", + Id: "insights", + Url: s.cfg.AppSubURL + "/alerting/insights", + Icon: "chart-line", + Children: insightsChildren, + }) + } + + // 5. Settings (parent with tab: Settings) + if c.GetOrgRole() == org.RoleAdmin { + settingsChildren := []*navtree.NavLink{ + { + Text: "Settings", Id: "alerting-admin", Url: s.cfg.AppSubURL + "/alerting/admin", Icon: "cog", + }, + } + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Settings", + SubTitle: "Alerting configuration and administration", + Id: "alerting-settings", + Url: s.cfg.AppSubURL + "/alerting/admin", + Icon: "cog", + Children: settingsChildren, + }) + } + + // Create alert rule (hidden from tabs) + if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) { + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Create alert rule", SubTitle: "Create an alert rule", Id: "alert", + Icon: "plus", Url: s.cfg.AppSubURL + "/alerting/new", HideFromTabs: true, IsCreateAction: true, + }) + } + + if len(alertChildNavs) > 0 { + var alertNav = navtree.NavLink{ + Text: "Alerting", + SubTitle: "Learn about problems in your systems moments after they occur", + Id: navtree.NavIDAlerting, + Icon: "bell", + Children: alertChildNavs, + SortWeight: navtree.WeightAlerting, + Url: s.cfg.AppSubURL + "/alerting", + } + + return &alertNav + } + + return nil +} + func (s *ServiceImpl) buildDataConnectionsNavLink(c *contextmodel.ReqContext) *navtree.NavLink { hasAccess := ac.HasAccess(s.accessControl, c) diff --git a/pkg/services/navtree/navtreeimpl/navtree_alerting_test.go b/pkg/services/navtree/navtreeimpl/navtree_alerting_test.go new file mode 100644 index 00000000000..12d36e5d520 --- /dev/null +++ b/pkg/services/navtree/navtreeimpl/navtree_alerting_test.go @@ -0,0 +1,393 @@ +package navtreeimpl + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" + accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/navtree" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" +) + +func TestBuildAlertNavLinks_FeatureToggle(t *testing.T) { + httpReq, _ := http.NewRequest(http.MethodGet, "", nil) + reqCtx := &contextmodel.ReqContext{ + SignedInUser: &user.SignedInUser{ + UserID: 1, + OrgID: 1, + OrgRole: org.RoleAdmin, + }, + Context: &web.Context{Req: httpReq}, + } + + permissions := []ac.Permission{ + {Action: ac.ActionAlertingRuleRead, Scope: "*"}, + {Action: ac.ActionAlertingNotificationsRead, Scope: "*"}, + {Action: ac.ActionAlertingRoutesRead, Scope: "*"}, + {Action: ac.ActionAlertingInstanceRead, Scope: "*"}, + } + + t.Run("Should use legacy navigation when flag is off", func(t *testing.T) { + service := ServiceImpl{ + log: log.New("navtree"), + cfg: setting.NewCfg(), + accessControl: accesscontrolmock.New().WithPermissions(permissions), + features: featuremgmt.WithFeatures(), // Flag off by default + } + + navLink := service.buildAlertNavLinks(reqCtx) + require.NotNil(t, navLink) + require.Equal(t, "Alerting", navLink.Text) + require.Equal(t, navtree.NavIDAlerting, navLink.Id) + + // Check that children are flat (legacy structure) + children := navLink.Children + require.NotEmpty(t, children) + + // In legacy, items are direct children, not grouped + hasAlertRules := false + hasContactPoints := false + for _, child := range children { + if child.Id == "alert-list" { + hasAlertRules = true + require.Empty(t, child.Children, "Legacy navigation should not have nested children") + } + if child.Id == "receivers" { + hasContactPoints = true + require.Empty(t, child.Children, "Legacy navigation should not have nested children") + } + } + require.True(t, hasAlertRules, "Should have alert rules in legacy navigation") + require.True(t, hasContactPoints, "Should have contact points in legacy navigation") + }) + + t.Run("Should use V2 navigation when flag is on", func(t *testing.T) { + service := ServiceImpl{ + log: log.New("navtree"), + cfg: setting.NewCfg(), + accessControl: accesscontrolmock.New().WithPermissions(permissions), + features: featuremgmt.WithFeatures("alertingNavigationV2"), + } + + navLink := service.buildAlertNavLinks(reqCtx) + require.NotNil(t, navLink) + require.Equal(t, "Alerting", navLink.Text) + require.Equal(t, navtree.NavIDAlerting, navLink.Id) + + // Check that children are grouped (V2 structure) + children := navLink.Children + require.NotEmpty(t, children) + + // In V2, we should have parent items with children + hasAlertRulesParent := false + hasNotificationConfigParent := false + hasInsightsParent := false + hasSettingsParent := false + + for _, child := range children { + if child.Id == "alert-rules" { + hasAlertRulesParent = true + require.NotEmpty(t, child.Children, "V2 navigation should have nested children for alert-rules") + // Check for expected tabs + hasAlertRulesTab := false + for _, tab := range child.Children { + if tab.Id == "alert-rules-list" { + hasAlertRulesTab = true + } + } + require.True(t, hasAlertRulesTab, "Should have alert-rules-list tab") + } + if child.Id == "notification-config" { + hasNotificationConfigParent = true + require.NotEmpty(t, child.Children, "V2 navigation should have nested children for notification-config") + } + if child.Id == "insights" { + hasInsightsParent = true + require.NotEmpty(t, child.Children, "V2 navigation should have nested children for insights") + } + if child.Id == "alerting-settings" { + hasSettingsParent = true + require.NotEmpty(t, child.Children, "V2 navigation should have nested children for settings") + } + } + + require.True(t, hasAlertRulesParent, "Should have alert-rules parent in V2 navigation") + require.True(t, hasNotificationConfigParent, "Should have notification-config parent in V2 navigation") + require.True(t, hasInsightsParent, "Should have insights parent in V2 navigation") + require.True(t, hasSettingsParent, "Should have settings parent in V2 navigation") + }) +} + +func TestBuildAlertNavLinks_Legacy(t *testing.T) { + httpReq, _ := http.NewRequest(http.MethodGet, "", nil) + reqCtx := &contextmodel.ReqContext{ + SignedInUser: &user.SignedInUser{ + UserID: 1, + OrgID: 1, + OrgRole: org.RoleAdmin, + }, + Context: &web.Context{Req: httpReq}, + } + + permissions := []ac.Permission{ + {Action: ac.ActionAlertingRuleRead, Scope: "*"}, + {Action: ac.ActionAlertingNotificationsRead, Scope: "*"}, + {Action: ac.ActionAlertingRoutesRead, Scope: "*"}, + {Action: ac.ActionAlertingInstanceRead, Scope: "*"}, + } + + service := ServiceImpl{ + log: log.New("navtree"), + cfg: setting.NewCfg(), + accessControl: accesscontrolmock.New().WithPermissions(permissions), + features: featuremgmt.WithFeatures(), + } + + t.Run("Should include all expected items in legacy navigation", func(t *testing.T) { + navLink := service.buildAlertNavLinksLegacy(reqCtx) + require.NotNil(t, navLink) + + children := navLink.Children + expectedIds := []string{"alert-list", "receivers", "am-routes", "alerting-admin"} + + foundIds := make(map[string]bool) + for _, child := range children { + foundIds[child.Id] = true + } + + for _, expectedId := range expectedIds { + require.True(t, foundIds[expectedId], "Should have %s in legacy navigation", expectedId) + } + }) + + t.Run("Should respect permissions in legacy navigation", func(t *testing.T) { + // User with limited permissions + limitedPermissions := []ac.Permission{ + {Action: ac.ActionAlertingRuleRead, Scope: "*"}, + } + + limitedService := ServiceImpl{ + log: log.New("navtree"), + cfg: setting.NewCfg(), + accessControl: accesscontrolmock.New().WithPermissions(limitedPermissions), + features: featuremgmt.WithFeatures(), + } + + navLink := limitedService.buildAlertNavLinksLegacy(reqCtx) + require.NotNil(t, navLink) + + children := navLink.Children + hasAlertRules := false + hasContactPoints := false + + for _, child := range children { + if child.Id == "alert-list" { + hasAlertRules = true + } + if child.Id == "receivers" { + hasContactPoints = true + } + } + + require.True(t, hasAlertRules, "Should have alert rules with read permission") + require.False(t, hasContactPoints, "Should not have contact points without notification permissions") + }) +} + +func TestBuildAlertNavLinks_V2(t *testing.T) { + httpReq, _ := http.NewRequest(http.MethodGet, "", nil) + reqCtx := &contextmodel.ReqContext{ + SignedInUser: &user.SignedInUser{ + UserID: 1, + OrgID: 1, + OrgRole: org.RoleAdmin, + }, + Context: &web.Context{Req: httpReq}, + } + + permissions := []ac.Permission{ + {Action: ac.ActionAlertingRuleRead, Scope: "*"}, + {Action: ac.ActionAlertingNotificationsRead, Scope: "*"}, + {Action: ac.ActionAlertingRoutesRead, Scope: "*"}, + {Action: ac.ActionAlertingInstanceRead, Scope: "*"}, + } + + service := ServiceImpl{ + log: log.New("navtree"), + cfg: setting.NewCfg(), + accessControl: accesscontrolmock.New().WithPermissions(permissions), + features: featuremgmt.WithFeatures("alertingNavigationV2", "alertingTriage", "alertingCentralAlertHistory", "alertRuleRestore", "alertingRuleRecoverDeleted"), + } + + t.Run("Should have correct parent structure in V2 navigation", func(t *testing.T) { + navLink := service.buildAlertNavLinksV2(reqCtx) + require.NotNil(t, navLink) + + children := navLink.Children + require.NotEmpty(t, children) + + // Verify parent items exist + parentIds := []string{"alert-rules", "notification-config", "insights", "alerting-settings"} + foundParents := make(map[string]bool) + + for _, child := range children { + if child.Id == "alert-activity" { + // Alert activity is a direct child, not a parent + continue + } + for _, parentId := range parentIds { + if child.Id == parentId { + foundParents[parentId] = true + require.NotEmpty(t, child.Children, "Parent %s should have children", parentId) + } + } + } + + for _, parentId := range parentIds { + require.True(t, foundParents[parentId], "Should have parent %s in V2 navigation", parentId) + } + }) + + t.Run("Should have correct tabs under Alert rules parent", func(t *testing.T) { + navLink := service.buildAlertNavLinksV2(reqCtx) + require.NotNil(t, navLink) + + var alertRulesParent *navtree.NavLink + for _, child := range navLink.Children { + if child.Id == "alert-rules" { + alertRulesParent = child + break + } + } + + require.NotNil(t, alertRulesParent, "Should have alert-rules parent") + require.NotEmpty(t, alertRulesParent.Children, "Alert rules should have tabs") + + tabIds := make(map[string]bool) + for _, tab := range alertRulesParent.Children { + tabIds[tab.Id] = true + } + + require.True(t, tabIds["alert-rules-list"], "Should have alert-rules-list tab") + require.True(t, tabIds["alert-rules-recently-deleted"], "Should have alert-rules-recently-deleted tab") + }) + + t.Run("Should have correct tabs under Notification configuration parent", func(t *testing.T) { + navLink := service.buildAlertNavLinksV2(reqCtx) + require.NotNil(t, navLink) + + var notificationConfigParent *navtree.NavLink + for _, child := range navLink.Children { + if child.Id == "notification-config" { + notificationConfigParent = child + break + } + } + + require.NotNil(t, notificationConfigParent, "Should have notification-config parent") + require.NotEmpty(t, notificationConfigParent.Children, "Notification config should have tabs") + + tabIds := make(map[string]bool) + for _, tab := range notificationConfigParent.Children { + tabIds[tab.Id] = true + } + + require.True(t, tabIds["notification-config-contact-points"], "Should have contact-points tab") + require.True(t, tabIds["notification-config-policies"], "Should have policies tab") + require.True(t, tabIds["notification-config-templates"], "Should have templates tab") + require.True(t, tabIds["notification-config-time-intervals"], "Should have time-intervals tab") + }) + + t.Run("Should have correct tabs under Insights parent", func(t *testing.T) { + navLink := service.buildAlertNavLinksV2(reqCtx) + require.NotNil(t, navLink) + + var insightsParent *navtree.NavLink + for _, child := range navLink.Children { + if child.Id == "insights" { + insightsParent = child + break + } + } + + require.NotNil(t, insightsParent, "Should have insights parent") + require.NotEmpty(t, insightsParent.Children, "Insights should have tabs") + + tabIds := make(map[string]bool) + for _, tab := range insightsParent.Children { + tabIds[tab.Id] = true + } + + require.True(t, tabIds["insights-system"], "Should have insights-system tab") + require.True(t, tabIds["insights-history"], "Should have insights-history tab") + }) + + t.Run("Should respect permissions in V2 navigation", func(t *testing.T) { + // User with limited permissions + limitedPermissions := []ac.Permission{ + {Action: ac.ActionAlertingRuleRead, Scope: "*"}, + } + + limitedService := ServiceImpl{ + log: log.New("navtree"), + cfg: setting.NewCfg(), + accessControl: accesscontrolmock.New().WithPermissions(limitedPermissions), + features: featuremgmt.WithFeatures("alertingNavigationV2"), + } + + navLink := limitedService.buildAlertNavLinksV2(reqCtx) + require.NotNil(t, navLink) + + // Should not have notification-config parent without permissions + hasNotificationConfig := false + for _, child := range navLink.Children { + if child.Id == "notification-config" { + hasNotificationConfig = true + } + } + + require.False(t, hasNotificationConfig, "Should not have notification-config without permissions") + }) + + t.Run("Should exclude future items from V2 navigation", func(t *testing.T) { + navLink := service.buildAlertNavLinksV2(reqCtx) + require.NotNil(t, navLink) + + // Check that future items are not present + futureIds := []string{ + "alert-rules-recording-rules", + "alert-rules-evaluation-chains", + "insights-alert-optimizer", + "insights-notification-history", + } + + allIds := make(map[string]bool) + collectIds(navLink, allIds) + + for _, futureId := range futureIds { + require.False(t, allIds[futureId], "Should not have future item %s", futureId) + } + }) +} + +// Helper function to collect all IDs from navigation tree +func collectIds(navLink *navtree.NavLink, ids map[string]bool) { + if navLink == nil { + return + } + if navLink.Id != "" { + ids[navLink.Id] = true + } + for _, child := range navLink.Children { + collectIds(child, ids) + } +} diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 34459b9581e..274fa97bd0d 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -212,6 +212,13 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { ) ), }, + { + path: '/alerting/insights', + roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]), + component: importAlertingComponent( + () => import(/* webpackChunkName: "InsightsPage" */ 'app/features/alerting/unified/insights/InsightsPage') + ), + }, { path: '/alerting/recently-deleted/', roles: () => ['Admin'], diff --git a/public/app/features/alerting/unified/NotificationPoliciesPage.tsx b/public/app/features/alerting/unified/NotificationPoliciesPage.tsx index 07d036fb341..98768e5d87e 100644 --- a/public/app/features/alerting/unified/NotificationPoliciesPage.tsx +++ b/public/app/features/alerting/unified/NotificationPoliciesPage.tsx @@ -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 { useNotificationConfigNav } from './navigation/useNotificationConfigNav'; import { useAlertmanager } from './state/AlertmanagerContext'; import { withPageErrorBoundary } from './withPageErrorBoundary'; @@ -107,8 +108,9 @@ function getActiveTabFromUrl(queryParams: UrlQueryMap, defaultTab: ActiveTab): Q } function NotificationPoliciesPage() { + const { navId, pageNav } = useNotificationConfigNav(); return ( - + ); diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx index 674521c483b..59fdb5e7140 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx @@ -22,6 +22,7 @@ import { AccessControlAction } from 'app/types/accessControl'; import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; import { usePagination } from '../../hooks/usePagination'; import { useURLSearchParams } from '../../hooks/useURLSearchParams'; +import { useNotificationConfigNav } from '../../navigation/useNotificationConfigNav'; import { useAlertmanager } from '../../state/AlertmanagerContext'; import { isExtraConfig } from '../../utils/alertmanager/extraConfigs'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; @@ -282,8 +283,9 @@ const ContactPointsList = ({ contactPoints, search, pageSize = DEFAULT_PAGE_SIZE }; function ContactPointsPage() { + const { navId, pageNav } = useNotificationConfigNav(); return ( - + ); diff --git a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryPage.tsx b/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryPage.tsx index 91463309afd..86985004614 100644 --- a/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryPage.tsx +++ b/public/app/features/alerting/unified/components/rules/central-state-history/CentralAlertHistoryPage.tsx @@ -1,11 +1,13 @@ +import { useInsightsNav } from '../../../navigation/useInsightsNav'; import { withPageErrorBoundary } from '../../../withPageErrorBoundary'; import { AlertingPageWrapper } from '../../AlertingPageWrapper'; import { CentralAlertHistoryScene } from './CentralAlertHistoryScene'; function HistoryPage() { + const { navId, pageNav } = useInsightsNav(); return ( - + ); diff --git a/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRulesPage.tsx b/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRulesPage.tsx index 69188c8de77..1473bdc9a7b 100644 --- a/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRulesPage.tsx +++ b/public/app/features/alerting/unified/components/rules/deleted-rules/DeletedRulesPage.tsx @@ -3,6 +3,7 @@ import { Alert } from '@grafana/ui'; import { alertRuleApi } from '../../../api/alertRuleApi'; import { GRAFANA_RULER_CONFIG } from '../../../api/featureDiscoveryApi'; +import { useAlertRulesNav } from '../../../navigation/useAlertRulesNav'; import { stringifyErrorLike } from '../../../utils/misc'; import { withPageErrorBoundary } from '../../../withPageErrorBoundary'; import { AlertingPageWrapper } from '../../AlertingPageWrapper'; @@ -18,9 +19,10 @@ function DeletedrulesPage() { rulerConfig: GRAFANA_RULER_CONFIG, filter: {}, // todo: add filters, and limit????? }); + const { navId, pageNav } = useAlertRulesNav(); return ( - + <> {error && ( diff --git a/public/app/features/alerting/unified/featureToggles.ts b/public/app/features/alerting/unified/featureToggles.ts index 15fa8e11fd8..e584ac4211c 100644 --- a/public/app/features/alerting/unified/featureToggles.ts +++ b/public/app/features/alerting/unified/featureToggles.ts @@ -31,3 +31,8 @@ export const shouldUseFullyCompatibleBackendFilters = () => * Saved searches feature - allows users to save and apply search queries on the Alert Rules page. */ export const shouldUseSavedSearches = () => config.featureToggles.alertingSavedSearches ?? false; + +/** + * New grouped navigation structure for Alerting + */ +export const shouldUseAlertingNavigationV2 = () => config.featureToggles.alertingNavigationV2 ?? false; diff --git a/public/app/features/alerting/unified/insights/InsightsPage.tsx b/public/app/features/alerting/unified/insights/InsightsPage.tsx new file mode 100644 index 00000000000..c9a3a04a0ce --- /dev/null +++ b/public/app/features/alerting/unified/insights/InsightsPage.tsx @@ -0,0 +1,41 @@ +import { Trans, t } from '@grafana/i18n'; + +import { AlertingPageWrapper } from '../components/AlertingPageWrapper'; +import { getInsightsScenes, insightsIsAvailable } from '../home/Insights'; +import { useInsightsNav } from '../navigation/useInsightsNav'; +import { isLocalDevEnv } from '../utils/misc'; +import { withPageErrorBoundary } from '../withPageErrorBoundary'; + +function InsightsPage() { + const insightsEnabled = insightsIsAvailable() || isLocalDevEnv(); + const insightsScene = getInsightsScenes(); + const { navId, pageNav } = useInsightsNav(); + + if (!insightsEnabled) { + return ( + +
+ + Insights are not available. Please configure the required data sources. + +
+
+ ); + } + + return ( + + + + ); +} + +export default withPageErrorBoundary(InsightsPage); diff --git a/public/app/features/alerting/unified/navigation/useAlertRulesNav.test.tsx b/public/app/features/alerting/unified/navigation/useAlertRulesNav.test.tsx new file mode 100644 index 00000000000..a1249198cf8 --- /dev/null +++ b/public/app/features/alerting/unified/navigation/useAlertRulesNav.test.tsx @@ -0,0 +1,119 @@ +import { renderHook } from '@testing-library/react'; +import { getWrapper } from 'test/test-utils'; + +import { config } from '@grafana/runtime'; + +import { useAlertRulesNav } from './useAlertRulesNav'; + +describe('useAlertRulesNav', () => { + const mockNavIndex = { + 'alert-rules': { + id: 'alert-rules', + text: 'Alert rules', + url: '/alerting/list', + icon: 'list-ul', + }, + 'alert-rules-list': { + id: 'alert-rules-list', + text: 'Alert rules', + url: '/alerting/list', + }, + 'alert-rules-recently-deleted': { + id: 'alert-rules-recently-deleted', + text: 'Recently deleted', + url: '/alerting/recently-deleted', + }, + 'alert-list': { + id: 'alert-list', + text: 'Alert rules', + url: '/alerting/list', + }, + }; + + const defaultPreloadedState = { + navIndex: mockNavIndex, + }; + + beforeEach(() => { + config.featureToggles.alertingNavigationV2 = false; + }); + + it('should return legacy navId when feature flag is off', () => { + const wrapper = getWrapper({ + preloadedState: defaultPreloadedState, + renderWithRouter: true, + historyOptions: { + initialEntries: ['/alerting/list'], + }, + }); + + const { result } = renderHook(() => useAlertRulesNav(), { wrapper }); + + expect(result.current.navId).toBe('alert-list'); + expect(result.current.pageNav).toBeUndefined(); + }); + + it('should return V2 navigation when feature flag is on', () => { + config.featureToggles.alertingNavigationV2 = true; + const wrapper = getWrapper({ + preloadedState: defaultPreloadedState, + renderWithRouter: true, + historyOptions: { + initialEntries: ['/alerting/list'], + }, + }); + + const { result } = renderHook(() => useAlertRulesNav(), { wrapper }); + + expect(result.current.navId).toBe('alert-rules'); + expect(result.current.pageNav).toBeDefined(); + // eslint-disable-next-line testing-library/no-node-access + expect(result.current.pageNav?.children).toBeDefined(); + // eslint-disable-next-line testing-library/no-node-access + expect(result.current.pageNav?.children?.length).toBeGreaterThan(0); + }); + + it('should filter tabs based on permissions', () => { + config.featureToggles.alertingNavigationV2 = true; + const limitedNavIndex = { + 'alert-rules': mockNavIndex['alert-rules'], + 'alert-rules-list': mockNavIndex['alert-rules-list'], + // Missing 'alert-rules-recently-deleted' - user doesn't have permission + }; + const wrapper = getWrapper({ + preloadedState: { + navIndex: limitedNavIndex, + }, + renderWithRouter: true, + historyOptions: { + initialEntries: ['/alerting/list'], + }, + }); + + const { result } = renderHook(() => useAlertRulesNav(), { 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-rules-list'); + }); + + it('should set active tab based on current path', () => { + config.featureToggles.alertingNavigationV2 = true; + const wrapper = getWrapper({ + preloadedState: defaultPreloadedState, + renderWithRouter: true, + historyOptions: { + initialEntries: ['/alerting/recently-deleted'], + }, + }); + + const { result } = renderHook(() => useAlertRulesNav(), { wrapper }); + + // eslint-disable-next-line testing-library/no-node-access + const recentlyDeletedTab = result.current.pageNav?.children?.find( + (tab) => tab.id === 'alert-rules-recently-deleted' + ); + expect(recentlyDeletedTab?.active).toBe(true); + }); +}); diff --git a/public/app/features/alerting/unified/navigation/useAlertRulesNav.ts b/public/app/features/alerting/unified/navigation/useAlertRulesNav.ts new file mode 100644 index 00000000000..634ac647069 --- /dev/null +++ b/public/app/features/alerting/unified/navigation/useAlertRulesNav.ts @@ -0,0 +1,66 @@ +import { useLocation } from 'react-router-dom-v5-compat'; + +import { NavModelItem } from '@grafana/data'; +import { t } from '@grafana/i18n'; +import { config } from '@grafana/runtime'; +import { useSelector } from 'app/types/store'; + +import { shouldUseAlertingNavigationV2 } from '../featureToggles'; + +export function useAlertRulesNav() { + const location = useLocation(); + const navIndex = useSelector((state) => state.navIndex); + const useV2Nav = shouldUseAlertingNavigationV2(); + + // If V2 navigation is not enabled, return legacy navId + if (!useV2Nav) { + return { + navId: 'alert-list', + pageNav: undefined, + }; + } + + const alertRulesNav = navIndex['alert-rules']; + if (!alertRulesNav) { + // Fallback to legacy if V2 nav doesn't exist + return { + navId: 'alert-list', + pageNav: undefined, + }; + } + + // All available tabs + const allTabs: NavModelItem[] = [ + { + id: 'alert-rules-list', + text: t('alerting.navigation.alert-rules', 'Alert rules'), + url: '/alerting/list', + active: location.pathname === '/alerting/list', + icon: 'list-ul', + parentItem: alertRulesNav, + }, + { + id: 'alert-rules-recently-deleted', + text: t('alerting.navigation.recently-deleted', 'Recently deleted'), + url: '/alerting/recently-deleted', + active: location.pathname === '/alerting/recently-deleted', + icon: 'trash-alt', + parentItem: alertRulesNav, + }, + ].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 that represents the Alert rules page with tabs as children + const pageNav: NavModelItem = { + ...alertRulesNav, + children: allTabs, + }; + + return { + navId: 'alert-rules', + pageNav, + }; +} diff --git a/public/app/features/alerting/unified/navigation/useInsightsNav.test.tsx b/public/app/features/alerting/unified/navigation/useInsightsNav.test.tsx new file mode 100644 index 00000000000..4600da2bf24 --- /dev/null +++ b/public/app/features/alerting/unified/navigation/useInsightsNav.test.tsx @@ -0,0 +1,114 @@ +import { renderHook } from '@testing-library/react'; +import { getWrapper } from 'test/test-utils'; + +import { config } from '@grafana/runtime'; + +import { useInsightsNav } from './useInsightsNav'; + +describe('useInsightsNav', () => { + const mockNavIndex = { + insights: { + id: 'insights', + text: 'Insights', + url: '/alerting/insights', + }, + 'insights-system': { + id: 'insights-system', + text: 'System Insights', + url: '/alerting/insights', + }, + 'insights-history': { + id: 'insights-history', + text: 'Alert state history', + url: '/alerting/history', + }, + 'alerts-history': { + id: 'alerts-history', + text: 'History', + url: '/alerting/history', + }, + }; + + const defaultPreloadedState = { + navIndex: mockNavIndex, + }; + + beforeEach(() => { + config.featureToggles.alertingNavigationV2 = false; + }); + + it('should return legacy navId when feature flag is off', () => { + const wrapper = getWrapper({ + preloadedState: defaultPreloadedState, + renderWithRouter: true, + historyOptions: { + initialEntries: ['/alerting/history'], + }, + }); + + const { result } = renderHook(() => useInsightsNav(), { wrapper }); + + expect(result.current.navId).toBe('alerts-history'); + expect(result.current.pageNav).toBeUndefined(); + }); + + it('should return V2 navigation when feature flag is on', () => { + config.featureToggles.alertingNavigationV2 = true; + const wrapper = getWrapper({ + preloadedState: defaultPreloadedState, + renderWithRouter: true, + historyOptions: { + initialEntries: ['/alerting/insights'], + }, + }); + + const { result } = renderHook(() => useInsightsNav(), { wrapper }); + + expect(result.current.navId).toBe('insights'); + expect(result.current.pageNav).toBeDefined(); + // eslint-disable-next-line testing-library/no-node-access + expect(result.current.pageNav?.children).toBeDefined(); + }); + + it('should set active tab based on current path', () => { + config.featureToggles.alertingNavigationV2 = true; + const wrapper = getWrapper({ + preloadedState: defaultPreloadedState, + renderWithRouter: true, + historyOptions: { + initialEntries: ['/alerting/history'], + }, + }); + + const { result } = renderHook(() => useInsightsNav(), { wrapper }); + + // eslint-disable-next-line testing-library/no-node-access + const historyTab = result.current.pageNav?.children?.find((tab) => tab.id === 'insights-history'); + expect(historyTab?.active).toBe(true); + }); + + it('should filter tabs based on permissions', () => { + config.featureToggles.alertingNavigationV2 = true; + const limitedNavIndex = { + insights: mockNavIndex.insights, + 'insights-system': mockNavIndex['insights-system'], + // Missing 'insights-history' - user doesn't have permission + }; + const wrapper = getWrapper({ + preloadedState: { + navIndex: limitedNavIndex, + }, + renderWithRouter: true, + historyOptions: { + initialEntries: ['/alerting/insights'], + }, + }); + + const { result } = renderHook(() => useInsightsNav(), { 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('insights-system'); + }); +}); diff --git a/public/app/features/alerting/unified/navigation/useInsightsNav.ts b/public/app/features/alerting/unified/navigation/useInsightsNav.ts new file mode 100644 index 00000000000..aec526af62e --- /dev/null +++ b/public/app/features/alerting/unified/navigation/useInsightsNav.ts @@ -0,0 +1,78 @@ +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 useInsightsNav() { + 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/history') { + return { + navId: 'alerts-history', + pageNav: undefined, + }; + } + // For insights page, it doesn't exist in legacy, so return undefined + return { + navId: undefined, + pageNav: undefined, + }; + } + + const insightsNav = navIndex.insights; + if (!insightsNav) { + // Fallback to legacy + if (location.pathname === '/alerting/history') { + return { + navId: 'alerts-history', + pageNav: undefined, + }; + } + return { + navId: undefined, + pageNav: undefined, + }; + } + + // All available tabs + const allTabs: NavModelItem[] = [ + { + id: 'insights-system', + text: t('alerting.navigation.system-insights', 'System Insights'), + url: '/alerting/insights', + active: location.pathname === '/alerting/insights', + icon: 'chart-line', + parentItem: insightsNav, + }, + { + id: 'insights-history', + text: t('alerting.navigation.alert-state-history', 'Alert state history'), + url: '/alerting/history', + active: location.pathname === '/alerting/history', + icon: 'history', + parentItem: insightsNav, + }, + ].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 that represents the Insights page with tabs as children + const pageNav: NavModelItem = { + ...insightsNav, + children: allTabs, + }; + + return { + navId: 'insights', + pageNav, + }; +} diff --git a/public/app/features/alerting/unified/navigation/useNotificationConfigNav.test.tsx b/public/app/features/alerting/unified/navigation/useNotificationConfigNav.test.tsx new file mode 100644 index 00000000000..a0ad7c3e0b4 --- /dev/null +++ b/public/app/features/alerting/unified/navigation/useNotificationConfigNav.test.tsx @@ -0,0 +1,131 @@ +import { renderHook } from '@testing-library/react'; +import { getWrapper } from 'test/test-utils'; + +import { config } from '@grafana/runtime'; + +import { useNotificationConfigNav } from './useNotificationConfigNav'; + +describe('useNotificationConfigNav', () => { + const mockNavIndex = { + 'notification-config': { + id: 'notification-config', + text: 'Notification configuration', + url: '/alerting/notifications', + }, + 'notification-config-contact-points': { + id: 'notification-config-contact-points', + text: 'Contact points', + url: '/alerting/notifications', + }, + 'notification-config-policies': { + id: 'notification-config-policies', + text: 'Notification policies', + url: '/alerting/routes', + }, + 'notification-config-templates': { + id: 'notification-config-templates', + text: 'Notification templates', + url: '/alerting/notifications/templates', + }, + 'notification-config-time-intervals': { + id: 'notification-config-time-intervals', + text: 'Time intervals', + url: '/alerting/routes?tab=time_intervals', + }, + receivers: { + id: 'receivers', + text: 'Contact points', + url: '/alerting/notifications', + }, + 'am-routes': { + id: 'am-routes', + text: 'Notification policies', + url: '/alerting/routes', + }, + }; + + const defaultPreloadedState = { + navIndex: mockNavIndex, + }; + + beforeEach(() => { + config.featureToggles.alertingNavigationV2 = false; + }); + + it('should return legacy navId when feature flag is off', () => { + const wrapper = getWrapper({ + preloadedState: defaultPreloadedState, + renderWithRouter: true, + historyOptions: { + initialEntries: ['/alerting/notifications'], + }, + }); + + const { result } = renderHook(() => useNotificationConfigNav(), { wrapper }); + + expect(result.current.navId).toBe('receivers'); + expect(result.current.pageNav).toBeUndefined(); + }); + + it('should return V2 navigation when feature flag is on', () => { + config.featureToggles.alertingNavigationV2 = true; + const wrapper = getWrapper({ + preloadedState: defaultPreloadedState, + renderWithRouter: true, + historyOptions: { + initialEntries: ['/alerting/notifications'], + }, + }); + + const { result } = renderHook(() => useNotificationConfigNav(), { wrapper }); + + expect(result.current.navId).toBe('notification-config'); + expect(result.current.pageNav).toBeDefined(); + // eslint-disable-next-line testing-library/no-node-access + expect(result.current.pageNav?.children).toBeDefined(); + }); + + it('should detect time intervals tab from query params', () => { + config.featureToggles.alertingNavigationV2 = true; + const wrapper = getWrapper({ + preloadedState: defaultPreloadedState, + renderWithRouter: true, + historyOptions: { + initialEntries: ['/alerting/routes?tab=time_intervals'], + }, + }); + + const { result } = renderHook(() => useNotificationConfigNav(), { wrapper }); + + // eslint-disable-next-line testing-library/no-node-access + const timeIntervalsTab = result.current.pageNav?.children?.find( + (tab) => tab.id === 'notification-config-time-intervals' + ); + expect(timeIntervalsTab?.active).toBe(true); + }); + + it('should filter tabs based on permissions', () => { + config.featureToggles.alertingNavigationV2 = true; + const limitedNavIndex = { + 'notification-config': mockNavIndex['notification-config'], + 'notification-config-contact-points': mockNavIndex['notification-config-contact-points'], + // Missing other tabs - user doesn't have permission + }; + const wrapper = getWrapper({ + preloadedState: { + navIndex: limitedNavIndex, + }, + renderWithRouter: true, + historyOptions: { + initialEntries: ['/alerting/notifications'], + }, + }); + + const { result } = renderHook(() => useNotificationConfigNav(), { 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('notification-config-contact-points'); + }); +}); diff --git a/public/app/features/alerting/unified/navigation/useNotificationConfigNav.ts b/public/app/features/alerting/unified/navigation/useNotificationConfigNav.ts new file mode 100644 index 00000000000..34b006a7eff --- /dev/null +++ b/public/app/features/alerting/unified/navigation/useNotificationConfigNav.ts @@ -0,0 +1,108 @@ +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 useNotificationConfigNav() { + const location = useLocation(); + const navIndex = useSelector((state) => state.navIndex); + const useV2Nav = shouldUseAlertingNavigationV2(); + + // If V2 navigation is not enabled, return legacy navId based on current path + if (!useV2Nav) { + if (location.pathname.includes('/alerting/notifications/templates')) { + return { + navId: 'receivers', + pageNav: undefined, + }; + } + if (location.pathname === '/alerting/routes') { + return { + navId: 'am-routes', + pageNav: undefined, + }; + } + return { + navId: 'receivers', + pageNav: undefined, + }; + } + + const notificationConfigNav = navIndex['notification-config']; + if (!notificationConfigNav) { + // Fallback to legacy navIds + if (location.pathname.includes('/alerting/notifications/templates')) { + return { + navId: 'receivers', + pageNav: undefined, + }; + } + if (location.pathname === '/alerting/routes') { + return { + navId: 'am-routes', + pageNav: undefined, + }; + } + return { + navId: 'receivers', + pageNav: undefined, + }; + } + + // Check if we're on the routes page with time_intervals tab + const isTimeIntervalsTab = location.pathname === '/alerting/routes' && location.search.includes('tab=time_intervals'); + + // All available tabs + const allTabs: NavModelItem[] = [ + { + id: 'notification-config-contact-points', + text: t('alerting.navigation.contact-points', 'Contact points'), + url: '/alerting/notifications', + active: location.pathname === '/alerting/notifications' && !location.pathname.includes('/templates'), + icon: 'comment-alt-share', + parentItem: notificationConfigNav, + }, + { + id: 'notification-config-policies', + text: t('alerting.navigation.notification-policies', 'Notification policies'), + url: '/alerting/routes', + active: location.pathname === '/alerting/routes' && !isTimeIntervalsTab, + icon: 'sitemap', + parentItem: notificationConfigNav, + }, + { + id: 'notification-config-templates', + text: t('alerting.navigation.notification-templates', 'Notification templates'), + url: '/alerting/notifications/templates', + active: location.pathname.includes('/alerting/notifications/templates'), + icon: 'file-alt', + parentItem: notificationConfigNav, + }, + { + id: 'notification-config-time-intervals', + text: t('alerting.navigation.time-intervals', 'Time intervals'), + url: '/alerting/routes?tab=time_intervals', + active: isTimeIntervalsTab, + icon: 'clock-nine', + parentItem: notificationConfigNav, + }, + ].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 that represents the Notification configuration page with tabs as children + const pageNav: NavModelItem = { + ...notificationConfigNav, + children: allTabs, + }; + + return { + navId: 'notification-config', + pageNav, + }; +} diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx index bec19c1cc99..506b99401a1 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v1.tsx @@ -20,6 +20,7 @@ import { shouldUsePrometheusRulesPrimary } from '../featureToggles'; import { useCombinedRuleNamespaces } from '../hooks/useCombinedRuleNamespaces'; import { useFilteredRules, useRulesFilter } from '../hooks/useFilteredRules'; import { useUnifiedAlertingSelector } from '../hooks/useUnifiedAlertingSelector'; +import { useAlertRulesNav } from '../navigation/useAlertRulesNav'; import { fetchAllPromAndRulerRulesAction, fetchAllPromRulesAction, fetchRulerRulesAction } from '../state/actions'; import { RULE_LIST_POLL_INTERVAL_MS } from '../utils/constants'; import { GRAFANA_RULES_SOURCE_NAME, getAllRulesSourceNames } from '../utils/datasource'; @@ -115,11 +116,14 @@ const RuleListV1 = () => { const combinedNamespaces: CombinedRuleNamespace[] = useCombinedRuleNamespaces(); const filteredNamespaces = useFilteredRules(combinedNamespaces, filterState); + const { navId, pageNav } = useAlertRulesNav(); + return ( // We don't want to show the Loading... indicator for the whole page. // We show separate indicators for Grafana-managed and Cloud rules } actions={} diff --git a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx index 284bd6ad757..0c2356a2df2 100644 --- a/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/RuleList.v2.tsx @@ -13,6 +13,7 @@ import { useListViewMode } from '../components/rules/Filter/RulesViewModeSelecto import { AIAlertRuleButtonComponent } from '../enterprise-components/AI/AIGenAlertRuleButton/addAIAlertRuleButton'; import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; import { useRulesFilter } from '../hooks/useFilteredRules'; +import { useAlertRulesNav } from '../navigation/useAlertRulesNav'; import { FilterView } from './FilterView'; import { GroupedView } from './GroupedView'; @@ -119,10 +120,12 @@ export function RuleListActions() { export default function RuleListPage() { const { isApplying } = useApplyDefaultSearch(); + const { navId, pageNav } = useAlertRulesNav(); return ( } isLoading={isApplying} actions={}