NavTree: Make it possible to configure where in nav tree plugins live (#55484)

* NewIA: Plugin nav config

* progress

* Progress

* Things are working

* Add monitoring node

* Add alerts and incidents

* added experiment with standalone page

* Refactoring by adding a type for navtree root

* First test working

* More tests

* more tests

* Progress on richer config and sorting

* Sort weight working

* Path config

* Improving logic for not including admin or cfg nodes, making it the last step so that enterprise can add admin nodes without having to worry about the section not existing

* fixed index routes

* removed file

* Fixes

* Fixing tests

* Fixing more tests and adding support for weight config

* Updates

* Remove unused fake

* More fixes

* Minor tweak

* Minor fix

* Can now control position using sortweight even when existing items have no sortweight

* Added tests for frontend standalone page logic

* more tests

* Remove unused fake and fixed lint issue

* Moving reading settings to navtree impl package

* remove nav_id setting prefix

* Remove old test file

* Fix trailing newline

* Fixed bug with adding nil node

* fixing lint issue

* remove some code we have to rethink

* move read settings to PrivideService and switch to util.SplitString
This commit is contained in:
Torkel Ödegaard
2022-09-28 08:29:35 +02:00
committed by GitHub
parent 202dce66ff
commit e31cb93ec0
33 changed files with 1064 additions and 586 deletions
+135 -17
View File
@@ -1,5 +1,10 @@
package navtree
import (
"encoding/json"
"sort"
)
const (
// These weights may be used by an extension to reliably place
// itself in relation to a particular item in the menu. The weights
@@ -25,6 +30,17 @@ const (
NavSectionConfig string = "config"
)
const (
NavIDDashboards = "dashboards"
NavIDDashboardsBrowse = "dashboards/browse"
NavIDCfg = "cfg" // NavIDCfg is the id for org configuration navigation node
NavIDAdmin = "admin"
NavIDAlertsAndIncidents = "alerts-and-incidents"
NavIDAlerting = "alerting"
NavIDMonitoring = "monitoring"
NavIDReporting = "reports"
)
type NavLink struct {
Id string `json:"id,omitempty"`
Text string `json:"text"`
@@ -47,24 +63,126 @@ type NavLink struct {
EmptyMessageId string `json:"emptyMessageId,omitempty"`
}
// NavIDCfg is the id for org configuration navigation node
const NavIDCfg = "cfg"
func (node *NavLink) Sort() {
Sort(node.Children)
}
func GetServerAdminNode(children []*NavLink) *NavLink {
url := ""
if len(children) > 0 {
url = children[0].Url
type NavTreeRoot struct {
Children []*NavLink
}
func (root *NavTreeRoot) AddSection(node *NavLink) {
root.Children = append(root.Children, node)
}
func (root *NavTreeRoot) RemoveSection(node *NavLink) {
var result []*NavLink
for _, child := range root.Children {
if child != node {
result = append(result, child)
}
}
return &NavLink{
Text: "Server admin",
SubTitle: "Manage all users and orgs",
Description: "Manage server-wide settings and access to resources such as organizations, users, and licenses",
HideFromTabs: true,
Id: "admin",
Icon: "shield",
Url: url,
SortWeight: WeightAdmin,
Section: NavSectionConfig,
Children: children,
root.Children = result
}
func (root *NavTreeRoot) FindById(id string) *NavLink {
return FindById(root.Children, id)
}
func (root *NavTreeRoot) RemoveEmptySectionsAndApplyNewInformationArchitecture(topNavEnabled bool) {
// Remove server admin node if it has no children or set the url to first child
if node := root.FindById(NavIDAdmin); node != nil {
if len(node.Children) == 0 {
root.RemoveSection(node)
} else {
node.Url = node.Children[0].Url
}
}
if topNavEnabled {
orgAdminNode := root.FindById(NavIDCfg)
if orgAdminNode != nil {
orgAdminNode.Url = "/admin"
orgAdminNode.Text = "Administration"
}
if serverAdminNode := root.FindById(NavIDAdmin); serverAdminNode != nil {
serverAdminNode.Url = "/admin/settings"
serverAdminNode.Text = "Server admin"
serverAdminNode.SortWeight = 10000
if orgAdminNode != nil {
orgAdminNode.Children = append(orgAdminNode.Children, serverAdminNode)
root.RemoveSection(serverAdminNode)
}
}
// Move reports into dashboards
if reports := root.FindById(NavIDReporting); reports != nil {
if dashboards := root.FindById(NavIDDashboards); dashboards != nil {
reports.SortWeight = 0
dashboards.Children = append(dashboards.Children, reports)
root.RemoveSection(reports)
}
}
// Change id of dashboards
if dashboards := root.FindById(NavIDDashboards); dashboards != nil {
dashboards.Id = "dashboards/browse"
}
}
// Remove top level cfg / administration node if it has no children (needs to be after topnav new info archicture logic above that moves server admin into it)
// Remove server admin node if it has no children or set the url to first child
if node := root.FindById(NavIDCfg); node != nil {
if len(node.Children) == 0 {
root.RemoveSection(node)
} else if !topNavEnabled {
node.Url = node.Children[0].Url
}
}
}
func (root *NavTreeRoot) Sort() {
Sort(root.Children)
}
func (root *NavTreeRoot) MarshalJSON() ([]byte, error) {
return json.Marshal(root.Children)
}
func Sort(nodes []*NavLink) {
sort.SliceStable(nodes, func(i, j int) bool {
iw := nodes[i].SortWeight
if iw == 0 {
iw = int64(i) + 1
}
jw := nodes[j].SortWeight
if jw == 0 {
jw = int64(j) + 1
}
return iw < jw
})
for _, child := range nodes {
child.Sort()
}
}
func FindById(nodes []*NavLink, id string) *NavLink {
for _, child := range nodes {
if child.Id == id {
return child
} else if len(child.Children) > 0 {
if found := FindById(child.Children, id); found != nil {
return found
}
}
}
return nil
}
+89
View File
@@ -0,0 +1,89 @@
package navtree
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestNavTreeRoot(t *testing.T) {
t.Run("Should remove empty admin and server admin sections", func(t *testing.T) {
treeRoot := NavTreeRoot{
Children: []*NavLink{
{Id: NavIDCfg},
{Id: NavIDAdmin},
},
}
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(false)
require.Equal(t, 0, len(treeRoot.Children))
})
t.Run("Should not remove admin sections when they have children", func(t *testing.T) {
treeRoot := NavTreeRoot{
Children: []*NavLink{
{Id: NavIDCfg, Children: []*NavLink{{Id: "child"}}},
{Id: NavIDAdmin, Children: []*NavLink{{Id: "child"}}},
},
}
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(false)
require.Equal(t, 2, len(treeRoot.Children))
})
t.Run("Should move admin section into cfg and rename when topnav is enabled", func(t *testing.T) {
treeRoot := NavTreeRoot{
Children: []*NavLink{
{Id: NavIDCfg},
{Id: NavIDAdmin, Children: []*NavLink{{Id: "child"}}},
},
}
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(true)
require.Equal(t, "Administration", treeRoot.Children[0].Text)
require.Equal(t, NavIDAdmin, treeRoot.Children[0].Children[0].Id)
})
t.Run("Should move reports into Dashboards", func(t *testing.T) {
treeRoot := NavTreeRoot{
Children: []*NavLink{
{Id: NavIDDashboards},
{Id: NavIDReporting},
},
}
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(true)
require.Equal(t, NavIDReporting, treeRoot.Children[0].Children[0].Id)
})
t.Run("Sorting by index", func(t *testing.T) {
treeRoot := NavTreeRoot{
Children: []*NavLink{
{Id: "1"},
{Id: "2"},
{Id: "3"},
},
}
treeRoot.Sort()
require.Equal(t, "1", treeRoot.Children[0].Id)
require.Equal(t, "3", treeRoot.Children[2].Id)
})
t.Run("Sorting by index and SortWeight", func(t *testing.T) {
treeRoot := NavTreeRoot{
Children: []*NavLink{
{Id: "1"},
{Id: "2"},
{Id: "3"},
{Id: "4", SortWeight: 1},
},
}
treeRoot.Sort()
require.Equal(t, "1", treeRoot.Children[0].Id)
require.Equal(t, "4", treeRoot.Children[1].Id)
})
}
+1 -1
View File
@@ -6,5 +6,5 @@ import (
)
type Service interface {
GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) ([]*NavLink, error)
GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) (*NavTreeRoot, error)
}
+70 -2
View File
@@ -12,7 +12,7 @@ import (
"github.com/grafana/grafana/pkg/services/serviceaccounts"
)
func (s *ServiceImpl) setupConfigNodes(c *models.ReqContext) ([]*navtree.NavLink, error) {
func (s *ServiceImpl) getOrgAdminNode(c *models.ReqContext) (*navtree.NavLink, error) {
var configNodes []*navtree.NavLink
hasAccess := ac.HasAccess(s.accessControl, c)
@@ -103,7 +103,75 @@ func (s *ServiceImpl) setupConfigNodes(c *models.ReqContext) ([]*navtree.NavLink
Url: s.cfg.AppSubURL + "/org/serviceaccounts",
})
}
return configNodes, nil
configNode := &navtree.NavLink{
Id: navtree.NavIDCfg,
Text: "Configuration",
SubTitle: "Organization: " + c.OrgName,
Icon: "cog",
Section: navtree.NavSectionConfig,
SortWeight: navtree.WeightConfig,
Children: configNodes,
}
return configNode, nil
}
func (s *ServiceImpl) getServerAdminNode(c *models.ReqContext) *navtree.NavLink {
hasAccess := ac.HasAccess(s.accessControl, c)
hasGlobalAccess := ac.HasGlobalAccess(s.accessControl, s.accesscontrolService, c)
orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead)
adminNavLinks := []*navtree.NavLink{}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Users", Description: "Manage and create users across the whole Grafana server", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user",
})
}
if hasGlobalAccess(ac.ReqGrafanaAdmin, orgsAccessEvaluator) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Organizations", Description: "Isolated instances of Grafana running on the same server", Id: "global-orgs", Url: s.cfg.AppSubURL + "/admin/orgs", Icon: "building",
})
}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Settings", Description: "View the settings defined in your Grafana config", Id: "server-settings", Url: s.cfg.AppSubURL + "/admin/settings", Icon: "sliders-v-alt",
})
}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) && s.features.IsEnabled(featuremgmt.FlagStorage) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Storage",
Id: "storage",
Description: "Manage file storage",
Icon: "cube",
Url: s.cfg.AppSubURL + "/admin/storage",
})
}
if s.cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "LDAP", Id: "ldap", Url: s.cfg.AppSubURL + "/admin/ldap", Icon: "book",
})
}
adminNode := &navtree.NavLink{
Text: "Server admin",
Description: "Manage server-wide settings and access to resources such as organizations, users, and licenses",
Id: navtree.NavIDAdmin,
Icon: "shield",
SortWeight: navtree.WeightAdmin,
Section: navtree.NavSectionConfig,
Children: adminNavLinks,
}
if len(adminNavLinks) > 0 {
adminNode.Url = adminNavLinks[0].Url
}
return adminNode
}
func (s *ServiceImpl) ReqCanAdminTeams(c *models.ReqContext) bool {
+160 -60
View File
@@ -3,6 +3,7 @@ package navtreeimpl
import (
"path"
"sort"
"strconv"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
@@ -10,15 +11,17 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/navtree"
"github.com/grafana/grafana/pkg/services/pluginsettings"
"github.com/grafana/grafana/pkg/util"
)
func (s *ServiceImpl) getAppLinks(c *models.ReqContext) ([]*navtree.NavLink, error) {
func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqContext) error {
topNavEnabled := s.features.IsEnabled(featuremgmt.FlagTopnav)
hasAccess := ac.HasAccess(s.accessControl, c)
appLinks := []*navtree.NavLink{}
pss, err := s.pluginSettings.GetPluginSettings(c.Req.Context(), &pluginsettings.GetArgs{OrgID: c.OrgID})
if err != nil {
return nil, err
return err
}
isPluginEnabled := func(plugin plugins.PluginDTO) bool {
@@ -43,63 +46,8 @@ func (s *ServiceImpl) getAppLinks(c *models.ReqContext) ([]*navtree.NavLink, err
continue
}
appLink := &navtree.NavLink{
Text: plugin.Name,
Id: "plugin-page-" + plugin.ID,
Img: plugin.Info.Logos.Small,
Section: navtree.NavSectionPlugin,
SortWeight: navtree.WeightPlugin,
}
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
appLink.Url = s.cfg.AppSubURL + "/a/" + plugin.ID
} else {
appLink.Url = path.Join(s.cfg.AppSubURL, plugin.DefaultNavURL)
}
for _, include := range plugin.Includes {
if !c.HasUserRole(include.Role) {
continue
}
if include.Type == "page" && include.AddToNav {
var link *navtree.NavLink
if len(include.Path) > 0 {
link = &navtree.NavLink{
Url: s.cfg.AppSubURL + include.Path,
Text: include.Name,
}
if include.DefaultNav && !s.features.IsEnabled(featuremgmt.FlagTopnav) {
appLink.Url = link.Url // Overwrite the hardcoded page logic
}
} else {
link = &navtree.NavLink{
Url: s.cfg.AppSubURL + "/plugins/" + plugin.ID + "/page/" + include.Slug,
Text: include.Name,
}
}
link.Icon = include.Icon
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,
}
appLink.Children = append(appLink.Children, link)
}
}
}
if len(appLink.Children) > 0 {
// 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{}
}
appLinks = append(appLinks, appLink)
if appNode := s.processAppPlugin(plugin, c, topNavEnabled, treeRoot); appNode != nil {
appLinks = append(appLinks, appNode)
}
}
@@ -109,5 +57,157 @@ func (s *ServiceImpl) getAppLinks(c *models.ReqContext) ([]*navtree.NavLink, err
})
}
return appLinks, nil
if topNavEnabled {
treeRoot.AddSection(&navtree.NavLink{
Text: "Apps",
Icon: "apps",
Description: "App plugins that extend the Grafana experience",
Id: "apps",
Children: appLinks,
Section: navtree.NavSectionCore,
Url: s.cfg.AppSubURL + "/apps",
})
} else {
for _, appLink := range appLinks {
treeRoot.AddSection(appLink)
}
}
return nil
}
func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqContext, topNavEnabled bool, treeRoot *navtree.NavTreeRoot) *navtree.NavLink {
appLink := &navtree.NavLink{
Text: plugin.Name,
Id: "plugin-page-" + plugin.ID,
Img: plugin.Info.Logos.Small,
Section: navtree.NavSectionPlugin,
SortWeight: navtree.WeightPlugin,
}
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
appLink.Url = s.cfg.AppSubURL + "/a/" + plugin.ID
} else {
appLink.Url = path.Join(s.cfg.AppSubURL, plugin.DefaultNavURL)
}
for _, include := range plugin.Includes {
if !c.HasUserRole(include.Role) {
continue
}
if include.Type == "page" && include.AddToNav {
link := &navtree.NavLink{
Text: include.Name,
Icon: include.Icon,
}
if len(include.Path) > 0 {
link.Url = s.cfg.AppSubURL + include.Path
if include.DefaultNav {
appLink.Url = link.Url
}
} else {
link.Url = s.cfg.AppSubURL + "/plugins/" + plugin.ID + "/page/" + include.Slug
}
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
sectionForPage.Children = append(sectionForPage.Children, link)
}
} else {
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,
}
appLink.Children = append(appLink.Children, link)
}
}
}
if len(appLink.Children) > 0 {
// 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{}
}
alertingNode := treeRoot.FindById(navtree.NavIDAlerting)
if navConfig, hasOverride := s.navigationAppConfig[plugin.ID]; hasOverride && topNavEnabled {
appLink.SortWeight = navConfig.SortWeight
if navNode := treeRoot.FindById(navConfig.SectionID); navNode != nil {
navNode.Children = append(navNode.Children, appLink)
} else {
if navConfig.SectionID == navtree.NavIDMonitoring {
treeRoot.AddSection(&navtree.NavLink{
Text: "Monitoring",
Id: navtree.NavIDMonitoring,
Description: "Monitoring and infrastructure apps",
Icon: "heart-rate",
Section: navtree.NavSectionCore,
Children: []*navtree.NavLink{appLink},
Url: s.cfg.AppSubURL + "/monitoring",
})
} else if navConfig.SectionID == navtree.NavIDAlertsAndIncidents && alertingNode != nil {
treeRoot.AddSection(&navtree.NavLink{
Text: "Alerts & incidents",
Id: navtree.NavIDAlertsAndIncidents,
Description: "Alerting and incident management apps",
Icon: "bell",
Section: navtree.NavSectionCore,
Children: []*navtree.NavLink{alertingNode, appLink},
Url: s.cfg.AppSubURL + "/alerts-and-incidents",
})
treeRoot.RemoveSection(alertingNode)
} else {
s.log.Error("Plugin app nav id not found", "pluginId", plugin.ID, "navId", navConfig.SectionID)
}
}
} else {
return appLink
}
}
return nil
}
func (s *ServiceImpl) readNavigationSettings() {
s.navigationAppConfig = map[string]NavigationAppConfig{
"grafana-k8s-app": {SectionID: navtree.NavIDMonitoring, SortWeight: 1},
"grafana-synthetic-monitoring-app": {SectionID: navtree.NavIDMonitoring, SortWeight: 2},
"grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1},
"grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2},
"grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3},
}
s.navigationAppPathConfig = map[string]NavigationAppConfig{
"/a/grafana-auth-app": {SectionID: navtree.NavIDCfg, SortWeight: 7},
}
sec := s.cfg.Raw.Section("navigation.apps")
for _, key := range sec.Keys() {
pluginId := key.Name()
// Support <id> <weight> value
values := util.SplitString(sec.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.navigationAppConfig[pluginId] = *appCfg
}
}
@@ -0,0 +1,196 @@
package navtreeimpl
import (
"net/http"
"testing"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/navtree"
"github.com/grafana/grafana/pkg/services/pluginsettings"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
"github.com/stretchr/testify/require"
)
func TestAddAppLinks(t *testing.T) {
httpReq, _ := http.NewRequest(http.MethodGet, "", nil)
reqCtx := &models.ReqContext{SignedInUser: &user.SignedInUser{}, Context: &web.Context{Req: httpReq}}
permissions := []ac.Permission{
{Action: plugins.ActionAppAccess, Scope: "*"},
}
testApp1 := plugins.PluginDTO{
JSONData: plugins.JSONData{
ID: "test-app1",
Name: "Test app1 name",
Type: plugins.App,
Includes: []*plugins.Includes{
{
Name: "Hello",
Path: "/a/test-app1/catalog",
Type: "page",
AddToNav: true,
DefaultNav: true,
},
{
Name: "Hello",
Path: "/a/test-app1/page2",
Type: "page",
AddToNav: true,
},
},
},
}
testApp2 := plugins.PluginDTO{
JSONData: plugins.JSONData{
ID: "test-app2",
Name: "Test app2 name",
Type: plugins.App,
Includes: []*plugins.Includes{
{
Name: "Hello",
Path: "/a/quick-app/catalog",
Type: "page",
AddToNav: true,
DefaultNav: true,
},
},
},
}
pluginSettings := pluginsettings.FakePluginSettings{Plugins: map[string]*pluginsettings.DTO{
testApp1.ID: {ID: 0, OrgID: 1, PluginID: testApp1.ID, PluginVersion: "1.0.0", Enabled: true},
testApp2.ID: {ID: 0, OrgID: 1, PluginID: testApp2.ID, PluginVersion: "1.0.0", Enabled: true},
}}
service := ServiceImpl{
log: log.New("navtree"),
cfg: setting.NewCfg(),
accessControl: accesscontrolmock.New().WithPermissions(permissions),
pluginSettings: &pluginSettings,
features: featuremgmt.WithFeatures(),
pluginStore: plugins.FakePluginStore{
PluginList: []plugins.PluginDTO{testApp1, testApp2},
},
}
t.Run("Should add enabled apps with pages", func(t *testing.T) {
treeRoot := navtree.NavTreeRoot{}
err := service.addAppLinks(&treeRoot, reqCtx)
require.NoError(t, err)
require.Equal(t, "Test app1 name", treeRoot.Children[0].Text)
require.Equal(t, "/a/test-app1/catalog", treeRoot.Children[0].Url)
require.Equal(t, "/a/test-app1/page2", treeRoot.Children[0].Children[1].Url)
})
t.Run("Should move apps to Apps category when topnav is enabled", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
treeRoot := navtree.NavTreeRoot{}
err := service.addAppLinks(&treeRoot, reqCtx)
require.NoError(t, err)
require.Equal(t, "Apps", treeRoot.Children[0].Text)
require.Equal(t, "Test app1 name", treeRoot.Children[0].Children[0].Text)
})
t.Run("Should move apps that have specific nav id configured to correct section", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
service.navigationAppConfig = map[string]NavigationAppConfig{
"test-app1": {SectionID: navtree.NavIDAdmin},
}
treeRoot := navtree.NavTreeRoot{}
treeRoot.AddSection(&navtree.NavLink{
Id: navtree.NavIDAdmin,
})
err := service.addAppLinks(&treeRoot, reqCtx)
require.NoError(t, err)
require.Equal(t, "plugin-page-test-app1", treeRoot.Children[0].Children[0].Id)
})
t.Run("Should add monitoring section if plugin exists that wants to live there", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
service.navigationAppConfig = map[string]NavigationAppConfig{
"test-app1": {SectionID: navtree.NavIDMonitoring},
}
treeRoot := navtree.NavTreeRoot{}
err := service.addAppLinks(&treeRoot, reqCtx)
require.NoError(t, err)
require.Equal(t, "Monitoring", treeRoot.Children[0].Text)
require.Equal(t, "Test app1 name", treeRoot.Children[0].Children[0].Text)
})
t.Run("Should add Alerts and incidents section if plugin exists that wants to live there", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
service.navigationAppConfig = map[string]NavigationAppConfig{
"test-app1": {SectionID: navtree.NavIDAlertsAndIncidents},
}
treeRoot := navtree.NavTreeRoot{}
treeRoot.AddSection(&navtree.NavLink{Id: navtree.NavIDAlerting, Text: "Alerting"})
err := service.addAppLinks(&treeRoot, reqCtx)
require.NoError(t, err)
require.Equal(t, "Alerts & incidents", treeRoot.Children[0].Text)
require.Equal(t, "Alerting", treeRoot.Children[0].Children[0].Text)
require.Equal(t, "Test app1 name", treeRoot.Children[0].Children[1].Text)
})
t.Run("Should be able to control app sort order with SortWeight", func(t *testing.T) {
service.features = featuremgmt.WithFeatures(featuremgmt.FlagTopnav)
service.navigationAppConfig = map[string]NavigationAppConfig{
"test-app2": {SectionID: navtree.NavIDMonitoring, SortWeight: 1},
"test-app1": {SectionID: navtree.NavIDMonitoring, SortWeight: 2},
}
treeRoot := navtree.NavTreeRoot{}
err := service.addAppLinks(&treeRoot, reqCtx)
treeRoot.Sort()
require.NoError(t, err)
require.Equal(t, "Test app2 name", treeRoot.Children[0].Children[0].Text)
require.Equal(t, "Test app1 name", treeRoot.Children[0].Children[1].Text)
})
}
func TestReadingNavigationSettings(t *testing.T) {
t.Run("Should include defaults", func(t *testing.T) {
service := ServiceImpl{
cfg: setting.NewCfg(),
}
_, _ = service.cfg.Raw.NewSection("navigation.apps")
service.readNavigationSettings()
require.Equal(t, "monitoring", service.navigationAppConfig["grafana-k8s-app"].SectionID)
})
t.Run("Can add additional overrides via ini system", func(t *testing.T) {
service := ServiceImpl{
cfg: setting.NewCfg(),
}
sec, _ := service.cfg.Raw.NewSection("navigation.apps")
_, _ = sec.NewKey("grafana-k8s-app", "dashboards")
_, _ = sec.NewKey("other-app", "admin 12")
service.readNavigationSettings()
require.Equal(t, "dashboards", service.navigationAppConfig["grafana-k8s-app"].SectionID)
require.Equal(t, "admin", service.navigationAppConfig["other-app"].SectionID)
require.Equal(t, int64(0), service.navigationAppConfig["grafana-k8s-app"].SortWeight)
require.Equal(t, int64(12), service.navigationAppConfig["other-app"].SortWeight)
})
}
+59 -140
View File
@@ -33,10 +33,19 @@ type ServiceImpl struct {
accesscontrolService ac.Service
kvStore kvstore.KVStore
apiKeyService apikey.Service
// Navigation
navigationAppConfig map[string]NavigationAppConfig
navigationAppPathConfig map[string]NavigationAppConfig
}
type NavigationAppConfig struct {
SectionID string
SortWeight int64
}
func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStore plugins.Store, pluginSettings pluginsettings.Service, starService star.Service, features *featuremgmt.FeatureManager, dashboardService dashboards.DashboardService, accesscontrolService ac.Service, kvStore kvstore.KVStore, apiKeyService apikey.Service) navtree.Service {
return &ServiceImpl{
service := &ServiceImpl{
cfg: cfg,
log: log.New("navtree service"),
accessControl: accessControl,
@@ -49,12 +58,16 @@ func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStor
kvStore: kvStore,
apiKeyService: apiKeyService,
}
service.readNavigationSettings()
return service
}
//nolint:gocyclo
func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) ([]*navtree.NavLink, error) {
func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) (*navtree.NavTreeRoot, error) {
hasAccess := ac.HasAccess(s.accessControl, c)
var navTree []*navtree.NavLink
treeRoot := &navtree.NavTreeRoot{}
if hasAccess(ac.ReqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsRead)) {
starredItemsLinks, err := s.buildStarredItemsNavLinks(c, prefs)
@@ -62,7 +75,7 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
return nil, err
}
navTree = append(navTree, &navtree.NavLink{
treeRoot.AddSection(&navtree.NavLink{
Text: "Starred",
Id: "starred",
Icon: "star",
@@ -74,25 +87,19 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
dashboardChildLinks := s.buildDashboardNavLinks(c, hasEditPerm)
dashboardsUrl := "/dashboards"
dashboardLink := &navtree.NavLink{
Text: "Dashboards",
Id: "dashboards",
Id: navtree.NavIDDashboards,
Description: "Create and manage dashboards to visualize your data",
SubTitle: "Manage dashboards and folders",
Icon: "apps",
Url: s.cfg.AppSubURL + dashboardsUrl,
Url: s.cfg.AppSubURL + "/dashboards",
SortWeight: navtree.WeightDashboard,
Section: navtree.NavSectionCore,
Children: dashboardChildLinks,
}
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
dashboardLink.Id = "dashboards/browse"
}
navTree = append(navTree, dashboardLink)
treeRoot.AddSection(dashboardLink)
}
canExplore := func(context *models.ReqContext) bool {
@@ -100,7 +107,7 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
}
if setting.ExploreEnabled && hasAccess(canExplore, ac.EvalPermission(ac.ActionDatasourcesExplore)) {
navTree = append(navTree, &navtree.NavLink{
treeRoot.AddSection(&navtree.NavLink{
Text: "Explore",
Id: "explore",
SubTitle: "Explore your data",
@@ -111,44 +118,25 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
})
}
navTree = s.addProfile(navTree, c)
if setting.ProfileEnabled && c.IsSignedIn {
treeRoot.AddSection(s.getProfileNode(c))
}
_, uaIsDisabledForOrg := s.cfg.UnifiedAlerting.DisabledOrgs[c.OrgID]
uaVisibleForOrg := s.cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg
if setting.AlertingEnabled != nil && *setting.AlertingEnabled {
navTree = append(navTree, s.buildLegacyAlertNavLinks(c)...)
if legacyAlertSection := s.buildLegacyAlertNavLinks(c); legacyAlertSection != nil {
treeRoot.AddSection(legacyAlertSection)
}
} else if uaVisibleForOrg {
navTree = append(navTree, s.buildAlertNavLinks(c, hasEditPerm)...)
if alertingSection := s.buildAlertNavLinks(c, hasEditPerm); alertingSection != nil {
treeRoot.AddSection(alertingSection)
}
}
if s.features.IsEnabled(featuremgmt.FlagDataConnectionsConsole) {
navTree = append(navTree, s.buildDataConnectionsNavLink(c))
}
appLinks, err := s.getAppLinks(c)
if err != nil {
return nil, err
}
// When topnav is enabled we can test new information architecture where plugins live in Apps category
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
navTree = append(navTree, &navtree.NavLink{
Text: "Apps",
Icon: "apps",
Description: "App plugins that extend the Grafana experience",
Id: "apps",
Children: appLinks,
Section: navtree.NavSectionCore,
Url: s.cfg.AppSubURL + "/apps",
})
} else {
navTree = append(navTree, appLinks...)
}
configNodes, err := s.setupConfigNodes(c)
if err != nil {
return navTree, err
treeRoot.AddSection(s.buildDataConnectionsNavLink(c))
}
if s.features.IsEnabled(featuremgmt.FlagLivePipeline) {
@@ -163,7 +151,8 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
liveNavLinks = append(liveNavLinks, &navtree.NavLink{
Text: "Cloud", Id: "live-cloud", Url: s.cfg.AppSubURL + "/live/cloud", Icon: "cloud-upload",
})
navTree = append(navTree, &navtree.NavLink{
treeRoot.AddSection(&navtree.NavLink{
Id: "live",
Text: "Live",
SubTitle: "Event streaming",
@@ -175,60 +164,37 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
})
}
var configNode *navtree.NavLink
var serverAdminNode *navtree.NavLink
orgAdminNode, err := s.getOrgAdminNode(c)
if len(configNodes) > 0 {
configNode = &navtree.NavLink{
Id: navtree.NavIDCfg,
Text: "Configuration",
SubTitle: "Organization: " + c.OrgName,
Icon: "cog",
Url: configNodes[0].Url,
Section: navtree.NavSectionConfig,
SortWeight: navtree.WeightConfig,
Children: configNodes,
}
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
configNode.Url = "/admin"
} else {
configNode.Url = configNodes[0].Url
}
navTree = append(navTree, configNode)
if orgAdminNode != nil {
treeRoot.AddSection(orgAdminNode)
} else if err != nil {
return nil, err
}
adminNavLinks := s.buildAdminNavLinks(c)
serverAdminNode := s.getServerAdminNode(c)
if len(adminNavLinks) > 0 {
serverAdminNode = navtree.GetServerAdminNode(adminNavLinks)
navTree = append(navTree, serverAdminNode)
if serverAdminNode != nil {
treeRoot.AddSection(serverAdminNode)
}
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
// Move server admin into Configuration and rename to administration
if configNode != nil && serverAdminNode != nil {
configNode.Text = "Administration"
serverAdminNode.Url = "/admin/server"
serverAdminNode.HideFromTabs = false
configNode.Children = append(configNode.Children, serverAdminNode)
adminNodeIndex := len(navTree) - 1
navTree = navTree[:adminNodeIndex]
}
s.addHelpLinks(treeRoot, c)
if err := s.addAppLinks(treeRoot, c); err != nil {
return nil, err
}
navTree = s.addHelpLinks(navTree, c)
return navTree, nil
return treeRoot, nil
}
func (s *ServiceImpl) addHelpLinks(navTree []*navtree.NavLink, c *models.ReqContext) []*navtree.NavLink {
func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqContext) {
if setting.HelpEnabled {
helpVersion := fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit)
if s.cfg.AnonymousHideVersion && !c.IsSignedIn {
helpVersion = setting.ApplicationName
}
navTree = append(navTree, &navtree.NavLink{
treeRoot.AddSection(&navtree.NavLink{
Text: "Help",
SubTitle: helpVersion,
Id: "help",
@@ -239,14 +205,6 @@ func (s *ServiceImpl) addHelpLinks(navTree []*navtree.NavLink, c *models.ReqCont
Children: []*navtree.NavLink{},
})
}
return navTree
}
func (s *ServiceImpl) addProfile(navTree []*navtree.NavLink, c *models.ReqContext) []*navtree.NavLink {
if setting.ProfileEnabled && c.IsSignedIn {
navTree = append(navTree, s.getProfileNode(c))
}
return navTree
}
func (s *ServiceImpl) getProfileNode(c *models.ReqContext) *navtree.NavLink {
@@ -352,11 +310,13 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
}
dashboardChildNavs := []*navtree.NavLink{}
if !s.features.IsEnabled(featuremgmt.FlagTopnav) {
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "Browse", Id: "dashboards/browse", Url: s.cfg.AppSubURL + "/dashboards", Icon: "sitemap",
Text: "Browse", Id: navtree.NavIDDashboardsBrowse, Url: s.cfg.AppSubURL + "/dashboards", Icon: "sitemap",
})
}
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "Playlists", Description: "Groups of dashboards that are displayed in a sequence", Id: "dashboards/playlists", Url: s.cfg.AppSubURL + "/playlists", Icon: "presentation-play",
})
@@ -388,7 +348,7 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
})
}
if hasEditPerm {
if hasEditPerm && !s.features.IsEnabled(featuremgmt.FlagTopnav) {
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
})
@@ -413,10 +373,11 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
})
}
}
return dashboardChildNavs
}
func (s *ServiceImpl) buildLegacyAlertNavLinks(c *models.ReqContext) []*navtree.NavLink {
func (s *ServiceImpl) buildLegacyAlertNavLinks(c *models.ReqContext) *navtree.NavLink {
var alertChildNavs []*navtree.NavLink
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert rules", Id: "alert-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul",
@@ -446,10 +407,10 @@ func (s *ServiceImpl) buildLegacyAlertNavLinks(c *models.ReqContext) []*navtree.
alertNav.Url = s.cfg.AppSubURL + "/alerting/list"
}
return []*navtree.NavLink{&alertNav}
return &alertNav
}
func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool) []*navtree.NavLink {
func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool) *navtree.NavLink {
hasAccess := ac.HasAccess(s.accessControl, c)
var alertChildNavs []*navtree.NavLink
@@ -497,7 +458,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool)
Text: "Alerting",
Description: "Learn about problems in your systems moments after they occur",
SubTitle: "Alert rules and notifications",
Id: "alerting",
Id: navtree.NavIDAlerting,
Icon: "bell",
Children: alertChildNavs,
Section: navtree.NavSectionCore,
@@ -510,8 +471,9 @@ func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool)
alertNav.Url = s.cfg.AppSubURL + "/alerting/list"
}
return []*navtree.NavLink{&alertNav}
return &alertNav
}
return nil
}
@@ -558,46 +520,3 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *models.ReqContext) *navtree
return navLink
}
func (s *ServiceImpl) buildAdminNavLinks(c *models.ReqContext) []*navtree.NavLink {
hasAccess := ac.HasAccess(s.accessControl, c)
hasGlobalAccess := ac.HasGlobalAccess(s.accessControl, s.accesscontrolService, c)
orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead)
adminNavLinks := []*navtree.NavLink{}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Users", Description: "Manage and create users across the whole Grafana server", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user",
})
}
if hasGlobalAccess(ac.ReqGrafanaAdmin, orgsAccessEvaluator) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Organizations", Description: "Isolated instances of Grafana running on the same server", Id: "global-orgs", Url: s.cfg.AppSubURL + "/admin/orgs", Icon: "building",
})
}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Settings", Description: "View the settings defined in your Grafana config", Id: "server-settings", Url: s.cfg.AppSubURL + "/admin/settings", Icon: "sliders-v-alt",
})
}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) && s.features.IsEnabled(featuremgmt.FlagStorage) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Storage",
Id: "storage",
Description: "Manage file storage",
Icon: "cube",
Url: s.cfg.AppSubURL + "/admin/storage",
})
}
if s.cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "LDAP", Id: "ldap", Url: s.cfg.AppSubURL + "/admin/ldap", Icon: "book",
})
}
return adminNavLinks
}