mirror of
https://github.com/grafana/grafana.git
synced 2026-01-15 13:48:14 +00:00
Compare commits
23 Commits
restore-da
...
alerting/m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4265fe39c | ||
|
|
2635a67630 | ||
|
|
30bacc4ef1 | ||
|
|
143c256a93 | ||
|
|
cc556f3792 | ||
|
|
661cd58bae | ||
|
|
b3625b95e3 | ||
|
|
774551589b | ||
|
|
dee9bc8fb9 | ||
|
|
6395a753d8 | ||
|
|
5b228fd7fa | ||
|
|
307cce059c | ||
|
|
a5d240751d | ||
|
|
d93867479f | ||
|
|
9f53141368 | ||
|
|
0413b76461 | ||
|
|
417d3d914a | ||
|
|
2a7f698c4c | ||
|
|
212bdb4400 | ||
|
|
2432756be8 | ||
|
|
a59df66e21 | ||
|
|
5bec0f1af7 | ||
|
|
954156d5b3 |
14
pkg/services/featuremgmt/toggles_gen.json
generated
14
pkg/services/featuremgmt/toggles_gen.json
generated
@@ -348,6 +348,20 @@
|
||||
"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",
|
||||
|
||||
@@ -436,38 +436,91 @@ 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-alerts", Url: s.cfg.AppSubURL + "/alerting/alerts", Icon: "bell", IsNew: true,
|
||||
Text: "Alert activity",
|
||||
SubTitle: "Visualize active and pending alerts",
|
||||
Id: "alert-activity",
|
||||
Url: s.cfg.AppSubURL + "/alerting/alerts",
|
||||
Icon: "bell",
|
||||
IsNew: true,
|
||||
Children: alertActivityChildren,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var alertRulesChildren []*navtree.NavLink
|
||||
if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) {
|
||||
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||
Text: "Alert rules", SubTitle: "Rules that determine whether an alert will fire", Id: "alert-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul",
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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...)) {
|
||||
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||
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",
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -479,54 +532,116 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
|
||||
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"})
|
||||
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.ActionAlertingInstanceRead),
|
||||
ac.EvalPermission(ac.ActionAlertingInstancesExternalRead),
|
||||
ac.EvalPermission(ac.ActionAlertingSilencesRead),
|
||||
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: "Silences", SubTitle: "Stop notifications from one or more alerting rules", Id: "silences", Url: s.cfg.AppSubURL + "/alerting/silences", Icon: "bell-slash"})
|
||||
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 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"})
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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",
|
||||
})
|
||||
}
|
||||
// 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))) {
|
||||
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||
Text: "History",
|
||||
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: "alerts-history",
|
||||
Id: "insights-history",
|
||||
Url: s.cfg.AppSubURL + "/alerting/history",
|
||||
Icon: "history",
|
||||
})
|
||||
}
|
||||
}
|
||||
//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) {
|
||||
|
||||
if len(insightsChildren) > 0 {
|
||||
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||
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",
|
||||
Text: "Insights",
|
||||
SubTitle: "Analytics and history for alerting",
|
||||
Id: "insights",
|
||||
Url: s.cfg.AppSubURL + "/alerting/insights",
|
||||
Icon: "chart-line",
|
||||
Children: insightsChildren,
|
||||
})
|
||||
}
|
||||
|
||||
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", Id: "alerting-admin", Url: s.cfg.AppSubURL + "/alerting/admin",
|
||||
Icon: "cog",
|
||||
Text: "Settings",
|
||||
SubTitle: "Alerting configuration and administration",
|
||||
Id: "alerting-settings",
|
||||
Url: s.cfg.AppSubURL + "/alerting/admin",
|
||||
Icon: "cog",
|
||||
Children: settingsChildren,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
158
pkg/services/navtree/navtreeimpl/navtree_alerting_test.go
Normal file
158
pkg/services/navtree/navtreeimpl/navtree_alerting_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
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,9 +37,24 @@ 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) => enrichWithInteractionTracking(item, state.megaMenuDocked));
|
||||
.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;
|
||||
});
|
||||
|
||||
const bookmarksItem = navItems.find((item) => item.id === 'bookmarks');
|
||||
if (bookmarksItem) {
|
||||
|
||||
@@ -35,11 +35,18 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte
|
||||
|
||||
if (shouldAddCrumb) {
|
||||
const activeChildIndex = node.children?.findIndex((child) => child.active) ?? -1;
|
||||
// Add tab to breadcrumbs if it's not the first active child
|
||||
if (activeChildIndex > 0) {
|
||||
// Add active tab to breadcrumbs if it exists and its URL is different from the node's URL
|
||||
// This ensures tabs show in breadcrumbs (including the first tab) while preventing duplication
|
||||
if (activeChildIndex >= 0) {
|
||||
const activeChild = node.children?.[activeChildIndex];
|
||||
if (activeChild) {
|
||||
crumbs.unshift({ text: activeChild.text, href: activeChild.url ?? '' });
|
||||
// Only add the active child if its URL doesn't match the node's URL
|
||||
// This prevents duplication when the pageNav is the active tab
|
||||
const nodeUrl = node.url?.split('?')[0] ?? '';
|
||||
const childUrl = activeChild.url?.split('?')[0] ?? '';
|
||||
if (nodeUrl !== childUrl) {
|
||||
crumbs.unshift({ text: activeChild.text, href: activeChild.url ?? '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
crumbs.unshift({ text: node.text, href: node.url ?? '' });
|
||||
|
||||
@@ -56,6 +56,17 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/time-intervals',
|
||||
roles: evaluateAccess([
|
||||
AccessControlAction.AlertingNotificationsRead,
|
||||
AccessControlAction.AlertingNotificationsExternalRead,
|
||||
...PERMISSIONS_TIME_INTERVALS_READ,
|
||||
]),
|
||||
component: importAlertingComponent(
|
||||
() => import(/* webpackChunkName: "TimeIntervalsPage" */ 'app/features/alerting/unified/TimeIntervalsPage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/alerting/routes/mute-timing/new',
|
||||
roles: evaluateAccess([
|
||||
@@ -212,6 +223,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'],
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AlertGroupFilter } from './components/alert-groups/AlertGroupFilter';
|
||||
import { useFilteredAmGroups } from './hooks/useFilteredAmGroups';
|
||||
import { useGroupedAlerts } from './hooks/useGroupedAlerts';
|
||||
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
|
||||
import { useAlertActivityNav } from './navigation/useAlertActivityNav';
|
||||
import { useAlertmanager } from './state/AlertmanagerContext';
|
||||
import { fetchAlertGroupsAction } from './state/actions';
|
||||
import { NOTIFICATIONS_POLL_INTERVAL_MS } from './utils/constants';
|
||||
@@ -113,8 +114,9 @@ const AlertGroups = () => {
|
||||
};
|
||||
|
||||
function AlertGroupsPage() {
|
||||
const { navId, pageNav } = useAlertActivityNav();
|
||||
return (
|
||||
<AlertmanagerPageWrapper navId="groups" accessType="instance">
|
||||
<AlertmanagerPageWrapper navId={navId || 'groups'} pageNav={pageNav} accessType="instance">
|
||||
<AlertGroups />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
|
||||
@@ -140,6 +140,35 @@ 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,115 +1,29 @@
|
||||
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 { TimeIntervalsTable } from './components/mute-timings/MuteTimingsTable';
|
||||
import { useNotificationConfigNav } from './navigation/useNotificationConfigNav';
|
||||
import { useAlertmanager } from './state/AlertmanagerContext';
|
||||
import { withPageErrorBoundary } from './withPageErrorBoundary';
|
||||
|
||||
enum ActiveTab {
|
||||
NotificationPolicies = 'notification_policies',
|
||||
TimeIntervals = 'time_intervals',
|
||||
}
|
||||
|
||||
const NotificationPoliciesTabs = () => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
// Alertmanager logic and data hooks
|
||||
const NotificationPoliciesContent = () => {
|
||||
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} />
|
||||
<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>
|
||||
<NotificationPoliciesList />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
tabContent: css({
|
||||
marginTop: theme.spacing(2),
|
||||
}),
|
||||
});
|
||||
|
||||
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() {
|
||||
const { navId, pageNav } = useNotificationConfigNav();
|
||||
|
||||
// Show only notification policies (no internal tabs)
|
||||
// Time intervals are accessible via the sidebar navigation
|
||||
return (
|
||||
<AlertmanagerPageWrapper navId="am-routes" accessType="notification">
|
||||
<NotificationPoliciesTabs />
|
||||
<AlertmanagerPageWrapper navId={navId || 'am-routes'} pageNav={pageNav} accessType="notification">
|
||||
<NotificationPoliciesContent />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,52 @@
|
||||
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';
|
||||
|
||||
function NotificationTemplates() {
|
||||
const TemplatesList = () => {
|
||||
const [createTemplateSupported, createTemplateAllowed] = useAlertmanagerAbility(
|
||||
AlertmanagerAction.CreateNotificationTemplate
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Text variant="body" color="secondary">
|
||||
<Trans i18nKey="alerting.notification-templates-tab.create-notification-templates-customize-notifications">
|
||||
Create notification templates to customize your notifications.
|
||||
</Trans>
|
||||
</Text>
|
||||
{createTemplateSupported && (
|
||||
<LinkButton
|
||||
icon="plus"
|
||||
variant="primary"
|
||||
href="/alerting/notifications/templates/new"
|
||||
disabled={!createTemplateAllowed}
|
||||
>
|
||||
<Trans i18nKey="alerting.notification-templates-tab.add-notification-template-group">
|
||||
Add notification template group
|
||||
</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
</Stack>
|
||||
<NotificationTemplates />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function NotificationTemplatesRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="" element={<TemplatesList />} />
|
||||
<Route path="new" element={<NewMessageTemplate />} />
|
||||
<Route path=":name/edit" element={<EditMessageTemplate />} />
|
||||
<Route path=":name/duplicate" element={<DuplicateMessageTemplate />} />
|
||||
@@ -15,4 +54,14 @@ function NotificationTemplates() {
|
||||
);
|
||||
}
|
||||
|
||||
export default withPageErrorBoundary(NotificationTemplates);
|
||||
function NotificationTemplatesPage() {
|
||||
const { navId, pageNav } = useNotificationConfigNav();
|
||||
|
||||
return (
|
||||
<AlertmanagerPageWrapper navId={navId || 'receivers'} pageNav={pageNav} accessType="notification">
|
||||
<NotificationTemplatesRoutes />
|
||||
</AlertmanagerPageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default withPageErrorBoundary(NotificationTemplatesPage);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
31
public/app/features/alerting/unified/TimeIntervalsPage.tsx
Normal file
31
public/app/features/alerting/unified/TimeIntervalsPage.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
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,6 +170,26 @@ 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(
|
||||
@@ -258,14 +278,6 @@ 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 () => {
|
||||
@@ -470,11 +482,6 @@ 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,19 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
EmptyState,
|
||||
LinkButton,
|
||||
LoadingPlaceholder,
|
||||
Pagination,
|
||||
Stack,
|
||||
Tab,
|
||||
TabContent,
|
||||
TabsBar,
|
||||
Text,
|
||||
} from '@grafana/ui';
|
||||
import { Alert, Button, EmptyState, LinkButton, LoadingPlaceholder, Pagination, Stack } 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';
|
||||
@@ -22,6 +8,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';
|
||||
@@ -30,7 +17,6 @@ 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';
|
||||
@@ -99,7 +85,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 />
|
||||
@@ -148,109 +134,19 @@ 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!} />
|
||||
<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>
|
||||
<ContactPointsTab />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -282,8 +178,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>
|
||||
);
|
||||
|
||||
@@ -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,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')}>
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, 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, insightsIsAvailable } from './Insights';
|
||||
import { getInsightsScenes } from './Insights';
|
||||
import { PluginIntegrations } from './PluginIntegrations';
|
||||
import SyntheticMonitoringCard from './SyntheticMonitoringCard';
|
||||
|
||||
function Home() {
|
||||
const insightsEnabled = insightsIsAvailable() || isLocalDevEnv();
|
||||
// Insights tab is not shown on Home page - Insights is available via the sidebar menu instead
|
||||
const insightsEnabled = false;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'insights' | 'overview'>(insightsEnabled ? 'insights' : 'overview');
|
||||
const insightsScene = getInsightsScenes();
|
||||
// Memoize the scene so it's only created once and properly initialized
|
||||
const insightsScene = useMemo(() => getInsightsScenes(), []);
|
||||
|
||||
return (
|
||||
<AlertingPageWrapper subTitle="Learn about problems in your systems moments after they occur" navId="alerting">
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
@@ -0,0 +1,134 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
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,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 { getRulesDataSources } from '../utils/datasource';
|
||||
|
||||
import { FilterView } from './FilterView';
|
||||
@@ -123,10 +124,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 />}
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
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="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'),
|
||||
}}
|
||||
>
|
||||
<AlertingPageWrapper navId={navId || 'alert-alerts'} pageNav={pageNav}>
|
||||
<UrlSyncContextProvider scene={triageScene} updateUrlOnInit={true} createBrowserHistorySteps={true}>
|
||||
<TriageScene key={triageScene.state.key} />
|
||||
</UrlSyncContextProvider>
|
||||
|
||||
@@ -938,10 +938,6 @@
|
||||
"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",
|
||||
@@ -1645,7 +1641,9 @@
|
||||
"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."
|
||||
"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"
|
||||
},
|
||||
"insights-menu-button-renderer": {
|
||||
"aria-label-rate-this-panel": "Rate this panel",
|
||||
@@ -1928,6 +1926,18 @@
|
||||
"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?"
|
||||
},
|
||||
@@ -1976,10 +1986,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -2045,12 +2051,6 @@
|
||||
"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"
|
||||
|
||||
Reference in New Issue
Block a user