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

* NewIA: Plugin nav config

* progress

* Progress

* Things are working

* Add monitoring node

* Add alerts and incidents

* added experiment with standalone page

* Refactoring by adding a type for navtree root

* First test working

* More tests

* more tests

* Progress on richer config and sorting

* Sort weight working

* Path config

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

* fixed index routes

* removed file

* Fixes

* Fixing tests

* Fixing more tests and adding support for weight config

* Updates

* Remove unused fake

* More fixes

* Minor tweak

* Minor fix

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

* Added tests for frontend standalone page logic

* more tests

* Remove unused fake and fixed lint issue

* Moving reading settings to navtree impl package

* remove nav_id setting prefix

* Remove old test file

* Fix trailing newline

* Fixed bug with adding nil node

* fixing lint issue

* remove some code we have to rethink

* move read settings to PrivideService and switch to util.SplitString
This commit is contained in:
Torkel Ödegaard 2022-09-28 08:29:35 +02:00 committed by GitHub
parent 202dce66ff
commit e31cb93ec0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 1064 additions and 586 deletions

View File

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

View File

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

View File

@ -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: &quotaimpl.Service{
Cfg: cfg,
},
pluginStore: &fakePluginStore{},
pluginStore: &plugins.FakePluginStore{},
LibraryPanelService: &mockLibraryPanelService{},
LibraryElementService: &mockLibraryElementService{},
DashboardService: dashboardService,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(),

View File

@ -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(),

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,10 @@
package navtree
import (
"encoding/json"
"sort"
)
const (
// These weights may be used by an extension to reliably place
// itself in relation to a particular item in the menu. The weights
@ -25,6 +30,17 @@ const (
NavSectionConfig string = "config"
)
const (
NavIDDashboards = "dashboards"
NavIDDashboardsBrowse = "dashboards/browse"
NavIDCfg = "cfg" // NavIDCfg is the id for org configuration navigation node
NavIDAdmin = "admin"
NavIDAlertsAndIncidents = "alerts-and-incidents"
NavIDAlerting = "alerting"
NavIDMonitoring = "monitoring"
NavIDReporting = "reports"
)
type NavLink struct {
Id string `json:"id,omitempty"`
Text string `json:"text"`
@ -47,24 +63,126 @@ type NavLink struct {
EmptyMessageId string `json:"emptyMessageId,omitempty"`
}
// NavIDCfg is the id for org configuration navigation node
const NavIDCfg = "cfg"
func (node *NavLink) Sort() {
Sort(node.Children)
}
func GetServerAdminNode(children []*NavLink) *NavLink {
url := ""
if len(children) > 0 {
url = children[0].Url
type NavTreeRoot struct {
Children []*NavLink
}
func (root *NavTreeRoot) AddSection(node *NavLink) {
root.Children = append(root.Children, node)
}
func (root *NavTreeRoot) RemoveSection(node *NavLink) {
var result []*NavLink
for _, child := range root.Children {
if child != node {
result = append(result, child)
}
}
return &NavLink{
Text: "Server admin",
SubTitle: "Manage all users and orgs",
Description: "Manage server-wide settings and access to resources such as organizations, users, and licenses",
HideFromTabs: true,
Id: "admin",
Icon: "shield",
Url: url,
SortWeight: WeightAdmin,
Section: NavSectionConfig,
Children: children,
root.Children = result
}
func (root *NavTreeRoot) FindById(id string) *NavLink {
return FindById(root.Children, id)
}
func (root *NavTreeRoot) RemoveEmptySectionsAndApplyNewInformationArchitecture(topNavEnabled bool) {
// Remove server admin node if it has no children or set the url to first child
if node := root.FindById(NavIDAdmin); node != nil {
if len(node.Children) == 0 {
root.RemoveSection(node)
} else {
node.Url = node.Children[0].Url
}
}
if topNavEnabled {
orgAdminNode := root.FindById(NavIDCfg)
if orgAdminNode != nil {
orgAdminNode.Url = "/admin"
orgAdminNode.Text = "Administration"
}
if serverAdminNode := root.FindById(NavIDAdmin); serverAdminNode != nil {
serverAdminNode.Url = "/admin/settings"
serverAdminNode.Text = "Server admin"
serverAdminNode.SortWeight = 10000
if orgAdminNode != nil {
orgAdminNode.Children = append(orgAdminNode.Children, serverAdminNode)
root.RemoveSection(serverAdminNode)
}
}
// Move reports into dashboards
if reports := root.FindById(NavIDReporting); reports != nil {
if dashboards := root.FindById(NavIDDashboards); dashboards != nil {
reports.SortWeight = 0
dashboards.Children = append(dashboards.Children, reports)
root.RemoveSection(reports)
}
}
// Change id of dashboards
if dashboards := root.FindById(NavIDDashboards); dashboards != nil {
dashboards.Id = "dashboards/browse"
}
}
// Remove top level cfg / administration node if it has no children (needs to be after topnav new info archicture logic above that moves server admin into it)
// Remove server admin node if it has no children or set the url to first child
if node := root.FindById(NavIDCfg); node != nil {
if len(node.Children) == 0 {
root.RemoveSection(node)
} else if !topNavEnabled {
node.Url = node.Children[0].Url
}
}
}
func (root *NavTreeRoot) Sort() {
Sort(root.Children)
}
func (root *NavTreeRoot) MarshalJSON() ([]byte, error) {
return json.Marshal(root.Children)
}
func Sort(nodes []*NavLink) {
sort.SliceStable(nodes, func(i, j int) bool {
iw := nodes[i].SortWeight
if iw == 0 {
iw = int64(i) + 1
}
jw := nodes[j].SortWeight
if jw == 0 {
jw = int64(j) + 1
}
return iw < jw
})
for _, child := range nodes {
child.Sort()
}
}
func FindById(nodes []*NavLink, id string) *NavLink {
for _, child := range nodes {
if child.Id == id {
return child
} else if len(child.Children) > 0 {
if found := FindById(child.Children, id); found != nil {
return found
}
}
}
return nil
}

View File

@ -0,0 +1,89 @@
package navtree
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestNavTreeRoot(t *testing.T) {
t.Run("Should remove empty admin and server admin sections", func(t *testing.T) {
treeRoot := NavTreeRoot{
Children: []*NavLink{
{Id: NavIDCfg},
{Id: NavIDAdmin},
},
}
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(false)
require.Equal(t, 0, len(treeRoot.Children))
})
t.Run("Should not remove admin sections when they have children", func(t *testing.T) {
treeRoot := NavTreeRoot{
Children: []*NavLink{
{Id: NavIDCfg, Children: []*NavLink{{Id: "child"}}},
{Id: NavIDAdmin, Children: []*NavLink{{Id: "child"}}},
},
}
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(false)
require.Equal(t, 2, len(treeRoot.Children))
})
t.Run("Should move admin section into cfg and rename when topnav is enabled", func(t *testing.T) {
treeRoot := NavTreeRoot{
Children: []*NavLink{
{Id: NavIDCfg},
{Id: NavIDAdmin, Children: []*NavLink{{Id: "child"}}},
},
}
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(true)
require.Equal(t, "Administration", treeRoot.Children[0].Text)
require.Equal(t, NavIDAdmin, treeRoot.Children[0].Children[0].Id)
})
t.Run("Should move reports into Dashboards", func(t *testing.T) {
treeRoot := NavTreeRoot{
Children: []*NavLink{
{Id: NavIDDashboards},
{Id: NavIDReporting},
},
}
treeRoot.RemoveEmptySectionsAndApplyNewInformationArchitecture(true)
require.Equal(t, NavIDReporting, treeRoot.Children[0].Children[0].Id)
})
t.Run("Sorting by index", func(t *testing.T) {
treeRoot := NavTreeRoot{
Children: []*NavLink{
{Id: "1"},
{Id: "2"},
{Id: "3"},
},
}
treeRoot.Sort()
require.Equal(t, "1", treeRoot.Children[0].Id)
require.Equal(t, "3", treeRoot.Children[2].Id)
})
t.Run("Sorting by index and SortWeight", func(t *testing.T) {
treeRoot := NavTreeRoot{
Children: []*NavLink{
{Id: "1"},
{Id: "2"},
{Id: "3"},
{Id: "4", SortWeight: 1},
},
}
treeRoot.Sort()
require.Equal(t, "1", treeRoot.Children[0].Id)
require.Equal(t, "4", treeRoot.Children[1].Id)
})
}

View File

@ -6,5 +6,5 @@ import (
)
type Service interface {
GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) ([]*NavLink, error)
GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) (*NavTreeRoot, error)
}

View File

@ -12,7 +12,7 @@ import (
"github.com/grafana/grafana/pkg/services/serviceaccounts"
)
func (s *ServiceImpl) setupConfigNodes(c *models.ReqContext) ([]*navtree.NavLink, error) {
func (s *ServiceImpl) getOrgAdminNode(c *models.ReqContext) (*navtree.NavLink, error) {
var configNodes []*navtree.NavLink
hasAccess := ac.HasAccess(s.accessControl, c)
@ -103,7 +103,75 @@ func (s *ServiceImpl) setupConfigNodes(c *models.ReqContext) ([]*navtree.NavLink
Url: s.cfg.AppSubURL + "/org/serviceaccounts",
})
}
return configNodes, nil
configNode := &navtree.NavLink{
Id: navtree.NavIDCfg,
Text: "Configuration",
SubTitle: "Organization: " + c.OrgName,
Icon: "cog",
Section: navtree.NavSectionConfig,
SortWeight: navtree.WeightConfig,
Children: configNodes,
}
return configNode, nil
}
func (s *ServiceImpl) getServerAdminNode(c *models.ReqContext) *navtree.NavLink {
hasAccess := ac.HasAccess(s.accessControl, c)
hasGlobalAccess := ac.HasGlobalAccess(s.accessControl, s.accesscontrolService, c)
orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead)
adminNavLinks := []*navtree.NavLink{}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Users", Description: "Manage and create users across the whole Grafana server", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user",
})
}
if hasGlobalAccess(ac.ReqGrafanaAdmin, orgsAccessEvaluator) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Organizations", Description: "Isolated instances of Grafana running on the same server", Id: "global-orgs", Url: s.cfg.AppSubURL + "/admin/orgs", Icon: "building",
})
}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Settings", Description: "View the settings defined in your Grafana config", Id: "server-settings", Url: s.cfg.AppSubURL + "/admin/settings", Icon: "sliders-v-alt",
})
}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) && s.features.IsEnabled(featuremgmt.FlagStorage) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Storage",
Id: "storage",
Description: "Manage file storage",
Icon: "cube",
Url: s.cfg.AppSubURL + "/admin/storage",
})
}
if s.cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "LDAP", Id: "ldap", Url: s.cfg.AppSubURL + "/admin/ldap", Icon: "book",
})
}
adminNode := &navtree.NavLink{
Text: "Server admin",
Description: "Manage server-wide settings and access to resources such as organizations, users, and licenses",
Id: navtree.NavIDAdmin,
Icon: "shield",
SortWeight: navtree.WeightAdmin,
Section: navtree.NavSectionConfig,
Children: adminNavLinks,
}
if len(adminNavLinks) > 0 {
adminNode.Url = adminNavLinks[0].Url
}
return adminNode
}
func (s *ServiceImpl) ReqCanAdminTeams(c *models.ReqContext) bool {

View File

@ -3,6 +3,7 @@ package navtreeimpl
import (
"path"
"sort"
"strconv"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
@ -10,15 +11,17 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/navtree"
"github.com/grafana/grafana/pkg/services/pluginsettings"
"github.com/grafana/grafana/pkg/util"
)
func (s *ServiceImpl) getAppLinks(c *models.ReqContext) ([]*navtree.NavLink, error) {
func (s *ServiceImpl) addAppLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqContext) error {
topNavEnabled := s.features.IsEnabled(featuremgmt.FlagTopnav)
hasAccess := ac.HasAccess(s.accessControl, c)
appLinks := []*navtree.NavLink{}
pss, err := s.pluginSettings.GetPluginSettings(c.Req.Context(), &pluginsettings.GetArgs{OrgID: c.OrgID})
if err != nil {
return nil, err
return err
}
isPluginEnabled := func(plugin plugins.PluginDTO) bool {
@ -43,63 +46,8 @@ func (s *ServiceImpl) getAppLinks(c *models.ReqContext) ([]*navtree.NavLink, err
continue
}
appLink := &navtree.NavLink{
Text: plugin.Name,
Id: "plugin-page-" + plugin.ID,
Img: plugin.Info.Logos.Small,
Section: navtree.NavSectionPlugin,
SortWeight: navtree.WeightPlugin,
}
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
appLink.Url = s.cfg.AppSubURL + "/a/" + plugin.ID
} else {
appLink.Url = path.Join(s.cfg.AppSubURL, plugin.DefaultNavURL)
}
for _, include := range plugin.Includes {
if !c.HasUserRole(include.Role) {
continue
}
if include.Type == "page" && include.AddToNav {
var link *navtree.NavLink
if len(include.Path) > 0 {
link = &navtree.NavLink{
Url: s.cfg.AppSubURL + include.Path,
Text: include.Name,
}
if include.DefaultNav && !s.features.IsEnabled(featuremgmt.FlagTopnav) {
appLink.Url = link.Url // Overwrite the hardcoded page logic
}
} else {
link = &navtree.NavLink{
Url: s.cfg.AppSubURL + "/plugins/" + plugin.ID + "/page/" + include.Slug,
Text: include.Name,
}
}
link.Icon = include.Icon
appLink.Children = append(appLink.Children, link)
}
if include.Type == "dashboard" && include.AddToNav {
dboardURL := include.DashboardURLPath()
if dboardURL != "" {
link := &navtree.NavLink{
Url: path.Join(s.cfg.AppSubURL, dboardURL),
Text: include.Name,
}
appLink.Children = append(appLink.Children, link)
}
}
}
if len(appLink.Children) > 0 {
// If we only have one child and it's the app default nav then remove it from children
if len(appLink.Children) == 1 && appLink.Children[0].Url == appLink.Url {
appLink.Children = []*navtree.NavLink{}
}
appLinks = append(appLinks, appLink)
if appNode := s.processAppPlugin(plugin, c, topNavEnabled, treeRoot); appNode != nil {
appLinks = append(appLinks, appNode)
}
}
@ -109,5 +57,157 @@ func (s *ServiceImpl) getAppLinks(c *models.ReqContext) ([]*navtree.NavLink, err
})
}
return appLinks, nil
if topNavEnabled {
treeRoot.AddSection(&navtree.NavLink{
Text: "Apps",
Icon: "apps",
Description: "App plugins that extend the Grafana experience",
Id: "apps",
Children: appLinks,
Section: navtree.NavSectionCore,
Url: s.cfg.AppSubURL + "/apps",
})
} else {
for _, appLink := range appLinks {
treeRoot.AddSection(appLink)
}
}
return nil
}
func (s *ServiceImpl) processAppPlugin(plugin plugins.PluginDTO, c *models.ReqContext, topNavEnabled bool, treeRoot *navtree.NavTreeRoot) *navtree.NavLink {
appLink := &navtree.NavLink{
Text: plugin.Name,
Id: "plugin-page-" + plugin.ID,
Img: plugin.Info.Logos.Small,
Section: navtree.NavSectionPlugin,
SortWeight: navtree.WeightPlugin,
}
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
appLink.Url = s.cfg.AppSubURL + "/a/" + plugin.ID
} else {
appLink.Url = path.Join(s.cfg.AppSubURL, plugin.DefaultNavURL)
}
for _, include := range plugin.Includes {
if !c.HasUserRole(include.Role) {
continue
}
if include.Type == "page" && include.AddToNav {
link := &navtree.NavLink{
Text: include.Name,
Icon: include.Icon,
}
if len(include.Path) > 0 {
link.Url = s.cfg.AppSubURL + include.Path
if include.DefaultNav {
appLink.Url = link.Url
}
} else {
link.Url = s.cfg.AppSubURL + "/plugins/" + plugin.ID + "/page/" + include.Slug
}
if pathConfig, ok := s.navigationAppPathConfig[include.Path]; ok {
if sectionForPage := treeRoot.FindById(pathConfig.SectionID); sectionForPage != nil {
link.Id = "standalone-plugin-page-" + include.Path
link.SortWeight = pathConfig.SortWeight
sectionForPage.Children = append(sectionForPage.Children, link)
}
} else {
appLink.Children = append(appLink.Children, link)
}
}
if include.Type == "dashboard" && include.AddToNav {
dboardURL := include.DashboardURLPath()
if dboardURL != "" {
link := &navtree.NavLink{
Url: path.Join(s.cfg.AppSubURL, dboardURL),
Text: include.Name,
}
appLink.Children = append(appLink.Children, link)
}
}
}
if len(appLink.Children) > 0 {
// If we only have one child and it's the app default nav then remove it from children
if len(appLink.Children) == 1 && appLink.Children[0].Url == appLink.Url {
appLink.Children = []*navtree.NavLink{}
}
alertingNode := treeRoot.FindById(navtree.NavIDAlerting)
if navConfig, hasOverride := s.navigationAppConfig[plugin.ID]; hasOverride && topNavEnabled {
appLink.SortWeight = navConfig.SortWeight
if navNode := treeRoot.FindById(navConfig.SectionID); navNode != nil {
navNode.Children = append(navNode.Children, appLink)
} else {
if navConfig.SectionID == navtree.NavIDMonitoring {
treeRoot.AddSection(&navtree.NavLink{
Text: "Monitoring",
Id: navtree.NavIDMonitoring,
Description: "Monitoring and infrastructure apps",
Icon: "heart-rate",
Section: navtree.NavSectionCore,
Children: []*navtree.NavLink{appLink},
Url: s.cfg.AppSubURL + "/monitoring",
})
} else if navConfig.SectionID == navtree.NavIDAlertsAndIncidents && alertingNode != nil {
treeRoot.AddSection(&navtree.NavLink{
Text: "Alerts & incidents",
Id: navtree.NavIDAlertsAndIncidents,
Description: "Alerting and incident management apps",
Icon: "bell",
Section: navtree.NavSectionCore,
Children: []*navtree.NavLink{alertingNode, appLink},
Url: s.cfg.AppSubURL + "/alerts-and-incidents",
})
treeRoot.RemoveSection(alertingNode)
} else {
s.log.Error("Plugin app nav id not found", "pluginId", plugin.ID, "navId", navConfig.SectionID)
}
}
} else {
return appLink
}
}
return nil
}
func (s *ServiceImpl) readNavigationSettings() {
s.navigationAppConfig = map[string]NavigationAppConfig{
"grafana-k8s-app": {SectionID: navtree.NavIDMonitoring, SortWeight: 1},
"grafana-synthetic-monitoring-app": {SectionID: navtree.NavIDMonitoring, SortWeight: 2},
"grafana-oncall-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 1},
"grafana-incident-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 2},
"grafana-ml-app": {SectionID: navtree.NavIDAlertsAndIncidents, SortWeight: 3},
}
s.navigationAppPathConfig = map[string]NavigationAppConfig{
"/a/grafana-auth-app": {SectionID: navtree.NavIDCfg, SortWeight: 7},
}
sec := s.cfg.Raw.Section("navigation.apps")
for _, key := range sec.Keys() {
pluginId := key.Name()
// Support <id> <weight> value
values := util.SplitString(sec.Key(key.Name()).MustString(""))
appCfg := &NavigationAppConfig{SectionID: values[0]}
if len(values) > 1 {
if weight, err := strconv.ParseInt(values[1], 10, 64); err == nil {
appCfg.SortWeight = weight
}
}
s.navigationAppConfig[pluginId] = *appCfg
}
}

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

View File

@ -33,10 +33,19 @@ type ServiceImpl struct {
accesscontrolService ac.Service
kvStore kvstore.KVStore
apiKeyService apikey.Service
// Navigation
navigationAppConfig map[string]NavigationAppConfig
navigationAppPathConfig map[string]NavigationAppConfig
}
type NavigationAppConfig struct {
SectionID string
SortWeight int64
}
func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStore plugins.Store, pluginSettings pluginsettings.Service, starService star.Service, features *featuremgmt.FeatureManager, dashboardService dashboards.DashboardService, accesscontrolService ac.Service, kvStore kvstore.KVStore, apiKeyService apikey.Service) navtree.Service {
return &ServiceImpl{
service := &ServiceImpl{
cfg: cfg,
log: log.New("navtree service"),
accessControl: accessControl,
@ -49,12 +58,16 @@ func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStor
kvStore: kvStore,
apiKeyService: apiKeyService,
}
service.readNavigationSettings()
return service
}
//nolint:gocyclo
func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) ([]*navtree.NavLink, error) {
func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) (*navtree.NavTreeRoot, error) {
hasAccess := ac.HasAccess(s.accessControl, c)
var navTree []*navtree.NavLink
treeRoot := &navtree.NavTreeRoot{}
if hasAccess(ac.ReqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsRead)) {
starredItemsLinks, err := s.buildStarredItemsNavLinks(c, prefs)
@ -62,7 +75,7 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
return nil, err
}
navTree = append(navTree, &navtree.NavLink{
treeRoot.AddSection(&navtree.NavLink{
Text: "Starred",
Id: "starred",
Icon: "star",
@ -74,25 +87,19 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
dashboardChildLinks := s.buildDashboardNavLinks(c, hasEditPerm)
dashboardsUrl := "/dashboards"
dashboardLink := &navtree.NavLink{
Text: "Dashboards",
Id: "dashboards",
Id: navtree.NavIDDashboards,
Description: "Create and manage dashboards to visualize your data",
SubTitle: "Manage dashboards and folders",
Icon: "apps",
Url: s.cfg.AppSubURL + dashboardsUrl,
Url: s.cfg.AppSubURL + "/dashboards",
SortWeight: navtree.WeightDashboard,
Section: navtree.NavSectionCore,
Children: dashboardChildLinks,
}
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
dashboardLink.Id = "dashboards/browse"
}
navTree = append(navTree, dashboardLink)
treeRoot.AddSection(dashboardLink)
}
canExplore := func(context *models.ReqContext) bool {
@ -100,7 +107,7 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
}
if setting.ExploreEnabled && hasAccess(canExplore, ac.EvalPermission(ac.ActionDatasourcesExplore)) {
navTree = append(navTree, &navtree.NavLink{
treeRoot.AddSection(&navtree.NavLink{
Text: "Explore",
Id: "explore",
SubTitle: "Explore your data",
@ -111,44 +118,25 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
})
}
navTree = s.addProfile(navTree, c)
if setting.ProfileEnabled && c.IsSignedIn {
treeRoot.AddSection(s.getProfileNode(c))
}
_, uaIsDisabledForOrg := s.cfg.UnifiedAlerting.DisabledOrgs[c.OrgID]
uaVisibleForOrg := s.cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg
if setting.AlertingEnabled != nil && *setting.AlertingEnabled {
navTree = append(navTree, s.buildLegacyAlertNavLinks(c)...)
if legacyAlertSection := s.buildLegacyAlertNavLinks(c); legacyAlertSection != nil {
treeRoot.AddSection(legacyAlertSection)
}
} else if uaVisibleForOrg {
navTree = append(navTree, s.buildAlertNavLinks(c, hasEditPerm)...)
if alertingSection := s.buildAlertNavLinks(c, hasEditPerm); alertingSection != nil {
treeRoot.AddSection(alertingSection)
}
}
if s.features.IsEnabled(featuremgmt.FlagDataConnectionsConsole) {
navTree = append(navTree, s.buildDataConnectionsNavLink(c))
}
appLinks, err := s.getAppLinks(c)
if err != nil {
return nil, err
}
// When topnav is enabled we can test new information architecture where plugins live in Apps category
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
navTree = append(navTree, &navtree.NavLink{
Text: "Apps",
Icon: "apps",
Description: "App plugins that extend the Grafana experience",
Id: "apps",
Children: appLinks,
Section: navtree.NavSectionCore,
Url: s.cfg.AppSubURL + "/apps",
})
} else {
navTree = append(navTree, appLinks...)
}
configNodes, err := s.setupConfigNodes(c)
if err != nil {
return navTree, err
treeRoot.AddSection(s.buildDataConnectionsNavLink(c))
}
if s.features.IsEnabled(featuremgmt.FlagLivePipeline) {
@ -163,7 +151,8 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
liveNavLinks = append(liveNavLinks, &navtree.NavLink{
Text: "Cloud", Id: "live-cloud", Url: s.cfg.AppSubURL + "/live/cloud", Icon: "cloud-upload",
})
navTree = append(navTree, &navtree.NavLink{
treeRoot.AddSection(&navtree.NavLink{
Id: "live",
Text: "Live",
SubTitle: "Event streaming",
@ -175,60 +164,37 @@ func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *
})
}
var configNode *navtree.NavLink
var serverAdminNode *navtree.NavLink
orgAdminNode, err := s.getOrgAdminNode(c)
if len(configNodes) > 0 {
configNode = &navtree.NavLink{
Id: navtree.NavIDCfg,
Text: "Configuration",
SubTitle: "Organization: " + c.OrgName,
Icon: "cog",
Url: configNodes[0].Url,
Section: navtree.NavSectionConfig,
SortWeight: navtree.WeightConfig,
Children: configNodes,
}
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
configNode.Url = "/admin"
} else {
configNode.Url = configNodes[0].Url
}
navTree = append(navTree, configNode)
if orgAdminNode != nil {
treeRoot.AddSection(orgAdminNode)
} else if err != nil {
return nil, err
}
adminNavLinks := s.buildAdminNavLinks(c)
serverAdminNode := s.getServerAdminNode(c)
if len(adminNavLinks) > 0 {
serverAdminNode = navtree.GetServerAdminNode(adminNavLinks)
navTree = append(navTree, serverAdminNode)
if serverAdminNode != nil {
treeRoot.AddSection(serverAdminNode)
}
if s.features.IsEnabled(featuremgmt.FlagTopnav) {
// Move server admin into Configuration and rename to administration
if configNode != nil && serverAdminNode != nil {
configNode.Text = "Administration"
serverAdminNode.Url = "/admin/server"
serverAdminNode.HideFromTabs = false
configNode.Children = append(configNode.Children, serverAdminNode)
adminNodeIndex := len(navTree) - 1
navTree = navTree[:adminNodeIndex]
}
s.addHelpLinks(treeRoot, c)
if err := s.addAppLinks(treeRoot, c); err != nil {
return nil, err
}
navTree = s.addHelpLinks(navTree, c)
return navTree, nil
return treeRoot, nil
}
func (s *ServiceImpl) addHelpLinks(navTree []*navtree.NavLink, c *models.ReqContext) []*navtree.NavLink {
func (s *ServiceImpl) addHelpLinks(treeRoot *navtree.NavTreeRoot, c *models.ReqContext) {
if setting.HelpEnabled {
helpVersion := fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit)
if s.cfg.AnonymousHideVersion && !c.IsSignedIn {
helpVersion = setting.ApplicationName
}
navTree = append(navTree, &navtree.NavLink{
treeRoot.AddSection(&navtree.NavLink{
Text: "Help",
SubTitle: helpVersion,
Id: "help",
@ -239,14 +205,6 @@ func (s *ServiceImpl) addHelpLinks(navTree []*navtree.NavLink, c *models.ReqCont
Children: []*navtree.NavLink{},
})
}
return navTree
}
func (s *ServiceImpl) addProfile(navTree []*navtree.NavLink, c *models.ReqContext) []*navtree.NavLink {
if setting.ProfileEnabled && c.IsSignedIn {
navTree = append(navTree, s.getProfileNode(c))
}
return navTree
}
func (s *ServiceImpl) getProfileNode(c *models.ReqContext) *navtree.NavLink {
@ -352,11 +310,13 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
}
dashboardChildNavs := []*navtree.NavLink{}
if !s.features.IsEnabled(featuremgmt.FlagTopnav) {
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "Browse", Id: "dashboards/browse", Url: s.cfg.AppSubURL + "/dashboards", Icon: "sitemap",
Text: "Browse", Id: navtree.NavIDDashboardsBrowse, Url: s.cfg.AppSubURL + "/dashboards", Icon: "sitemap",
})
}
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "Playlists", Description: "Groups of dashboards that are displayed in a sequence", Id: "dashboards/playlists", Url: s.cfg.AppSubURL + "/playlists", Icon: "presentation-play",
})
@ -388,7 +348,7 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
})
}
if hasEditPerm {
if hasEditPerm && !s.features.IsEnabled(featuremgmt.FlagTopnav) {
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true,
})
@ -413,10 +373,11 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b
})
}
}
return dashboardChildNavs
}
func (s *ServiceImpl) buildLegacyAlertNavLinks(c *models.ReqContext) []*navtree.NavLink {
func (s *ServiceImpl) buildLegacyAlertNavLinks(c *models.ReqContext) *navtree.NavLink {
var alertChildNavs []*navtree.NavLink
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Alert rules", Id: "alert-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul",
@ -446,10 +407,10 @@ func (s *ServiceImpl) buildLegacyAlertNavLinks(c *models.ReqContext) []*navtree.
alertNav.Url = s.cfg.AppSubURL + "/alerting/list"
}
return []*navtree.NavLink{&alertNav}
return &alertNav
}
func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool) []*navtree.NavLink {
func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool) *navtree.NavLink {
hasAccess := ac.HasAccess(s.accessControl, c)
var alertChildNavs []*navtree.NavLink
@ -497,7 +458,7 @@ func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool)
Text: "Alerting",
Description: "Learn about problems in your systems moments after they occur",
SubTitle: "Alert rules and notifications",
Id: "alerting",
Id: navtree.NavIDAlerting,
Icon: "bell",
Children: alertChildNavs,
Section: navtree.NavSectionCore,
@ -510,8 +471,9 @@ func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool)
alertNav.Url = s.cfg.AppSubURL + "/alerting/list"
}
return []*navtree.NavLink{&alertNav}
return &alertNav
}
return nil
}
@ -558,46 +520,3 @@ func (s *ServiceImpl) buildDataConnectionsNavLink(c *models.ReqContext) *navtree
return navLink
}
func (s *ServiceImpl) buildAdminNavLinks(c *models.ReqContext) []*navtree.NavLink {
hasAccess := ac.HasAccess(s.accessControl, c)
hasGlobalAccess := ac.HasGlobalAccess(s.accessControl, s.accesscontrolService, c)
orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead)
adminNavLinks := []*navtree.NavLink{}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Users", Description: "Manage and create users across the whole Grafana server", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user",
})
}
if hasGlobalAccess(ac.ReqGrafanaAdmin, orgsAccessEvaluator) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Organizations", Description: "Isolated instances of Grafana running on the same server", Id: "global-orgs", Url: s.cfg.AppSubURL + "/admin/orgs", Icon: "building",
})
}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Settings", Description: "View the settings defined in your Grafana config", Id: "server-settings", Url: s.cfg.AppSubURL + "/admin/settings", Icon: "sliders-v-alt",
})
}
if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) && s.features.IsEnabled(featuremgmt.FlagStorage) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "Storage",
Id: "storage",
Description: "Manage file storage",
Icon: "cube",
Url: s.cfg.AppSubURL + "/admin/storage",
})
}
if s.cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)) {
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
Text: "LDAP", Id: "ldap", Url: s.cfg.AppSubURL + "/admin/ldap", Icon: "book",
})
}
return adminNavLinks
}

View 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
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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' };

View File

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

View File

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

View File

@ -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')
),
},
]
: [];