package navtreeimpl import ( "path" "sort" "strconv" "strings" "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" 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/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/util" ) func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *contextmodel.ReqContext) error { hasAccess := ac.HasAccess(s.accessControl, c) appLinks := []*navtree.NavLink{} pss, err := s.pluginSettings.GetPluginSettings(c.Req.Context(), &pluginsettings.GetArgs{OrgID: c.GetOrgID()}) if err != nil { return err } isPluginEnabled := func(plugin pluginstore.Plugin) bool { if plugin.AutoEnabled { return true } for _, ps := range pss { if ps.PluginID == plugin.ID { return ps.Enabled } } return false } enabledAccessibleAppPluginMap := make(map[string]*pluginstore.Plugin) for _, plugin := range s.pluginStore.Plugins(c.Req.Context(), plugins.TypeApp) { if !isPluginEnabled(plugin) { continue } if !hasAccess(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, pluginaccesscontrol.ScopeProvider.GetResourceScope(plugin.ID))) { continue } enabledAccessibleAppPluginMap[plugin.ID] = &plugin if appNode := s.processAppPlugin(plugin, c, treeRoot); appNode != nil { appLinks = append(appLinks, appNode) } } if adaptiveTelemetryPlugin := enabledAccessibleAppPluginMap["grafana-adaptivetelemetry-app"]; adaptiveTelemetryPlugin != nil { if adaptiveTelemetrySection := treeRoot.FindById(navtree.NavIDAdaptiveTelemetry); adaptiveTelemetrySection != nil { // If the adaptivetelemetry app is enabled, and the adaptiveTelemetrySection exists, then update the section to point to the plugin adaptiveTelemetrySection.Url = s.cfg.AppSubURL + "/a/" + adaptiveTelemetryPlugin.ID adaptiveTelemetrySection.PluginID = adaptiveTelemetryPlugin.ID } } if len(appLinks) > 0 { sort.SliceStable(appLinks, func(i, j int) bool { return appLinks[i].Text < appLinks[j].Text }) } for _, appLink := range appLinks { treeRoot.AddSection(appLink) } return nil } // shouldIncludeInvestigations checks if the investigations feature should be included for the assistant app // see https://github.com/grafana/grafana-assistant-app/issues/2007 for more details func (s *ServiceImpl) shouldIncludeInvestigations(plugin pluginstore.Plugin, include *plugins.Includes, c *contextmodel.ReqContext) bool { if plugin.ID != "grafana-assistant-app" || include.Name != "Investigations" { return true } ps, err := s.pluginSettings.GetPluginSettingByPluginID(c.Req.Context(), &pluginsettings.GetByPluginIDArgs{ PluginID: plugin.ID, OrgID: c.GetOrgID(), }) if err != nil { return false } loopData, exists := ps.JSONData["loop"] if !exists { return false } loopConfig, ok := loopData.(map[string]any) if !ok { return false } enabled, ok := loopConfig["enabled"].(bool) return ok && enabled } func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmodel.ReqContext, treeRoot *navtree.NavTreeRoot) *navtree.NavLink { hasAccessToInclude := s.hasAccessToInclude(c, plugin.ID) appLink := &navtree.NavLink{ Text: plugin.Name, Id: "plugin-page-" + plugin.ID, Img: plugin.Info.Logos.Small, SubTitle: plugin.Info.Description, SortWeight: navtree.WeightPlugin, IsSection: true, PluginID: plugin.ID, Url: s.cfg.AppSubURL + "/a/" + plugin.ID, } for _, include := range plugin.Includes { if !hasAccessToInclude(include) { continue } if !s.shouldIncludeInvestigations(plugin, include, c) { continue } if include.Type == "page" { link := &navtree.NavLink{ Text: include.Name, Icon: include.Icon, PluginID: plugin.ID, } if len(include.Path) > 0 { link.Url = s.cfg.AppSubURL + include.Path if include.DefaultNav && include.AddToNav { appLink.Url = link.Url } } else { link.Url = s.cfg.AppSubURL + "/plugins/" + plugin.ID + "/page/" + include.Slug } // Register standalone plugin pages to certain sections using the Grafana config if pathConfig, ok := s.navigationAppPathConfig[include.Path]; ok { if sectionForPage := treeRoot.FindById(pathConfig.SectionID); sectionForPage != nil { link.Id = "standalone-plugin-page-" + include.Path link.SortWeight = pathConfig.SortWeight if len(pathConfig.SubTitle) > 0 { link.SubTitle = pathConfig.SubTitle } // Check if the section already has a page with the same URL, and in that case override it // (This only happens if it is explicitly set by `navigation.app_standalone_pages` in the INI config) isOverridingCorePage := false for _, child := range sectionForPage.Children { if child.Url == link.Url { child.Id = link.Id child.SortWeight = link.SortWeight child.PluginID = link.PluginID child.Children = []*navtree.NavLink{} isOverridingCorePage = true break } } // Append the page to the section if !isOverridingCorePage { sectionForPage.Children = append(sectionForPage.Children, link) } } // Register the page under the app } else if include.AddToNav { appLink.Children = append(appLink.Children, link) } } if include.Type == "dashboard" && include.AddToNav { dboardURL := include.DashboardURLPath() if dboardURL != "" { link := &navtree.NavLink{ Url: path.Join(s.cfg.AppSubURL, dboardURL), Text: include.Name, PluginID: plugin.ID, } appLink.Children = append(appLink.Children, link) } } } // Apps without any nav children are not part of navtree if len(appLink.Children) == 0 { return nil } // If we only have one child and it's the app default nav then remove it from children if len(appLink.Children) == 1 && appLink.Children[0].Url == appLink.Url { appLink.Children = []*navtree.NavLink{} } // Remove default nav child childrenWithoutDefault := []*navtree.NavLink{} for _, child := range appLink.Children { if child.Url != appLink.Url { childrenWithoutDefault = append(childrenWithoutDefault, child) } } appLink.Children = childrenWithoutDefault s.addPluginToSection(c, treeRoot, plugin, appLink) if plugin.ID == "grafana-slo-app" { // Add Service Center as a standalone nav item under Alerts & IRM if alertsSection := treeRoot.FindById(navtree.NavIDAlertsAndIncidents); alertsSection != nil { serviceLink := &navtree.NavLink{ Text: "Service center", Id: "standalone-plugin-page-slo-services", Url: s.cfg.AppSubURL + "/a/grafana-slo-app/services", SortWeight: 1, IsNew: true, } alertsSection.Children = append(alertsSection.Children, serviceLink) } } return nil } func (s *ServiceImpl) addPluginToSection(c *contextmodel.ReqContext, treeRoot *navtree.NavTreeRoot, plugin pluginstore.Plugin, appLink *navtree.NavLink) { // Handle moving apps into specific navtree sections var alertingNodes []*navtree.NavLink alertingNode := treeRoot.FindById(navtree.NavIDAlerting) if alertingNode != nil { alertingNodes = append(alertingNodes, alertingNode) } sectionID := navtree.NavIDApps if navConfig, hasOverride := s.navigationAppConfig[plugin.ID]; hasOverride { appLink.SortWeight = navConfig.SortWeight sectionID = navConfig.SectionID if len(navConfig.Text) > 0 { appLink.Text = navConfig.Text } if len(navConfig.Icon) > 0 { appLink.Icon = navConfig.Icon } if len(navConfig.SubTitle) > 0 { appLink.SubTitle = navConfig.SubTitle } if navConfig.IsNew { appLink.IsNew = true } } sectionChildren := []*navtree.NavLink{appLink} // asserts pages expand to root Observability section instead of it's own node if plugin.ID == "grafana-asserts-app" { sectionChildren = appLink.Children // keep current sorting if the pages, but above all the other apps for _, child := range sectionChildren { child.SortWeight = -100 + child.SortWeight child.Id = "standalone-plugin-page-" + strings.ReplaceAll(strings.ToLower(child.Text), " ", "-") } } if sectionID == navtree.NavIDRoot { treeRoot.AddSection(appLink) } else if navNode := treeRoot.FindById(sectionID); navNode != nil { navNode.Children = append(navNode.Children, sectionChildren...) } else { switch sectionID { case navtree.NavIDApps: treeRoot.AddSection(&navtree.NavLink{ Text: "More apps", Icon: "layer-group", SubTitle: "App plugins that extend the Grafana experience", Id: navtree.NavIDApps, Children: sectionChildren, SortWeight: navtree.WeightApps, Url: s.cfg.AppSubURL + "/apps", }) case navtree.NavIDObservability: treeRoot.AddSection(&navtree.NavLink{ Text: "Observability", Id: navtree.NavIDObservability, SubTitle: "Monitor infrastructure and applications in real time with Grafana Cloud's fully managed observability suite", Icon: "heart-rate", SortWeight: navtree.WeightObservability, Children: sectionChildren, Url: s.cfg.AppSubURL + "/observability", }) case navtree.NavIDInfrastructure: treeRoot.AddSection(&navtree.NavLink{ Text: "Infrastructure", Id: navtree.NavIDInfrastructure, SubTitle: "Understand your infrastructure's health", Icon: "heart-rate", SortWeight: navtree.WeightInfrastructure, Children: sectionChildren, Url: s.cfg.AppSubURL + "/infrastructure", }) case navtree.NavIDAlertsAndIncidents: alertsAndIncidentsChildren := []*navtree.NavLink{} for _, alertingNode := range alertingNodes { if alertingNode.Id == "alerting" { alertingNode.SortWeight = 2 } alertsAndIncidentsChildren = append(alertsAndIncidentsChildren, alertingNode) treeRoot.RemoveSection(alertingNode) } alertsAndIncidentsChildren = append(alertsAndIncidentsChildren, appLink) treeRoot.AddSection(&navtree.NavLink{ Text: "Alerts & IRM", Id: navtree.NavIDAlertsAndIncidents, SubTitle: "Alerting and incident management apps", Icon: "bell", SortWeight: navtree.WeightAlertsAndIncidents, Children: alertsAndIncidentsChildren, Url: s.cfg.AppSubURL + "/alerts-and-incidents", }) case navtree.NavIDTestingAndSynthetics: treeRoot.AddSection(&navtree.NavLink{ Text: "Testing & synthetics", Id: navtree.NavIDTestingAndSynthetics, SubTitle: "Optimize performance with k6 and Synthetic Monitoring insights", Icon: "k6", SortWeight: navtree.WeightTestingAndSynthetics, Children: sectionChildren, Url: s.cfg.AppSubURL + "/testing-and-synthetics", }) case navtree.NavIDAdaptiveTelemetry: treeRoot.AddSection(&navtree.NavLink{ Text: "Adaptive Telemetry", Id: navtree.NavIDAdaptiveTelemetry, SubTitle: "Reduce noise, cut costs, and accelerate troubleshooting by intelligently ingesting only the telemetry data that matters most.", Icon: "adaptive-telemetry", SortWeight: navtree.WeightAdaptiveTelemetry, Children: sectionChildren, Url: "adaptive-telemetry", // Use the icon URL from the first "Adaptive Telemetry" plugin in the list (they will all be the same) Img: s.cfg.AppSubURL + plugin.Info.Logos.Large, IsNew: true, }) default: s.log.Error("Plugin app nav id not found", "pluginId", plugin.ID, "navId", sectionID) } } } func (s *ServiceImpl) hasAccessToInclude(c *contextmodel.ReqContext, pluginID string) func(include *plugins.Includes) bool { hasAccess := ac.HasAccess(s.accessControl, c) return func(include *plugins.Includes) bool { if include.RequiresRBACAction() && !hasAccess(pluginaccesscontrol.GetPluginRouteEvaluator(pluginID, include.Action)) { s.log.Debug("plugin include is covered by RBAC, user doesn't have access", "plugin", pluginID, "include", include.Name) return false } else if !include.RequiresRBACAction() && !c.HasUserRole(include.Role) { return false } return true } } func (s *ServiceImpl) readNavigationSettings() { s.navigationAppConfig = map[string]NavigationAppConfig{ "grafana-asserts-app": {SectionID: navtree.NavIDObservability, SortWeight: 1, Icon: "asserts"}, "grafana-kowalski-app": {SectionID: navtree.NavIDObservability, SortWeight: 2, Text: "Frontend"}, "grafana-app-observability-app": {SectionID: navtree.NavIDObservability, SortWeight: 3, Text: "Application"}, "grafana-dbo11y-app": {SectionID: navtree.NavIDObservability, SortWeight: 4, Text: "Database", IsNew: true}, "grafana-k8s-app": {SectionID: navtree.NavIDObservability, SortWeight: 5, Text: "Kubernetes"}, "grafana-csp-app": {SectionID: navtree.NavIDObservability, SortWeight: 6, Icon: "cloud-provider"}, "grafana-metricsdrilldown-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 1, Text: "Metrics"}, "grafana-lokiexplore-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 2, Text: "Logs"}, "grafana-exploretraces-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 3, Text: "Traces"}, "grafana-pyroscope-app": {SectionID: navtree.NavIDDrilldown, SortWeight: 4, Text: "Profiles"}, "grafana-synthetic-monitoring-app": {SectionID: navtree.NavIDTestingAndSynthetics, SortWeight: 2, Text: "Synthetics"}, "grafana-irm-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3, Text: "IRM"}, "grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 4, Text: "OnCall"}, "grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 5, Text: "Incident"}, "grafana-assistant-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightAssistant, Text: "Assistant", SubTitle: "AI-powered assistant for Grafana", Icon: "ai-sparkle", IsNew: true}, "grafana-ml-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightAIAndML, Text: "Machine Learning", SubTitle: "Explore AI and machine learning features", Icon: "gf-ml-alt"}, "grafana-slo-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 7}, "grafana-cloud-link-app": {SectionID: navtree.NavIDCfgPlugins, SortWeight: 3}, "grafana-adaptive-metrics-app": {SectionID: navtree.NavIDAdaptiveTelemetry, SortWeight: 1}, "grafana-adaptivelogs-app": {SectionID: navtree.NavIDAdaptiveTelemetry, SortWeight: 2}, "grafana-adaptivetraces-app": {SectionID: navtree.NavIDAdaptiveTelemetry, SortWeight: 3}, "grafana-adaptiveprofiles-app": {SectionID: navtree.NavIDAdaptiveTelemetry, SortWeight: 4}, "grafana-cmab-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightCMAB, Icon: "cmab-logo", IsNew: true}, "grafana-easystart-app": {SectionID: navtree.NavIDRoot, SortWeight: navtree.WeightApps + 1, Text: "Connections", Icon: "adjust-circle"}, "k6-app": {SectionID: navtree.NavIDTestingAndSynthetics, SortWeight: 1, Text: "Performance"}, } //nolint:staticcheck // not yet migrated to OpenFeature if s.features.IsEnabledGlobally(featuremgmt.FlagGrafanaAdvisor) { s.navigationAppConfig["grafana-advisor-app"] = NavigationAppConfig{ SectionID: navtree.NavIDCfg, Text: "Advisor", SubTitle: "Run checks and get suggestions to fix issues", IsNew: true, } } s.navigationAppPathConfig = map[string]NavigationAppConfig{ "/a/grafana-auth-app": { SectionID: navtree.NavIDCfgAccess, SortWeight: 2, SubTitle: "Use policies to control automated access to metrics, logs, traces, and other Grafana Cloud services", }, } appSections := s.cfg.Raw.Section("navigation.app_sections") appStandalonePages := s.cfg.Raw.Section("navigation.app_standalone_pages") for _, key := range appSections.Keys() { pluginId := key.Name() // Support value values := util.SplitString(appSections.Key(key.Name()).MustString("")) appCfg := &NavigationAppConfig{SectionID: values[0]} if len(values) > 1 { if weight, err := strconv.ParseInt(values[1], 10, 64); err == nil { appCfg.SortWeight = weight } } // Only apply the new values, don't completely overwrite the entry if it exists if entry, ok := s.navigationAppConfig[pluginId]; ok { entry.SectionID = appCfg.SectionID if appCfg.SortWeight != 0 { entry.SortWeight = appCfg.SortWeight } s.navigationAppConfig[pluginId] = entry } else { s.navigationAppConfig[pluginId] = *appCfg } } for _, key := range appStandalonePages.Keys() { url := key.Name() // Support value values := util.SplitString(appStandalonePages.Key(key.Name()).MustString("")) appCfg := &NavigationAppConfig{SectionID: values[0]} if len(values) > 1 { if weight, err := strconv.ParseInt(values[1], 10, 64); err == nil { appCfg.SortWeight = weight } } s.navigationAppPathConfig[url] = *appCfg } }