From e31cb93ec01ee76de9da30f1c80bda733142b1a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Wed, 28 Sep 2022 08:29:35 +0200 Subject: [PATCH] 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 --- packages/grafana-data/src/types/icon.ts | 1 + pkg/api/api.go | 4 + pkg/api/dashboard_test.go | 7 +- pkg/api/datasources_test.go | 5 +- pkg/api/dtos/index.go | 2 +- pkg/api/fakes.go | 100 --------- pkg/api/frontendsettings_test.go | 3 +- pkg/api/index.go | 7 +- pkg/api/login_test.go | 2 +- pkg/api/plugins_test.go | 46 ++-- .../usagestats/service/usage_stats_test.go | 27 +-- .../usagestats/statscollector/service_test.go | 47 +---- pkg/middleware/middleware_test.go | 2 +- pkg/plugins/fakes.go | 38 ++++ .../manager/dashboards/filestore_test.go | 27 +-- pkg/plugins/manager/fakes/fakes.go | 24 --- pkg/services/licensing/oss.go | 17 +- pkg/services/navtree/models.go | 152 +++++++++++-- pkg/services/navtree/models_test.go | 89 ++++++++ pkg/services/navtree/navtree.go | 2 +- pkg/services/navtree/navtreeimpl/admin.go | 72 ++++++- pkg/services/navtree/navtreeimpl/applinks.go | 198 ++++++++++++----- .../navtree/navtreeimpl/applinks_test.go | 196 +++++++++++++++++ pkg/services/navtree/navtreeimpl/navtree.go | 199 ++++++------------ pkg/services/pluginsettings/fake.go | 77 +++++++ .../plugins/config_reader_test.go | 22 +- pkg/services/updatechecker/plugins_test.go | 63 +++--- pkg/setting/setting.go | 22 +- pkg/setting/setting_plugins.go | 6 +- .../plugins/components/AppRootPage.tsx | 14 +- public/app/features/plugins/utils.test.ts | 89 ++++++-- public/app/features/plugins/utils.ts | 43 +++- public/app/routes/routes.tsx | 25 +-- 33 files changed, 1053 insertions(+), 575 deletions(-) create mode 100644 pkg/plugins/fakes.go create mode 100644 pkg/services/navtree/models_test.go create mode 100644 pkg/services/navtree/navtreeimpl/applinks_test.go create mode 100644 pkg/services/pluginsettings/fake.go diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index d53de9d1a3b..b39bdb0a1fe 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -105,6 +105,7 @@ export const availableIconsIndex = { grafana: true, 'graph-bar': true, heart: true, + 'heart-rate': true, 'heart-break': true, history: true, home: true, diff --git a/pkg/api/api.go b/pkg/api/api.go index c70c81152e7..12f7e524671 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -156,6 +156,10 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/alerting/", reqSignedIn, hs.Index) r.Get("/alerting/*", 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 r.Get("/verify", hs.Index) diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 57f57c79a82..d40d2220635 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -20,6 +20,7 @@ import ( "github.com/grafana/grafana/pkg/framework/coremodel/registry" "github.com/grafana/grafana/pkg/infra/usagestats" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/annotations/annotationstest" @@ -58,7 +59,7 @@ func TestGetHomeDashboard(t *testing.T) { hs := &HTTPServer{ Cfg: cfg, - pluginStore: &fakePluginStore{}, + pluginStore: &plugins.FakePluginStore{}, SQLStore: mockstore.NewSQLStoreMock(), preferenceService: prefService, dashboardVersionService: dashboardVersionService, @@ -141,7 +142,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { hs := &HTTPServer{ Cfg: setting.NewCfg(), - pluginStore: &fakePluginStore{}, + pluginStore: &plugins.FakePluginStore{}, SQLStore: mockSQLStore, AccessControl: accesscontrolmock.New(), Features: featuremgmt.WithFeatures(), @@ -1027,7 +1028,7 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s QuotaService: "aimpl.Service{ Cfg: cfg, }, - pluginStore: &fakePluginStore{}, + pluginStore: &plugins.FakePluginStore{}, LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, DashboardService: dashboardService, diff --git a/pkg/api/datasources_test.go b/pkg/api/datasources_test.go index cb27e6059e4..7ef272919f9 100644 --- a/pkg/api/datasources_test.go +++ b/pkg/api/datasources_test.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources/permissions" @@ -47,7 +48,7 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) { // handler func being tested hs := &HTTPServer{ Cfg: setting.NewCfg(), - pluginStore: &fakePluginStore{}, + pluginStore: &plugins.FakePluginStore{}, DataSourcesService: &dataSourcesServiceMock{ expectedDatasources: ds, }, @@ -71,7 +72,7 @@ func TestDataSourcesProxy_userLoggedIn(t *testing.T) { // handler func being tested hs := &HTTPServer{ Cfg: setting.NewCfg(), - pluginStore: &fakePluginStore{}, + pluginStore: &plugins.FakePluginStore{}, } sc.handlerFunc = hs.DeleteDataSourceByName sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 980f0c4bb78..ddb9494ed93 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -15,7 +15,7 @@ type IndexViewData struct { GoogleAnalyticsId string GoogleAnalytics4Id string GoogleTagManagerId string - NavTree []*navtree.NavLink + NavTree *navtree.NavTreeRoot BuildVersion string BuildCommit string Theme string diff --git a/pkg/api/fakes.go b/pkg/api/fakes.go index 6b1a2c6f34f..e39a4214028 100644 --- a/pkg/api/fakes.go +++ b/pkg/api/fakes.go @@ -2,11 +2,8 @@ package api import ( "context" - "time" - "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" - "github.com/grafana/grafana/pkg/services/pluginsettings" ) type fakePluginInstaller struct { @@ -37,34 +34,6 @@ func (pm *fakePluginInstaller) Remove(_ context.Context, pluginID string) error 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 { plugins.RendererManager } @@ -82,72 +51,3 @@ type fakePluginStaticRouteResolver struct { func (psrr *fakePluginStaticRouteResolver) Routes() []*plugins.StaticRoute { 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 -} diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index 26c3a939090..04f64840e33 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + "github.com/grafana/grafana/pkg/plugins" accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing" @@ -51,7 +52,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt. }, SQLStore: sqlStore, SettingsProvider: setting.ProvideProvider(cfg), - pluginStore: &fakePluginStore{}, + pluginStore: &plugins.FakePluginStore{}, grafanaUpdateChecker: &updatechecker.GrafanaService{}, AccessControl: accesscontrolmock.New().WithDisabled(), PluginSettings: pluginSettings.ProvideService(sqlStore, secretsService), diff --git a/pkg/api/index.go b/pkg/api/index.go index 47e3a764747..65c66b67e44 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -3,7 +3,6 @@ package api import ( "fmt" "net/http" - "sort" "strings" "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) - sort.SliceStable(data.NavTree, func(i, j int) bool { - return data.NavTree[i].SortWeight < data.NavTree[j].SortWeight - }) + // This will remove empty cfg or admin sections and move sections around if topnav is enabled + data.NavTree.RemoveEmptySectionsAndApplyNewInformationArchitecture(hs.Features.IsEnabled(featuremgmt.FlagTopnav)) + data.NavTree.Sort() return &data, nil } diff --git a/pkg/api/login_test.go b/pkg/api/login_test.go index 8009b1a1c84..27452b9bd09 100644 --- a/pkg/api/login_test.go +++ b/pkg/api/login_test.go @@ -44,7 +44,7 @@ func fakeSetIndexViewData(t *testing.T) { data := &dtos.IndexViewData{ User: &dtos.CurrentUser{}, Settings: map[string]interface{}{}, - NavTree: []*navtree.NavLink{}, + NavTree: &navtree.NavTreeRoot{}, } return data, nil } diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index 1965d578340..0b5b9747338 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -175,10 +175,8 @@ func Test_GetPluginAssets(t *testing.T) { requestedFile: {}, }, } - service := &fakePluginStore{ - plugins: map[string]plugins.PluginDTO{ - pluginID: p, - }, + service := &plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{p}, } l := &logtest.Fake{} @@ -200,10 +198,8 @@ func Test_GetPluginAssets(t *testing.T) { }, PluginDir: pluginDir, } - service := &fakePluginStore{ - plugins: map[string]plugins.PluginDTO{ - pluginID: p, - }, + service := &plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{p}, } l := &logtest.Fake{} @@ -223,10 +219,8 @@ func Test_GetPluginAssets(t *testing.T) { }, PluginDir: pluginDir, } - service := &fakePluginStore{ - plugins: map[string]plugins.PluginDTO{ - pluginID: p, - }, + service := &plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{p}, } l := &logtest.Fake{} @@ -248,10 +242,8 @@ func Test_GetPluginAssets(t *testing.T) { }, PluginDir: pluginDir, } - service := &fakePluginStore{ - plugins: map[string]plugins.PluginDTO{ - pluginID: p, - }, + service := &plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{p}, } 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) { - service := &fakePluginStore{ - plugins: map[string]plugins.PluginDTO{}, + service := &plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{}, } 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) { - service := &fakePluginStore{ - plugins: map[string]plugins.PluginDTO{ - pluginID: { - Class: plugins.Core, + service := &plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{ + { + 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) { - pluginStore := fakePluginStore{plugins: map[string]plugins.PluginDTO{ - "test-app": { + pluginStore := plugins.FakePluginStore{PluginList: []plugins.PluginDTO{ + { PluginDir: "/grafana/plugins/test-app/dist", Class: "external", 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", Class: "core", 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}, "mysql": {ID: 0, OrgID: 1, PluginID: "mysql", PluginVersion: "", Enabled: true}}, } diff --git a/pkg/infra/usagestats/service/usage_stats_test.go b/pkg/infra/usagestats/service/usage_stats_test.go index 318b8a054d6..94c1909eb42 100644 --- a/pkg/infra/usagestats/service/usage_stats_test.go +++ b/pkg/infra/usagestats/service/usage_stats_test.go @@ -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 { req *http.Request responseBuffer *bytes.Buffer @@ -242,7 +217,7 @@ func createService(t *testing.T, cfg setting.Cfg, sqlStore sqlstore.Store, withD return ProvideService( &cfg, - &fakePluginStore{}, + &plugins.FakePluginStore{}, kvstore.ProvideService(sqlStore), routing.NewRouteRegister(), tracing.InitializeTracerForTest(), diff --git a/pkg/infra/usagestats/statscollector/service_test.go b/pkg/infra/usagestats/statscollector/service_test.go index 41ca2b68127..1b3aea6d286 100644 --- a/pkg/infra/usagestats/statscollector/service_test.go +++ b/pkg/infra/usagestats/statscollector/service_test.go @@ -424,52 +424,19 @@ func (m *mockSocial) GetOAuthProviders() map[string]bool { 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) { t.Helper() - s.plugins = &fakePluginStore{ - plugins: map[string]plugins.PluginDTO{ - datasources.DS_ES: { - Signature: "internal", - }, - datasources.DS_PROMETHEUS: { - Signature: "internal", - }, - datasources.DS_GRAPHITE: { - Signature: "internal", - }, - datasources.DS_MYSQL: { - Signature: "internal", - }, + s.plugins = &plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{ + {JSONData: plugins.JSONData{ID: datasources.DS_ES}, Signature: "internal"}, + {JSONData: plugins.JSONData{ID: datasources.DS_PROMETHEUS}, Signature: "internal"}, + {JSONData: plugins.JSONData{ID: datasources.DS_GRAPHITE}, Signature: "internal"}, + {JSONData: plugins.JSONData{ID: 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 { t.Helper() @@ -484,7 +451,7 @@ func createService(t testing.TB, cfg *setting.Cfg, store sqlstore.Store, opts .. cfg, store, &mockSocial{}, - &fakePluginStore{}, + &plugins.FakePluginStore{}, featuremgmt.WithFeatures("feature1", "feature2"), o.datasources, httpclient.NewProvider(), diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 0a68fce668a..09f7a12daae 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -120,7 +120,7 @@ func TestMiddlewareContext(t *testing.T) { data := &dtos.IndexViewData{ User: &dtos.CurrentUser{}, Settings: map[string]interface{}{}, - NavTree: []*navtree.NavLink{}, + NavTree: &navtree.NavTreeRoot{}, } t.Log("Calling HTML", "data", data) c.HTML(http.StatusOK, "index-template", data) diff --git a/pkg/plugins/fakes.go b/pkg/plugins/fakes.go new file mode 100644 index 00000000000..c33fc110bb6 --- /dev/null +++ b/pkg/plugins/fakes.go @@ -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 +} diff --git a/pkg/plugins/manager/dashboards/filestore_test.go b/pkg/plugins/manager/dashboards/filestore_test.go index ab3834c3b21..ae9697864bf 100644 --- a/pkg/plugins/manager/dashboards/filestore_test.go +++ b/pkg/plugins/manager/dashboards/filestore_test.go @@ -190,10 +190,11 @@ func setupPluginDashboardsForTest(t *testing.T) *FileStoreManager { t.Helper() return &FileStoreManager{ - pluginStore: &fakePluginStore{ - plugins: map[string]plugins.PluginDTO{ - "pluginWithoutDashboards": { + pluginStore: &plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{ + { JSONData: plugins.JSONData{ + ID: "pluginWithoutDashboards", Includes: []*plugins.Includes{ { Type: "page", @@ -201,9 +202,10 @@ func setupPluginDashboardsForTest(t *testing.T) *FileStoreManager { }, }, }, - "pluginWithDashboards": { + { PluginDir: "plugins/plugin-id", JSONData: plugins.JSONData{ + ID: "pluginWithDashboards", Includes: []*plugins.Includes{ { 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 -} diff --git a/pkg/plugins/manager/fakes/fakes.go b/pkg/plugins/manager/fakes/fakes.go index 5097f614cbb..e8f82e1e791 100644 --- a/pkg/plugins/manager/fakes/fakes.go +++ b/pkg/plugins/manager/fakes/fakes.go @@ -162,30 +162,6 @@ func (pc *FakePluginClient) RunStream(_ context.Context, _ *backend.RunStreamReq 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 { Store map[string]*plugins.Plugin } diff --git a/pkg/services/licensing/oss.go b/pkg/services/licensing/oss.go index 9a835084eb8..ae47a21191b 100644 --- a/pkg/services/licensing/oss.go +++ b/pkg/services/licensing/oss.go @@ -55,16 +55,15 @@ func ProvideService(cfg *setting.Cfg, hooksService *hooks.HooksService) *OSSLice HooksService: hooksService, } l.HooksService.AddIndexDataHook(func(indexData *dtos.IndexViewData, req *models.ReqContext) { - for _, node := range indexData.NavTree { - if node.Id == "admin" { - node.Children = append(node.Children, &navtree.NavLink{ - Text: "Stats and license", - Id: "upgrading", - Url: l.LicenseURL(req.IsGrafanaAdmin), - Icon: "unlock", - }) - } + if adminNode := indexData.NavTree.FindById(navtree.NavIDAdmin); adminNode != nil { + adminNode.Children = append(adminNode.Children, &navtree.NavLink{ + Text: "Stats and license", + Id: "upgrading", + Url: l.LicenseURL(req.IsGrafanaAdmin), + Icon: "unlock", + }) } }) + return l } diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index 7bad049b31a..46fe6738271 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -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) +} + +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 { - url := "" - if len(children) > 0 { - url = children[0].Url + return iw < jw + }) + + for _, child := range nodes { + child.Sort() } - 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, +} + +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 } diff --git a/pkg/services/navtree/models_test.go b/pkg/services/navtree/models_test.go new file mode 100644 index 00000000000..21cd52a8dce --- /dev/null +++ b/pkg/services/navtree/models_test.go @@ -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) + }) +} diff --git a/pkg/services/navtree/navtree.go b/pkg/services/navtree/navtree.go index 5f77a3a51fd..37cbe63c27d 100644 --- a/pkg/services/navtree/navtree.go +++ b/pkg/services/navtree/navtree.go @@ -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) } diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index a86ed5bae94..af3bcff0d6f 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -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 { diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index 57e4bb47776..d6a8978f759 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -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,71 +46,168 @@ 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 appNode := s.processAppPlugin(plugin, c, topNavEnabled, treeRoot); appNode != nil { + appLinks = append(appLinks, appNode) } + } - if s.features.IsEnabled(featuremgmt.FlagTopnav) { - appLink.Url = s.cfg.AppSubURL + "/a/" + plugin.ID - } else { - appLink.Url = path.Join(s.cfg.AppSubURL, plugin.DefaultNavURL) + if len(appLinks) > 0 { + sort.SliceStable(appLinks, func(i, j int) bool { + return appLinks[i].Text < appLinks[j].Text + }) + } + + 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 !c.HasUserRole(include.Role) { - continue + if include.Type == "page" && include.AddToNav { + link := &navtree.NavLink{ + Text: include.Name, + Icon: include.Icon, } - 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, - } + 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) } - link.Icon = include.Icon + } 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 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 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{} + 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) + } } - appLinks = append(appLinks, appLink) + } else { + return appLink } } - if len(appLinks) > 0 { - sort.SliceStable(appLinks, func(i, j int) bool { - return appLinks[i].Text < appLinks[j].Text - }) + 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}, } - 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 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 + } } diff --git a/pkg/services/navtree/navtreeimpl/applinks_test.go b/pkg/services/navtree/navtreeimpl/applinks_test.go new file mode 100644 index 00000000000..96819370a9f --- /dev/null +++ b/pkg/services/navtree/navtreeimpl/applinks_test.go @@ -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) + }) +} diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 839a668da52..b723fe536b0 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -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) - 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 { 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 -} diff --git a/pkg/services/pluginsettings/fake.go b/pkg/services/pluginsettings/fake.go new file mode 100644 index 00000000000..4a8f3cf4122 --- /dev/null +++ b/pkg/services/pluginsettings/fake.go @@ -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 +} diff --git a/pkg/services/provisioning/plugins/config_reader_test.go b/pkg/services/provisioning/plugins/config_reader_test.go index 0cc16b5ad84..497cca2c11c 100644 --- a/pkg/services/provisioning/plugins/config_reader_test.go +++ b/pkg/services/provisioning/plugins/config_reader_test.go @@ -33,7 +33,7 @@ func TestConfigReader(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) require.Error(t, err) 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) { - pm := fakePluginStore{ - apps: map[string]plugins.PluginDTO{ - "test-plugin": {}, - "test-plugin-2": {}, + pm := plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{ + {JSONData: plugins.JSONData{ID: "test-plugin"}}, + {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 -} diff --git a/pkg/services/updatechecker/plugins_test.go b/pkg/services/updatechecker/plugins_test.go index 44994f92882..6028e735e8d 100644 --- a/pkg/services/updatechecker/plugins_test.go +++ b/pkg/services/updatechecker/plugins_test.go @@ -19,10 +19,11 @@ func TestPluginUpdateChecker_HasUpdate(t *testing.T) { availableUpdates: map[string]string{ "test-ds": "1.0.0", }, - pluginStore: fakePluginStore{ - plugins: map[string]plugins.PluginDTO{ - "test-ds": { + pluginStore: plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{ + { JSONData: plugins.JSONData{ + ID: "test-ds", Info: plugins.Info{Version: "0.9.0"}, }, }, @@ -41,20 +42,23 @@ func TestPluginUpdateChecker_HasUpdate(t *testing.T) { "test-panel": "0.9.0", "test-app": "0.0.1", }, - pluginStore: fakePluginStore{ - plugins: map[string]plugins.PluginDTO{ - "test-ds": { + pluginStore: plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{ + { JSONData: plugins.JSONData{ + ID: "test-ds", Info: plugins.Info{Version: "0.9.0"}, }, }, - "test-panel": { + { JSONData: plugins.JSONData{ + ID: "test-panel", Info: plugins.Info{Version: "0.9.0"}, }, }, - "test-app": { + { JSONData: plugins.JSONData{ + ID: "test-app", Info: plugins.Info{Version: "0.9.0"}, }, }, @@ -80,10 +84,11 @@ func TestPluginUpdateChecker_HasUpdate(t *testing.T) { availableUpdates: map[string]string{ "test-panel": "0.9.0", }, - pluginStore: fakePluginStore{ - plugins: map[string]plugins.PluginDTO{ - "test-ds": { + pluginStore: plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{ + { JSONData: plugins.JSONData{ + ID: "test-ds", Info: plugins.Info{Version: "1.0.0"}, }, }, @@ -122,31 +127,35 @@ func TestPluginUpdateChecker_checkForUpdates(t *testing.T) { availableUpdates: map[string]string{ "test-app": "1.0.0", }, - pluginStore: fakePluginStore{ - plugins: map[string]plugins.PluginDTO{ - "test-ds": { + pluginStore: plugins.FakePluginStore{ + PluginList: []plugins.PluginDTO{ + { JSONData: plugins.JSONData{ ID: "test-ds", Info: plugins.Info{Version: "0.9.0"}, + Type: plugins.DataSource, }, }, - "test-app": { + { JSONData: plugins.JSONData{ ID: "test-app", Info: plugins.Info{Version: "0.5.0"}, + Type: plugins.App, }, }, - "test-panel": { + { JSONData: plugins.JSONData{ ID: "test-panel", Info: plugins.Info{Version: "2.5.7"}, + Type: plugins.Panel, }, }, - "test-core-panel": { + { Class: plugins.Core, JSONData: plugins.JSONData{ ID: "test-core-panel", 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 } - -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 -} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index d8b1d36216d..c4d182eb8ff 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -226,12 +226,13 @@ type Cfg struct { Packaging string // Paths - HomePath string - ProvisioningPath string - DataPath string - LogsPath string - PluginsPath string - BundledPluginsPath string + HomePath string + ProvisioningPath string + DataPath string + LogsPath string + PluginsPath string + BundledPluginsPath string + EnterpriseLicensePath string // SMTP email settings Smtp SmtpSettings @@ -263,7 +264,9 @@ type Cfg struct { CSPTemplate string AngularSupportEnabled bool - TempDataLifetime time.Duration + TempDataLifetime time.Duration + + // Plugins PluginsEnableAlpha bool PluginsAppsSkipVerifyTLS bool PluginSettings PluginSettings @@ -272,8 +275,9 @@ type Cfg struct { PluginCatalogHiddenPlugins []string PluginAdminEnabled bool PluginAdminExternalManageEnabled bool - DisableSanitizeHtml bool - EnterpriseLicensePath string + + // Panels + DisableSanitizeHtml bool // Metrics MetricsEndpointEnabled bool diff --git a/pkg/setting/setting_plugins.go b/pkg/setting/setting_plugins.go index 760c4762bf1..95d5f0e7fd0 100644 --- a/pkg/setting/setting_plugins.go +++ b/pkg/setting/setting_plugins.go @@ -29,19 +29,23 @@ func (cfg *Cfg) readPluginSettings(iniFile *ini.File) error { cfg.PluginsEnableAlpha = pluginsSection.Key("enable_alpha").MustBool(false) cfg.PluginsAppsSkipVerifyTLS = pluginsSection.Key("app_tls_skip_verify_insecure").MustBool(false) cfg.PluginSettings = extractPluginSettings(iniFile.Sections()) + pluginsAllowUnsigned := pluginsSection.Key("allow_loading_unsigned_plugins").MustString("") + for _, plug := range strings.Split(pluginsAllowUnsigned, ",") { plug = strings.TrimSpace(plug) cfg.PluginsAllowUnsigned = append(cfg.PluginsAllowUnsigned, plug) } + cfg.PluginCatalogURL = pluginsSection.Key("plugin_catalog_url").MustString("https://grafana.com/grafana/plugins/") cfg.PluginAdminEnabled = pluginsSection.Key("plugin_admin_enabled").MustBool(true) cfg.PluginAdminExternalManageEnabled = pluginsSection.Key("plugin_admin_external_manage_enabled").MustBool(false) - catalogHiddenPlugins := pluginsSection.Key("plugin_catalog_hidden_plugins").MustString("") + for _, plug := range strings.Split(catalogHiddenPlugins, ",") { plug = strings.TrimSpace(plug) cfg.PluginCatalogHiddenPlugins = append(cfg.PluginCatalogHiddenPlugins, plug) } + return nil } diff --git a/public/app/features/plugins/components/AppRootPage.tsx b/public/app/features/plugins/components/AppRootPage.tsx index 31c7180b04a..0e9771dad77 100644 --- a/public/app/features/plugins/components/AppRootPage.tsx +++ b/public/app/features/plugins/components/AppRootPage.tsx @@ -39,7 +39,9 @@ export function AppRootPage({ match, queryParams, location }: Props) { const portalNode = useMemo(() => createHtmlPortalNode(), []); const { plugin, loading, pluginNav } = state; 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]); @@ -53,12 +55,12 @@ export function AppRootPage({ match, queryParams, location }: Props) { ); if (!plugin || match.params.pluginId !== plugin.meta.id) { - return {loading && }; + return {loading && }; } if (!plugin.root) { return ( - +
No root app page component found
;
); @@ -120,9 +122,9 @@ const stateSlice = createSlice({ }, }); -function getLoadingPageProps(): Partial { - if (config.featureToggles.topnav) { - return { navId: 'apps' }; +function getLoadingPageProps(sectionNav: NavModel | null): Partial { + if (config.featureToggles.topnav && sectionNav) { + return { navModel: sectionNav }; } const loading = { text: 'Loading plugin' }; diff --git a/public/app/features/plugins/utils.test.ts b/public/app/features/plugins/utils.test.ts index 94eb01753c9..755fc884dd5 100644 --- a/public/app/features/plugins/utils.test.ts +++ b/public/app/features/plugins/utils.test.ts @@ -1,41 +1,64 @@ import { Location as HistoryLocation } from 'history'; +import { NavIndex, NavModelItem } from '@grafana/data'; import { config } from '@grafana/runtime'; import { buildPluginSectionNav } from './utils'; describe('buildPluginSectionNav', () => { const pluginNav = { main: { text: 'Plugin nav' }, node: { text: 'Plugin nav' } }; - const appsSection = { - text: 'apps', - id: 'apps', + const app1: NavModelItem = { + text: 'App1', + id: 'plugin-page-app1', children: [ { - text: 'App1', - children: [ - { - text: 'page1', - url: '/a/plugin1/page1', - }, - { - text: 'page2', - url: '/a/plugin1/page2', - }, - ], + text: 'page1', + url: '/a/plugin1/page1', + }, + { + 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', () => { config.featureToggles.topnav = false; - const result = buildPluginSectionNav({} as HistoryLocation, pluginNav, {}); + const result = buildPluginSectionNav({} as HistoryLocation, pluginNav, {}, 'app1'); expect(result).toBe(pluginNav); }); it('Should return return section nav if topnav is enabled', () => { 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'); }); @@ -44,9 +67,39 @@ describe('buildPluginSectionNav', () => { const result = buildPluginSectionNav( { pathname: '/a/plugin1/page2', search: '' } as HistoryLocation, null, - navIndex + navIndex, + 'app1' ); expect(result?.main.children![0].children![1].active).toBe(true); 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(); + }); }); diff --git a/public/app/features/plugins/utils.ts b/public/app/features/plugins/utils.ts index ac45dd80bda..8a36d8066ff 100644 --- a/public/app/features/plugins/utils.ts +++ b/public/app/features/plugins/utils.ts @@ -2,7 +2,6 @@ import { Location as HistoryLocation } from 'history'; import { GrafanaPlugin, NavIndex, NavModel, NavModelItem, PanelPluginMeta, PluginType } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { getNavModel } from 'app/core/selectors/navModel'; import { importPanelPluginFromMeta } from './importPanelPlugin'; import { getPluginSettings } from './pluginSettings'; @@ -33,20 +32,24 @@ export async function loadPlugin(pluginId: string): Promise { 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 if (!config.featureToggles.topnav) { return pluginNav; } - const originalSection = getNavModel(navIndex, 'apps').main; - const section = { ...originalSection }; + const section = { ...getPluginSection(location, navIndex, pluginId) }; // 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; let activePage: NavModelItem | undefined; - // Set active page + // Find and set active page section.children = (section?.children ?? []).map((child) => { if (child.children) { return { @@ -62,9 +65,39 @@ export function buildPluginSectionNav(location: HistoryLocation, pluginNav: NavM return pluginPage; }), }; + } else { + if (currentUrl.startsWith(child.url ?? '')) { + activePage = { + ...child, + active: true, + }; + return activePage; + } } return child; }); 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; +} diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 2555cac9131..fb7d0a0474e 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -21,8 +21,6 @@ import { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamic import { RouteDescriptor } from '../core/navigation/types'; import { getPublicDashboardRoutes } from '../features/dashboard/routes'; -import { pluginHasRootPage } from './utils'; - export const extraRoutes: RouteDescriptor[] = []; export function getAppRoutes(): RouteDescriptor[] { @@ -32,21 +30,20 @@ export function getAppRoutes(): RouteDescriptor[] { path: '/apps', component: () => , }, + { + path: '/alerts-and-incidents', + component: () => , + }, + { + path: '/monitoring', + component: () => , + }, { path: '/a/:pluginId', exact: true, - component: (props) => { - const hasRoot = pluginHasRootPage(props.match.params.pluginId, config.bootData.navTree); - const hasQueryParams = Object.keys(props.queryParams).length > 0; - if (hasRoot || hasQueryParams) { - const AppRootPage = SafeDynamicImport( - () => import(/* webpackChunkName: "AppRootPage" */ 'app/features/plugins/components/AppRootPage') - ); - return ; - } else { - return ; - } - }, + component: SafeDynamicImport( + () => import(/* webpackChunkName: "AppRootPage" */ 'app/features/plugins/components/AppRootPage') + ), }, ] : [];