Compare commits

..
Author SHA1 Message Date
Kristina Demeshchik 00e11fa4d2 tests cleanup 2026-01-13 11:29:20 -05:00
Kristina Demeshchik c3502eed0b max id calculation 2026-01-13 11:16:41 -05:00
Kristina Demeshchik 34e8cfef52 fix frontend logic 2026-01-13 11:10:39 -05:00
Kristina Demeshchik fbf4be69ae udpapte tests 2026-01-13 10:35:48 -05:00
Kristina Demeshchik 5df7d43f8c Update migration function to look into rows 2026-01-13 10:08:51 -05:00
Will Assis 69ccfd6bfc unified-storage: fix sharedwithme search not returning folders (#116089)
* unified-storage: fix dashboard sharedwithme search not returning folders shared with the user
2026-01-12 15:33:34 -05:00
41 changed files with 523 additions and 1470 deletions
@@ -46,7 +46,7 @@
"x": 0,
"y": 0
},
"id": 1,
"id": 23,
"options": {
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
"mode": "markdown"
@@ -77,7 +77,7 @@
"x": 0,
"y": 0
},
"id": 23,
"id": 24,
"panels": [],
"targets": [
{
@@ -31,53 +31,6 @@
"cursorSync": "Off",
"editable": false,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"id": 1,
"title": "Application Monitoring",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "prometheus",
"spec": {}
},
"datasource": {
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "text",
"spec": {
"pluginVersion": "",
"options": {
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
"mode": "markdown"
},
"fieldConfig": {
"defaults": {},
"overrides": []
}
}
}
}
},
"panel-10": {
"kind": "Panel",
"spec": {
@@ -1024,6 +977,53 @@
}
}
},
"panel-23": {
"kind": "Panel",
"spec": {
"id": 23,
"title": "Application Monitoring",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "prometheus",
"spec": {}
},
"datasource": {
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "text",
"spec": {
"pluginVersion": "",
"options": {
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
"mode": "markdown"
},
"fieldConfig": {
"defaults": {},
"overrides": []
}
}
}
}
},
"panel-6": {
"kind": "Panel",
"spec": {
@@ -1259,7 +1259,7 @@
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-1"
"name": "panel-23"
}
}
}
@@ -32,55 +32,6 @@
"cursorSync": "Off",
"editable": false,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"id": 1,
"title": "Application Monitoring",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "prometheus",
"version": "v0",
"datasource": {
"name": "default-ds-uid"
},
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "text",
"version": "",
"spec": {
"options": {
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
"mode": "markdown"
},
"fieldConfig": {
"defaults": {},
"overrides": []
}
}
}
}
},
"panel-10": {
"kind": "Panel",
"spec": {
@@ -1067,6 +1018,55 @@
}
}
},
"panel-23": {
"kind": "Panel",
"spec": {
"id": 23,
"title": "Application Monitoring",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "prometheus",
"version": "v0",
"datasource": {
"name": "default-ds-uid"
},
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "text",
"version": "",
"spec": {
"options": {
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
"mode": "markdown"
},
"fieldConfig": {
"defaults": {},
"overrides": []
}
}
}
}
},
"panel-6": {
"kind": "Panel",
"spec": {
@@ -1310,7 +1310,7 @@
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-1"
"name": "panel-23"
}
}
}
@@ -432,6 +432,21 @@ func getPanels(dashboard map[string]interface{}) []map[string]interface{} {
}
}
// Also get panels from rows
if rows, ok := dashboard["rows"].([]interface{}); ok {
for _, rowInterface := range rows {
if row, ok := rowInterface.(map[string]interface{}); ok {
if rowPanels, ok := row["panels"].([]interface{}); ok {
for _, panelInterface := range rowPanels {
if panel, ok := panelInterface.(map[string]interface{}); ok {
panels = append(panels, panel)
}
}
}
}
}
}
return panels
}
@@ -46,7 +46,8 @@ func upgradeToGridLayout(dashboard map[string]interface{}) {
widthFactor := gridColumnCount / 12.0
// Find max panel ID (lines 1014-1021 in TS)
maxPanelID := getMaxPanelID(rows)
// Also check top-level panels which may have been assigned IDs by ensurePanelsHaveUniqueIds
maxPanelID := getMaxPanelID(dashboard, rows)
nextRowID := maxPanelID + 1
// Match frontend: dashboard.panels already exists with top-level panels
@@ -269,10 +270,25 @@ func (r *rowArea) getPanelPosition(panelHeight int, panelWidth int) map[string]i
return r.getPanelPosition(panelHeight, panelWidth)
}
func getMaxPanelID(rows []interface{}) int {
func getMaxPanelID(dashboard map[string]interface{}, rows []interface{}) int {
maxID := 0
hasValidID := false
// Check top-level panels first (these may have been assigned IDs by ensurePanelsHaveUniqueIds)
if panels, ok := dashboard["panels"].([]interface{}); ok {
for _, panelInterface := range panels {
if panel, ok := panelInterface.(map[string]interface{}); ok {
if id := GetIntValue(panel, "id", 0); id > 0 {
hasValidID = true
if id > maxID {
maxID = id
}
}
}
}
}
// Also check panels inside rows
for _, rowInterface := range rows {
if row, ok := rowInterface.(map[string]interface{}); ok {
if panels, ok := row["panels"].([]interface{}); ok {
@@ -40,7 +40,7 @@
"x": 0,
"y": 0
},
"id": 1,
"id": 23,
"options": {
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
"mode": "markdown"
@@ -71,7 +71,7 @@
"x": 0,
"y": 0
},
"id": 23,
"id": 24,
"panels": [],
"targets": [
{
@@ -35,7 +35,7 @@
"x": 0,
"y": 0
},
"id": 1,
"id": 23,
"options": {
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
"mode": "markdown"
@@ -51,7 +51,7 @@
"x": 0,
"y": 0
},
"id": 23,
"id": 24,
"panels": [],
"title": "Application Service",
"type": "row"
+17 -8
View File
@@ -552,6 +552,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
// gets dashboards that the user was granted read access to
permissions := user.GetPermissions()
dashboardPermissions := permissions[dashboards.ActionDashboardsRead]
folderPermissions := permissions[dashboards.ActionFoldersRead]
dashboardUids := make([]string, 0)
sharedDashboards := make([]string, 0)
@@ -562,6 +563,13 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}
}
}
for _, folderPermission := range folderPermissions {
if folderUid, found := strings.CutPrefix(folderPermission, dashboards.ScopeFoldersPrefix); found {
if !slices.Contains(dashboardUids, folderUid) && folderUid != foldermodel.SharedWithMeFolderUID && folderUid != foldermodel.GeneralFolderUID {
dashboardUids = append(dashboardUids, folderUid)
}
}
}
if len(dashboardUids) == 0 {
return sharedDashboards, nil
@@ -572,9 +580,15 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
return sharedDashboards, err
}
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
if err != nil {
return sharedDashboards, err
}
dashboardSearchRequest := &resourcepb.ResourceSearchRequest{
Fields: []string{"folder"},
Limit: int64(len(dashboardUids)),
Federated: []*resourcepb.ResourceKey{folderKey},
Fields: []string{"folder"},
Limit: int64(len(dashboardUids)),
Options: &resourcepb.ListOptions{
Key: key,
Fields: []*resourcepb.Requirement{{
@@ -610,12 +624,6 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}
}
// only folders the user has access to will be returned here
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
if err != nil {
return sharedDashboards, err
}
folderSearchRequest := &resourcepb.ResourceSearchRequest{
Fields: []string{"folder"},
Limit: int64(len(allFolders)),
@@ -628,6 +636,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}},
},
}
// only folders the user has access to will be returned here
foldersResult, err := s.client.Search(ctx, folderSearchRequest)
if err != nil {
return sharedDashboards, err
+27 -3
View File
@@ -507,6 +507,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
[]byte("publicfolder"), // folder uid
},
},
{
Key: &resourcepb.ResourceKey{
Name: "sharedfolder",
Resource: "folder",
},
Cells: [][]byte{
[]byte("privatefolder"), // folder uid
},
},
},
},
}
@@ -550,6 +559,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
[]byte("privatefolder"), // folder uid
},
},
{
Key: &resourcepb.ResourceKey{
Name: "sharedfolder",
Resource: "folder",
},
Cells: [][]byte{
[]byte("privatefolder"), // folder uid
},
},
},
},
}
@@ -571,6 +589,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
allPermissions := make(map[int64]map[string][]string)
permissions := make(map[string][]string)
permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"}
permissions[dashboards.ActionFoldersRead] = []string{"folders:uid:sharedfolder"}
allPermissions[1] = permissions
// "Permissions" is where we store the uid of dashboards shared with the user
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions}))
@@ -581,14 +600,19 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
// first call gets all dashboards user has permission for
firstCall := mockClient.MockCalls[0]
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder"})
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder", "sharedfolder"})
// verify federated field is set to include folders
assert.NotNil(t, firstCall.Federated)
assert.Equal(t, 1, len(firstCall.Federated))
assert.Equal(t, "folder.grafana.app", firstCall.Federated[0].Group)
assert.Equal(t, "folders", firstCall.Federated[0].Resource)
// second call gets folders associated with the previous dashboards
secondCall := mockClient.MockCalls[1]
assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"})
// lastly, search ONLY for dashboards user has permission to read that are within folders the user does NOT have
// lastly, search ONLY for dashboards and folders user has permission to read that are within folders the user does NOT have
// permission to read
thirdCall := mockClient.MockCalls[2]
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"})
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder", "sharedfolder"})
resp := rr.Result()
defer func() {
-14
View File
@@ -348,20 +348,6 @@
"expression": "true"
}
},
{
"metadata": {
"name": "alertingNavigationV2",
"resourceVersion": "1767827323622",
"creationTimestamp": "2026-01-07T23:08:43Z",
"deletionTimestamp": "2026-01-12T18:34:54Z"
},
"spec": {
"description": "Enable new grouped navigation structure for Alerting",
"stage": "experimental",
"codeowner": "@grafana/alerting-squad",
"expression": "false"
}
},
{
"metadata": {
"name": "alertingNotificationHistory",
+41 -156
View File
@@ -436,212 +436,97 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
hasAccess := ac.HasAccess(s.accessControl, c)
var alertChildNavs []*navtree.NavLink
var alertActivityChildren []*navtree.NavLink
//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))) {
alertActivityChildren = append(alertActivityChildren, &navtree.NavLink{
Text: "Alerts",
SubTitle: "Visualize active and pending alerts",
Id: "alert-activity-alerts",
Url: s.cfg.AppSubURL + "/alerting/alerts",
Icon: "bell",
})
}
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingInstanceRead), ac.EvalPermission(ac.ActionAlertingInstancesExternalRead))) {
alertActivityChildren = append(alertActivityChildren, &navtree.NavLink{
Text: "Active notifications",
SubTitle: "See grouped alerts with active notifications",
Id: "alert-activity-groups",
Url: s.cfg.AppSubURL + "/alerting/groups",
Icon: "layer-group",
})
}
if len(alertActivityChildren) > 0 {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert activity",
SubTitle: "Visualize active and pending alerts",
Id: "alert-activity",
Url: s.cfg.AppSubURL + "/alerting/alerts",
Icon: "bell",
IsNew: true,
Children: alertActivityChildren,
Text: "Alert activity", SubTitle: "Visualize active and pending alerts", Id: "alert-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true,
})
}
}
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,
Text: "Alert rules", SubTitle: "Rules that determine whether an alert will fire", Id: "alert-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul",
})
}
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",
})
}
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",
})
}
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,
Text: "Contact points", SubTitle: "Choose how to notify your contact points when an alert instance fires", Id: "receivers", Url: s.cfg.AppSubURL + "/alerting/notifications",
Icon: "comment-alt-share",
})
}
var insightsChildren []*navtree.NavLink
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",
})
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),
)) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Notification policies", SubTitle: "Determine how alerts are routed to contact points", Id: "am-routes", Url: s.cfg.AppSubURL + "/alerting/routes", Icon: "sitemap"})
}
// Alert state history
if hasAccess(ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingInstanceRead),
ac.EvalPermission(ac.ActionAlertingInstancesExternalRead),
ac.EvalPermission(ac.ActionAlertingSilencesRead),
)) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Silences", SubTitle: "Stop notifications from one or more alerting rules", Id: "silences", Url: s.cfg.AppSubURL + "/alerting/silences", Icon: "bell-slash"})
}
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingInstanceRead), ac.EvalPermission(ac.ActionAlertingInstancesExternalRead))) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Alert groups", SubTitle: "See grouped alerts with active notifications", Id: "groups", Url: s.cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"})
}
//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",
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "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",
Id: "alerts-history",
Url: s.cfg.AppSubURL + "/alerting/history",
Icon: "history",
})
}
}
if len(insightsChildren) > 0 {
//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) {
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,
Text: "Recently deleted",
SubTitle: "Any items listed here for more than 30 days will be automatically deleted.",
Id: "alerts/recently-deleted",
Url: s.cfg.AppSubURL + "/alerting/recently-deleted",
})
}
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,
Text: "Settings", Id: "alerting-admin", Url: s.cfg.AppSubURL + "/alerting/admin",
Icon: "cog",
})
}
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,
Text: "Create alert rule", SubTitle: "Create an alert rule", Id: "alert",
Icon: "plus", Url: s.cfg.AppSubURL + "/alerting/new", HideFromTabs: true, IsCreateAction: true,
})
}
@@ -1,158 +0,0 @@
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"
)
// Test fixtures
func setupTestContext() *contextmodel.ReqContext {
httpReq, _ := http.NewRequest(http.MethodGet, "", nil)
return &contextmodel.ReqContext{
SignedInUser: &user.SignedInUser{
UserID: 1,
OrgID: 1,
OrgRole: org.RoleAdmin,
},
Context: &web.Context{Req: httpReq},
}
}
func setupTestService(permissions []ac.Permission, featureFlags ...string) ServiceImpl {
// Convert string slice to []any for WithFeatures
flags := make([]any, len(featureFlags))
for i, flag := range featureFlags {
flags[i] = flag
}
return ServiceImpl{
log: log.New("navtree"),
cfg: setting.NewCfg(),
accessControl: accesscontrolmock.New().WithPermissions(permissions),
features: featuremgmt.WithFeatures(flags...),
}
}
func fullPermissions() []ac.Permission {
return []ac.Permission{
{Action: ac.ActionAlertingRuleRead, Scope: "*"},
{Action: ac.ActionAlertingNotificationsRead, Scope: "*"},
{Action: ac.ActionAlertingRoutesRead, Scope: "*"},
{Action: ac.ActionAlertingInstanceRead, Scope: "*"},
}
}
// Helper to find a nav link by ID
func findNavLink(navLink *navtree.NavLink, id string) *navtree.NavLink {
if navLink == nil {
return nil
}
if navLink.Id == id {
return navLink
}
for _, child := range navLink.Children {
if found := findNavLink(child, id); found != nil {
return found
}
}
return nil
}
// Helper to check if a nav link has a child with given ID
func hasChildWithId(parent *navtree.NavLink, childId string) bool {
if parent == nil {
return false
}
for _, child := range parent.Children {
if child.Id == childId {
return true
}
}
return false
}
func TestBuildAlertNavLinks(t *testing.T) {
reqCtx := setupTestContext()
allFeatureFlags := []string{"alertingTriage", "alertingCentralAlertHistory", "alertRuleRestore", "alertingRuleRecoverDeleted"}
service := setupTestService(fullPermissions(), allFeatureFlags...)
t.Run("Should have correct parent structure", func(t *testing.T) {
navLink := service.buildAlertNavLinks(reqCtx)
require.NotNil(t, navLink)
require.NotEmpty(t, navLink.Children)
// Verify all parent items exist with children
parentIds := []string{"alert-rules", "notification-config", "insights", "alerting-settings"}
for _, parentId := range parentIds {
parent := findNavLink(navLink, parentId)
require.NotNil(t, parent, "Should have parent %s", parentId)
require.NotEmpty(t, parent.Children, "Parent %s should have children", parentId)
}
})
t.Run("Should have correct tabs under each parent", func(t *testing.T) {
navLink := service.buildAlertNavLinks(reqCtx)
require.NotNil(t, navLink)
// Table-driven test for tab verification
tests := []struct {
parentId string
expectedTabs []string
}{
{"alert-rules", []string{"alert-rules-list", "alert-rules-recently-deleted"}},
{"notification-config", []string{"notification-config-contact-points", "notification-config-policies", "notification-config-templates", "notification-config-time-intervals"}},
{"insights", []string{"insights-system", "insights-history"}},
}
for _, tt := range tests {
parent := findNavLink(navLink, tt.parentId)
require.NotNil(t, parent, "Should have %s parent", tt.parentId)
for _, expectedTab := range tt.expectedTabs {
require.True(t, hasChildWithId(parent, expectedTab), "Parent %s should have tab %s", tt.parentId, expectedTab)
}
}
})
t.Run("Should respect permissions", func(t *testing.T) {
limitedPermissions := []ac.Permission{
{Action: ac.ActionAlertingRuleRead, Scope: "*"},
}
limitedService := setupTestService(limitedPermissions)
navLink := limitedService.buildAlertNavLinks(reqCtx)
require.NotNil(t, navLink)
// Should not have notification-config without notification permissions
require.Nil(t, findNavLink(navLink, "notification-config"), "Should not have notification-config without permissions")
})
t.Run("Should exclude future items", func(t *testing.T) {
navLink := service.buildAlertNavLinks(reqCtx)
require.NotNil(t, navLink)
// Verify future items are not present
futureIds := []string{
"alert-rules-recording-rules",
"alert-rules-evaluation-chains",
"insights-alert-optimizer",
"insights-notification-history",
}
for _, futureId := range futureIds {
require.Nil(t, findNavLink(navLink, futureId), "Should not have future item %s", futureId)
}
})
}
@@ -37,24 +37,9 @@ export const MegaMenu = memo(
const pinnedItems = usePinnedItems();
// Remove profile + help from tree
// For Alerting navigation, flatten the sidebar to show only top-level items (hide nested children/tabs)
const navItems = navTree
.filter((item) => item.id !== 'profile' && item.id !== 'help')
.map((item) => {
const enriched = enrichWithInteractionTracking(item, state.megaMenuDocked);
// If this is Alerting section, flatten children for sidebar display
// Children are still available in navIndex for breadcrumbs and page navigation
if (item.id === 'alerting' && enriched.children) {
return {
...enriched,
children: enriched.children.map((child) => ({
...child,
children: undefined, // Remove nested children from sidebar, but keep them for page navigation
})),
};
}
return enriched;
});
.map((item) => enrichWithInteractionTracking(item, state.megaMenuDocked));
const bookmarksItem = navItems.find((item) => item.id === 'bookmarks');
if (bookmarksItem) {
@@ -35,18 +35,11 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte
if (shouldAddCrumb) {
const activeChildIndex = node.children?.findIndex((child) => child.active) ?? -1;
// Add active tab to breadcrumbs if it exists and its URL is different from the node's URL
// This ensures tabs show in breadcrumbs (including the first tab) while preventing duplication
if (activeChildIndex >= 0) {
// Add tab to breadcrumbs if it's not the first active child
if (activeChildIndex > 0) {
const activeChild = node.children?.[activeChildIndex];
if (activeChild) {
// Only add the active child if its URL doesn't match the node's URL
// This prevents duplication when the pageNav is the active tab
const nodeUrl = node.url?.split('?')[0] ?? '';
const childUrl = activeChild.url?.split('?')[0] ?? '';
if (nodeUrl !== childUrl) {
crumbs.unshift({ text: activeChild.text, href: activeChild.url ?? '' });
}
crumbs.unshift({ text: activeChild.text, href: activeChild.url ?? '' });
}
}
crumbs.unshift({ text: node.text, href: node.url ?? '' });
-18
View File
@@ -56,17 +56,6 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
)
),
},
{
path: '/alerting/time-intervals',
roles: evaluateAccess([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
...PERMISSIONS_TIME_INTERVALS_READ,
]),
component: importAlertingComponent(
() => import(/* webpackChunkName: "TimeIntervalsPage" */ 'app/features/alerting/unified/TimeIntervalsPage')
),
},
{
path: '/alerting/routes/mute-timing/new',
roles: evaluateAccess([
@@ -223,13 +212,6 @@ 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'],
@@ -14,7 +14,6 @@ import { AlertGroupFilter } from './components/alert-groups/AlertGroupFilter';
import { useFilteredAmGroups } from './hooks/useFilteredAmGroups';
import { useGroupedAlerts } from './hooks/useGroupedAlerts';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { useAlertActivityNav } from './navigation/useAlertActivityNav';
import { useAlertmanager } from './state/AlertmanagerContext';
import { fetchAlertGroupsAction } from './state/actions';
import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants';
@@ -114,9 +113,8 @@ const AlertGroups = () => {
};
function AlertGroupsPage() {
const { navId, pageNav } = useAlertActivityNav();
return (
<AlertmanagerPageWrapper navId={navId || 'groups'} pageNav={pageNav} accessType="instance">
<AlertmanagerPageWrapper navId="groups" accessType="instance">
<AlertGroups />
</AlertmanagerPageWrapper>
);
@@ -140,35 +140,6 @@ const getRootRoute = async () => {
};
describe('NotificationPolicies', () => {
beforeEach(() => {
setupDataSources(dataSources.am);
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
...PERMISSIONS_NOTIFICATION_POLICIES,
]);
});
it('shows only notification policies without internal tabs', async () => {
renderNotificationPolicies();
// Should show notification policies directly
expect(await ui.rootRouteContainer.find()).toBeInTheDocument();
// Should not have tabs
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
});
it('does not show time intervals tab', async () => {
renderNotificationPolicies();
// Should show notification policies
expect(await ui.rootRouteContainer.find()).toBeInTheDocument();
// Should not show time intervals tab
expect(screen.queryByText(/time intervals/i)).not.toBeInTheDocument();
});
// combobox hack :/
beforeAll(() => {
const mockGetBoundingClientRect = jest.fn(() => ({
@@ -1,29 +1,115 @@
import { css } from '@emotion/css';
import { useState } from 'react';
import { GrafanaTheme2, UrlQueryMap } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Tab, TabContent, TabsBar, useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { useMuteTimings } from 'app/features/alerting/unified/components/mute-timings/useMuteTimings';
import { NotificationPoliciesList } from 'app/features/alerting/unified/components/notification-policies/NotificationPoliciesList';
import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities';
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import { GrafanaAlertmanagerWarning } from './components/GrafanaAlertmanagerWarning';
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
import { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
import { useAlertmanager } from './state/AlertmanagerContext';
import { withPageErrorBoundary } from './withPageErrorBoundary';
const NotificationPoliciesContent = () => {
enum ActiveTab {
NotificationPolicies = 'notification_policies',
TimeIntervals = 'time_intervals',
}
const NotificationPoliciesTabs = () => {
const styles = useStyles2(getStyles);
// Alertmanager logic and data hooks
const { selectedAlertmanager = '' } = useAlertmanager();
const [policiesSupported, canSeePoliciesTab] = useAlertmanagerAbility(AlertmanagerAction.ViewNotificationPolicyTree);
const [timingsSupported, canSeeTimingsTab] = useAlertmanagerAbility(AlertmanagerAction.ViewTimeInterval);
const availableTabs = [
canSeePoliciesTab && ActiveTab.NotificationPolicies,
canSeeTimingsTab && ActiveTab.TimeIntervals,
].filter((tab) => !!tab);
const { data: muteTimings = [] } = useMuteTimings({
alertmanager: selectedAlertmanager,
skip: !canSeeTimingsTab,
});
// Tab state management
const [queryParams, setQueryParams] = useQueryParams();
const { tab } = getActiveTabFromUrl(queryParams, availableTabs[0]);
const [activeTab, setActiveTab] = useState<ActiveTab>(tab);
const muteTimingsTabActive = activeTab === ActiveTab.TimeIntervals;
const policyTreeTabActive = activeTab === ActiveTab.NotificationPolicies;
const numberOfMuteTimings = muteTimings.length;
return (
<>
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager} />
<NotificationPoliciesList />
<TabsBar>
{policiesSupported && canSeePoliciesTab && (
<Tab
label={t('alerting.notification-policies-tabs.label-notification-policies', 'Notification Policies')}
active={policyTreeTabActive}
onChangeTab={() => {
setActiveTab(ActiveTab.NotificationPolicies);
setQueryParams({ tab: ActiveTab.NotificationPolicies });
}}
/>
)}
{timingsSupported && canSeeTimingsTab && (
<Tab
label={t('alerting.notification-policies-tabs.label-time-intervals', 'Time intervals')}
active={muteTimingsTabActive}
counter={numberOfMuteTimings}
onChangeTab={() => {
setActiveTab(ActiveTab.TimeIntervals);
setQueryParams({ tab: ActiveTab.TimeIntervals });
}}
/>
)}
</TabsBar>
<TabContent className={styles.tabContent}>
{policyTreeTabActive && <NotificationPoliciesList />}
{muteTimingsTabActive && <TimeIntervalsTable />}
</TabContent>
</>
);
};
function NotificationPoliciesPage() {
const { navId, pageNav } = useNotificationConfigNav();
const getStyles = (theme: GrafanaTheme2) => ({
tabContent: css({
marginTop: theme.spacing(2),
}),
});
// Show only notification policies (no internal tabs)
// Time intervals are accessible via the sidebar navigation
interface QueryParamValues {
tab: ActiveTab;
}
function getActiveTabFromUrl(queryParams: UrlQueryMap, defaultTab: ActiveTab): QueryParamValues {
let tab = defaultTab;
if (queryParams.tab === ActiveTab.NotificationPolicies) {
tab = ActiveTab.NotificationPolicies;
}
if (queryParams.tab === ActiveTab.TimeIntervals) {
tab = ActiveTab.TimeIntervals;
}
return {
tab,
};
}
function NotificationPoliciesPage() {
return (
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
<NotificationPoliciesContent />
<AlertmanagerPageWrapper navId="am-routes" accessType="notification">
<NotificationPoliciesTabs />
</AlertmanagerPageWrapper>
);
}
@@ -1,52 +1,13 @@
import { Route, Routes } from 'react-router-dom-v5-compat';
import { Trans } from '@grafana/i18n';
import { LinkButton, Stack, Text } from '@grafana/ui';
import { AlertmanagerPageWrapper } from './components/AlertingPageWrapper';
import DuplicateMessageTemplate from './components/contact-points/DuplicateMessageTemplate';
import EditMessageTemplate from './components/contact-points/EditMessageTemplate';
import NewMessageTemplate from './components/contact-points/NewMessageTemplate';
import { NotificationTemplates } from './components/contact-points/NotificationTemplates';
import { AlertmanagerAction, useAlertmanagerAbility } from './hooks/useAbilities';
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
import { withPageErrorBoundary } from './withPageErrorBoundary';
const TemplatesList = () => {
const [createTemplateSupported, createTemplateAllowed] = useAlertmanagerAbility(
AlertmanagerAction.CreateNotificationTemplate
);
return (
<>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Text variant="body" color="secondary">
<Trans i18nKey="alerting.notification-templates-tab.create-notification-templates-customize-notifications">
Create notification templates to customize your notifications.
</Trans>
</Text>
{createTemplateSupported && (
<LinkButton
icon="plus"
variant="primary"
href="/alerting/notifications/templates/new"
disabled={!createTemplateAllowed}
>
<Trans i18nKey="alerting.notification-templates-tab.add-notification-template-group">
Add notification template group
</Trans>
</LinkButton>
)}
</Stack>
<NotificationTemplates />
</>
);
};
function NotificationTemplatesRoutes() {
function NotificationTemplates() {
return (
<Routes>
<Route path="" element={<TemplatesList />} />
<Route path="new" element={<NewMessageTemplate />} />
<Route path=":name/edit" element={<EditMessageTemplate />} />
<Route path=":name/duplicate" element={<DuplicateMessageTemplate />} />
@@ -54,14 +15,4 @@ function NotificationTemplatesRoutes() {
);
}
function NotificationTemplatesPage() {
const { navId, pageNav } = useNotificationConfigNav();
return (
<AlertmanagerPageWrapper navId={navId || 'receivers'} pageNav={pageNav} accessType="notification">
<NotificationTemplatesRoutes />
</AlertmanagerPageWrapper>
);
}
export default withPageErrorBoundary(NotificationTemplatesPage);
export default withPageErrorBoundary(NotificationTemplates);
@@ -1,65 +0,0 @@
import { render, screen } from 'test/test-utils';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction } from 'app/types/accessControl';
import TimeIntervalsPage from './TimeIntervalsPage';
import { defaultConfig } from './components/mute-timings/mocks';
import { setupMswServer } from './mockApi';
import { grantUserPermissions, mockDataSource } from './mocks';
import { setTimeIntervalsListEmpty } from './mocks/server/configure';
import { setAlertmanagerConfig } from './mocks/server/entities/alertmanagers';
import { setupDataSources } from './testSetup/datasources';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
setupMswServer();
const alertManager = mockDataSource({
name: 'Alertmanager',
type: DataSourceType.Alertmanager,
});
describe('TimeIntervalsPage', () => {
beforeEach(() => {
setupDataSources(alertManager);
setAlertmanagerConfig(GRAFANA_RULES_SOURCE_NAME, defaultConfig);
setTimeIntervalsListEmpty(); // Mock empty time intervals list so component renders
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingTimeIntervalsRead,
]);
});
it('renders time intervals table', async () => {
const mockNavIndex = {
'notification-config': {
id: 'notification-config',
text: 'Notification configuration',
url: '/alerting/notifications',
},
'notification-config-time-intervals': {
id: 'notification-config-time-intervals',
text: 'Time intervals',
url: '/alerting/time-intervals',
},
};
const store = configureStore({
navIndex: mockNavIndex,
});
render(<TimeIntervalsPage />, {
store,
historyOptions: {
initialEntries: ['/alerting/time-intervals'],
},
});
// Should show time intervals content
// When empty, it shows "You haven't created any time intervals yet"
// When loading, it shows "Loading time intervals..."
// When error, it shows "Error loading time intervals"
// All contain "time intervals" - use getAllByText since there are multiple matches (tab, description, empty state)
const timeIntervalsTexts = await screen.findAllByText(/time intervals/i, {}, { timeout: 5000 });
expect(timeIntervalsTexts.length).toBeGreaterThan(0);
});
});
@@ -1,31 +0,0 @@
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';
// Content component that uses AlertmanagerContext
// This must be rendered within AlertmanagerPageWrapper
function TimeIntervalsPageContent() {
const { selectedAlertmanager } = useAlertmanager();
return (
<>
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager!} />
<TimeIntervalsTable />
</>
);
}
function TimeIntervalsPage() {
const { navId, pageNav } = useNotificationConfigNav();
return (
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
<TimeIntervalsPageContent />
</AlertmanagerPageWrapper>
);
}
export default withPageErrorBoundary(TimeIntervalsPage);
@@ -170,26 +170,6 @@ describe('contact points', () => {
});
});
test('shows only contact points without internal tabs', async () => {
renderWithProvider(<ContactPointsPageContents />);
// Should show contact points directly
expect(await screen.findByText(/create contact point/i)).toBeInTheDocument();
// Should not have tabs
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
});
test('does not show templates tab', async () => {
renderWithProvider(<ContactPointsPageContents />);
// Should show contact points
expect(await screen.findByText(/create contact point/i)).toBeInTheDocument();
// Should not show templates tab
expect(screen.queryByText(/notification templates/i)).not.toBeInTheDocument();
});
describe('templates tab', () => {
it('does not show a warning for a "misconfigured" template', async () => {
renderWithProvider(
@@ -278,6 +258,14 @@ describe('contact points', () => {
// there should be view buttons though - one for provisioned, and one for the un-editable contact point
const viewButtons = screen.getAllByRole('link', { name: /^view$/i });
expect(viewButtons).toHaveLength(2);
// check buttons in Notification Templates
const notificationTemplatesTab = screen.getByRole('tab', { name: 'Notification Templates' });
await user.click(notificationTemplatesTab);
expect(screen.getByRole('link', { name: 'Add notification template group' })).toHaveAttribute(
'aria-disabled',
'true'
);
});
it('allows deleting when not disabled', async () => {
@@ -482,6 +470,11 @@ describe('contact points', () => {
const viewButton = screen.getByRole('link', { name: /^view$/i });
expect(viewButton).toBeInTheDocument();
expect(viewButton).toBeEnabled();
// check buttons in Notification Templates
const notificationTemplatesTab = screen.getByRole('tab', { name: 'Notification Templates' });
await user.click(notificationTemplatesTab);
expect(screen.queryByRole('link', { name: 'Add notification template group' })).not.toBeInTheDocument();
});
});
@@ -1,5 +1,19 @@
import { useMemo } from 'react';
import { Trans, t } from '@grafana/i18n';
import { Alert, Button, EmptyState, LinkButton, LoadingPlaceholder, Pagination, Stack } from '@grafana/ui';
import {
Alert,
Button,
EmptyState,
LinkButton,
LoadingPlaceholder,
Pagination,
Stack,
Tab,
TabContent,
TabsBar,
Text,
} from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
import { makeAMLink, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
@@ -8,7 +22,6 @@ 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';
@@ -17,6 +30,7 @@ import { AlertmanagerPageWrapper } from '../AlertingPageWrapper';
import { GrafanaAlertmanagerWarning } from '../GrafanaAlertmanagerWarning';
import { ContactPoint } from './ContactPoint';
import { NotificationTemplates } from './NotificationTemplates';
import { ContactPointsFilter } from './components/ContactPointsFilter';
import { GlobalConfigAlert } from './components/GlobalConfigAlert';
import { useContactPointsWithStatus } from './useContactPoints';
@@ -85,7 +99,7 @@ const ContactPointsTab = () => {
}
return (
<Stack direction="column" gap={1}>
<>
{/* TODO we can add some additional info here with a ToggleTip */}
<Stack direction="row" alignItems="end" justifyContent="space-between">
<ContactPointsFilter />
@@ -134,19 +148,109 @@ const ContactPointsTab = () => {
<GlobalConfigAlert alertManagerName={selectedAlertmanager!} />
)}
{ExportDrawer}
</Stack>
</>
);
};
const NotificationTemplatesTab = () => {
const [createTemplateSupported, createTemplateAllowed] = useAlertmanagerAbility(
AlertmanagerAction.CreateNotificationTemplate
);
return (
<>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Text variant="body" color="secondary">
<Trans i18nKey="alerting.notification-templates-tab.create-notification-templates-customize-notifications">
Create notification templates to customize your notifications.
</Trans>
</Text>
{createTemplateSupported && (
<LinkButton
icon="plus"
variant="primary"
href="/alerting/notifications/templates/new"
disabled={!createTemplateAllowed}
>
<Trans i18nKey="alerting.notification-templates-tab.add-notification-template-group">
Add notification template group
</Trans>
</LinkButton>
)}
</Stack>
<NotificationTemplates />
</>
);
};
const useTabQueryParam = (defaultTab: ActiveTab) => {
const [queryParams, setQueryParams] = useURLSearchParams();
const param = useMemo(() => {
const queryParam = queryParams.get('tab');
if (!queryParam || !Object.values(ActiveTab).map(String).includes(queryParam)) {
return defaultTab;
}
return queryParam || defaultTab;
}, [defaultTab, queryParams]);
const setParam = (tab: ActiveTab) => setQueryParams({ tab });
return [param, setParam] as const;
};
export const ContactPointsPageContents = () => {
const { selectedAlertmanager } = useAlertmanager();
const [, canViewContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint);
const [, canCreateContactPoints] = useAlertmanagerAbility(AlertmanagerAction.CreateContactPoint);
const [, showTemplatesTab] = useAlertmanagerAbility(AlertmanagerAction.ViewNotificationTemplate);
const showContactPointsTab = canViewContactPoints || canCreateContactPoints;
// Depending on permissions, user may not have access to all tabs,
// but we can default to picking the first one that they definitely _do_ have access to
const defaultTab = [
showContactPointsTab && ActiveTab.ContactPoints,
showTemplatesTab && ActiveTab.NotificationTemplates,
].filter((tab) => !!tab)[0];
const [activeTab, setActiveTab] = useTabQueryParam(defaultTab);
const { contactPoints } = useContactPointsWithStatus({
alertmanager: selectedAlertmanager!,
});
const showingContactPoints = activeTab === ActiveTab.ContactPoints;
const showNotificationTemplates = activeTab === ActiveTab.NotificationTemplates;
// Show only contact points (no internal tabs)
// Templates are accessible via the sidebar navigation
return (
<>
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager!} />
<ContactPointsTab />
<Stack direction="column">
<TabsBar>
{showContactPointsTab && (
<Tab
label={t('alerting.contact-points-page-contents.label-contact-points', 'Contact Points')}
active={showingContactPoints}
counter={contactPoints.length}
onChangeTab={() => setActiveTab(ActiveTab.ContactPoints)}
/>
)}
{showTemplatesTab && (
<Tab
label={t('alerting.contact-points-page-contents.label-notification-templates', 'Notification Templates')}
active={showNotificationTemplates}
onChangeTab={() => setActiveTab(ActiveTab.NotificationTemplates)}
/>
)}
</TabsBar>
<TabContent>
<Stack direction="column">
{showingContactPoints && <ContactPointsTab />}
{showNotificationTemplates && <NotificationTemplatesTab />}
</Stack>
</TabContent>
</Stack>
</>
);
};
@@ -178,9 +282,8 @@ const ContactPointsList = ({ contactPoints, search, pageSize = DEFAULT_PAGE_SIZE
};
function ContactPointsPage() {
const { navId, pageNav } = useNotificationConfigNav();
return (
<AlertmanagerPageWrapper navId={navId || 'receivers'} pageNav={pageNav} accessType="notification">
<AlertmanagerPageWrapper navId="receivers" accessType="notification">
<ContactPointsPageContents />
</AlertmanagerPageWrapper>
);
@@ -1,13 +1,11 @@
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={navId || 'alerts-history'} pageNav={pageNav} isLoading={false}>
<AlertingPageWrapper navId="alerts-history" isLoading={false}>
<CentralAlertHistoryScene />
</AlertingPageWrapper>
);
@@ -3,7 +3,6 @@ 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';
@@ -19,10 +18,9 @@ function DeletedrulesPage() {
rulerConfig: GRAFANA_RULER_CONFIG,
filter: {}, // todo: add filters, and limit?????
});
const { navId, pageNav } = useAlertRulesNav();
return (
<AlertingPageWrapper navId={navId || 'alerts/recently-deleted'} pageNav={pageNav} isLoading={isLoading}>
<AlertingPageWrapper navId="alerts/recently-deleted" isLoading={isLoading}>
<>
{error && (
<Alert title={t('alerting.deleted-rules.errorloading', 'Failed to load alert deleted rules')}>
@@ -1,24 +1,23 @@
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { t } from '@grafana/i18n';
import { Box, Stack, Tab, TabContent, TabsBar } from '@grafana/ui';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { isLocalDevEnv } from '../utils/misc';
import { withPageErrorBoundary } from '../withPageErrorBoundary';
import GettingStarted, { WelcomeHeader } from './GettingStarted';
import IRMCard from './IRMCard';
import { getInsightsScenes } from './Insights';
import { getInsightsScenes, insightsIsAvailable } from './Insights';
import { PluginIntegrations } from './PluginIntegrations';
import SyntheticMonitoringCard from './SyntheticMonitoringCard';
function Home() {
// Insights tab is not shown on Home page - Insights is available via the sidebar menu instead
const insightsEnabled = false;
const insightsEnabled = insightsIsAvailable() || isLocalDevEnv();
const [activeTab, setActiveTab] = useState<'insights' | 'overview'>(insightsEnabled ? 'insights' : 'overview');
// Memoize the scene so it's only created once and properly initialized
const insightsScene = useMemo(() => getInsightsScenes(), []);
const insightsScene = getInsightsScenes();
return (
<AlertingPageWrapper subTitle="Learn about problems in your systems moments after they occur" navId="alerting">
@@ -1,44 +0,0 @@
import { useMemo } from 'react';
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 { navId, pageNav } = useInsightsNav();
// Memoize the scene so it's only created once and properly initialized
const insightsScene = useMemo(() => getInsightsScenes(), []);
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);
@@ -1,134 +0,0 @@
import { renderHook } from '@testing-library/react';
import { getWrapper } from 'test/test-utils';
import { configureStore } from 'app/store/configureStore';
import { useAlertActivityNav } from './useAlertActivityNav';
describe('useAlertActivityNav', () => {
const mockNavIndex = {
'alert-activity': {
id: 'alert-activity',
text: 'Alert activity',
url: '/alerting/alerts',
},
'alert-activity-alerts': {
id: 'alert-activity-alerts',
text: 'Alerts',
url: '/alerting/alerts',
},
'alert-activity-groups': {
id: 'alert-activity-groups',
text: 'Active notifications',
url: '/alerting/groups',
},
};
const defaultPreloadedState = {
navIndex: mockNavIndex,
};
it('should return navigation with pageNav for Alerts tab', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/alerts'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBe('alert-activity');
expect(result.current.pageNav).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children).toBeDefined();
// The pageNav should represent Alert Activity (not the active tab) for consistent title
expect(result.current.pageNav?.text).toBe('Alert activity');
});
it('should return navigation with pageNav for Active notifications tab', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/groups'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBe('alert-activity');
expect(result.current.pageNav).toBeDefined();
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children).toBeDefined();
// The pageNav should represent Alert Activity (not the active tab) for consistent title
expect(result.current.pageNav?.text).toBe('Alert activity');
});
it('should set active tab based on current path', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/groups'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
const activeNotificationsTab = result.current.pageNav?.children?.find((tab) => tab.id === 'alert-activity-groups');
expect(activeNotificationsTab?.active).toBe(true);
// eslint-disable-next-line testing-library/no-node-access
const alertsTab = result.current.pageNav?.children?.find((tab) => tab.id === 'alert-activity-alerts');
expect(alertsTab?.active).toBe(false);
});
it('should filter tabs based on permissions', () => {
const limitedNavIndex = {
'alert-activity': mockNavIndex['alert-activity'],
'alert-activity-alerts': mockNavIndex['alert-activity-alerts'],
// Missing 'alert-activity-groups' - user doesn't have permission
};
const store = configureStore({
navIndex: limitedNavIndex,
});
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/alerts'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.length).toBe(1);
// eslint-disable-next-line testing-library/no-node-access
expect(result.current.pageNav?.children?.[0].id).toBe('alert-activity-alerts');
});
it('should return undefined when alert-activity nav is missing', () => {
const store = configureStore({
navIndex: {},
});
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/groups'],
},
});
const { result } = renderHook(() => useAlertActivityNav(), { wrapper });
expect(result.current.navId).toBeUndefined();
expect(result.current.pageNav).toBeUndefined();
});
});
@@ -1,57 +0,0 @@
import { useLocation } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useSelector } from 'app/types/store';
export function useAlertActivityNav() {
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
const alertActivityNav = navIndex['alert-activity'];
if (!alertActivityNav) {
return {
navId: undefined,
pageNav: undefined,
};
}
// All available tabs
const allTabs = [
{
id: 'alert-activity-alerts',
text: t('alerting.navigation.alerts', 'Alerts'),
url: '/alerting/alerts',
active: location.pathname === '/alerting/alerts',
icon: 'bell',
parentItem: alertActivityNav,
},
{
id: 'alert-activity-groups',
text: t('alerting.navigation.active-notifications', 'Active notifications'),
url: '/alerting/groups',
active: location.pathname === '/alerting/groups',
icon: 'layer-group',
parentItem: alertActivityNav,
},
].filter((tab) => {
// Filter based on permissions - if nav item doesn't exist, user doesn't have permission
const navItem = navIndex[tab.id];
return navItem !== undefined;
});
// Create pageNav structure following the same pattern as useNotificationConfigNav
// Keep "Alert Activity" as the pageNav (not the active tab) so the title and subtitle stay consistent
// The tabs are children, and the breadcrumb utility will add the active tab to breadcrumbs
// (including the first tab, after our fix to the breadcrumb utility)
const pageNav: NavModelItem = {
...alertActivityNav,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
children: allTabs as NavModelItem[],
};
return {
navId: 'alert-activity',
pageNav,
};
}
@@ -1,95 +0,0 @@
import { renderHook } from '@testing-library/react';
import { getWrapper } from 'test/test-utils';
import { configureStore } from 'app/store/configureStore';
import { useAlertRulesNav } from './useAlertRulesNav';
describe('useAlertRulesNav', () => {
const mockNavIndex = {
'alert-rules': {
id: 'alert-rules',
text: 'Alert rules',
url: '/alerting/list',
icon: 'list-ul' as const,
},
'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',
},
};
const defaultPreloadedState = {
navIndex: mockNavIndex,
};
it('should return navigation with pageNav', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
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', () => {
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 store = configureStore({
navIndex: limitedNavIndex,
});
const wrapper = getWrapper({
store,
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', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
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);
});
});
@@ -1,54 +0,0 @@
import { useLocation } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useSelector } from 'app/types/store';
export function useAlertRulesNav() {
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
const alertRulesNav = navIndex['alert-rules'];
if (!alertRulesNav) {
return {
navId: undefined,
pageNav: undefined,
};
}
// All available tabs
const allTabs = [
{
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,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
children: allTabs as NavModelItem[],
};
return {
navId: 'alert-rules',
pageNav,
};
}
@@ -1,90 +0,0 @@
import { renderHook } from '@testing-library/react';
import { getWrapper } from 'test/test-utils';
import { configureStore } from 'app/store/configureStore';
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',
},
};
const defaultPreloadedState = {
navIndex: mockNavIndex,
};
it('should return navigation with pageNav', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
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', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
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', () => {
const limitedNavIndex = {
insights: mockNavIndex.insights,
'insights-system': mockNavIndex['insights-system'],
// Missing 'insights-history' - user doesn't have permission
};
const store = configureStore({
navIndex: limitedNavIndex,
});
const wrapper = getWrapper({
store,
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');
});
});
@@ -1,54 +0,0 @@
import { useLocation } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useSelector } from 'app/types/store';
export function useInsightsNav() {
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
const insightsNav = navIndex.insights;
if (!insightsNav) {
return {
navId: undefined,
pageNav: undefined,
};
}
// All available tabs
const allTabs = [
{
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,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
children: allTabs as NavModelItem[],
};
return {
navId: 'insights',
pageNav,
};
}
@@ -1,102 +0,0 @@
import { renderHook } from '@testing-library/react';
import { getWrapper } from 'test/test-utils';
import { configureStore } from 'app/store/configureStore';
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/time-intervals',
},
};
const defaultPreloadedState = {
navIndex: mockNavIndex,
};
it('should return navigation with pageNav', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
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 path', () => {
const store = configureStore(defaultPreloadedState);
const wrapper = getWrapper({
store,
renderWithRouter: true,
historyOptions: {
initialEntries: ['/alerting/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', () => {
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 store = configureStore({
navIndex: limitedNavIndex,
});
const wrapper = getWrapper({
store,
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');
});
});
@@ -1,86 +0,0 @@
import { useLocation } from 'react-router-dom-v5-compat';
import { NavModelItem } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useSelector } from 'app/types/store';
export function useNotificationConfigNav() {
const location = useLocation();
const navIndex = useSelector((state) => state.navIndex);
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 time intervals page
const isTimeIntervalsTab = location.pathname === '/alerting/time-intervals';
// All available tabs
const allTabs = [
{
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/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,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
children: allTabs as NavModelItem[],
};
return {
navId: 'notification-config',
pageNav,
};
}
@@ -20,7 +20,6 @@ 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';
@@ -116,14 +115,11 @@ 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={navId}
pageNav={pageNav}
navId="alert-list"
isLoading={false}
renderTitle={(title) => <RuleListPageTitle title={title} />}
actions={<RuleListActionButtons hasAlertRulesCreated={hasAlertRulesCreated} />}
@@ -13,7 +13,6 @@ 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 { getRulesDataSources } from '../utils/datasource';
import { FilterView } from './FilterView';
@@ -124,12 +123,10 @@ export function RuleListActions() {
export default function RuleListPage() {
const { isApplying } = useApplyDefaultSearch();
const { navId, pageNav } = useAlertRulesNav();
return (
<AlertingPageWrapper
navId={navId}
pageNav={pageNav}
navId="alert-list"
renderTitle={(title) => <RuleListPageTitle title={title} />}
isLoading={isApplying}
actions={<RuleListActions />}
@@ -1,16 +1,23 @@
import { t } from '@grafana/i18n';
import { UrlSyncContextProvider } from '@grafana/scenes';
import { withErrorBoundary } from '@grafana/ui';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { useAlertActivityNav } from '../navigation/useAlertActivityNav';
import { TriageScene, triageScene } from './scene/TriageScene';
export const TriagePage = () => {
const { navId, pageNav } = useAlertActivityNav();
return (
<AlertingPageWrapper navId={navId || 'alert-alerts'} pageNav={pageNav}>
<AlertingPageWrapper
navId="alert-alerts"
subTitle={t(
'alerting.pages.triage.subtitle',
'See what is currently alerting and explore historical data to investigate current or past issues.'
)}
pageNav={{
text: t('alerting.pages.triage.title', 'Alerts'),
}}
>
<UrlSyncContextProvider scene={triageScene} updateUrlOnInit={true} createBrowserHistorySteps={true}>
<TriageScene key={triageScene.state.key} />
</UrlSyncContextProvider>
@@ -816,14 +816,17 @@ export class DashboardMigrator {
let yPos = 0;
const widthFactor = GRID_COLUMN_COUNT / 12;
const maxPanelId =
max(
flattenDeep(
map(old.rows, (row) => {
return map(row.panels, 'id');
})
).filter((id) => id != null)
) || 0;
// Find max panel ID from both rows and existing top-level panels
// Top-level panels may have been assigned IDs by ensurePanelsHaveUniqueIds
const rowPanelIds = flattenDeep(
map(old.rows, (row) => {
return map(row.panels, 'id');
})
).filter((id) => id != null);
const topLevelPanelIds = map(this.dashboard.panels, 'id').filter((id) => id != null);
const maxPanelId = max([...rowPanelIds, ...topLevelPanelIds]) || 0;
let nextRowId = maxPanelId + 1;
if (!old.rows) {
@@ -525,6 +525,14 @@ export class DashboardModel implements TimeModel {
}
}
// Also check panels in legacy rows (pre-v16 dashboard format)
// This ensures unique IDs are assigned before the row upgrade migration runs
for (const panel of this.rawPanelIterator()) {
if (panel.id > max) {
max = panel.id;
}
}
return max + 1;
}
@@ -539,6 +547,26 @@ export class DashboardModel implements TimeModel {
}
}
/**
* Iterates over panels from the original raw dashboard data, including legacy rows.
* This is needed to find panel IDs before row upgrade migration runs.
*/
private *rawPanelIterator() {
// @ts-expect-error - rows is a legacy property not included in the modern Dashboard schema
const rows = this.originalDashboard?.rows;
if (Array.isArray(rows)) {
for (const row of rows) {
const rowPanels = row?.panels;
if (Array.isArray(rowPanels)) {
for (const panel of rowPanels) {
yield panel;
}
}
}
}
}
forEachPanel(callback: (panel: PanelModel, index: number) => void) {
for (let i = 0; i < this.panels.length; i++) {
callback(this.panels[i], i);
+15 -15
View File
@@ -938,6 +938,10 @@
"label-search-by-name-or-type": "Search by name or type",
"placeholder-search": "Search"
},
"contact-points-page-contents": {
"label-contact-points": "Contact Points",
"label-notification-templates": "Notification Templates"
},
"contact-points-tab": {
"aria-label-add-contact-point": "add contact point",
"aria-label-export-all": "export all",
@@ -1641,9 +1645,7 @@
"insights": {
"monitor-status-of-system": "Monitor the status of your system",
"monitor-status-system-tooltip": "Alerting insights provides pre-built dashboards to monitor your alerting data.",
"monitor-status-system-tooltip-identify": "You can identify patterns in why things go wrong and discover trends in alerting performance within your organization.",
"not-available": "Insights are not available. Please configure the required data sources.",
"subtitle": "Analytics and history for alerting"
"monitor-status-system-tooltip-identify": "You can identify patterns in why things go wrong and discover trends in alerting performance within your organization."
},
"insights-menu-button-renderer": {
"aria-label-rate-this-panel": "Rate this panel",
@@ -1926,18 +1928,6 @@
"select-group": "Select group",
"select-namespace": "Select namespace"
},
"navigation": {
"active-notifications": "Active notifications",
"alert-rules": "Alert rules",
"alert-state-history": "Alert state history",
"alerts": "Alerts",
"contact-points": "Contact points",
"notification-policies": "Notification policies",
"notification-templates": "Notification templates",
"recently-deleted": "Recently deleted",
"system-insights": "System Insights",
"time-intervals": "Time intervals"
},
"need-help-info": {
"need-help": "Need help?"
},
@@ -1986,6 +1976,10 @@
"title-error-loading-alertmanager-config": "Error loading Alertmanager config",
"title-notification-policies-have-changed": "Notification policies have changed"
},
"notification-policies-tabs": {
"label-notification-policies": "Notification Policies",
"label-time-intervals": "Time intervals"
},
"notification-policy-drawer": {
"view-notification-policy-tree": "View notification policy tree"
},
@@ -2051,6 +2045,12 @@
"body-selected-alertmanager-not-found": "The selected Alertmanager no longer exists or you may not have permission to access it. You can select a different Alertmanager from the dropdown.",
"title-selected-alertmanager-not-found": "Selected Alertmanager not found."
},
"pages": {
"triage": {
"subtitle": "See what is currently alerting and explore historical data to investigate current or past issues.",
"title": "Alerts"
}
},
"panel-alert-tab-content": {
"alert": {
"title-errors-loading-rules": "Errors loading rules"