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
This commit is contained in:
+1
-1
@@ -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/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
|
Generated
+4
@@ -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"
|
||||
|
||||
+13
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 (
|
||||
<AlertmanagerPageWrapper navId="am-routes" accessType="notification">
|
||||
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
|
||||
<NotificationPoliciesTabs />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<AlertmanagerPageWrapper navId="receivers" accessType="notification">
|
||||
<AlertmanagerPageWrapper navId={navId || 'receivers'} pageNav={pageNav} accessType="notification">
|
||||
<ContactPointsPageContents />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
|
||||
+3
-1
@@ -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 (
|
||||
<AlertingPageWrapper navId="alerts-history" isLoading={false}>
|
||||
<AlertingPageWrapper navId={navId || 'alerts-history'} pageNav={pageNav} isLoading={false}>
|
||||
<CentralAlertHistoryScene />
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
|
||||
+3
-1
@@ -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 (
|
||||
<AlertingPageWrapper navId="alerts/recently-deleted" isLoading={isLoading}>
|
||||
<AlertingPageWrapper navId={navId || 'alerts/recently-deleted'} pageNav={pageNav} isLoading={isLoading}>
|
||||
<>
|
||||
{error && (
|
||||
<Alert title={t('alerting.deleted-rules.errorloading', 'Failed to load alert deleted rules')}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<AlertingPageWrapper
|
||||
navId={navId || 'insights'}
|
||||
pageNav={pageNav}
|
||||
subTitle={t('alerting.insights.subtitle', 'Analytics and history for alerting')}
|
||||
>
|
||||
<div>
|
||||
<Trans i18nKey="alerting.insights.not-available">
|
||||
Insights are not available. Please configure the required data sources.
|
||||
</Trans>
|
||||
</div>
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper
|
||||
navId={navId || 'insights'}
|
||||
pageNav={pageNav}
|
||||
subTitle={t('alerting.insights.subtitle', 'Analytics and history for alerting')}
|
||||
>
|
||||
<insightsScene.Component model={insightsScene} />
|
||||
</AlertingPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default withPageErrorBoundary(InsightsPage);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
<AlertingPageWrapper
|
||||
navId="alert-list"
|
||||
navId={navId}
|
||||
pageNav={pageNav}
|
||||
isLoading={false}
|
||||
renderTitle={(title) => <RuleListPageTitle title={title} />}
|
||||
actions={<RuleListActionButtons hasAlertRulesCreated={hasAlertRulesCreated} />}
|
||||
|
||||
@@ -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 (
|
||||
<AlertingPageWrapper
|
||||
navId="alert-list"
|
||||
navId={navId}
|
||||
pageNav={pageNav}
|
||||
renderTitle={(title) => <RuleListPageTitle title={title} />}
|
||||
isLoading={isApplying}
|
||||
actions={<RuleListActions />}
|
||||
|
||||
Reference in New Issue
Block a user