mirror of
https://github.com/grafana/grafana.git
synced 2025-01-27 16:57:14 -06:00
NavTree: Make it possible to configure where in nav tree plugins live (#55484)
* NewIA: Plugin nav config * progress * Progress * Things are working * Add monitoring node * Add alerts and incidents * added experiment with standalone page * Refactoring by adding a type for navtree root * First test working * More tests * more tests * Progress on richer config and sorting * Sort weight working * Path config * Improving logic for not including admin or cfg nodes, making it the last step so that enterprise can add admin nodes without having to worry about the section not existing * fixed index routes * removed file * Fixes * Fixing tests * Fixing more tests and adding support for weight config * Updates * Remove unused fake * More fixes * Minor tweak * Minor fix * Can now control position using sortweight even when existing items have no sortweight * Added tests for frontend standalone page logic * more tests * Remove unused fake and fixed lint issue * Moving reading settings to navtree impl package * remove nav_id setting prefix * Remove old test file * Fix trailing newline * Fixed bug with adding nil node * fixing lint issue * remove some code we have to rethink * move read settings to PrivideService and switch to util.SplitString
This commit is contained in:
parent
202dce66ff
commit
e31cb93ec0
@ -105,6 +105,7 @@ export const availableIconsIndex = {
|
||||
grafana: true,
|
||||
'graph-bar': true,
|
||||
heart: true,
|
||||
'heart-rate': true,
|
||||
'heart-break': true,
|
||||
history: 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("/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)
|
||||
|
@ -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,
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
100
pkg/api/fakes.go
100
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
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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}},
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -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(),
|
||||
|
@ -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)
|
||||
|
38
pkg/plugins/fakes.go
Normal file
38
pkg/plugins/fakes.go
Normal file
@ -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()
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -1,5 +1,10 @@
|
||||
package navtree
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const (
|
||||
// These weights may be used by an extension to reliably place
|
||||
// itself in relation to a particular item in the menu. The weights
|
||||
@ -25,6 +30,17 @@ const (
|
||||
NavSectionConfig string = "config"
|
||||
)
|
||||
|
||||
const (
|
||||
NavIDDashboards = "dashboards"
|
||||
NavIDDashboardsBrowse = "dashboards/browse"
|
||||
NavIDCfg = "cfg" // NavIDCfg is the id for org configuration navigation node
|
||||
NavIDAdmin = "admin"
|
||||
NavIDAlertsAndIncidents = "alerts-and-incidents"
|
||||
NavIDAlerting = "alerting"
|
||||
NavIDMonitoring = "monitoring"
|
||||
NavIDReporting = "reports"
|
||||
)
|
||||
|
||||
type NavLink struct {
|
||||
Id string `json:"id,omitempty"`
|
||||
Text string `json:"text"`
|
||||
@ -47,24 +63,126 @@ type NavLink struct {
|
||||
EmptyMessageId string `json:"emptyMessageId,omitempty"`
|
||||
}
|
||||
|
||||
// NavIDCfg is the id for org configuration navigation node
|
||||
const NavIDCfg = "cfg"
|
||||
func (node *NavLink) Sort() {
|
||||
Sort(node.Children)
|
||||
}
|
||||
|
||||
func GetServerAdminNode(children []*NavLink) *NavLink {
|
||||
url := ""
|
||||
if len(children) > 0 {
|
||||
url = children[0].Url
|
||||
type NavTreeRoot struct {
|
||||
Children []*NavLink
|
||||
}
|
||||
|
||||
func (root *NavTreeRoot) AddSection(node *NavLink) {
|
||||
root.Children = append(root.Children, node)
|
||||
}
|
||||
|
||||
func (root *NavTreeRoot) RemoveSection(node *NavLink) {
|
||||
var result []*NavLink
|
||||
|
||||
for _, child := range root.Children {
|
||||
if child != node {
|
||||
result = append(result, child)
|
||||
}
|
||||
}
|
||||
return &NavLink{
|
||||
Text: "Server admin",
|
||||
SubTitle: "Manage all users and orgs",
|
||||
Description: "Manage server-wide settings and access to resources such as organizations, users, and licenses",
|
||||
HideFromTabs: true,
|
||||
Id: "admin",
|
||||
Icon: "shield",
|
||||
Url: url,
|
||||
SortWeight: WeightAdmin,
|
||||
Section: NavSectionConfig,
|
||||
Children: children,
|
||||
|
||||
root.Children = result
|
||||
}
|
||||
|
||||
func (root *NavTreeRoot) FindById(id string) *NavLink {
|
||||
return FindById(root.Children, id)
|
||||
}
|
||||
|
||||
func (root *NavTreeRoot) RemoveEmptySectionsAndApplyNewInformationArchitecture(topNavEnabled bool) {
|
||||
// Remove server admin node if it has no children or set the url to first child
|
||||
if node := root.FindById(NavIDAdmin); node != nil {
|
||||
if len(node.Children) == 0 {
|
||||
root.RemoveSection(node)
|
||||
} else {
|
||||
node.Url = node.Children[0].Url
|
||||
}
|
||||
}
|
||||
|
||||
if topNavEnabled {
|
||||
orgAdminNode := root.FindById(NavIDCfg)
|
||||
|
||||
if orgAdminNode != nil {
|
||||
orgAdminNode.Url = "/admin"
|
||||
orgAdminNode.Text = "Administration"
|
||||
}
|
||||
|
||||
if serverAdminNode := root.FindById(NavIDAdmin); serverAdminNode != nil {
|
||||
serverAdminNode.Url = "/admin/settings"
|
||||
serverAdminNode.Text = "Server admin"
|
||||
serverAdminNode.SortWeight = 10000
|
||||
|
||||
if orgAdminNode != nil {
|
||||
orgAdminNode.Children = append(orgAdminNode.Children, serverAdminNode)
|
||||
root.RemoveSection(serverAdminNode)
|
||||
}
|
||||
}
|
||||
|
||||
// Move reports into dashboards
|
||||
if reports := root.FindById(NavIDReporting); reports != nil {
|
||||
if dashboards := root.FindById(NavIDDashboards); dashboards != nil {
|
||||
reports.SortWeight = 0
|
||||
dashboards.Children = append(dashboards.Children, reports)
|
||||
root.RemoveSection(reports)
|
||||
}
|
||||
}
|
||||
|
||||
// Change id of dashboards
|
||||
if dashboards := root.FindById(NavIDDashboards); dashboards != nil {
|
||||
dashboards.Id = "dashboards/browse"
|
||||
}
|
||||
}
|
||||
|
||||
// Remove top level cfg / administration node if it has no children (needs to be after topnav new info archicture logic above that moves server admin into it)
|
||||
// Remove server admin node if it has no children or set the url to first child
|
||||
if node := root.FindById(NavIDCfg); node != nil {
|
||||
if len(node.Children) == 0 {
|
||||
root.RemoveSection(node)
|
||||
} else if !topNavEnabled {
|
||||
node.Url = node.Children[0].Url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (root *NavTreeRoot) Sort() {
|
||||
Sort(root.Children)
|
||||
}
|
||||
|
||||
func (root *NavTreeRoot) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(root.Children)
|
||||
}
|
||||
|
||||
func Sort(nodes []*NavLink) {
|
||||
sort.SliceStable(nodes, func(i, j int) bool {
|
||||
iw := nodes[i].SortWeight
|
||||
if iw == 0 {
|
||||
iw = int64(i) + 1
|
||||
}
|
||||
jw := nodes[j].SortWeight
|
||||
if jw == 0 {
|
||||
jw = int64(j) + 1
|
||||
}
|
||||
|
||||
return iw < jw
|
||||
})
|
||||
|
||||
for _, child := range nodes {
|
||||
child.Sort()
|
||||
}
|
||||
}
|
||||
|
||||
func FindById(nodes []*NavLink, id string) *NavLink {
|
||||
for _, child := range nodes {
|
||||
if child.Id == id {
|
||||
return child
|
||||
} else if len(child.Children) > 0 {
|
||||
if found := FindById(child.Children, id); found != nil {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
89
pkg/services/navtree/models_test.go
Normal file
89
pkg/services/navtree/models_test.go
Normal file
@ -0,0 +1,89 @@
|
||||
package navtree
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNavTreeRoot(t *testing.T) {
|
||||
t.Run("Should remove empty admin and server admin sections", func(t *testing.T) {
|
||||
treeRoot := NavTreeRoot{
|
||||
Children: []*NavLink{
|
||||
{Id: NavIDCfg},
|
||||
{Id: NavIDAdmin},
|
||||
},
|
||||
}
|
||||
|
||||
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(false)
|
||||
|
||||
require.Equal(t, 0, len(treeRoot.Children))
|
||||
})
|
||||
|
||||
t.Run("Should not remove admin sections when they have children", func(t *testing.T) {
|
||||
treeRoot := NavTreeRoot{
|
||||
Children: []*NavLink{
|
||||
{Id: NavIDCfg, Children: []*NavLink{{Id: "child"}}},
|
||||
{Id: NavIDAdmin, Children: []*NavLink{{Id: "child"}}},
|
||||
},
|
||||
}
|
||||
|
||||
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(false)
|
||||
|
||||
require.Equal(t, 2, len(treeRoot.Children))
|
||||
})
|
||||
|
||||
t.Run("Should move admin section into cfg and rename when topnav is enabled", func(t *testing.T) {
|
||||
treeRoot := NavTreeRoot{
|
||||
Children: []*NavLink{
|
||||
{Id: NavIDCfg},
|
||||
{Id: NavIDAdmin, Children: []*NavLink{{Id: "child"}}},
|
||||
},
|
||||
}
|
||||
|
||||
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(true)
|
||||
|
||||
require.Equal(t, "Administration", treeRoot.Children[0].Text)
|
||||
require.Equal(t, NavIDAdmin, treeRoot.Children[0].Children[0].Id)
|
||||
})
|
||||
|
||||
t.Run("Should move reports into Dashboards", func(t *testing.T) {
|
||||
treeRoot := NavTreeRoot{
|
||||
Children: []*NavLink{
|
||||
{Id: NavIDDashboards},
|
||||
{Id: NavIDReporting},
|
||||
},
|
||||
}
|
||||
|
||||
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(true)
|
||||
|
||||
require.Equal(t, NavIDReporting, treeRoot.Children[0].Children[0].Id)
|
||||
})
|
||||
|
||||
t.Run("Sorting by index", func(t *testing.T) {
|
||||
treeRoot := NavTreeRoot{
|
||||
Children: []*NavLink{
|
||||
{Id: "1"},
|
||||
{Id: "2"},
|
||||
{Id: "3"},
|
||||
},
|
||||
}
|
||||
treeRoot.Sort()
|
||||
require.Equal(t, "1", treeRoot.Children[0].Id)
|
||||
require.Equal(t, "3", treeRoot.Children[2].Id)
|
||||
})
|
||||
|
||||
t.Run("Sorting by index and SortWeight", func(t *testing.T) {
|
||||
treeRoot := NavTreeRoot{
|
||||
Children: []*NavLink{
|
||||
{Id: "1"},
|
||||
{Id: "2"},
|
||||
{Id: "3"},
|
||||
{Id: "4", SortWeight: 1},
|
||||
},
|
||||
}
|
||||
treeRoot.Sort()
|
||||
require.Equal(t, "1", treeRoot.Children[0].Id)
|
||||
require.Equal(t, "4", treeRoot.Children[1].Id)
|
||||
})
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -3,6 +3,7 @@ package navtreeimpl
|
||||
import (
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
@ -10,15 +11,17 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/navtree"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsettings"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
func (s *ServiceImpl) getAppLinks(c *models.ReqContext) ([]*navtree.NavLink, error) {
|
||||
func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqContext) error {
|
||||
topNavEnabled := s.features.IsEnabled(featuremgmt.FlagTopnav)
|
||||
hasAccess := ac.HasAccess(s.accessControl, c)
|
||||
appLinks := []*navtree.NavLink{}
|
||||
|
||||
pss, err := s.pluginSettings.GetPluginSettings(c.Req.Context(), &pluginsettings.GetArgs{OrgID: c.OrgID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return err
|
||||
}
|
||||
|
||||
isPluginEnabled := func(plugin plugins.PluginDTO) bool {
|
||||
@ -43,63 +46,8 @@ func (s *ServiceImpl) getAppLinks(c *models.ReqContext) ([]*navtree.NavLink, err
|
||||
continue
|
||||
}
|
||||
|
||||
appLink := &navtree.NavLink{
|
||||
Text: plugin.Name,
|
||||
Id: "plugin-page-" + plugin.ID,
|
||||
Img: plugin.Info.Logos.Small,
|
||||
Section: navtree.NavSectionPlugin,
|
||||
SortWeight: navtree.WeightPlugin,
|
||||
}
|
||||
|
||||
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
||||
appLink.Url = s.cfg.AppSubURL + "/a/" + plugin.ID
|
||||
} else {
|
||||
appLink.Url = path.Join(s.cfg.AppSubURL, plugin.DefaultNavURL)
|
||||
}
|
||||
|
||||
for _, include := range plugin.Includes {
|
||||
if !c.HasUserRole(include.Role) {
|
||||
continue
|
||||
}
|
||||
|
||||
if include.Type == "page" && include.AddToNav {
|
||||
var link *navtree.NavLink
|
||||
if len(include.Path) > 0 {
|
||||
link = &navtree.NavLink{
|
||||
Url: s.cfg.AppSubURL + include.Path,
|
||||
Text: include.Name,
|
||||
}
|
||||
if include.DefaultNav && !s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
||||
appLink.Url = link.Url // Overwrite the hardcoded page logic
|
||||
}
|
||||
} else {
|
||||
link = &navtree.NavLink{
|
||||
Url: s.cfg.AppSubURL + "/plugins/" + plugin.ID + "/page/" + include.Slug,
|
||||
Text: include.Name,
|
||||
}
|
||||
}
|
||||
link.Icon = include.Icon
|
||||
appLink.Children = append(appLink.Children, link)
|
||||
}
|
||||
|
||||
if include.Type == "dashboard" && include.AddToNav {
|
||||
dboardURL := include.DashboardURLPath()
|
||||
if dboardURL != "" {
|
||||
link := &navtree.NavLink{
|
||||
Url: path.Join(s.cfg.AppSubURL, dboardURL),
|
||||
Text: include.Name,
|
||||
}
|
||||
appLink.Children = append(appLink.Children, link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(appLink.Children) > 0 {
|
||||
// If we only have one child and it's the app default nav then remove it from children
|
||||
if len(appLink.Children) == 1 && appLink.Children[0].Url == appLink.Url {
|
||||
appLink.Children = []*navtree.NavLink{}
|
||||
}
|
||||
appLinks = append(appLinks, appLink)
|
||||
if appNode := s.processAppPlugin(plugin, c, topNavEnabled, treeRoot); appNode != nil {
|
||||
appLinks = append(appLinks, appNode)
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,5 +57,157 @@ func (s *ServiceImpl) getAppLinks(c *models.ReqContext) ([]*navtree.NavLink, err
|
||||
})
|
||||
}
|
||||
|
||||
return appLinks, nil
|
||||
if topNavEnabled {
|
||||
treeRoot.AddSection(&navtree.NavLink{
|
||||
Text: "Apps",
|
||||
Icon: "apps",
|
||||
Description: "App plugins that extend the Grafana experience",
|
||||
Id: "apps",
|
||||
Children: appLinks,
|
||||
Section: navtree.NavSectionCore,
|
||||
Url: s.cfg.AppSubURL + "/apps",
|
||||
})
|
||||
} else {
|
||||
for _, appLink := range appLinks {
|
||||
treeRoot.AddSection(appLink)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqContext, topNavEnabled bool, treeRoot *navtree.NavTreeRoot) *navtree.NavLink {
|
||||
appLink := &navtree.NavLink{
|
||||
Text: plugin.Name,
|
||||
Id: "plugin-page-" + plugin.ID,
|
||||
Img: plugin.Info.Logos.Small,
|
||||
Section: navtree.NavSectionPlugin,
|
||||
SortWeight: navtree.WeightPlugin,
|
||||
}
|
||||
|
||||
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
||||
appLink.Url = s.cfg.AppSubURL + "/a/" + plugin.ID
|
||||
} else {
|
||||
appLink.Url = path.Join(s.cfg.AppSubURL, plugin.DefaultNavURL)
|
||||
}
|
||||
|
||||
for _, include := range plugin.Includes {
|
||||
if !c.HasUserRole(include.Role) {
|
||||
continue
|
||||
}
|
||||
|
||||
if include.Type == "page" && include.AddToNav {
|
||||
link := &navtree.NavLink{
|
||||
Text: include.Name,
|
||||
Icon: include.Icon,
|
||||
}
|
||||
|
||||
if len(include.Path) > 0 {
|
||||
link.Url = s.cfg.AppSubURL + include.Path
|
||||
if include.DefaultNav {
|
||||
appLink.Url = link.Url
|
||||
}
|
||||
} else {
|
||||
link.Url = s.cfg.AppSubURL + "/plugins/" + plugin.ID + "/page/" + include.Slug
|
||||
}
|
||||
|
||||
if pathConfig, ok := s.navigationAppPathConfig[include.Path]; ok {
|
||||
if sectionForPage := treeRoot.FindById(pathConfig.SectionID); sectionForPage != nil {
|
||||
link.Id = "standalone-plugin-page-" + include.Path
|
||||
link.SortWeight = pathConfig.SortWeight
|
||||
sectionForPage.Children = append(sectionForPage.Children, link)
|
||||
}
|
||||
} else {
|
||||
appLink.Children = append(appLink.Children, link)
|
||||
}
|
||||
}
|
||||
|
||||
if include.Type == "dashboard" && include.AddToNav {
|
||||
dboardURL := include.DashboardURLPath()
|
||||
if dboardURL != "" {
|
||||
link := &navtree.NavLink{
|
||||
Url: path.Join(s.cfg.AppSubURL, dboardURL),
|
||||
Text: include.Name,
|
||||
}
|
||||
appLink.Children = append(appLink.Children, link)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(appLink.Children) > 0 {
|
||||
// If we only have one child and it's the app default nav then remove it from children
|
||||
if len(appLink.Children) == 1 && appLink.Children[0].Url == appLink.Url {
|
||||
appLink.Children = []*navtree.NavLink{}
|
||||
}
|
||||
|
||||
alertingNode := treeRoot.FindById(navtree.NavIDAlerting)
|
||||
|
||||
if navConfig, hasOverride := s.navigationAppConfig[plugin.ID]; hasOverride && topNavEnabled {
|
||||
appLink.SortWeight = navConfig.SortWeight
|
||||
|
||||
if navNode := treeRoot.FindById(navConfig.SectionID); navNode != nil {
|
||||
navNode.Children = append(navNode.Children, appLink)
|
||||
} else {
|
||||
if navConfig.SectionID == navtree.NavIDMonitoring {
|
||||
treeRoot.AddSection(&navtree.NavLink{
|
||||
Text: "Monitoring",
|
||||
Id: navtree.NavIDMonitoring,
|
||||
Description: "Monitoring and infrastructure apps",
|
||||
Icon: "heart-rate",
|
||||
Section: navtree.NavSectionCore,
|
||||
Children: []*navtree.NavLink{appLink},
|
||||
Url: s.cfg.AppSubURL + "/monitoring",
|
||||
})
|
||||
} else if navConfig.SectionID == navtree.NavIDAlertsAndIncidents && alertingNode != nil {
|
||||
treeRoot.AddSection(&navtree.NavLink{
|
||||
Text: "Alerts & incidents",
|
||||
Id: navtree.NavIDAlertsAndIncidents,
|
||||
Description: "Alerting and incident management apps",
|
||||
Icon: "bell",
|
||||
Section: navtree.NavSectionCore,
|
||||
Children: []*navtree.NavLink{alertingNode, appLink},
|
||||
Url: s.cfg.AppSubURL + "/alerts-and-incidents",
|
||||
})
|
||||
treeRoot.RemoveSection(alertingNode)
|
||||
} else {
|
||||
s.log.Error("Plugin app nav id not found", "pluginId", plugin.ID, "navId", navConfig.SectionID)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return appLink
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) readNavigationSettings() {
|
||||
s.navigationAppConfig = map[string]NavigationAppConfig{
|
||||
"grafana-k8s-app": {SectionID: navtree.NavIDMonitoring, SortWeight: 1},
|
||||
"grafana-synthetic-monitoring-app": {SectionID: navtree.NavIDMonitoring, SortWeight: 2},
|
||||
"grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1},
|
||||
"grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2},
|
||||
"grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3},
|
||||
}
|
||||
|
||||
s.navigationAppPathConfig = map[string]NavigationAppConfig{
|
||||
"/a/grafana-auth-app": {SectionID: navtree.NavIDCfg, SortWeight: 7},
|
||||
}
|
||||
|
||||
sec := s.cfg.Raw.Section("navigation.apps")
|
||||
|
||||
for _, key := range sec.Keys() {
|
||||
pluginId := key.Name()
|
||||
// Support <id> <weight> value
|
||||
values := util.SplitString(sec.Key(key.Name()).MustString(""))
|
||||
|
||||
appCfg := &NavigationAppConfig{SectionID: values[0]}
|
||||
if len(values) > 1 {
|
||||
if weight, err := strconv.ParseInt(values[1], 10, 64); err == nil {
|
||||
appCfg.SortWeight = weight
|
||||
}
|
||||
}
|
||||
|
||||
s.navigationAppConfig[pluginId] = *appCfg
|
||||
}
|
||||
}
|
||||
|
196
pkg/services/navtree/navtreeimpl/applinks_test.go
Normal file
196
pkg/services/navtree/navtreeimpl/applinks_test.go
Normal file
@ -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
|
||||
kvStore kvstore.KVStore
|
||||
apiKeyService apikey.Service
|
||||
|
||||
// Navigation
|
||||
navigationAppConfig map[string]NavigationAppConfig
|
||||
navigationAppPathConfig map[string]NavigationAppConfig
|
||||
}
|
||||
|
||||
type NavigationAppConfig struct {
|
||||
SectionID string
|
||||
SortWeight int64
|
||||
}
|
||||
|
||||
func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStore plugins.Store, pluginSettings pluginsettings.Service, starService star.Service, features *featuremgmt.FeatureManager, dashboardService dashboards.DashboardService, accesscontrolService ac.Service, kvStore kvstore.KVStore, apiKeyService apikey.Service) navtree.Service {
|
||||
return &ServiceImpl{
|
||||
service := &ServiceImpl{
|
||||
cfg: cfg,
|
||||
log: log.New("navtree service"),
|
||||
accessControl: accessControl,
|
||||
@ -49,12 +58,16 @@ func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStor
|
||||
kvStore: kvStore,
|
||||
apiKeyService: apiKeyService,
|
||||
}
|
||||
|
||||
service.readNavigationSettings()
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
//nolint:gocyclo
|
||||
func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) ([]*navtree.NavLink, error) {
|
||||
func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) (*navtree.NavTreeRoot, error) {
|
||||
hasAccess := ac.HasAccess(s.accessControl, c)
|
||||
var navTree []*navtree.NavLink
|
||||
treeRoot := &navtree.NavTreeRoot{}
|
||||
|
||||
if hasAccess(ac.ReqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsRead)) {
|
||||
starredItemsLinks, err := s.buildStarredItemsNavLinks(c, prefs)
|
||||
@ -62,7 +75,7 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
|
||||
return nil, err
|
||||
}
|
||||
|
||||
navTree = append(navTree, &navtree.NavLink{
|
||||
treeRoot.AddSection(&navtree.NavLink{
|
||||
Text: "Starred",
|
||||
Id: "starred",
|
||||
Icon: "star",
|
||||
@ -74,25 +87,19 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
|
||||
|
||||
dashboardChildLinks := s.buildDashboardNavLinks(c, hasEditPerm)
|
||||
|
||||
dashboardsUrl := "/dashboards"
|
||||
|
||||
dashboardLink := &navtree.NavLink{
|
||||
Text: "Dashboards",
|
||||
Id: "dashboards",
|
||||
Id: navtree.NavIDDashboards,
|
||||
Description: "Create and manage dashboards to visualize your data",
|
||||
SubTitle: "Manage dashboards and folders",
|
||||
Icon: "apps",
|
||||
Url: s.cfg.AppSubURL + dashboardsUrl,
|
||||
Url: s.cfg.AppSubURL + "/dashboards",
|
||||
SortWeight: navtree.WeightDashboard,
|
||||
Section: navtree.NavSectionCore,
|
||||
Children: dashboardChildLinks,
|
||||
}
|
||||
|
||||
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
||||
dashboardLink.Id = "dashboards/browse"
|
||||
}
|
||||
|
||||
navTree = append(navTree, dashboardLink)
|
||||
treeRoot.AddSection(dashboardLink)
|
||||
}
|
||||
|
||||
canExplore := func(context *models.ReqContext) bool {
|
||||
@ -100,7 +107,7 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
|
||||
}
|
||||
|
||||
if setting.ExploreEnabled && hasAccess(canExplore, ac.EvalPermission(ac.ActionDatasourcesExplore)) {
|
||||
navTree = append(navTree, &navtree.NavLink{
|
||||
treeRoot.AddSection(&navtree.NavLink{
|
||||
Text: "Explore",
|
||||
Id: "explore",
|
||||
SubTitle: "Explore your data",
|
||||
@ -111,44 +118,25 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
|
||||
})
|
||||
}
|
||||
|
||||
navTree = s.addProfile(navTree, c)
|
||||
if setting.ProfileEnabled && c.IsSignedIn {
|
||||
treeRoot.AddSection(s.getProfileNode(c))
|
||||
}
|
||||
|
||||
_, uaIsDisabledForOrg := s.cfg.UnifiedAlerting.DisabledOrgs[c.OrgID]
|
||||
uaVisibleForOrg := s.cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg
|
||||
|
||||
if setting.AlertingEnabled != nil && *setting.AlertingEnabled {
|
||||
navTree = append(navTree, s.buildLegacyAlertNavLinks(c)...)
|
||||
if legacyAlertSection := s.buildLegacyAlertNavLinks(c); legacyAlertSection != nil {
|
||||
treeRoot.AddSection(legacyAlertSection)
|
||||
}
|
||||
} else if uaVisibleForOrg {
|
||||
navTree = append(navTree, s.buildAlertNavLinks(c, hasEditPerm)...)
|
||||
if alertingSection := s.buildAlertNavLinks(c, hasEditPerm); alertingSection != nil {
|
||||
treeRoot.AddSection(alertingSection)
|
||||
}
|
||||
}
|
||||
|
||||
if s.features.IsEnabled(featuremgmt.FlagDataConnectionsConsole) {
|
||||
navTree = append(navTree, s.buildDataConnectionsNavLink(c))
|
||||
}
|
||||
|
||||
appLinks, err := s.getAppLinks(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// When topnav is enabled we can test new information architecture where plugins live in Apps category
|
||||
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
||||
navTree = append(navTree, &navtree.NavLink{
|
||||
Text: "Apps",
|
||||
Icon: "apps",
|
||||
Description: "App plugins that extend the Grafana experience",
|
||||
Id: "apps",
|
||||
Children: appLinks,
|
||||
Section: navtree.NavSectionCore,
|
||||
Url: s.cfg.AppSubURL + "/apps",
|
||||
})
|
||||
} else {
|
||||
navTree = append(navTree, appLinks...)
|
||||
}
|
||||
|
||||
configNodes, err := s.setupConfigNodes(c)
|
||||
if err != nil {
|
||||
return navTree, err
|
||||
treeRoot.AddSection(s.buildDataConnectionsNavLink(c))
|
||||
}
|
||||
|
||||
if s.features.IsEnabled(featuremgmt.FlagLivePipeline) {
|
||||
@ -163,7 +151,8 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
|
||||
liveNavLinks = append(liveNavLinks, &navtree.NavLink{
|
||||
Text: "Cloud", Id: "live-cloud", Url: s.cfg.AppSubURL + "/live/cloud", Icon: "cloud-upload",
|
||||
})
|
||||
navTree = append(navTree, &navtree.NavLink{
|
||||
|
||||
treeRoot.AddSection(&navtree.NavLink{
|
||||
Id: "live",
|
||||
Text: "Live",
|
||||
SubTitle: "Event streaming",
|
||||
@ -175,60 +164,37 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
|
||||
})
|
||||
}
|
||||
|
||||
var configNode *navtree.NavLink
|
||||
var serverAdminNode *navtree.NavLink
|
||||
orgAdminNode, err := s.getOrgAdminNode(c)
|
||||
|
||||
if len(configNodes) > 0 {
|
||||
configNode = &navtree.NavLink{
|
||||
Id: navtree.NavIDCfg,
|
||||
Text: "Configuration",
|
||||
SubTitle: "Organization: " + c.OrgName,
|
||||
Icon: "cog",
|
||||
Url: configNodes[0].Url,
|
||||
Section: navtree.NavSectionConfig,
|
||||
SortWeight: navtree.WeightConfig,
|
||||
Children: configNodes,
|
||||
}
|
||||
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
||||
configNode.Url = "/admin"
|
||||
} else {
|
||||
configNode.Url = configNodes[0].Url
|
||||
}
|
||||
navTree = append(navTree, configNode)
|
||||
if orgAdminNode != nil {
|
||||
treeRoot.AddSection(orgAdminNode)
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
adminNavLinks := s.buildAdminNavLinks(c)
|
||||
serverAdminNode := s.getServerAdminNode(c)
|
||||
|
||||
if len(adminNavLinks) > 0 {
|
||||
serverAdminNode = navtree.GetServerAdminNode(adminNavLinks)
|
||||
navTree = append(navTree, serverAdminNode)
|
||||
if serverAdminNode != nil {
|
||||
treeRoot.AddSection(serverAdminNode)
|
||||
}
|
||||
|
||||
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
||||
// Move server admin into Configuration and rename to administration
|
||||
if configNode != nil && serverAdminNode != nil {
|
||||
configNode.Text = "Administration"
|
||||
serverAdminNode.Url = "/admin/server"
|
||||
serverAdminNode.HideFromTabs = false
|
||||
configNode.Children = append(configNode.Children, serverAdminNode)
|
||||
adminNodeIndex := len(navTree) - 1
|
||||
navTree = navTree[:adminNodeIndex]
|
||||
}
|
||||
s.addHelpLinks(treeRoot, c)
|
||||
|
||||
if err := s.addAppLinks(treeRoot, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
navTree = s.addHelpLinks(navTree, c)
|
||||
|
||||
return navTree, nil
|
||||
return treeRoot, nil
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) addHelpLinks(navTree []*navtree.NavLink, c *models.ReqContext) []*navtree.NavLink {
|
||||
func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqContext) {
|
||||
if setting.HelpEnabled {
|
||||
helpVersion := fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit)
|
||||
if s.cfg.AnonymousHideVersion && !c.IsSignedIn {
|
||||
helpVersion = setting.ApplicationName
|
||||
}
|
||||
|
||||
navTree = append(navTree, &navtree.NavLink{
|
||||
treeRoot.AddSection(&navtree.NavLink{
|
||||
Text: "Help",
|
||||
SubTitle: helpVersion,
|
||||
Id: "help",
|
||||
@ -239,14 +205,6 @@ func (s *ServiceImpl) addHelpLinks(navTree []*navtree.NavLink, c *models.ReqCont
|
||||
Children: []*navtree.NavLink{},
|
||||
})
|
||||
}
|
||||
return navTree
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) addProfile(navTree []*navtree.NavLink, c *models.ReqContext) []*navtree.NavLink {
|
||||
if setting.ProfileEnabled && c.IsSignedIn {
|
||||
navTree = append(navTree, s.getProfileNode(c))
|
||||
}
|
||||
return navTree
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) getProfileNode(c *models.ReqContext) *navtree.NavLink {
|
||||
@ -352,11 +310,13 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
|
||||
}
|
||||
|
||||
dashboardChildNavs := []*navtree.NavLink{}
|
||||
|
||||
if !s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
||||
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
|
||||
Text: "Browse", Id: "dashboards/browse", Url: s.cfg.AppSubURL + "/dashboards", Icon: "sitemap",
|
||||
Text: "Browse", Id: navtree.NavIDDashboardsBrowse, Url: s.cfg.AppSubURL + "/dashboards", Icon: "sitemap",
|
||||
})
|
||||
}
|
||||
|
||||
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
|
||||
Text: "Playlists", Description: "Groups of dashboards that are displayed in a sequence", Id: "dashboards/playlists", Url: s.cfg.AppSubURL + "/playlists", Icon: "presentation-play",
|
||||
})
|
||||
@ -388,7 +348,7 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
|
||||
})
|
||||
}
|
||||
|
||||
if hasEditPerm {
|
||||
if hasEditPerm && !s.features.IsEnabled(featuremgmt.FlagTopnav) {
|
||||
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
|
||||
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
|
||||
})
|
||||
@ -413,10 +373,11 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return dashboardChildNavs
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) buildLegacyAlertNavLinks(c *models.ReqContext) []*navtree.NavLink {
|
||||
func (s *ServiceImpl) buildLegacyAlertNavLinks(c *models.ReqContext) *navtree.NavLink {
|
||||
var alertChildNavs []*navtree.NavLink
|
||||
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
|
||||
Text: "Alert rules", Id: "alert-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul",
|
||||
@ -446,10 +407,10 @@ func (s *ServiceImpl) buildLegacyAlertNavLinks(c *models.ReqContext) []*navtree.
|
||||
alertNav.Url = s.cfg.AppSubURL + "/alerting/list"
|
||||
}
|
||||
|
||||
return []*navtree.NavLink{&alertNav}
|
||||
return &alertNav
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool) []*navtree.NavLink {
|
||||
func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool) *navtree.NavLink {
|
||||
hasAccess := ac.HasAccess(s.accessControl, c)
|
||||
var alertChildNavs []*navtree.NavLink
|
||||
|
||||
@ -497,7 +458,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool)
|
||||
Text: "Alerting",
|
||||
Description: "Learn about problems in your systems moments after they occur",
|
||||
SubTitle: "Alert rules and notifications",
|
||||
Id: "alerting",
|
||||
Id: navtree.NavIDAlerting,
|
||||
Icon: "bell",
|
||||
Children: alertChildNavs,
|
||||
Section: navtree.NavSectionCore,
|
||||
@ -510,8 +471,9 @@ func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool)
|
||||
alertNav.Url = s.cfg.AppSubURL + "/alerting/list"
|
||||
}
|
||||
|
||||
return []*navtree.NavLink{&alertNav}
|
||||
return &alertNav
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -558,46 +520,3 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *models.ReqContext) *navtree
|
||||
|
||||
return navLink
|
||||
}
|
||||
|
||||
func (s *ServiceImpl) buildAdminNavLinks(c *models.ReqContext) []*navtree.NavLink {
|
||||
hasAccess := ac.HasAccess(s.accessControl, c)
|
||||
hasGlobalAccess := ac.HasGlobalAccess(s.accessControl, s.accesscontrolService, c)
|
||||
orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead)
|
||||
adminNavLinks := []*navtree.NavLink{}
|
||||
|
||||
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) {
|
||||
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
|
||||
Text: "Users", Description: "Manage and create users across the whole Grafana server", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user",
|
||||
})
|
||||
}
|
||||
|
||||
if hasGlobalAccess(ac.ReqGrafanaAdmin, orgsAccessEvaluator) {
|
||||
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
|
||||
Text: "Organizations", Description: "Isolated instances of Grafana running on the same server", Id: "global-orgs", Url: s.cfg.AppSubURL + "/admin/orgs", Icon: "building",
|
||||
})
|
||||
}
|
||||
|
||||
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) {
|
||||
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
|
||||
Text: "Settings", Description: "View the settings defined in your Grafana config", Id: "server-settings", Url: s.cfg.AppSubURL + "/admin/settings", Icon: "sliders-v-alt",
|
||||
})
|
||||
}
|
||||
|
||||
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) && s.features.IsEnabled(featuremgmt.FlagStorage) {
|
||||
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
|
||||
Text: "Storage",
|
||||
Id: "storage",
|
||||
Description: "Manage file storage",
|
||||
Icon: "cube",
|
||||
Url: s.cfg.AppSubURL + "/admin/storage",
|
||||
})
|
||||
}
|
||||
|
||||
if s.cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)) {
|
||||
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
|
||||
Text: "LDAP", Id: "ldap", Url: s.cfg.AppSubURL + "/admin/ldap", Icon: "book",
|
||||
})
|
||||
}
|
||||
|
||||
return adminNavLinks
|
||||
}
|
||||
|
77
pkg/services/pluginsettings/fake.go
Normal file
77
pkg/services/pluginsettings/fake.go
Normal file
@ -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) {
|
||||
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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 <Page {...getLoadingPageProps()}>{loading && <PageLoader />}</Page>;
|
||||
return <Page {...getLoadingPageProps(sectionNav)}>{loading && <PageLoader />}</Page>;
|
||||
}
|
||||
|
||||
if (!plugin.root) {
|
||||
return (
|
||||
<Page navModel={getWarningNav('Plugin load error')}>
|
||||
<Page navModel={sectionNav ?? getWarningNav('Plugin load error')}>
|
||||
<div>No root app page component found</div>;
|
||||
</Page>
|
||||
);
|
||||
@ -120,9 +122,9 @@ const stateSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
function getLoadingPageProps(): Partial<PageProps> {
|
||||
if (config.featureToggles.topnav) {
|
||||
return { navId: 'apps' };
|
||||
function getLoadingPageProps(sectionNav: NavModel | null): Partial<PageProps> {
|
||||
if (config.featureToggles.topnav && sectionNav) {
|
||||
return { navModel: sectionNav };
|
||||
}
|
||||
|
||||
const loading = { text: 'Loading plugin' };
|
||||
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
@ -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<GrafanaPlugin> {
|
||||
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;
|
||||
}
|
||||
|
@ -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: () => <NavLandingPage navId="apps" />,
|
||||
},
|
||||
{
|
||||
path: '/alerts-and-incidents',
|
||||
component: () => <NavLandingPage navId="alerts-and-incidents" />,
|
||||
},
|
||||
{
|
||||
path: '/monitoring',
|
||||
component: () => <NavLandingPage navId="monitoring" />,
|
||||
},
|
||||
{
|
||||
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 <AppRootPage {...props} />;
|
||||
} else {
|
||||
return <NavLandingPage navId={`plugin-page-${props.match.params.pluginId}`} />;
|
||||
}
|
||||
},
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "AppRootPage" */ 'app/features/plugins/components/AppRootPage')
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
Loading…
Reference in New Issue
Block a user