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
pull/55886/head
Torkel Ödegaard 3 years ago committed by GitHub
parent 202dce66ff
commit e31cb93ec0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      packages/grafana-data/src/types/icon.ts
  2. 4
      pkg/api/api.go
  3. 7
      pkg/api/dashboard_test.go
  4. 5
      pkg/api/datasources_test.go
  5. 2
      pkg/api/dtos/index.go
  6. 100
      pkg/api/fakes.go
  7. 3
      pkg/api/frontendsettings_test.go
  8. 7
      pkg/api/index.go
  9. 2
      pkg/api/login_test.go
  10. 46
      pkg/api/plugins_test.go
  11. 27
      pkg/infra/usagestats/service/usage_stats_test.go
  12. 47
      pkg/infra/usagestats/statscollector/service_test.go
  13. 2
      pkg/middleware/middleware_test.go
  14. 38
      pkg/plugins/fakes.go
  15. 27
      pkg/plugins/manager/dashboards/filestore_test.go
  16. 24
      pkg/plugins/manager/fakes/fakes.go
  17. 17
      pkg/services/licensing/oss.go
  18. 152
      pkg/services/navtree/models.go
  19. 89
      pkg/services/navtree/models_test.go
  20. 2
      pkg/services/navtree/navtree.go
  21. 72
      pkg/services/navtree/navtreeimpl/admin.go
  22. 198
      pkg/services/navtree/navtreeimpl/applinks.go
  23. 196
      pkg/services/navtree/navtreeimpl/applinks_test.go
  24. 199
      pkg/services/navtree/navtreeimpl/navtree.go
  25. 77
      pkg/services/pluginsettings/fake.go
  26. 22
      pkg/services/provisioning/plugins/config_reader_test.go
  27. 63
      pkg/services/updatechecker/plugins_test.go
  28. 22
      pkg/setting/setting.go
  29. 6
      pkg/setting/setting_plugins.go
  30. 14
      public/app/features/plugins/components/AppRootPage.tsx
  31. 89
      public/app/features/plugins/utils.test.ts
  32. 43
      public/app/features/plugins/utils.ts
  33. 25
      public/app/routes/routes.tsx

@ -105,6 +105,7 @@ export const availableIconsIndex = {
grafana: true, grafana: true,
'graph-bar': true, 'graph-bar': true,
heart: true, heart: true,
'heart-rate': true,
'heart-break': true, 'heart-break': true,
history: true, history: true,
home: true, home: true,

@ -156,6 +156,10 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/alerting/", reqSignedIn, hs.Index) r.Get("/alerting/", reqSignedIn, hs.Index)
r.Get("/alerting/*", reqSignedIn, hs.Index) r.Get("/alerting/*", reqSignedIn, hs.Index)
r.Get("/library-panels/", reqSignedIn, hs.Index) r.Get("/library-panels/", reqSignedIn, hs.Index)
r.Get("/monitoring/", reqSignedIn, hs.Index)
r.Get("/monitoring/*", reqSignedIn, hs.Index)
r.Get("/alerts-and-incidents", reqSignedIn, hs.Index)
r.Get("/alerts-and-incidents/*", reqSignedIn, hs.Index)
// sign up // sign up
r.Get("/verify", hs.Index) r.Get("/verify", hs.Index)

@ -20,6 +20,7 @@ import (
"github.com/grafana/grafana/pkg/framework/coremodel/registry" "github.com/grafana/grafana/pkg/framework/coremodel/registry"
"github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/infra/usagestats"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/annotations/annotationstest" "github.com/grafana/grafana/pkg/services/annotations/annotationstest"
@ -58,7 +59,7 @@ func TestGetHomeDashboard(t *testing.T) {
hs := &HTTPServer{ hs := &HTTPServer{
Cfg: cfg, Cfg: cfg,
pluginStore: &fakePluginStore{}, pluginStore: &plugins.FakePluginStore{},
SQLStore: mockstore.NewSQLStoreMock(), SQLStore: mockstore.NewSQLStoreMock(),
preferenceService: prefService, preferenceService: prefService,
dashboardVersionService: dashboardVersionService, dashboardVersionService: dashboardVersionService,
@ -141,7 +142,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
hs := &HTTPServer{ hs := &HTTPServer{
Cfg: setting.NewCfg(), Cfg: setting.NewCfg(),
pluginStore: &fakePluginStore{}, pluginStore: &plugins.FakePluginStore{},
SQLStore: mockSQLStore, SQLStore: mockSQLStore,
AccessControl: accesscontrolmock.New(), AccessControl: accesscontrolmock.New(),
Features: featuremgmt.WithFeatures(), Features: featuremgmt.WithFeatures(),
@ -1027,7 +1028,7 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s
QuotaService: &quotaimpl.Service{ QuotaService: &quotaimpl.Service{
Cfg: cfg, Cfg: cfg,
}, },
pluginStore: &fakePluginStore{}, pluginStore: &plugins.FakePluginStore{},
LibraryPanelService: &mockLibraryPanelService{}, LibraryPanelService: &mockLibraryPanelService{},
LibraryElementService: &mockLibraryElementService{}, LibraryElementService: &mockLibraryElementService{},
DashboardService: dashboardService, DashboardService: dashboardService,

@ -17,6 +17,7 @@ import (
"github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
ac "github.com/grafana/grafana/pkg/services/accesscontrol" ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/datasources/permissions" "github.com/grafana/grafana/pkg/services/datasources/permissions"
@ -47,7 +48,7 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) {
// handler func being tested // handler func being tested
hs := &HTTPServer{ hs := &HTTPServer{
Cfg: setting.NewCfg(), Cfg: setting.NewCfg(),
pluginStore: &fakePluginStore{}, pluginStore: &plugins.FakePluginStore{},
DataSourcesService: &dataSourcesServiceMock{ DataSourcesService: &dataSourcesServiceMock{
expectedDatasources: ds, expectedDatasources: ds,
}, },
@ -71,7 +72,7 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) {
// handler func being tested // handler func being tested
hs := &HTTPServer{ hs := &HTTPServer{
Cfg: setting.NewCfg(), Cfg: setting.NewCfg(),
pluginStore: &fakePluginStore{}, pluginStore: &plugins.FakePluginStore{},
} }
sc.handlerFunc = hs.DeleteDataSourceByName sc.handlerFunc = hs.DeleteDataSourceByName
sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec()

@ -15,7 +15,7 @@ type IndexViewData struct {
GoogleAnalyticsId string GoogleAnalyticsId string
GoogleAnalytics4Id string GoogleAnalytics4Id string
GoogleTagManagerId string GoogleTagManagerId string
NavTree []*navtree.NavLink NavTree *navtree.NavTreeRoot
BuildVersion string BuildVersion string
BuildCommit string BuildCommit string
Theme string Theme string

@ -2,11 +2,8 @@ package api
import ( import (
"context" "context"
"time"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/pluginsettings"
) )
type fakePluginInstaller struct { type fakePluginInstaller struct {
@ -37,34 +34,6 @@ func (pm *fakePluginInstaller) Remove(_ context.Context, pluginID string) error
return nil return nil
} }
type fakePluginStore struct {
plugins.Store
plugins map[string]plugins.PluginDTO
}
func (pr fakePluginStore) Plugin(_ context.Context, pluginID string) (plugins.PluginDTO, bool) {
p, exists := pr.plugins[pluginID]
return p, exists
}
func (pr fakePluginStore) Plugins(_ context.Context, pluginTypes ...plugins.Type) []plugins.PluginDTO {
var result []plugins.PluginDTO
if len(pluginTypes) == 0 {
pluginTypes = plugins.PluginTypes
}
for _, v := range pr.plugins {
for _, t := range pluginTypes {
if v.Type == t {
result = append(result, v)
}
}
}
return result
}
type fakeRendererManager struct { type fakeRendererManager struct {
plugins.RendererManager plugins.RendererManager
} }
@ -82,72 +51,3 @@ type fakePluginStaticRouteResolver struct {
func (psrr *fakePluginStaticRouteResolver) Routes() []*plugins.StaticRoute { func (psrr *fakePluginStaticRouteResolver) Routes() []*plugins.StaticRoute {
return psrr.routes return psrr.routes
} }
type fakePluginSettings struct {
pluginsettings.Service
plugins map[string]*pluginsettings.DTO
}
// GetPluginSettings returns all Plugin Settings for the provided Org
func (ps *fakePluginSettings) GetPluginSettings(_ context.Context, _ *pluginsettings.GetArgs) ([]*pluginsettings.InfoDTO, error) {
res := []*pluginsettings.InfoDTO{}
for _, dto := range ps.plugins {
res = append(res, &pluginsettings.InfoDTO{
PluginID: dto.PluginID,
OrgID: dto.OrgID,
Enabled: dto.Enabled,
Pinned: dto.Pinned,
PluginVersion: dto.PluginVersion,
})
}
return res, nil
}
// GetPluginSettingByPluginID returns a Plugin Settings by Plugin ID
func (ps *fakePluginSettings) GetPluginSettingByPluginID(ctx context.Context, args *pluginsettings.GetByPluginIDArgs) (*pluginsettings.DTO, error) {
if res, ok := ps.plugins[args.PluginID]; ok {
return res, nil
}
return nil, models.ErrPluginSettingNotFound
}
// UpdatePluginSetting updates a Plugin Setting
func (ps *fakePluginSettings) UpdatePluginSetting(ctx context.Context, args *pluginsettings.UpdateArgs) error {
var secureData map[string][]byte
if args.SecureJSONData != nil {
secureData := map[string][]byte{}
for k, v := range args.SecureJSONData {
secureData[k] = ([]byte)(v)
}
}
// save
ps.plugins[args.PluginID] = &pluginsettings.DTO{
ID: int64(len(ps.plugins)),
OrgID: args.OrgID,
PluginID: args.PluginID,
PluginVersion: args.PluginVersion,
JSONData: args.JSONData,
SecureJSONData: secureData,
Enabled: args.Enabled,
Pinned: args.Pinned,
Updated: time.Now(),
}
return nil
}
// UpdatePluginSettingPluginVersion updates a Plugin Setting's plugin version
func (ps *fakePluginSettings) UpdatePluginSettingPluginVersion(ctx context.Context, args *pluginsettings.UpdatePluginVersionArgs) error {
if res, ok := ps.plugins[args.PluginID]; ok {
res.PluginVersion = args.PluginVersion
return nil
}
return models.ErrPluginSettingNotFound
}
// DecryptedValues decrypts the encrypted secureJSONData of the provided plugin setting and
// returns the decrypted values.
func (ps *fakePluginSettings) DecryptedValues(dto *pluginsettings.DTO) map[string]string {
// TODO: Implement
return nil
}

@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/grafana/grafana/pkg/plugins"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/licensing"
@ -51,7 +52,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt.
}, },
SQLStore: sqlStore, SQLStore: sqlStore,
SettingsProvider: setting.ProvideProvider(cfg), SettingsProvider: setting.ProvideProvider(cfg),
pluginStore: &fakePluginStore{}, pluginStore: &plugins.FakePluginStore{},
grafanaUpdateChecker: &updatechecker.GrafanaService{}, grafanaUpdateChecker: &updatechecker.GrafanaService{},
AccessControl: accesscontrolmock.New().WithDisabled(), AccessControl: accesscontrolmock.New().WithDisabled(),
PluginSettings: pluginSettings.ProvideService(sqlStore, secretsService), PluginSettings: pluginSettings.ProvideService(sqlStore, secretsService),

@ -3,7 +3,6 @@ package api
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"sort"
"strings" "strings"
"github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/dtos"
@ -150,9 +149,9 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat
hs.HooksService.RunIndexDataHooks(&data, c) hs.HooksService.RunIndexDataHooks(&data, c)
sort.SliceStable(data.NavTree, func(i, j int) bool { // This will remove empty cfg or admin sections and move sections around if topnav is enabled
return data.NavTree[i].SortWeight < data.NavTree[j].SortWeight data.NavTree.RemoveEmptySectionsAndApplyNewInformationArchitecture(hs.Features.IsEnabled(featuremgmt.FlagTopnav))
}) data.NavTree.Sort()
return &data, nil return &data, nil
} }

@ -44,7 +44,7 @@ func fakeSetIndexViewData(t *testing.T) {
data := &dtos.IndexViewData{ data := &dtos.IndexViewData{
User: &dtos.CurrentUser{}, User: &dtos.CurrentUser{},
Settings: map[string]interface{}{}, Settings: map[string]interface{}{},
NavTree: []*navtree.NavLink{}, NavTree: &navtree.NavTreeRoot{},
} }
return data, nil return data, nil
} }

@ -175,10 +175,8 @@ func Test_GetPluginAssets(t *testing.T) {
requestedFile: {}, requestedFile: {},
}, },
} }
service := &fakePluginStore{ service := &plugins.FakePluginStore{
plugins: map[string]plugins.PluginDTO{ PluginList: []plugins.PluginDTO{p},
pluginID: p,
},
} }
l := &logtest.Fake{} l := &logtest.Fake{}
@ -200,10 +198,8 @@ func Test_GetPluginAssets(t *testing.T) {
}, },
PluginDir: pluginDir, PluginDir: pluginDir,
} }
service := &fakePluginStore{ service := &plugins.FakePluginStore{
plugins: map[string]plugins.PluginDTO{ PluginList: []plugins.PluginDTO{p},
pluginID: p,
},
} }
l := &logtest.Fake{} l := &logtest.Fake{}
@ -223,10 +219,8 @@ func Test_GetPluginAssets(t *testing.T) {
}, },
PluginDir: pluginDir, PluginDir: pluginDir,
} }
service := &fakePluginStore{ service := &plugins.FakePluginStore{
plugins: map[string]plugins.PluginDTO{ PluginList: []plugins.PluginDTO{p},
pluginID: p,
},
} }
l := &logtest.Fake{} l := &logtest.Fake{}
@ -248,10 +242,8 @@ func Test_GetPluginAssets(t *testing.T) {
}, },
PluginDir: pluginDir, PluginDir: pluginDir,
} }
service := &fakePluginStore{ service := &plugins.FakePluginStore{
plugins: map[string]plugins.PluginDTO{ PluginList: []plugins.PluginDTO{p},
pluginID: p,
},
} }
l := &logtest.Fake{} l := &logtest.Fake{}
@ -271,8 +263,8 @@ func Test_GetPluginAssets(t *testing.T) {
}) })
t.Run("Given a request for an non-existing plugin", func(t *testing.T) { t.Run("Given a request for an non-existing plugin", func(t *testing.T) {
service := &fakePluginStore{ service := &plugins.FakePluginStore{
plugins: map[string]plugins.PluginDTO{}, PluginList: []plugins.PluginDTO{},
} }
l := &logtest.Fake{} l := &logtest.Fake{}
@ -292,10 +284,11 @@ func Test_GetPluginAssets(t *testing.T) {
}) })
t.Run("Given a request for a core plugin's file", func(t *testing.T) { t.Run("Given a request for a core plugin's file", func(t *testing.T) {
service := &fakePluginStore{ service := &plugins.FakePluginStore{
plugins: map[string]plugins.PluginDTO{ PluginList: []plugins.PluginDTO{
pluginID: { {
Class: plugins.Core, JSONData: plugins.JSONData{ID: pluginID},
Class: plugins.Core,
}, },
}, },
} }
@ -392,8 +385,8 @@ func (c *fakePluginClient) QueryData(ctx context.Context, req *backend.QueryData
} }
func Test_PluginsList_AccessControl(t *testing.T) { func Test_PluginsList_AccessControl(t *testing.T) {
pluginStore := fakePluginStore{plugins: map[string]plugins.PluginDTO{ pluginStore := plugins.FakePluginStore{PluginList: []plugins.PluginDTO{
"test-app": { {
PluginDir: "/grafana/plugins/test-app/dist", PluginDir: "/grafana/plugins/test-app/dist",
Class: "external", Class: "external",
DefaultNavURL: "/plugins/test-app/page/test", DefaultNavURL: "/plugins/test-app/page/test",
@ -410,7 +403,7 @@ func Test_PluginsList_AccessControl(t *testing.T) {
}, },
}, },
}, },
"mysql": { {
PluginDir: "/grafana/public/app/plugins/datasource/mysql", PluginDir: "/grafana/public/app/plugins/datasource/mysql",
Class: "core", Class: "core",
Pinned: false, Pinned: false,
@ -428,7 +421,8 @@ func Test_PluginsList_AccessControl(t *testing.T) {
}, },
}, },
}} }}
pluginSettings := fakePluginSettings{plugins: map[string]*pluginsettings.DTO{
pluginSettings := pluginsettings.FakePluginSettings{Plugins: map[string]*pluginsettings.DTO{
"test-app": {ID: 0, OrgID: 1, PluginID: "test-app", PluginVersion: "1.0.0", Enabled: true}, "test-app": {ID: 0, OrgID: 1, PluginID: "test-app", PluginVersion: "1.0.0", Enabled: true},
"mysql": {ID: 0, OrgID: 1, PluginID: "mysql", PluginVersion: "", Enabled: true}}, "mysql": {ID: 0, OrgID: 1, PluginID: "mysql", PluginVersion: "", Enabled: true}},
} }

@ -203,31 +203,6 @@ func TestRegisterMetrics(t *testing.T) {
}) })
} }
type fakePluginStore struct {
plugins.Store
plugins map[string]plugins.PluginDTO
}
func (pr fakePluginStore) Plugin(_ context.Context, pluginID string) (plugins.PluginDTO, bool) {
p, exists := pr.plugins[pluginID]
return p, exists
}
func (pr fakePluginStore) Plugins(_ context.Context, pluginTypes ...plugins.Type) []plugins.PluginDTO {
var result []plugins.PluginDTO
for _, v := range pr.plugins {
for _, t := range pluginTypes {
if v.Type == t {
result = append(result, v)
}
}
}
return result
}
type httpResp struct { type httpResp struct {
req *http.Request req *http.Request
responseBuffer *bytes.Buffer responseBuffer *bytes.Buffer
@ -242,7 +217,7 @@ func createService(t *testing.T, cfg setting.Cfg, sqlStore sqlstore.Store, withD
return ProvideService( return ProvideService(
&cfg, &cfg,
&fakePluginStore{}, &plugins.FakePluginStore{},
kvstore.ProvideService(sqlStore), kvstore.ProvideService(sqlStore),
routing.NewRouteRegister(), routing.NewRouteRegister(),
tracing.InitializeTracerForTest(), tracing.InitializeTracerForTest(),

@ -424,52 +424,19 @@ func (m *mockSocial) GetOAuthProviders() map[string]bool {
return m.OAuthProviders return m.OAuthProviders
} }
type fakePluginStore struct {
plugins.Store
plugins map[string]plugins.PluginDTO
}
func (pr fakePluginStore) Plugin(_ context.Context, pluginID string) (plugins.PluginDTO, bool) {
p, exists := pr.plugins[pluginID]
return p, exists
}
func setupSomeDataSourcePlugins(t *testing.T, s *Service) { func setupSomeDataSourcePlugins(t *testing.T, s *Service) {
t.Helper() t.Helper()
s.plugins = &fakePluginStore{ s.plugins = &plugins.FakePluginStore{
plugins: map[string]plugins.PluginDTO{ PluginList: []plugins.PluginDTO{
datasources.DS_ES: { {JSONData: plugins.JSONData{ID: datasources.DS_ES}, Signature: "internal"},
Signature: "internal", {JSONData: plugins.JSONData{ID: datasources.DS_PROMETHEUS}, Signature: "internal"},
}, {JSONData: plugins.JSONData{ID: datasources.DS_GRAPHITE}, Signature: "internal"},
datasources.DS_PROMETHEUS: { {JSONData: plugins.JSONData{ID: datasources.DS_MYSQL}, Signature: "internal"},
Signature: "internal",
},
datasources.DS_GRAPHITE: {
Signature: "internal",
},
datasources.DS_MYSQL: {
Signature: "internal",
},
}, },
} }
} }
func (pr fakePluginStore) Plugins(_ context.Context, pluginTypes ...plugins.Type) []plugins.PluginDTO {
var result []plugins.PluginDTO
for _, v := range pr.plugins {
for _, t := range pluginTypes {
if v.Type == t {
result = append(result, v)
}
}
}
return result
}
func createService(t testing.TB, cfg *setting.Cfg, store sqlstore.Store, opts ...func(*serviceOptions)) *Service { func createService(t testing.TB, cfg *setting.Cfg, store sqlstore.Store, opts ...func(*serviceOptions)) *Service {
t.Helper() t.Helper()
@ -484,7 +451,7 @@ func createService(t testing.TB, cfg *setting.Cfg, store sqlstore.Store, opts ..
cfg, cfg,
store, store,
&mockSocial{}, &mockSocial{},
&fakePluginStore{}, &plugins.FakePluginStore{},
featuremgmt.WithFeatures("feature1", "feature2"), featuremgmt.WithFeatures("feature1", "feature2"),
o.datasources, o.datasources,
httpclient.NewProvider(), httpclient.NewProvider(),

@ -120,7 +120,7 @@ func TestMiddlewareContext(t *testing.T) {
data := &dtos.IndexViewData{ data := &dtos.IndexViewData{
User: &dtos.CurrentUser{}, User: &dtos.CurrentUser{},
Settings: map[string]interface{}{}, Settings: map[string]interface{}{},
NavTree: []*navtree.NavLink{}, NavTree: &navtree.NavTreeRoot{},
} }
t.Log("Calling HTML", "data", data) t.Log("Calling HTML", "data", data)
c.HTML(http.StatusOK, "index-template", data) c.HTML(http.StatusOK, "index-template", data)

@ -0,0 +1,38 @@
package plugins
import (
"context"
)
type FakePluginStore struct {
Store
PluginList []PluginDTO
}
func (pr FakePluginStore) Plugin(_ context.Context, pluginID string) (PluginDTO, bool) {
for _, v := range pr.PluginList {
if v.ID == pluginID {
return v, true
}
}
return PluginDTO{}, false
}
func (pr FakePluginStore) Plugins(_ context.Context, pluginTypes ...Type) []PluginDTO {
var result []PluginDTO
if len(pluginTypes) == 0 {
pluginTypes = PluginTypes
}
for _, v := range pr.PluginList {
for _, t := range pluginTypes {
if v.Type == t {
result = append(result, v)
}
}
}
return result
}

@ -190,10 +190,11 @@ func setupPluginDashboardsForTest(t *testing.T) *FileStoreManager {
t.Helper() t.Helper()
return &FileStoreManager{ return &FileStoreManager{
pluginStore: &fakePluginStore{ pluginStore: &plugins.FakePluginStore{
plugins: map[string]plugins.PluginDTO{ PluginList: []plugins.PluginDTO{
"pluginWithoutDashboards": { {
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "pluginWithoutDashboards",
Includes: []*plugins.Includes{ Includes: []*plugins.Includes{
{ {
Type: "page", Type: "page",
@ -201,9 +202,10 @@ func setupPluginDashboardsForTest(t *testing.T) *FileStoreManager {
}, },
}, },
}, },
"pluginWithDashboards": { {
PluginDir: "plugins/plugin-id", PluginDir: "plugins/plugin-id",
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "pluginWithDashboards",
Includes: []*plugins.Includes{ Includes: []*plugins.Includes{
{ {
Type: "page", Type: "page",
@ -223,20 +225,3 @@ func setupPluginDashboardsForTest(t *testing.T) *FileStoreManager {
}, },
} }
} }
type fakePluginStore struct {
plugins map[string]plugins.PluginDTO
}
func (pr fakePluginStore) Plugin(_ context.Context, pluginID string) (plugins.PluginDTO, bool) {
p, exists := pr.plugins[pluginID]
return p, exists
}
func (pr fakePluginStore) Plugins(_ context.Context, _ ...plugins.Type) []plugins.PluginDTO {
var result []plugins.PluginDTO
for _, v := range pr.plugins {
result = append(result, v)
}
return result
}

@ -162,30 +162,6 @@ func (pc *FakePluginClient) RunStream(_ context.Context, _ *backend.RunStreamReq
return backendplugin.ErrMethodNotImplemented return backendplugin.ErrMethodNotImplemented
} }
type FakePluginStore struct {
Store map[string]plugins.PluginDTO
}
func NewFakePluginStore() *FakePluginStore {
return &FakePluginStore{
Store: make(map[string]plugins.PluginDTO),
}
}
func (f *FakePluginStore) Plugin(_ context.Context, id string) (plugins.PluginDTO, bool) {
p, exists := f.Store[id]
return p, exists
}
func (f *FakePluginStore) Plugins(_ context.Context, _ ...plugins.Type) []plugins.PluginDTO {
var res []plugins.PluginDTO
for _, p := range f.Store {
res = append(res, p)
}
return res
}
type FakePluginRegistry struct { type FakePluginRegistry struct {
Store map[string]*plugins.Plugin Store map[string]*plugins.Plugin
} }

@ -55,16 +55,15 @@ func ProvideService(cfg *setting.Cfg, hooksService *hooks.HooksService) *OSSLice
HooksService: hooksService, HooksService: hooksService,
} }
l.HooksService.AddIndexDataHook(func(indexData *dtos.IndexViewData, req *models.ReqContext) { l.HooksService.AddIndexDataHook(func(indexData *dtos.IndexViewData, req *models.ReqContext) {
for _, node := range indexData.NavTree { if adminNode := indexData.NavTree.FindById(navtree.NavIDAdmin); adminNode != nil {
if node.Id == "admin" { adminNode.Children = append(adminNode.Children, &navtree.NavLink{
node.Children = append(node.Children, &navtree.NavLink{ Text: "Stats and license",
Text: "Stats and license", Id: "upgrading",
Id: "upgrading", Url: l.LicenseURL(req.IsGrafanaAdmin),
Url: l.LicenseURL(req.IsGrafanaAdmin), Icon: "unlock",
Icon: "unlock", })
})
}
} }
}) })
return l return l
} }

@ -1,5 +1,10 @@
package navtree package navtree
import (
"encoding/json"
"sort"
)
const ( const (
// These weights may be used by an extension to reliably place // These weights may be used by an extension to reliably place
// itself in relation to a particular item in the menu. The weights // itself in relation to a particular item in the menu. The weights
@ -25,6 +30,17 @@ const (
NavSectionConfig string = "config" 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 { type NavLink struct {
Id string `json:"id,omitempty"` Id string `json:"id,omitempty"`
Text string `json:"text"` Text string `json:"text"`
@ -47,24 +63,126 @@ type NavLink struct {
EmptyMessageId string `json:"emptyMessageId,omitempty"` EmptyMessageId string `json:"emptyMessageId,omitempty"`
} }
// NavIDCfg is the id for org configuration navigation node func (node *NavLink) Sort() {
const NavIDCfg = "cfg" Sort(node.Children)
}
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)
}
}
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
}
func GetServerAdminNode(children []*NavLink) *NavLink { return iw < jw
url := "" })
if len(children) > 0 {
url = children[0].Url for _, child := range nodes {
child.Sort()
} }
return &NavLink{ }
Text: "Server admin",
SubTitle: "Manage all users and orgs", func FindById(nodes []*NavLink, id string) *NavLink {
Description: "Manage server-wide settings and access to resources such as organizations, users, and licenses", for _, child := range nodes {
HideFromTabs: true, if child.Id == id {
Id: "admin", return child
Icon: "shield", } else if len(child.Children) > 0 {
Url: url, if found := FindById(child.Children, id); found != nil {
SortWeight: WeightAdmin, return found
Section: NavSectionConfig, }
Children: children, }
} }
return nil
} }

@ -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)
})
}

@ -6,5 +6,5 @@ import (
) )
type Service interface { 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)
} }

@ -12,7 +12,7 @@ import (
"github.com/grafana/grafana/pkg/services/serviceaccounts" "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 var configNodes []*navtree.NavLink
hasAccess := ac.HasAccess(s.accessControl, c) 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", 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 { func (s *ServiceImpl) ReqCanAdminTeams(c *models.ReqContext) bool {

@ -3,6 +3,7 @@ package navtreeimpl
import ( import (
"path" "path"
"sort" "sort"
"strconv"
"github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins"
@ -10,15 +11,17 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/navtree" "github.com/grafana/grafana/pkg/services/navtree"
"github.com/grafana/grafana/pkg/services/pluginsettings" "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) hasAccess := ac.HasAccess(s.accessControl, c)
appLinks := []*navtree.NavLink{} appLinks := []*navtree.NavLink{}
pss, err := s.pluginSettings.GetPluginSettings(c.Req.Context(), &pluginsettings.GetArgs{OrgID: c.OrgID}) pss, err := s.pluginSettings.GetPluginSettings(c.Req.Context(), &pluginsettings.GetArgs{OrgID: c.OrgID})
if err != nil { if err != nil {
return nil, err return err
} }
isPluginEnabled := func(plugin plugins.PluginDTO) bool { isPluginEnabled := func(plugin plugins.PluginDTO) bool {
@ -43,71 +46,168 @@ func (s *ServiceImpl) getAppLinks(c *models.ReqContext) ([]*navtree.NavLink, err
continue continue
} }
appLink := &navtree.NavLink{ if appNode := s.processAppPlugin(plugin, c, topNavEnabled, treeRoot); appNode != nil {
Text: plugin.Name, appLinks = append(appLinks, appNode)
Id: "plugin-page-" + plugin.ID,
Img: plugin.Info.Logos.Small,
Section: navtree.NavSectionPlugin,
SortWeight: navtree.WeightPlugin,
} }
}
if s.features.IsEnabled(featuremgmt.FlagTopnav) { if len(appLinks) > 0 {
appLink.Url = s.cfg.AppSubURL + "/a/" + plugin.ID sort.SliceStable(appLinks, func(i, j int) bool {
} else { return appLinks[i].Text < appLinks[j].Text
appLink.Url = path.Join(s.cfg.AppSubURL, plugin.DefaultNavURL) })
}
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
} }
for _, include := range plugin.Includes { if include.Type == "page" && include.AddToNav {
if !c.HasUserRole(include.Role) { link := &navtree.NavLink{
continue Text: include.Name,
Icon: include.Icon,
} }
if include.Type == "page" && include.AddToNav { if len(include.Path) > 0 {
var link *navtree.NavLink link.Url = s.cfg.AppSubURL + include.Path
if len(include.Path) > 0 { if include.DefaultNav {
link = &navtree.NavLink{ appLink.Url = link.Url
Url: s.cfg.AppSubURL + include.Path, }
Text: include.Name, } else {
} link.Url = s.cfg.AppSubURL + "/plugins/" + plugin.ID + "/page/" + include.Slug
if include.DefaultNav && !s.features.IsEnabled(featuremgmt.FlagTopnav) { }
appLink.Url = link.Url // Overwrite the hardcoded page logic
} if pathConfig, ok := s.navigationAppPathConfig[include.Path]; ok {
} else { if sectionForPage := treeRoot.FindById(pathConfig.SectionID); sectionForPage != nil {
link = &navtree.NavLink{ link.Id = "standalone-plugin-page-" + include.Path
Url: s.cfg.AppSubURL + "/plugins/" + plugin.ID + "/page/" + include.Slug, link.SortWeight = pathConfig.SortWeight
Text: include.Name, sectionForPage.Children = append(sectionForPage.Children, link)
}
} }
link.Icon = include.Icon } else {
appLink.Children = append(appLink.Children, link) appLink.Children = append(appLink.Children, link)
} }
}
if include.Type == "dashboard" && include.AddToNav { if include.Type == "dashboard" && include.AddToNav {
dboardURL := include.DashboardURLPath() dboardURL := include.DashboardURLPath()
if dboardURL != "" { if dboardURL != "" {
link := &navtree.NavLink{ link := &navtree.NavLink{
Url: path.Join(s.cfg.AppSubURL, dboardURL), Url: path.Join(s.cfg.AppSubURL, dboardURL),
Text: include.Name, Text: include.Name,
}
appLink.Children = append(appLink.Children, link)
} }
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 len(appLink.Children) > 0 { if navNode := treeRoot.FindById(navConfig.SectionID); navNode != nil {
// If we only have one child and it's the app default nav then remove it from children navNode.Children = append(navNode.Children, appLink)
if len(appLink.Children) == 1 && appLink.Children[0].Url == appLink.Url { } else {
appLink.Children = []*navtree.NavLink{} 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)
}
} }
appLinks = append(appLinks, appLink) } else {
return appLink
} }
} }
if len(appLinks) > 0 { return nil
sort.SliceStable(appLinks, func(i, j int) bool { }
return appLinks[i].Text < appLinks[j].Text
}) 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},
} }
return appLinks, nil 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)
})
}

@ -33,10 +33,19 @@ type ServiceImpl struct {
accesscontrolService ac.Service accesscontrolService ac.Service
kvStore kvstore.KVStore kvStore kvstore.KVStore
apiKeyService apikey.Service 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 { 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, cfg: cfg,
log: log.New("navtree service"), log: log.New("navtree service"),
accessControl: accessControl, accessControl: accessControl,
@ -49,12 +58,16 @@ func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStor
kvStore: kvStore, kvStore: kvStore,
apiKeyService: apiKeyService, apiKeyService: apiKeyService,
} }
service.readNavigationSettings()
return service
} }
//nolint:gocyclo //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) hasAccess := ac.HasAccess(s.accessControl, c)
var navTree []*navtree.NavLink treeRoot := &navtree.NavTreeRoot{}
if hasAccess(ac.ReqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsRead)) { if hasAccess(ac.ReqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsRead)) {
starredItemsLinks, err := s.buildStarredItemsNavLinks(c, prefs) starredItemsLinks, err := s.buildStarredItemsNavLinks(c, prefs)
@ -62,7 +75,7 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
return nil, err return nil, err
} }
navTree = append(navTree, &navtree.NavLink{ treeRoot.AddSection(&navtree.NavLink{
Text: "Starred", Text: "Starred",
Id: "starred", Id: "starred",
Icon: "star", Icon: "star",
@ -74,25 +87,19 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
dashboardChildLinks := s.buildDashboardNavLinks(c, hasEditPerm) dashboardChildLinks := s.buildDashboardNavLinks(c, hasEditPerm)
dashboardsUrl := "/dashboards"
dashboardLink := &navtree.NavLink{ dashboardLink := &navtree.NavLink{
Text: "Dashboards", Text: "Dashboards",
Id: "dashboards", Id: navtree.NavIDDashboards,
Description: "Create and manage dashboards to visualize your data", Description: "Create and manage dashboards to visualize your data",
SubTitle: "Manage dashboards and folders", SubTitle: "Manage dashboards and folders",
Icon: "apps", Icon: "apps",
Url: s.cfg.AppSubURL + dashboardsUrl, Url: s.cfg.AppSubURL + "/dashboards",
SortWeight: navtree.WeightDashboard, SortWeight: navtree.WeightDashboard,
Section: navtree.NavSectionCore, Section: navtree.NavSectionCore,
Children: dashboardChildLinks, Children: dashboardChildLinks,
} }
if s.features.IsEnabled(featuremgmt.FlagTopnav) { treeRoot.AddSection(dashboardLink)
dashboardLink.Id = "dashboards/browse"
}
navTree = append(navTree, dashboardLink)
} }
canExplore := func(context *models.ReqContext) bool { 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)) { if setting.ExploreEnabled && hasAccess(canExplore, ac.EvalPermission(ac.ActionDatasourcesExplore)) {
navTree = append(navTree, &navtree.NavLink{ treeRoot.AddSection(&navtree.NavLink{
Text: "Explore", Text: "Explore",
Id: "explore", Id: "explore",
SubTitle: "Explore your data", 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] _, uaIsDisabledForOrg := s.cfg.UnifiedAlerting.DisabledOrgs[c.OrgID]
uaVisibleForOrg := s.cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg uaVisibleForOrg := s.cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg
if setting.AlertingEnabled != nil && *setting.AlertingEnabled { 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 { } 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) { if s.features.IsEnabled(featuremgmt.FlagDataConnectionsConsole) {
navTree = append(navTree, s.buildDataConnectionsNavLink(c)) treeRoot.AddSection(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
} }
if s.features.IsEnabled(featuremgmt.FlagLivePipeline) { 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{ liveNavLinks = append(liveNavLinks, &navtree.NavLink{
Text: "Cloud", Id: "live-cloud", Url: s.cfg.AppSubURL + "/live/cloud", Icon: "cloud-upload", 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", Id: "live",
Text: "Live", Text: "Live",
SubTitle: "Event streaming", SubTitle: "Event streaming",
@ -175,60 +164,37 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
}) })
} }
var configNode *navtree.NavLink orgAdminNode, err := s.getOrgAdminNode(c)
var serverAdminNode *navtree.NavLink
if len(configNodes) > 0 { if orgAdminNode != nil {
configNode = &navtree.NavLink{ treeRoot.AddSection(orgAdminNode)
Id: navtree.NavIDCfg, } else if err != nil {
Text: "Configuration", return nil, err
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)
} }
adminNavLinks := s.buildAdminNavLinks(c) serverAdminNode := s.getServerAdminNode(c)
if len(adminNavLinks) > 0 { if serverAdminNode != nil {
serverAdminNode = navtree.GetServerAdminNode(adminNavLinks) treeRoot.AddSection(serverAdminNode)
navTree = append(navTree, serverAdminNode)
} }
if s.features.IsEnabled(featuremgmt.FlagTopnav) { s.addHelpLinks(treeRoot, c)
// 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]
}
}
navTree = s.addHelpLinks(navTree, c) if err := s.addAppLinks(treeRoot, c); err != nil {
return nil, err
}
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 { if setting.HelpEnabled {
helpVersion := fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit) helpVersion := fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit)
if s.cfg.AnonymousHideVersion && !c.IsSignedIn { if s.cfg.AnonymousHideVersion && !c.IsSignedIn {
helpVersion = setting.ApplicationName helpVersion = setting.ApplicationName
} }
navTree = append(navTree, &navtree.NavLink{ treeRoot.AddSection(&navtree.NavLink{
Text: "Help", Text: "Help",
SubTitle: helpVersion, SubTitle: helpVersion,
Id: "help", Id: "help",
@ -239,14 +205,6 @@ func (s *ServiceImpl) addHelpLinks(navTree []*navtree.NavLink, c *models.ReqCont
Children: []*navtree.NavLink{}, 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 { 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{} dashboardChildNavs := []*navtree.NavLink{}
if !s.features.IsEnabled(featuremgmt.FlagTopnav) { if !s.features.IsEnabled(featuremgmt.FlagTopnav) {
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ 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{ 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", 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{ dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
}) })
@ -413,10 +373,11 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
}) })
} }
} }
return dashboardChildNavs return dashboardChildNavs
} }
func (s *ServiceImpl) buildLegacyAlertNavLinks(c *models.ReqContext) []*navtree.NavLink { func (s *ServiceImpl) buildLegacyAlertNavLinks(c *models.ReqContext) *navtree.NavLink {
var alertChildNavs []*navtree.NavLink var alertChildNavs []*navtree.NavLink
alertChildNavs = append(alertChildNavs, &navtree.NavLink{ alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert rules", Id: "alert-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul", 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" 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) hasAccess := ac.HasAccess(s.accessControl, c)
var alertChildNavs []*navtree.NavLink var alertChildNavs []*navtree.NavLink
@ -497,7 +458,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool)
Text: "Alerting", Text: "Alerting",
Description: "Learn about problems in your systems moments after they occur", Description: "Learn about problems in your systems moments after they occur",
SubTitle: "Alert rules and notifications", SubTitle: "Alert rules and notifications",
Id: "alerting", Id: navtree.NavIDAlerting,
Icon: "bell", Icon: "bell",
Children: alertChildNavs, Children: alertChildNavs,
Section: navtree.NavSectionCore, Section: navtree.NavSectionCore,
@ -510,8 +471,9 @@ func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool)
alertNav.Url = s.cfg.AppSubURL + "/alerting/list" alertNav.Url = s.cfg.AppSubURL + "/alerting/list"
} }
return []*navtree.NavLink{&alertNav} return &alertNav
} }
return nil return nil
} }
@ -558,46 +520,3 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *models.ReqContext) *navtree
return navLink 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
}

@ -0,0 +1,77 @@
package pluginsettings
import (
"context"
"time"
"github.com/grafana/grafana/pkg/models"
)
type FakePluginSettings struct {
Service
Plugins map[string]*DTO
}
// GetPluginSettings returns all Plugin Settings for the provided Org
func (ps *FakePluginSettings) GetPluginSettings(_ context.Context, _ *GetArgs) ([]*InfoDTO, error) {
res := []*InfoDTO{}
for _, dto := range ps.Plugins {
res = append(res, &InfoDTO{
PluginID: dto.PluginID,
OrgID: dto.OrgID,
Enabled: dto.Enabled,
Pinned: dto.Pinned,
PluginVersion: dto.PluginVersion,
})
}
return res, nil
}
// GetPluginSettingByPluginID returns a Plugin Settings by Plugin ID
func (ps *FakePluginSettings) GetPluginSettingByPluginID(ctx context.Context, args *GetByPluginIDArgs) (*DTO, error) {
if res, ok := ps.Plugins[args.PluginID]; ok {
return res, nil
}
return nil, models.ErrPluginSettingNotFound
}
// UpdatePluginSetting updates a Plugin Setting
func (ps *FakePluginSettings) UpdatePluginSetting(ctx context.Context, args *UpdateArgs) error {
var secureData map[string][]byte
if args.SecureJSONData != nil {
secureData := map[string][]byte{}
for k, v := range args.SecureJSONData {
secureData[k] = ([]byte)(v)
}
}
// save
ps.Plugins[args.PluginID] = &DTO{
ID: int64(len(ps.Plugins)),
OrgID: args.OrgID,
PluginID: args.PluginID,
PluginVersion: args.PluginVersion,
JSONData: args.JSONData,
SecureJSONData: secureData,
Enabled: args.Enabled,
Pinned: args.Pinned,
Updated: time.Now(),
}
return nil
}
// UpdatePluginSettingPluginVersion updates a Plugin Setting's plugin version
func (ps *FakePluginSettings) UpdatePluginSettingPluginVersion(ctx context.Context, args *UpdatePluginVersionArgs) error {
if res, ok := ps.Plugins[args.PluginID]; ok {
res.PluginVersion = args.PluginVersion
return nil
}
return models.ErrPluginSettingNotFound
}
// DecryptedValues decrypts the encrypted secureJSONData of the provided plugin setting and
// returns the decrypted values.
func (ps *FakePluginSettings) DecryptedValues(dto *DTO) map[string]string {
// TODO: Implement
return nil
}

@ -33,7 +33,7 @@ func TestConfigReader(t *testing.T) {
}) })
t.Run("Unknown app plugin should return error", func(t *testing.T) { t.Run("Unknown app plugin should return error", func(t *testing.T) {
cfgProvider := newConfigReader(log.New("test logger"), fakePluginStore{}) cfgProvider := newConfigReader(log.New("test logger"), plugins.FakePluginStore{})
_, err := cfgProvider.readConfig(context.Background(), unknownApp) _, err := cfgProvider.readConfig(context.Background(), unknownApp)
require.Error(t, err) require.Error(t, err)
require.Equal(t, "plugin not installed: \"nonexisting\"", err.Error()) require.Equal(t, "plugin not installed: \"nonexisting\"", err.Error())
@ -47,10 +47,10 @@ func TestConfigReader(t *testing.T) {
}) })
t.Run("Can read correct properties", func(t *testing.T) { t.Run("Can read correct properties", func(t *testing.T) {
pm := fakePluginStore{ pm := plugins.FakePluginStore{
apps: map[string]plugins.PluginDTO{ PluginList: []plugins.PluginDTO{
"test-plugin": {}, {JSONData: plugins.JSONData{ID: "test-plugin"}},
"test-plugin-2": {}, {JSONData: plugins.JSONData{ID: "test-plugin-2"}},
}, },
} }
@ -87,15 +87,3 @@ func TestConfigReader(t *testing.T) {
} }
}) })
} }
type fakePluginStore struct {
plugins.Store
apps map[string]plugins.PluginDTO
}
func (pr fakePluginStore) Plugin(_ context.Context, pluginID string) (plugins.PluginDTO, bool) {
p, exists := pr.apps[pluginID]
return p, exists
}

@ -19,10 +19,11 @@ func TestPluginUpdateChecker_HasUpdate(t *testing.T) {
availableUpdates: map[string]string{ availableUpdates: map[string]string{
"test-ds": "1.0.0", "test-ds": "1.0.0",
}, },
pluginStore: fakePluginStore{ pluginStore: plugins.FakePluginStore{
plugins: map[string]plugins.PluginDTO{ PluginList: []plugins.PluginDTO{
"test-ds": { {
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "test-ds",
Info: plugins.Info{Version: "0.9.0"}, Info: plugins.Info{Version: "0.9.0"},
}, },
}, },
@ -41,20 +42,23 @@ func TestPluginUpdateChecker_HasUpdate(t *testing.T) {
"test-panel": "0.9.0", "test-panel": "0.9.0",
"test-app": "0.0.1", "test-app": "0.0.1",
}, },
pluginStore: fakePluginStore{ pluginStore: plugins.FakePluginStore{
plugins: map[string]plugins.PluginDTO{ PluginList: []plugins.PluginDTO{
"test-ds": { {
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "test-ds",
Info: plugins.Info{Version: "0.9.0"}, Info: plugins.Info{Version: "0.9.0"},
}, },
}, },
"test-panel": { {
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "test-panel",
Info: plugins.Info{Version: "0.9.0"}, Info: plugins.Info{Version: "0.9.0"},
}, },
}, },
"test-app": { {
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "test-app",
Info: plugins.Info{Version: "0.9.0"}, Info: plugins.Info{Version: "0.9.0"},
}, },
}, },
@ -80,10 +84,11 @@ func TestPluginUpdateChecker_HasUpdate(t *testing.T) {
availableUpdates: map[string]string{ availableUpdates: map[string]string{
"test-panel": "0.9.0", "test-panel": "0.9.0",
}, },
pluginStore: fakePluginStore{ pluginStore: plugins.FakePluginStore{
plugins: map[string]plugins.PluginDTO{ PluginList: []plugins.PluginDTO{
"test-ds": { {
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "test-ds",
Info: plugins.Info{Version: "1.0.0"}, Info: plugins.Info{Version: "1.0.0"},
}, },
}, },
@ -122,31 +127,35 @@ func TestPluginUpdateChecker_checkForUpdates(t *testing.T) {
availableUpdates: map[string]string{ availableUpdates: map[string]string{
"test-app": "1.0.0", "test-app": "1.0.0",
}, },
pluginStore: fakePluginStore{ pluginStore: plugins.FakePluginStore{
plugins: map[string]plugins.PluginDTO{ PluginList: []plugins.PluginDTO{
"test-ds": { {
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "test-ds", ID: "test-ds",
Info: plugins.Info{Version: "0.9.0"}, Info: plugins.Info{Version: "0.9.0"},
Type: plugins.DataSource,
}, },
}, },
"test-app": { {
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "test-app", ID: "test-app",
Info: plugins.Info{Version: "0.5.0"}, Info: plugins.Info{Version: "0.5.0"},
Type: plugins.App,
}, },
}, },
"test-panel": { {
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "test-panel", ID: "test-panel",
Info: plugins.Info{Version: "2.5.7"}, Info: plugins.Info{Version: "2.5.7"},
Type: plugins.Panel,
}, },
}, },
"test-core-panel": { {
Class: plugins.Core, Class: plugins.Core,
JSONData: plugins.JSONData{ JSONData: plugins.JSONData{
ID: "test-core-panel", ID: "test-core-panel",
Info: plugins.Info{Version: "0.0.1"}, Info: plugins.Info{Version: "0.0.1"},
Type: plugins.Panel,
}, },
}, },
}, },
@ -195,23 +204,3 @@ func (c *fakeHTTPClient) Get(url string) (*http.Response, error) {
return resp, nil return resp, nil
} }
type fakePluginStore struct {
plugins.Store
plugins map[string]plugins.PluginDTO
}
func (pr fakePluginStore) Plugin(_ context.Context, pluginID string) (plugins.PluginDTO, bool) {
p, exists := pr.plugins[pluginID]
return p, exists
}
func (pr fakePluginStore) Plugins(_ context.Context, _ ...plugins.Type) []plugins.PluginDTO {
var result []plugins.PluginDTO
for _, p := range pr.plugins {
result = append(result, p)
}
return result
}

@ -226,12 +226,13 @@ type Cfg struct {
Packaging string Packaging string
// Paths // Paths
HomePath string HomePath string
ProvisioningPath string ProvisioningPath string
DataPath string DataPath string
LogsPath string LogsPath string
PluginsPath string PluginsPath string
BundledPluginsPath string BundledPluginsPath string
EnterpriseLicensePath string
// SMTP email settings // SMTP email settings
Smtp SmtpSettings Smtp SmtpSettings
@ -263,7 +264,9 @@ type Cfg struct {
CSPTemplate string CSPTemplate string
AngularSupportEnabled bool AngularSupportEnabled bool
TempDataLifetime time.Duration TempDataLifetime time.Duration
// Plugins
PluginsEnableAlpha bool PluginsEnableAlpha bool
PluginsAppsSkipVerifyTLS bool PluginsAppsSkipVerifyTLS bool
PluginSettings PluginSettings PluginSettings PluginSettings
@ -272,8 +275,9 @@ type Cfg struct {
PluginCatalogHiddenPlugins []string PluginCatalogHiddenPlugins []string
PluginAdminEnabled bool PluginAdminEnabled bool
PluginAdminExternalManageEnabled bool PluginAdminExternalManageEnabled bool
DisableSanitizeHtml bool
EnterpriseLicensePath string // Panels
DisableSanitizeHtml bool
// Metrics // Metrics
MetricsEndpointEnabled bool MetricsEndpointEnabled bool

@ -29,19 +29,23 @@ func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error {
cfg.PluginsEnableAlpha = pluginsSection.Key("enable_alpha").MustBool(false) cfg.PluginsEnableAlpha = pluginsSection.Key("enable_alpha").MustBool(false)
cfg.PluginsAppsSkipVerifyTLS = pluginsSection.Key("app_tls_skip_verify_insecure").MustBool(false) cfg.PluginsAppsSkipVerifyTLS = pluginsSection.Key("app_tls_skip_verify_insecure").MustBool(false)
cfg.PluginSettings = extractPluginSettings(iniFile.Sections()) cfg.PluginSettings = extractPluginSettings(iniFile.Sections())
pluginsAllowUnsigned := pluginsSection.Key("allow_loading_unsigned_plugins").MustString("") pluginsAllowUnsigned := pluginsSection.Key("allow_loading_unsigned_plugins").MustString("")
for _, plug := range strings.Split(pluginsAllowUnsigned, ",") { for _, plug := range strings.Split(pluginsAllowUnsigned, ",") {
plug = strings.TrimSpace(plug) plug = strings.TrimSpace(plug)
cfg.PluginsAllowUnsigned = append(cfg.PluginsAllowUnsigned, plug) cfg.PluginsAllowUnsigned = append(cfg.PluginsAllowUnsigned, plug)
} }
cfg.PluginCatalogURL = pluginsSection.Key("plugin_catalog_url").MustString("https://grafana.com/grafana/plugins/") cfg.PluginCatalogURL = pluginsSection.Key("plugin_catalog_url").MustString("https://grafana.com/grafana/plugins/")
cfg.PluginAdminEnabled = pluginsSection.Key("plugin_admin_enabled").MustBool(true) cfg.PluginAdminEnabled = pluginsSection.Key("plugin_admin_enabled").MustBool(true)
cfg.PluginAdminExternalManageEnabled = pluginsSection.Key("plugin_admin_external_manage_enabled").MustBool(false) cfg.PluginAdminExternalManageEnabled = pluginsSection.Key("plugin_admin_external_manage_enabled").MustBool(false)
catalogHiddenPlugins := pluginsSection.Key("plugin_catalog_hidden_plugins").MustString("") catalogHiddenPlugins := pluginsSection.Key("plugin_catalog_hidden_plugins").MustString("")
for _, plug := range strings.Split(catalogHiddenPlugins, ",") { for _, plug := range strings.Split(catalogHiddenPlugins, ",") {
plug = strings.TrimSpace(plug) plug = strings.TrimSpace(plug)
cfg.PluginCatalogHiddenPlugins = append(cfg.PluginCatalogHiddenPlugins, plug) cfg.PluginCatalogHiddenPlugins = append(cfg.PluginCatalogHiddenPlugins, plug)
} }
return nil return nil
} }

@ -39,7 +39,9 @@ export function AppRootPage({ match, queryParams, location }: Props) {
const portalNode = useMemo(() => createHtmlPortalNode(), []); const portalNode = useMemo(() => createHtmlPortalNode(), []);
const { plugin, loading, pluginNav } = state; const { plugin, loading, pluginNav } = state;
const sectionNav = useSelector( const sectionNav = useSelector(
createSelector(getNavIndex, (navIndex) => buildPluginSectionNav(location, pluginNav, navIndex)) createSelector(getNavIndex, (navIndex) =>
buildPluginSectionNav(location, pluginNav, navIndex, match.params.pluginId)
)
); );
const context = useMemo(() => buildPluginPageContext(sectionNav), [sectionNav]); const context = useMemo(() => buildPluginPageContext(sectionNav), [sectionNav]);
@ -53,12 +55,12 @@ export function AppRootPage({ match, queryParams, location }: Props) {
); );
if (!plugin || match.params.pluginId !== plugin.meta.id) { if (!plugin || match.params.pluginId !== plugin.meta.id) {
return <Page {...getLoadingPageProps()}>{loading && <PageLoader />}</Page>; return <Page {...getLoadingPageProps(sectionNav)}>{loading && <PageLoader />}</Page>;
} }
if (!plugin.root) { if (!plugin.root) {
return ( return (
<Page navModel={getWarningNav('Plugin load error')}> <Page navModel={sectionNav ?? getWarningNav('Plugin load error')}>
<div>No root app page component found</div>; <div>No root app page component found</div>;
</Page> </Page>
); );
@ -120,9 +122,9 @@ const stateSlice = createSlice({
}, },
}); });
function getLoadingPageProps(): Partial<PageProps> { function getLoadingPageProps(sectionNav: NavModel | null): Partial<PageProps> {
if (config.featureToggles.topnav) { if (config.featureToggles.topnav && sectionNav) {
return { navId: 'apps' }; return { navModel: sectionNav };
} }
const loading = { text: 'Loading plugin' }; const loading = { text: 'Loading plugin' };

@ -1,41 +1,64 @@
import { Location as HistoryLocation } from 'history'; import { Location as HistoryLocation } from 'history';
import { NavIndex, NavModelItem } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { buildPluginSectionNav } from './utils'; import { buildPluginSectionNav } from './utils';
describe('buildPluginSectionNav', () => { describe('buildPluginSectionNav', () => {
const pluginNav = { main: { text: 'Plugin nav' }, node: { text: 'Plugin nav' } }; const pluginNav = { main: { text: 'Plugin nav' }, node: { text: 'Plugin nav' } };
const appsSection = { const app1: NavModelItem = {
text: 'apps', text: 'App1',
id: 'apps', id: 'plugin-page-app1',
children: [ children: [
{ {
text: 'App1', text: 'page1',
children: [ url: '/a/plugin1/page1',
{ },
text: 'page1', {
url: '/a/plugin1/page1', text: 'page2',
}, url: '/a/plugin1/page2',
{
text: 'page2',
url: '/a/plugin1/page2',
},
],
}, },
], ],
}; };
const navIndex = { apps: appsSection };
const appsSection = {
text: 'apps',
id: 'apps',
children: [app1],
};
const adminSection: NavModelItem = {
text: 'Admin',
id: 'admin',
children: [],
};
const standalonePluginPage = {
id: 'standalone-plugin-page-/a/app2/config',
text: 'Standalone page',
parentItem: adminSection,
};
adminSection.children = [standalonePluginPage];
app1.parentItem = appsSection;
const navIndex: NavIndex = {
apps: appsSection,
[app1.id!]: appsSection.children[0],
[standalonePluginPage.id]: standalonePluginPage,
};
it('Should return pluginNav if topnav is disabled', () => { it('Should return pluginNav if topnav is disabled', () => {
config.featureToggles.topnav = false; config.featureToggles.topnav = false;
const result = buildPluginSectionNav({} as HistoryLocation, pluginNav, {}); const result = buildPluginSectionNav({} as HistoryLocation, pluginNav, {}, 'app1');
expect(result).toBe(pluginNav); expect(result).toBe(pluginNav);
}); });
it('Should return return section nav if topnav is enabled', () => { it('Should return return section nav if topnav is enabled', () => {
config.featureToggles.topnav = true; config.featureToggles.topnav = true;
const result = buildPluginSectionNav({} as HistoryLocation, pluginNav, navIndex); const result = buildPluginSectionNav({} as HistoryLocation, pluginNav, navIndex, 'app1');
expect(result?.main.text).toBe('apps'); expect(result?.main.text).toBe('apps');
}); });
@ -44,9 +67,39 @@ describe('buildPluginSectionNav', () => {
const result = buildPluginSectionNav( const result = buildPluginSectionNav(
{ pathname: '/a/plugin1/page2', search: '' } as HistoryLocation, { pathname: '/a/plugin1/page2', search: '' } as HistoryLocation,
null, null,
navIndex navIndex,
'app1'
); );
expect(result?.main.children![0].children![1].active).toBe(true); expect(result?.main.children![0].children![1].active).toBe(true);
expect(result?.node.text).toBe('page2'); expect(result?.node.text).toBe('page2');
}); });
it('Should handle standalone page', () => {
config.featureToggles.topnav = true;
const result = buildPluginSectionNav(
{ pathname: '/a/app2/config', search: '' } as HistoryLocation,
pluginNav,
navIndex,
'app2'
);
expect(result?.main.text).toBe('Admin');
expect(result?.node.text).toBe('Standalone page');
});
it('Should throw error if app not found in navtree', () => {
config.featureToggles.topnav = true;
const action = () => {
buildPluginSectionNav({} as HistoryLocation, pluginNav, navIndex, 'app3');
};
expect(action).toThrowError();
});
it('Should throw error if app has no section', () => {
config.featureToggles.topnav = true;
app1.parentItem = undefined;
const action = () => {
buildPluginSectionNav({} as HistoryLocation, pluginNav, navIndex, 'app1');
};
expect(action).toThrowError();
});
}); });

@ -2,7 +2,6 @@ import { Location as HistoryLocation } from 'history';
import { GrafanaPlugin, NavIndex, NavModel, NavModelItem, PanelPluginMeta, PluginType } from '@grafana/data'; import { GrafanaPlugin, NavIndex, NavModel, NavModelItem, PanelPluginMeta, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { getNavModel } from 'app/core/selectors/navModel';
import { importPanelPluginFromMeta } from './importPanelPlugin'; import { importPanelPluginFromMeta } from './importPanelPlugin';
import { getPluginSettings } from './pluginSettings'; import { getPluginSettings } from './pluginSettings';
@ -33,20 +32,24 @@ export async function loadPlugin(pluginId: string): Promise<GrafanaPlugin> {
return result; return result;
} }
export function buildPluginSectionNav(location: HistoryLocation, pluginNav: NavModel | null, navIndex: NavIndex) { export function buildPluginSectionNav(
location: HistoryLocation,
pluginNav: NavModel | null,
navIndex: NavIndex,
pluginId: string
) {
// When topnav is disabled we only just show pluginNav like before // When topnav is disabled we only just show pluginNav like before
if (!config.featureToggles.topnav) { if (!config.featureToggles.topnav) {
return pluginNav; return pluginNav;
} }
const originalSection = getNavModel(navIndex, 'apps').main; const section = { ...getPluginSection(location, navIndex, pluginId) };
const section = { ...originalSection };
// If we have plugin nav don't set active page in section as it will cause double breadcrumbs // If we have plugin nav don't set active page in section as it will cause double breadcrumbs
const currentUrl = config.appSubUrl + location.pathname + location.search; const currentUrl = config.appSubUrl + location.pathname + location.search;
let activePage: NavModelItem | undefined; let activePage: NavModelItem | undefined;
// Set active page // Find and set active page
section.children = (section?.children ?? []).map((child) => { section.children = (section?.children ?? []).map((child) => {
if (child.children) { if (child.children) {
return { return {
@ -62,9 +65,39 @@ export function buildPluginSectionNav(location: HistoryLocation, pluginNav: NavM
return pluginPage; return pluginPage;
}), }),
}; };
} else {
if (currentUrl.startsWith(child.url ?? '')) {
activePage = {
...child,
active: true,
};
return activePage;
}
} }
return child; return child;
}); });
return { main: section, node: activePage ?? section }; return { main: section, node: activePage ?? section };
} }
// TODO make work for sub pages
export function getPluginSection(location: HistoryLocation, navIndex: NavIndex, pluginId: string): NavModelItem {
// First check if this page exist in navIndex using path, some plugin pages are not under their own section
const byPath = navIndex[`standalone-plugin-page-${location.pathname}`];
if (byPath) {
const parent = byPath.parentItem!;
// in case the standalone page is in nested section
return parent.parentItem ?? parent;
}
const navTreeNodeForPlugin = navIndex[`plugin-page-${pluginId}`];
if (!navTreeNodeForPlugin) {
throw new Error('Plugin not found in navigation tree');
}
if (!navTreeNodeForPlugin.parentItem) {
throw new Error('Could not find plugin section');
}
return navTreeNodeForPlugin.parentItem;
}

@ -21,8 +21,6 @@ import { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamic
import { RouteDescriptor } from '../core/navigation/types'; import { RouteDescriptor } from '../core/navigation/types';
import { getPublicDashboardRoutes } from '../features/dashboard/routes'; import { getPublicDashboardRoutes } from '../features/dashboard/routes';
import { pluginHasRootPage } from './utils';
export const extraRoutes: RouteDescriptor[] = []; export const extraRoutes: RouteDescriptor[] = [];
export function getAppRoutes(): RouteDescriptor[] { export function getAppRoutes(): RouteDescriptor[] {
@ -32,21 +30,20 @@ export function getAppRoutes(): RouteDescriptor[] {
path: '/apps', path: '/apps',
component: () => <NavLandingPage navId="apps" />, component: () => <NavLandingPage navId="apps" />,
}, },
{
path: '/alerts-and-incidents',
component: () => <NavLandingPage navId="alerts-and-incidents" />,
},
{
path: '/monitoring',
component: () => <NavLandingPage navId="monitoring" />,
},
{ {
path: '/a/:pluginId', path: '/a/:pluginId',
exact: true, exact: true,
component: (props) => { component: SafeDynamicImport(
const hasRoot = pluginHasRootPage(props.match.params.pluginId, config.bootData.navTree); () => import(/* webpackChunkName: "AppRootPage" */ 'app/features/plugins/components/AppRootPage')
const hasQueryParams = Object.keys(props.queryParams).length > 0; ),
if (hasRoot || hasQueryParams) {
const AppRootPage = SafeDynamicImport(
() => import(/* webpackChunkName: "AppRootPage" */ 'app/features/plugins/components/AppRootPage')
);
return <AppRootPage {...props} />;
} else {
return <NavLandingPage navId={`plugin-page-${props.match.params.pluginId}`} />;
}
},
}, },
] ]
: []; : [];

Loading…
Cancel
Save