Plugins: Refactor GetPluginDashboards/LoadPluginDashboard (#46316)

Refactors GetPluginDashboards/LoadPluginDashboard by moving database 
interaction from plugin management to the plugindashboards service.

Fixes #44553

Co-authored-by: Will Browne <wbrowne@users.noreply.github.com>
This commit is contained in:
Marcus Efraimsson 2022-03-10 18:38:04 +01:00 committed by GitHub
parent d076cabb60
commit 6c7d326499
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1434 additions and 579 deletions

View File

@ -40,6 +40,7 @@ import (
"github.com/grafana/grafana/pkg/services/sqlstore/mockstore"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
"github.com/grafana/grafana/pkg/web/webtest"
"github.com/stretchr/testify/require"
)
@ -446,3 +447,31 @@ func mockRequestBody(v interface{}) io.ReadCloser {
b, _ := json.Marshal(v)
return io.NopCloser(bytes.NewReader(b))
}
// APITestServerOption option func for customizing HTTPServer configuration
// when setting up an API test server via SetupAPITestServer.
type APITestServerOption func(hs *HTTPServer)
// SetupAPITestServer sets up a webtest.Server ready for testing all
// routes registered via HTTPServer.registerRoutes().
// Optionally customize HTTPServer configuration by providing APITestServerOption
// option(s).
func SetupAPITestServer(t *testing.T, opts ...APITestServerOption) *webtest.Server {
t.Helper()
hs := &HTTPServer{
RouteRegister: routing.NewRouteRegister(),
Cfg: setting.NewCfg(),
AccessControl: accesscontrolmock.New().WithDisabled(),
Features: featuremgmt.WithFeatures(),
searchUsersService: &searchusers.OSSService{},
}
for _, opt := range opts {
opt(hs)
}
hs.registerRoutes()
s := webtest.NewServer(t, hs.RouteRegister)
return s
}

View File

@ -48,6 +48,7 @@ import (
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/ngalert"
"github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/services/plugindashboards"
pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/query"
@ -100,7 +101,7 @@ type HTTPServer struct {
PluginRequestValidator models.PluginRequestValidator
pluginClient plugins.Client
pluginStore plugins.Store
pluginDashboardManager plugins.PluginDashboardManager
pluginDashboardService plugindashboards.Service
pluginStaticRouteResolver plugins.StaticRouteResolver
pluginErrorResolver plugins.ErrorResolver
SearchService search.Service
@ -152,7 +153,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
renderService rendering.Service, licensing models.Licensing, hooksService *hooks.HooksService,
cacheService *localcache.CacheService, sqlStore *sqlstore.SQLStore, alertEngine *alerting.AlertEngine,
pluginRequestValidator models.PluginRequestValidator, pluginStaticRouteResolver plugins.StaticRouteResolver,
pluginDashboardManager plugins.PluginDashboardManager, pluginStore plugins.Store, pluginClient plugins.Client,
pluginDashboardService plugindashboards.Service, pluginStore plugins.Store, pluginClient plugins.Client,
pluginErrorResolver plugins.ErrorResolver, settingsProvider setting.Provider,
dataSourceCache datasources.CacheService, userTokenService models.UserTokenService,
cleanUpService *cleanup.CleanUpService, shortURLService shorturls.Service, queryHistoryService queryhistory.Service,
@ -191,7 +192,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
pluginClient: pluginClient,
pluginStore: pluginStore,
pluginStaticRouteResolver: pluginStaticRouteResolver,
pluginDashboardManager: pluginDashboardManager,
pluginDashboardService: pluginDashboardService,
pluginErrorResolver: pluginErrorResolver,
grafanaUpdateChecker: grafanaUpdateChecker,
pluginsUpdateChecker: pluginsUpdateChecker,

View File

@ -0,0 +1,35 @@
package api
import (
"errors"
"net/http"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/web"
)
// GetPluginDashboards get plugin dashboards.
//
// /api/plugins/:pluginId/dashboards
func (hs *HTTPServer) GetPluginDashboards(c *models.ReqContext) response.Response {
pluginID := web.Params(c.Req)[":pluginId"]
listReq := &plugindashboards.ListPluginDashboardsRequest{
OrgID: c.OrgId,
PluginID: pluginID,
}
list, err := hs.pluginDashboardService.ListPluginDashboards(c.Req.Context(), listReq)
if err != nil {
var notFound plugins.NotFoundError
if errors.As(err, &notFound) {
return response.Error(http.StatusNotFound, notFound.Error(), nil)
}
return response.Error(http.StatusInternalServerError, "Failed to get plugin dashboards", err)
}
return response.JSON(http.StatusOK, list.Items)
}

View File

@ -0,0 +1,128 @@
package api
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"testing"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/web/webtest"
"github.com/stretchr/testify/require"
)
func TestGetPluginDashboards(t *testing.T) {
const existingPluginID = "existing-plugin"
pluginDashboardService := &pluginDashboardServiceMock{
pluginDashboards: map[string][]*plugindashboards.PluginDashboard{
existingPluginID: {
{
PluginId: existingPluginID,
UID: "a",
Title: "A",
},
{
PluginId: existingPluginID,
UID: "b",
Title: "B",
},
},
},
unexpectedErrors: map[string]error{
"boom": fmt.Errorf("BOOM"),
},
}
s := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.pluginDashboardService = pluginDashboardService
})
t.Run("Not signed in should return 404 Not Found", func(t *testing.T) {
req := s.NewGetRequest("/api/plugins/test/dashboards")
resp, err := s.Send(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("Signed in and not org admin should return 403 Forbidden", func(t *testing.T) {
user := &models.SignedInUser{
UserId: 1,
OrgRole: models.ROLE_EDITOR,
}
resp, err := sendGetPluginDashboardsRequestForSignedInUser(t, s, existingPluginID, user)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusForbidden, resp.StatusCode)
})
t.Run("Signed in and org admin", func(t *testing.T) {
user := &models.SignedInUser{
UserId: 1,
OrgId: 1,
OrgRole: models.ROLE_ADMIN,
}
t.Run("When plugin doesn't exist should return 404 Not Found", func(t *testing.T) {
resp, err := sendGetPluginDashboardsRequestForSignedInUser(t, s, "not-exists", user)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("When result is unexpected error should return 500 Internal Server Error", func(t *testing.T) {
resp, err := sendGetPluginDashboardsRequestForSignedInUser(t, s, "boom", user)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusInternalServerError, resp.StatusCode)
})
t.Run("When plugin exists should return 200 OK with expected payload", func(t *testing.T) {
resp, err := sendGetPluginDashboardsRequestForSignedInUser(t, s, existingPluginID, user)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
bytes, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
var listResp []*plugindashboards.PluginDashboard
err = json.Unmarshal(bytes, &listResp)
require.NoError(t, err)
require.NotNil(t, listResp)
require.Len(t, listResp, 2)
require.Equal(t, pluginDashboardService.pluginDashboards[existingPluginID], listResp)
})
})
}
func sendGetPluginDashboardsRequestForSignedInUser(t *testing.T, s *webtest.Server, pluginID string, user *models.SignedInUser) (*http.Response, error) {
t.Helper()
req := s.NewGetRequest(fmt.Sprintf("/api/plugins/%s/dashboards", pluginID))
webtest.RequestWithSignedInUser(req, user)
return s.Send(req)
}
type pluginDashboardServiceMock struct {
plugindashboards.Service
pluginDashboards map[string][]*plugindashboards.PluginDashboard
unexpectedErrors map[string]error
}
func (m *pluginDashboardServiceMock) ListPluginDashboards(ctx context.Context, req *plugindashboards.ListPluginDashboardsRequest) (*plugindashboards.ListPluginDashboardsResponse, error) {
if pluginDashboards, exists := m.pluginDashboards[req.PluginID]; exists {
return &plugindashboards.ListPluginDashboardsResponse{
Items: pluginDashboards,
}, nil
}
if err, exists := m.unexpectedErrors[req.PluginID]; exists {
return nil, err
}
return nil, plugins.NotFoundError{PluginID: req.PluginID}
}

View File

@ -182,22 +182,6 @@ func (hs *HTTPServer) UpdatePluginSetting(c *models.ReqContext) response.Respons
return response.Success("Plugin settings updated")
}
func (hs *HTTPServer) GetPluginDashboards(c *models.ReqContext) response.Response {
pluginID := web.Params(c.Req)[":pluginId"]
list, err := hs.pluginDashboardManager.GetPluginDashboards(c.Req.Context(), c.OrgId, pluginID)
if err != nil {
var notFound plugins.NotFoundError
if errors.As(err, &notFound) {
return response.Error(404, notFound.Error(), nil)
}
return response.Error(500, "Failed to get plugin dashboards", err)
}
return response.JSON(200, list)
}
func (hs *HTTPServer) GetPluginMarkdown(c *models.ReqContext) response.Response {
pluginID := web.Params(c.Req)[":pluginId"]
name := web.Params(c.Req)[":name"]

View File

@ -2,10 +2,10 @@ package plugins
import (
"context"
"io"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
)
@ -73,26 +73,32 @@ type PluginLoaderAuthorizer interface {
CanLoadPlugin(plugin *Plugin) bool
}
type PluginDashboardInfoDTO struct {
UID string `json:"uid"`
PluginId string `json:"pluginId"`
Title string `json:"title"`
Imported bool `json:"imported"`
ImportedUri string `json:"importedUri"`
ImportedUrl string `json:"importedUrl"`
Slug string `json:"slug"`
DashboardId int64 `json:"dashboardId"`
FolderId int64 `json:"folderId"`
ImportedRevision int64 `json:"importedRevision"`
Revision int64 `json:"revision"`
Description string `json:"description"`
Path string `json:"path"`
Removed bool `json:"removed"`
// ListPluginDashboardFilesArgs list plugin dashboard files argument model.
type ListPluginDashboardFilesArgs struct {
PluginID string
}
type PluginDashboardManager interface {
// GetPluginDashboards gets dashboards for a certain org/plugin.
GetPluginDashboards(ctx context.Context, orgID int64, pluginID string) ([]*PluginDashboardInfoDTO, error)
// LoadPluginDashboard loads a plugin dashboard.
LoadPluginDashboard(ctx context.Context, pluginID, path string) (*models.Dashboard, error)
// GetPluginDashboardFilesArgs list plugin dashboard files result model.
type ListPluginDashboardFilesResult struct {
FileReferences []string
}
// GetPluginDashboardFileContentsArgs get plugin dashboard file content argument model.
type GetPluginDashboardFileContentsArgs struct {
PluginID string
FileReference string
}
// GetPluginDashboardFileContentsResult get plugin dashboard file content result model.
type GetPluginDashboardFileContentsResult struct {
Content io.ReadCloser
}
// DashboardFileStore is the interface for plugin dashboard file storage.
type DashboardFileStore interface {
// ListPluginDashboardFiles lists plugin dashboard files.
ListPluginDashboardFiles(ctx context.Context, args *ListPluginDashboardFilesArgs) (*ListPluginDashboardFilesResult, error)
// GetPluginDashboardFileContents gets the referenced plugin dashboard file content.
GetPluginDashboardFileContents(ctx context.Context, args *GetPluginDashboardFileContentsArgs) (*GetPluginDashboardFileContentsResult, error)
}

View File

@ -0,0 +1,92 @@
package manager
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/util"
)
var openDashboardFile = func(name string) (fs.File, error) {
// Wrapping in filepath.Clean to properly handle
// gosec G304 Potential file inclusion via variable rule.
return os.Open(filepath.Clean(name))
}
func (m *PluginManager) ListPluginDashboardFiles(ctx context.Context, args *plugins.ListPluginDashboardFilesArgs) (*plugins.ListPluginDashboardFilesResult, error) {
if args == nil {
return nil, fmt.Errorf("args cannot be nil")
}
if len(strings.TrimSpace(args.PluginID)) == 0 {
return nil, fmt.Errorf("args.PluginID cannot be empty")
}
plugin, exists := m.Plugin(ctx, args.PluginID)
if !exists {
return nil, plugins.NotFoundError{PluginID: args.PluginID}
}
references := []string{}
for _, include := range plugin.DashboardIncludes() {
references = append(references, include.Path)
}
return &plugins.ListPluginDashboardFilesResult{
FileReferences: references,
}, nil
}
func (m *PluginManager) GetPluginDashboardFileContents(ctx context.Context, args *plugins.GetPluginDashboardFileContentsArgs) (*plugins.GetPluginDashboardFileContentsResult, error) {
if args == nil {
return nil, fmt.Errorf("args cannot be nil")
}
if len(strings.TrimSpace(args.PluginID)) == 0 {
return nil, fmt.Errorf("args.PluginID cannot be empty")
}
if len(strings.TrimSpace(args.FileReference)) == 0 {
return nil, fmt.Errorf("args.FileReference cannot be empty")
}
plugin, exists := m.Plugin(ctx, args.PluginID)
if !exists {
return nil, plugins.NotFoundError{PluginID: args.PluginID}
}
var includedFile *plugins.Includes
for _, include := range plugin.DashboardIncludes() {
if args.FileReference == include.Path {
includedFile = include
break
}
}
if includedFile == nil {
return nil, fmt.Errorf("plugin dashboard file not found")
}
cleanPath, err := util.CleanRelativePath(includedFile.Path)
if err != nil {
// CleanRelativePath should clean and make the path relative so this is not expected to fail
return nil, err
}
dashboardFilePath := filepath.Join(plugin.PluginDir, cleanPath)
file, err := openDashboardFile(dashboardFilePath)
if err != nil {
return nil, err
}
return &plugins.GetPluginDashboardFileContentsResult{
Content: file,
}, nil
}
var _ plugins.DashboardFileStore = &PluginManager{}

View File

@ -0,0 +1,223 @@
package manager
import (
"context"
"io"
"testing"
"testing/fstest"
"github.com/grafana/grafana/pkg/plugins"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDashboardFileStore(t *testing.T) {
m := setupPluginDashboardsForTest(t)
t.Run("Input validation", func(t *testing.T) {
t.Run("ListPluginDashboardFiles", func(t *testing.T) {
testCases := []struct {
name string
args *plugins.ListPluginDashboardFilesArgs
}{
{
name: "nil args should return error",
},
{
name: "empty args.PluginID should return error",
args: &plugins.ListPluginDashboardFilesArgs{},
},
{
name: "args.PluginID with only space should return error",
args: &plugins.ListPluginDashboardFilesArgs{PluginID: " \t "},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
res, err := m.ListPluginDashboardFiles(context.Background(), tc.args)
assert.Error(t, err)
assert.Nil(t, res)
})
}
})
t.Run("GetPluginDashboardFileContents", func(t *testing.T) {
testCases := []struct {
name string
args *plugins.GetPluginDashboardFileContentsArgs
}{
{
name: "nil args should return error",
},
{
name: "empty args.PluginID should return error",
args: &plugins.GetPluginDashboardFileContentsArgs{},
},
{
name: "args.PluginID with only space should return error",
args: &plugins.GetPluginDashboardFileContentsArgs{PluginID: " "},
},
{
name: "empty args.FileReference should return error",
args: &plugins.GetPluginDashboardFileContentsArgs{
PluginID: "pluginWithDashboards",
},
},
{
name: "args.FileReference with only space should return error",
args: &plugins.GetPluginDashboardFileContentsArgs{
PluginID: "pluginWithDashboard",
FileReference: " \t",
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
res, err := m.GetPluginDashboardFileContents(context.Background(), tc.args)
assert.Error(t, err)
assert.Nil(t, res)
})
}
})
})
t.Run("Plugin without dashboards", func(t *testing.T) {
t.Run("Should return zero file references", func(t *testing.T) {
res, err := m.ListPluginDashboardFiles(context.Background(), &plugins.ListPluginDashboardFilesArgs{
PluginID: "pluginWithoutDashboards",
})
require.NoError(t, err)
require.NotNil(t, res)
require.Len(t, res.FileReferences, 0)
})
t.Run("Should return file not found error when trying to get non-existing plugin dashboard file content", func(t *testing.T) {
res, err := m.GetPluginDashboardFileContents(context.Background(), &plugins.GetPluginDashboardFileContentsArgs{
PluginID: "pluginWithoutDashboards",
FileReference: "dashboards/dash2.json",
})
require.Error(t, err)
require.EqualError(t, err, "plugin dashboard file not found")
require.Nil(t, res)
})
})
t.Run("Plugin with dashboards", func(t *testing.T) {
t.Run("Should return two file references", func(t *testing.T) {
res, err := m.ListPluginDashboardFiles(context.Background(), &plugins.ListPluginDashboardFilesArgs{
PluginID: "pluginWithDashboards",
})
require.NoError(t, err)
require.NotNil(t, res)
require.Len(t, res.FileReferences, 2)
})
t.Run("With filesystem", func(t *testing.T) {
origOpenDashboardFile := openDashboardFile
mapFs := fstest.MapFS{
"plugins/plugin-id/dashboards/dash1.json": {
Data: []byte("dash1"),
},
"plugins/plugin-id/dashboards/dash2.json": {
Data: []byte("dash2"),
},
"plugins/plugin-id/dashboards/dash3.json": {
Data: []byte("dash3"),
},
"plugins/plugin-id/dash2.json": {
Data: []byte("dash2"),
},
}
openDashboardFile = mapFs.Open
t.Cleanup(func() {
openDashboardFile = origOpenDashboardFile
})
t.Run("Should return file not found error when trying to get non-existing plugin dashboard file content", func(t *testing.T) {
res, err := m.GetPluginDashboardFileContents(context.Background(), &plugins.GetPluginDashboardFileContentsArgs{
PluginID: "pluginWithDashboards",
FileReference: "dashboards/dash3.json",
})
require.Error(t, err)
require.EqualError(t, err, "plugin dashboard file not found")
require.Nil(t, res)
})
t.Run("Should return file content for dashboards/dash1.json", func(t *testing.T) {
res, err := m.GetPluginDashboardFileContents(context.Background(), &plugins.GetPluginDashboardFileContentsArgs{
PluginID: "pluginWithDashboards",
FileReference: "dashboards/dash1.json",
})
require.NoError(t, err)
require.NotNil(t, res)
require.NotNil(t, res.Content)
b, err := io.ReadAll(res.Content)
require.NoError(t, err)
require.Equal(t, "dash1", string(b))
require.NoError(t, res.Content.Close())
})
t.Run("Should return file content for dashboards/dash2.json", func(t *testing.T) {
res, err := m.GetPluginDashboardFileContents(context.Background(), &plugins.GetPluginDashboardFileContentsArgs{
PluginID: "pluginWithDashboards",
FileReference: "dashboards/dash2.json",
})
require.NoError(t, err)
require.NotNil(t, res)
require.NotNil(t, res.Content)
b, err := io.ReadAll(res.Content)
require.NoError(t, err)
require.Equal(t, "dash2", string(b))
require.NoError(t, res.Content.Close())
})
t.Run("Should return error when trying to read relative file", func(t *testing.T) {
res, err := m.GetPluginDashboardFileContents(context.Background(), &plugins.GetPluginDashboardFileContentsArgs{
PluginID: "pluginWithDashboards",
FileReference: "dashboards/../dash2.json",
})
require.Error(t, err)
require.EqualError(t, err, "plugin dashboard file not found")
require.Nil(t, res)
})
})
})
}
func setupPluginDashboardsForTest(t *testing.T) *PluginManager {
t.Helper()
return &PluginManager{
store: map[string]*plugins.Plugin{
"pluginWithoutDashboards": {
JSONData: plugins.JSONData{
Includes: []*plugins.Includes{
{
Type: "page",
},
},
},
},
"pluginWithDashboards": {
PluginDir: "plugins/plugin-id",
JSONData: plugins.JSONData{
Includes: []*plugins.Includes{
{
Type: "page",
},
{
Type: "dashboard",
Path: "dashboards/dash1.json",
},
{
Type: "dashboard",
Path: "dashboards/dash2.json",
},
},
},
},
},
}
}

View File

@ -1,135 +0,0 @@
package manager
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/util"
)
func (m *PluginManager) GetPluginDashboards(ctx context.Context, orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) {
plugin, exists := m.Plugin(ctx, pluginID)
if !exists {
return nil, plugins.NotFoundError{PluginID: pluginID}
}
result := make([]*plugins.PluginDashboardInfoDTO, 0)
// load current dashboards
query := models.GetDashboardsByPluginIdQuery{OrgId: orgID, PluginId: pluginID}
if err := bus.Dispatch(ctx, &query); err != nil {
return nil, err
}
existingMatches := make(map[int64]bool)
for _, include := range plugin.Includes {
if include.Type != plugins.TypeDashboard {
continue
}
dashboard, err := m.LoadPluginDashboard(ctx, plugin.ID, include.Path)
if err != nil {
return nil, err
}
res := &plugins.PluginDashboardInfoDTO{}
res.UID = dashboard.Uid
res.Path = include.Path
res.PluginId = plugin.ID
res.Title = dashboard.Title
res.Revision = dashboard.Data.Get("revision").MustInt64(1)
// find existing dashboard
for _, existingDash := range query.Result {
if existingDash.Slug == dashboard.Slug {
res.UID = existingDash.Uid
res.DashboardId = existingDash.Id
res.Imported = true
res.ImportedUri = "db/" + existingDash.Slug
res.ImportedUrl = existingDash.GetUrl()
res.ImportedRevision = existingDash.Data.Get("revision").MustInt64(1)
existingMatches[existingDash.Id] = true
}
}
result = append(result, res)
}
// find deleted dashboards
for _, dash := range query.Result {
if _, exists := existingMatches[dash.Id]; !exists {
result = append(result, &plugins.PluginDashboardInfoDTO{
UID: dash.Uid,
Slug: dash.Slug,
DashboardId: dash.Id,
Removed: true,
})
}
}
return result, nil
}
func (m *PluginManager) LoadPluginDashboard(ctx context.Context, pluginID, path string) (*models.Dashboard, error) {
if len(strings.TrimSpace(pluginID)) == 0 {
return nil, fmt.Errorf("pluginID cannot be empty")
}
if len(strings.TrimSpace(path)) == 0 {
return nil, fmt.Errorf("path cannot be empty")
}
plugin, exists := m.Plugin(ctx, pluginID)
if !exists {
return nil, plugins.NotFoundError{PluginID: pluginID}
}
cleanPath, err := util.CleanRelativePath(path)
if err != nil {
// CleanRelativePath should clean and make the path relative so this is not expected to fail
return nil, err
}
dashboardFilePath := filepath.Join(plugin.PluginDir, cleanPath)
included := false
for _, include := range plugin.DashboardIncludes() {
if filepath.Join(plugin.PluginDir, include.Path) == dashboardFilePath {
included = true
break
}
}
if !included {
return nil, fmt.Errorf("dashboard not included in plugin")
}
// nolint:gosec
// We can ignore the gosec G304 warning on this one because `plugin.PluginDir` is based
// on plugin folder structure on disk and not user input. `path` input validation above
// should only allow paths defined in the plugin's plugin.json.
reader, err := os.Open(dashboardFilePath)
if err != nil {
return nil, err
}
defer func() {
if err := reader.Close(); err != nil {
m.log.Warn("Failed to close file", "path", dashboardFilePath, "err", err)
}
}()
data, err := simplejson.NewFromReader(reader)
if err != nil {
return nil, err
}
return models.NewDashboardFromJson(data), nil
}

View File

@ -1,64 +0,0 @@
package manager
import (
"context"
"testing"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin/provider"
"github.com/grafana/grafana/pkg/plugins/manager/loader"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/require"
)
func TestGetPluginDashboards(t *testing.T) {
cfg := &setting.Cfg{
PluginSettings: setting.PluginSettings{
"test-app": map[string]string{
"path": "testdata/test-app",
},
},
}
pmCfg := plugins.FromGrafanaCfg(cfg)
pm, err := ProvideService(cfg, loader.New(pmCfg, nil,
signature.NewUnsignedAuthorizer(pmCfg), &provider.Service{}))
require.NoError(t, err)
bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardQuery) error {
if query.Slug == "nginx-connections" {
dash := models.NewDashboard("Nginx Connections")
dash.Data.Set("revision", "1.1")
query.Result = dash
return nil
}
return models.ErrDashboardNotFound
})
bus.AddHandler("test", func(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
var data = simplejson.New()
data.Set("title", "Nginx Connections")
data.Set("revision", 22)
query.Result = []*models.Dashboard{
{Slug: "nginx-connections", Data: data},
}
return nil
})
dashboards, err := pm.GetPluginDashboards(context.Background(), 1, "test-app")
require.NoError(t, err)
require.Len(t, dashboards, 2)
require.Equal(t, "Nginx Connections", dashboards[0].Title)
require.Equal(t, int64(25), dashboards[0].Revision)
require.Equal(t, int64(22), dashboards[0].ImportedRevision)
require.Equal(t, "db/nginx-connections", dashboards[0].ImportedUri)
require.Equal(t, int64(2), dashboards[1].Revision)
require.Equal(t, int64(0), dashboards[1].ImportedRevision)
}

View File

@ -24,7 +24,6 @@ const (
var _ plugins.Client = (*PluginManager)(nil)
var _ plugins.Store = (*PluginManager)(nil)
var _ plugins.PluginDashboardManager = (*PluginManager)(nil)
var _ plugins.StaticRouteResolver = (*PluginManager)(nil)
var _ plugins.RendererManager = (*PluginManager)(nil)

View File

@ -17,7 +17,7 @@ import (
"github.com/grafana/grafana/pkg/services/live/pushhttp"
"github.com/grafana/grafana/pkg/services/ngalert"
"github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/services/plugindashboards"
plugindashboardsservice "github.com/grafana/grafana/pkg/services/plugindashboards/service"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/rendering"
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
@ -35,8 +35,9 @@ func ProvideBackgroundServiceRegistry(
metrics *metrics.InternalMetricsService, secretsService *secretsManager.SecretsService,
remoteCache *remotecache.RemoteCache, thumbnailsService thumbs.Service,
// Need to make sure these are initialized, is there a better place to put them?
_ *plugindashboards.Service, _ *dashboardsnapshots.Service,
_ *alerting.AlertNotificationService, _ serviceaccounts.Service, _ *guardian.Provider,
_ *dashboardsnapshots.Service, _ *alerting.AlertNotificationService,
_ serviceaccounts.Service, _ *guardian.Provider,
_ *plugindashboardsservice.DashboardUpdater,
) *BackgroundServiceRegistry {
return NewBackgroundServiceRegistry(
httpServer,

View File

@ -58,6 +58,7 @@ import (
"github.com/grafana/grafana/pkg/services/notifications"
"github.com/grafana/grafana/pkg/services/oauthtoken"
"github.com/grafana/grafana/pkg/services/plugindashboards"
plugindashboardsservice "github.com/grafana/grafana/pkg/services/plugindashboards/service"
"github.com/grafana/grafana/pkg/services/pluginsettings"
pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service"
"github.com/grafana/grafana/pkg/services/query"
@ -126,8 +127,8 @@ var wireBasicSet = wire.NewSet(
manager.ProvideService,
wire.Bind(new(plugins.Client), new(*manager.PluginManager)),
wire.Bind(new(plugins.Store), new(*manager.PluginManager)),
wire.Bind(new(plugins.DashboardFileStore), new(*manager.PluginManager)),
wire.Bind(new(plugins.StaticRouteResolver), new(*manager.PluginManager)),
wire.Bind(new(plugins.PluginDashboardManager), new(*manager.PluginManager)),
wire.Bind(new(plugins.RendererManager), new(*manager.PluginManager)),
coreplugin.ProvideCoreRegistry,
loader.ProvideService,
@ -212,11 +213,14 @@ var wireBasicSet = wire.NewSet(
dashboardstore.ProvideDashboardStore,
wire.Bind(new(dashboards.DashboardService), new(*dashboardservice.DashboardServiceImpl)),
wire.Bind(new(dashboards.DashboardProvisioningService), new(*dashboardservice.DashboardServiceImpl)),
wire.Bind(new(dashboards.PluginService), new(*dashboardservice.DashboardServiceImpl)),
wire.Bind(new(dashboards.FolderService), new(*dashboardservice.FolderServiceImpl)),
wire.Bind(new(dashboards.Store), new(*dashboardstore.DashboardStore)),
dashboardimportservice.ProvideService,
wire.Bind(new(dashboardimport.Service), new(*dashboardimportservice.ImportDashboardService)),
plugindashboards.ProvideService,
plugindashboardsservice.ProvideService,
wire.Bind(new(plugindashboards.Service), new(*plugindashboardsservice.Service)),
plugindashboardsservice.ProvideDashboardUpdater,
alerting.ProvideDashAlertExtractorService,
wire.Bind(new(alerting.DashAlertExtractor), new(*alerting.DashAlertExtractorService)),
comments.ProvideService,

View File

@ -43,9 +43,8 @@ func TestImportDashboardAPI(t *testing.T) {
cmd := &dashboardimport.ImportDashboardRequest{}
jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err)
req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes))
req.Header.Add("Content-Type", "application/json")
resp, err := s.Send(req)
req := s.NewPostRequest("/api/dashboards/import", bytes.NewReader(jsonBytes))
resp, err := s.SendJSON(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
@ -58,12 +57,11 @@ func TestImportDashboardAPI(t *testing.T) {
}
jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err)
req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes))
req.Header.Add("Content-Type", "application/json")
req := s.NewPostRequest("/api/dashboards/import", bytes.NewReader(jsonBytes))
webtest.RequestWithSignedInUser(req, &models.SignedInUser{
UserId: 1,
})
resp, err := s.Send(req)
resp, err := s.SendJSON(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
@ -75,12 +73,11 @@ func TestImportDashboardAPI(t *testing.T) {
}
jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err)
req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes))
req.Header.Add("Content-Type", "application/json")
req := s.NewPostRequest("/api/dashboards/import", bytes.NewReader(jsonBytes))
webtest.RequestWithSignedInUser(req, &models.SignedInUser{
UserId: 1,
})
resp, err := s.Send(req)
resp, err := s.SendJSON(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode)
@ -93,12 +90,11 @@ func TestImportDashboardAPI(t *testing.T) {
}
jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err)
req := s.NewRequest(http.MethodPost, "/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes))
req.Header.Add("Content-Type", "application/json")
req := s.NewPostRequest("/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes))
webtest.RequestWithSignedInUser(req, &models.SignedInUser{
UserId: 1,
})
resp, err := s.Send(req)
resp, err := s.SendJSON(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode)
@ -136,12 +132,11 @@ func TestImportDashboardAPI(t *testing.T) {
}
jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err)
req := s.NewRequest(http.MethodPost, "/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes))
req.Header.Add("Content-Type", "application/json")
req := s.NewPostRequest("/api/dashboards/import?trimdefaults=true", bytes.NewReader(jsonBytes))
webtest.RequestWithSignedInUser(req, &models.SignedInUser{
UserId: 1,
})
resp, err := s.Send(req)
resp, err := s.SendJSON(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusOK, resp.StatusCode)
@ -165,12 +160,11 @@ func TestImportDashboardAPI(t *testing.T) {
}
jsonBytes, err := json.Marshal(cmd)
require.NoError(t, err)
req := s.NewRequest(http.MethodPost, "/api/dashboards/import", bytes.NewReader(jsonBytes))
req.Header.Add("Content-Type", "application/json")
req := s.NewPostRequest("/api/dashboards/import", bytes.NewReader(jsonBytes))
webtest.RequestWithSignedInUser(req, &models.SignedInUser{
UserId: 1,
})
resp, err := s.Send(req)
resp, err := s.SendJSON(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusForbidden, resp.StatusCode)

View File

@ -12,18 +12,19 @@ import (
"github.com/grafana/grafana/pkg/services/dashboardimport/utils"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/schemaloader"
)
func ProvideService(routeRegister routing.RouteRegister,
quotaService *quota.QuotaService, schemaLoaderService *schemaloader.SchemaLoaderService,
pluginDashboardManager plugins.PluginDashboardManager, pluginStore plugins.Store,
pluginDashboardService plugindashboards.Service, pluginStore plugins.Store,
libraryPanelService librarypanels.Service, dashboardService dashboards.DashboardService,
ac accesscontrol.AccessControl,
) *ImportDashboardService {
s := &ImportDashboardService{
pluginDashboardManager: pluginDashboardManager,
pluginDashboardService: pluginDashboardService,
dashboardService: dashboardService,
libraryPanelService: libraryPanelService,
}
@ -35,7 +36,7 @@ func ProvideService(routeRegister routing.RouteRegister,
}
type ImportDashboardService struct {
pluginDashboardManager plugins.PluginDashboardManager
pluginDashboardService plugindashboards.Service
dashboardService dashboards.DashboardService
libraryPanelService librarypanels.Service
}
@ -43,9 +44,14 @@ type ImportDashboardService struct {
func (s *ImportDashboardService) ImportDashboard(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) {
var dashboard *models.Dashboard
if req.PluginId != "" {
var err error
if dashboard, err = s.pluginDashboardManager.LoadPluginDashboard(ctx, req.PluginId, req.Path); err != nil {
loadReq := &plugindashboards.LoadPluginDashboardRequest{
PluginID: req.PluginId,
Reference: req.Path,
}
if resp, err := s.pluginDashboardService.LoadPluginDashboard(ctx, loadReq); err != nil {
return nil, err
} else {
dashboard = resp.Dashboard
}
} else {
dashboard = models.NewDashboardFromJson(req.Dashboard)

View File

@ -8,16 +8,16 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/dashboardimport"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/stretchr/testify/require"
)
func TestImportDashboardService(t *testing.T) {
t.Run("When importing a plugin dashboard should save dashboard and sync library panels", func(t *testing.T) {
pluginDashboardManager := &pluginDashboardManagerMock{
pluginDashboardService := &pluginDashboardServiceMock{
loadPluginDashboardFunc: loadTestDashboard,
}
@ -52,7 +52,7 @@ func TestImportDashboardService(t *testing.T) {
},
}
s := &ImportDashboardService{
pluginDashboardManager: pluginDashboardManager,
pluginDashboardService: pluginDashboardService,
dashboardService: dashboardService,
libraryPanelService: libraryPanelService,
}
@ -108,11 +108,14 @@ func TestImportDashboardService(t *testing.T) {
libraryPanelService: libraryPanelService,
}
dash, err := loadTestDashboard(context.Background(), "", "dashboard.json")
loadResp, err := loadTestDashboard(context.Background(), &plugindashboards.LoadPluginDashboardRequest{
PluginID: "",
Reference: "dashboard.json",
})
require.NoError(t, err)
req := &dashboardimport.ImportDashboardRequest{
Dashboard: dash.Data,
Dashboard: loadResp.Dashboard.Data,
Path: "plugin_dashboard.json",
Inputs: []dashboardimport.ImportDashboardInput{
{Name: "*", Type: "datasource", Value: "prom"},
@ -136,10 +139,10 @@ func TestImportDashboardService(t *testing.T) {
})
}
func loadTestDashboard(ctx context.Context, pluginID, path string) (*models.Dashboard, error) {
func loadTestDashboard(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error) {
// It's safe to ignore gosec warning G304 since this is a test and arguments comes from test configuration.
// nolint:gosec
bytes, err := ioutil.ReadFile(filepath.Join("testdata", path))
bytes, err := ioutil.ReadFile(filepath.Join("testdata", req.Reference))
if err != nil {
return nil, err
}
@ -149,17 +152,19 @@ func loadTestDashboard(ctx context.Context, pluginID, path string) (*models.Dash
return nil, err
}
return models.NewDashboardFromJson(dashboardJSON), nil
return &plugindashboards.LoadPluginDashboardResponse{
Dashboard: models.NewDashboardFromJson(dashboardJSON),
}, nil
}
type pluginDashboardManagerMock struct {
plugins.PluginDashboardManager
loadPluginDashboardFunc func(ctx context.Context, pluginID, path string) (*models.Dashboard, error)
type pluginDashboardServiceMock struct {
plugindashboards.Service
loadPluginDashboardFunc func(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error)
}
func (m *pluginDashboardManagerMock) LoadPluginDashboard(ctx context.Context, pluginID, path string) (*models.Dashboard, error) {
func (m *pluginDashboardServiceMock) LoadPluginDashboard(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error) {
if m.loadPluginDashboardFunc != nil {
return m.loadPluginDashboardFunc(ctx, pluginID, path)
return m.loadPluginDashboardFunc(ctx, req)
}
return nil, nil

View File

@ -6,6 +6,8 @@ import (
"github.com/grafana/grafana/pkg/models"
)
//go:generate mockery --name Store --structname FakeDashboardStore --inpackage --filename database_mock.go
// DashboardService is a service for operating on dashboards.
type DashboardService interface {
SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error)
@ -16,6 +18,11 @@ type DashboardService interface {
UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error
}
// PluginService is a service for operating on plugin dashboards.
type PluginService interface {
GetDashboardsByPluginID(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error
}
//go:generate mockery --name DashboardProvisioningService --structname FakeDashboardProvisioning --inpackage --filename dashboard_provisioning_mock.go
// DashboardProvisioningService is a service for operating on provisioned dashboards.
type DashboardProvisioningService interface {
@ -29,7 +36,6 @@ type DashboardProvisioningService interface {
DeleteOrphanedProvisionedDashboards(ctx context.Context, cmd *models.DeleteOrphanedProvisionedDashboardsCommand) error
}
//go:generate mockery --name Store --structname FakeDashboardStore --inpackage --filename database_mock.go
// Store is a dashboard store.
type Store interface {
// ValidateDashboardBeforeSave validates a dashboard before save.
@ -46,4 +52,6 @@ type Store interface {
// SaveAlerts saves dashboard alerts.
SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error
UnprovisionDashboard(ctx context.Context, id int64) error
// GetDashboardsByPluginID retrieves dashboards identified by plugin.
GetDashboardsByPluginID(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error
}

View File

@ -635,3 +635,14 @@ func EnsureTagsExist(sess *sqlstore.DBSession, tags []*models.Tag) ([]*models.Ta
return tags, nil
}
func (d *DashboardStore) GetDashboardsByPluginID(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
return d.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
var dashboards = make([]*models.Dashboard, 0)
whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + d.sqlStore.Dialect.BooleanStr(false)
err := dbSession.Where(whereExpr, query.OrgId, query.PluginId).Find(&dashboards)
query.Result = dashboards
return err
})
}

View File

@ -421,7 +421,7 @@ func TestDashboardDataAccessGivenPluginWithImportedDashboards(t *testing.T) {
OrgId: 1,
}
err := sqlStore.GetDashboardsByPluginId(context.Background(), &query)
err := dashboardStore.GetDashboardsByPluginID(context.Background(), &query)
require.NoError(t, err)
require.Equal(t, len(query.Result), 2)
}

View File

@ -5,9 +5,8 @@ package dashboards
import (
context "context"
mock "github.com/stretchr/testify/mock"
models "github.com/grafana/grafana/pkg/models"
mock "github.com/stretchr/testify/mock"
)
// FakeDashboardStore is an autogenerated mock type for the Store type
@ -29,6 +28,20 @@ func (_m *FakeDashboardStore) DeleteOrphanedProvisionedDashboards(ctx context.Co
return r0
}
// GetDashboardsByPluginID provides a mock function with given fields: ctx, query
func (_m *FakeDashboardStore) GetDashboardsByPluginID(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
ret := _m.Called(ctx, query)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, *models.GetDashboardsByPluginIdQuery) error); ok {
r0 = rf(ctx, query)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetFolderByTitle provides a mock function with given fields: orgID, title
func (_m *FakeDashboardStore) GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) {
ret := _m.Called(orgID, title)

View File

@ -443,6 +443,10 @@ func (dr *DashboardServiceImpl) UnprovisionDashboard(ctx context.Context, dashbo
return dr.dashboardStore.UnprovisionDashboard(ctx, dashboardId)
}
func (dr *DashboardServiceImpl) GetDashboardsByPluginID(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
return dr.dashboardStore.GetDashboardsByPluginID(ctx, query)
}
func (dr *DashboardServiceImpl) setDefaultPermissions(ctx context.Context, dto *m.SaveDashboardDTO, dash *models.Dashboard, provisioned bool) error {
inFolder := dash.FolderId > 0
if dr.features.IsEnabled(featuremgmt.FlagAccesscontrol) {

View File

@ -2,166 +2,55 @@ package plugindashboards
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/dashboardimport"
"github.com/grafana/grafana/pkg/services/pluginsettings"
)
func ProvideService(bus bus.Bus, pluginStore plugins.Store, pluginDashboardManager plugins.PluginDashboardManager,
dashboardImportService dashboardimport.Service, pluginSettingsService pluginsettings.Service) *Service {
s := newService(bus, pluginStore, pluginDashboardManager, dashboardImportService, pluginSettingsService)
s.updateAppDashboards()
return s
// PluginDashboard plugin dashboard model..
type PluginDashboard struct {
UID string `json:"uid"`
PluginId string `json:"pluginId"`
Title string `json:"title"`
Imported bool `json:"imported"`
ImportedUri string `json:"importedUri"`
ImportedUrl string `json:"importedUrl"`
Slug string `json:"slug"`
DashboardId int64 `json:"dashboardId"`
FolderId int64 `json:"folderId"`
ImportedRevision int64 `json:"importedRevision"`
Revision int64 `json:"revision"`
Description string `json:"description"`
Reference string `json:"path"`
Removed bool `json:"removed"`
}
func newService(bus bus.Bus, pluginStore plugins.Store, pluginDashboardManager plugins.PluginDashboardManager,
dashboardImportService dashboardimport.Service, pluginSettingsService pluginsettings.Service) *Service {
s := &Service{
bus: bus,
pluginStore: pluginStore,
pluginDashboardManager: pluginDashboardManager,
dashboardImportService: dashboardImportService,
pluginSettingsService: pluginSettingsService,
logger: log.New("plugindashboards"),
}
bus.AddEventListener(s.handlePluginStateChanged)
return s
// ListPluginDashboardsRequest request object for listing plugin dashboards.
type ListPluginDashboardsRequest struct {
OrgID int64
PluginID string
}
type Service struct {
bus bus.Bus
pluginStore plugins.Store
pluginDashboardManager plugins.PluginDashboardManager
dashboardImportService dashboardimport.Service
pluginSettingsService pluginsettings.Service
logger log.Logger
// ListPluginDashboardsResponse response object for listing plugin dashboards.
type ListPluginDashboardsResponse struct {
Items []*PluginDashboard
}
func (s *Service) updateAppDashboards() {
s.logger.Debug("Looking for app dashboard updates")
pluginSettings, err := s.pluginSettingsService.GetPluginSettings(context.Background(), 0)
if err != nil {
s.logger.Error("Failed to get all plugin settings", "error", err)
return
}
for _, pluginSetting := range pluginSettings {
// ignore disabled plugins
if !pluginSetting.Enabled {
continue
}
if pluginDef, exists := s.pluginStore.Plugin(context.Background(), pluginSetting.PluginId); exists {
if pluginDef.Info.Version != pluginSetting.PluginVersion {
s.syncPluginDashboards(context.Background(), pluginDef, pluginSetting.OrgId)
}
}
}
// LoadPluginDashboardRequest request object for loading a plugin dashboard.
type LoadPluginDashboardRequest struct {
PluginID string
Reference string
}
func (s *Service) syncPluginDashboards(ctx context.Context, plugin plugins.PluginDTO, orgID int64) {
s.logger.Info("Syncing plugin dashboards to DB", "pluginId", plugin.ID)
// Get plugin dashboards
dashboards, err := s.pluginDashboardManager.GetPluginDashboards(ctx, orgID, plugin.ID)
if err != nil {
s.logger.Error("Failed to load app dashboards", "error", err)
return
}
// Update dashboards with updated revisions
for _, dash := range dashboards {
// remove removed ones
if dash.Removed {
s.logger.Info("Deleting plugin dashboard", "pluginId", plugin.ID, "dashboard", dash.Slug)
deleteCmd := models.DeleteDashboardCommand{OrgId: orgID, Id: dash.DashboardId}
if err := s.bus.Dispatch(ctx, &deleteCmd); err != nil {
s.logger.Error("Failed to auto update app dashboard", "pluginId", plugin.ID, "error", err)
return
}
continue
}
// update updated ones
if dash.ImportedRevision != dash.Revision {
if err := s.autoUpdateAppDashboard(ctx, dash, orgID); err != nil {
s.logger.Error("Failed to auto update app dashboard", "pluginId", plugin.ID, "error", err)
return
}
}
}
// update version in plugin_setting table to mark that we have processed the update
query := models.GetPluginSettingByIdQuery{PluginId: plugin.ID, OrgId: orgID}
if err := s.pluginSettingsService.GetPluginSettingById(ctx, &query); err != nil {
s.logger.Error("Failed to read plugin setting by ID", "error", err)
return
}
appSetting := query.Result
cmd := models.UpdatePluginSettingVersionCmd{
OrgId: appSetting.OrgId,
PluginId: appSetting.PluginId,
PluginVersion: plugin.Info.Version,
}
if err := s.pluginSettingsService.UpdatePluginSettingVersion(ctx, &cmd); err != nil {
s.logger.Error("Failed to update plugin setting version", "error", err)
}
// LoadPluginDashboardResponse response object for loading a plugin dashboard.
type LoadPluginDashboardResponse struct {
Dashboard *models.Dashboard
}
func (s *Service) handlePluginStateChanged(ctx context.Context, event *models.PluginStateChangedEvent) error {
s.logger.Info("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled)
// Service interface for listing plugin dashboards.
type Service interface {
// ListPluginDashboards list plugin dashboards identified by org/plugin.
ListPluginDashboards(ctx context.Context, req *ListPluginDashboardsRequest) (*ListPluginDashboardsResponse, error)
if event.Enabled {
p, exists := s.pluginStore.Plugin(ctx, event.PluginId)
if !exists {
return fmt.Errorf("plugin %s not found. Could not sync plugin dashboards", event.PluginId)
}
s.syncPluginDashboards(ctx, p, event.OrgId)
} else {
query := models.GetDashboardsByPluginIdQuery{PluginId: event.PluginId, OrgId: event.OrgId}
if err := s.bus.Dispatch(ctx, &query); err != nil {
return err
}
for _, dash := range query.Result {
s.logger.Info("Deleting plugin dashboard", "pluginId", event.PluginId, "dashboard", dash.Slug)
deleteCmd := models.DeleteDashboardCommand{OrgId: dash.OrgId, Id: dash.Id}
if err := s.bus.Dispatch(ctx, &deleteCmd); err != nil {
return err
}
}
}
return nil
}
func (s *Service) autoUpdateAppDashboard(ctx context.Context, pluginDashInfo *plugins.PluginDashboardInfoDTO, orgID int64) error {
dash, err := s.pluginDashboardManager.LoadPluginDashboard(ctx, pluginDashInfo.PluginId, pluginDashInfo.Path)
if err != nil {
return err
}
s.logger.Info("Auto updating App dashboard", "dashboard", dash.Title, "newRev",
pluginDashInfo.Revision, "oldRev", pluginDashInfo.ImportedRevision)
_, err = s.dashboardImportService.ImportDashboard(ctx, &dashboardimport.ImportDashboardRequest{
PluginId: pluginDashInfo.PluginId,
User: &models.SignedInUser{UserId: 0, OrgRole: models.ROLE_ADMIN, OrgId: orgID},
Path: pluginDashInfo.Path,
FolderId: 0,
Dashboard: dash.Data,
Overwrite: true,
Inputs: nil,
})
return err
// LoadPluginDashboard loads a plugin dashboard identified by plugin and reference.
LoadPluginDashboard(ctx context.Context, req *LoadPluginDashboardRequest) (*LoadPluginDashboardResponse, error)
}

View File

@ -0,0 +1,181 @@
package service
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/dashboardimport"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/services/pluginsettings"
)
func ProvideDashboardUpdater(bus bus.Bus, pluginStore plugins.Store, pluginDashboardService plugindashboards.Service,
dashboardImportService dashboardimport.Service, pluginSettingsService pluginsettings.Service,
dashboardPluginService dashboards.PluginService, dashboardService dashboards.DashboardService) *DashboardUpdater {
du := newDashboardUpdater(bus, pluginStore, pluginDashboardService, dashboardImportService,
pluginSettingsService, dashboardPluginService, dashboardService)
du.updateAppDashboards()
return du
}
func newDashboardUpdater(bus bus.Bus, pluginStore plugins.Store,
pluginDashboardService plugindashboards.Service, dashboardImportService dashboardimport.Service,
pluginSettingsService pluginsettings.Service, dashboardPluginService dashboards.PluginService,
dashboardService dashboards.DashboardService) *DashboardUpdater {
s := &DashboardUpdater{
pluginStore: pluginStore,
pluginDashboardService: pluginDashboardService,
dashboardImportService: dashboardImportService,
pluginSettingsService: pluginSettingsService,
dashboardPluginService: dashboardPluginService,
dashboardService: dashboardService,
logger: log.New("plugindashboards"),
}
bus.AddEventListener(s.handlePluginStateChanged)
return s
}
type DashboardUpdater struct {
pluginStore plugins.Store
pluginDashboardService plugindashboards.Service
dashboardImportService dashboardimport.Service
pluginSettingsService pluginsettings.Service
dashboardPluginService dashboards.PluginService
dashboardService dashboards.DashboardService
logger log.Logger
}
func (du *DashboardUpdater) updateAppDashboards() {
du.logger.Debug("Looking for app dashboard updates")
pluginSettings, err := du.pluginSettingsService.GetPluginSettings(context.Background(), 0)
if err != nil {
du.logger.Error("Failed to get all plugin settings", "error", err)
return
}
for _, pluginSetting := range pluginSettings {
// ignore disabled plugins
if !pluginSetting.Enabled {
continue
}
if pluginDef, exists := du.pluginStore.Plugin(context.Background(), pluginSetting.PluginId); exists {
if pluginDef.Info.Version != pluginSetting.PluginVersion {
du.syncPluginDashboards(context.Background(), pluginDef, pluginSetting.OrgId)
}
}
}
}
func (du *DashboardUpdater) syncPluginDashboards(ctx context.Context, plugin plugins.PluginDTO, orgID int64) {
du.logger.Info("Syncing plugin dashboards to DB", "pluginId", plugin.ID)
// Get plugin dashboards
req := &plugindashboards.ListPluginDashboardsRequest{
OrgID: orgID,
PluginID: plugin.ID,
}
resp, err := du.pluginDashboardService.ListPluginDashboards(ctx, req)
if err != nil {
du.logger.Error("Failed to load app dashboards", "error", err)
return
}
// Update dashboards with updated revisions
for _, dash := range resp.Items {
// remove removed ones
if dash.Removed {
du.logger.Info("Deleting plugin dashboard", "pluginId", plugin.ID, "dashboard", dash.Slug)
if err := du.dashboardService.DeleteDashboard(ctx, dash.DashboardId, orgID); err != nil {
du.logger.Error("Failed to auto update app dashboard", "pluginId", plugin.ID, "error", err)
return
}
continue
}
// update updated ones
if dash.ImportedRevision != dash.Revision {
if err := du.autoUpdateAppDashboard(ctx, dash, orgID); err != nil {
du.logger.Error("Failed to auto update app dashboard", "pluginId", plugin.ID, "error", err)
return
}
}
}
// update version in plugin_setting table to mark that we have processed the update
query := models.GetPluginSettingByIdQuery{PluginId: plugin.ID, OrgId: orgID}
if err := du.pluginSettingsService.GetPluginSettingById(ctx, &query); err != nil {
du.logger.Error("Failed to read plugin setting by ID", "error", err)
return
}
appSetting := query.Result
cmd := models.UpdatePluginSettingVersionCmd{
OrgId: appSetting.OrgId,
PluginId: appSetting.PluginId,
PluginVersion: plugin.Info.Version,
}
if err := du.pluginSettingsService.UpdatePluginSettingVersion(ctx, &cmd); err != nil {
du.logger.Error("Failed to update plugin setting version", "error", err)
}
}
func (du *DashboardUpdater) handlePluginStateChanged(ctx context.Context, event *models.PluginStateChangedEvent) error {
du.logger.Info("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled)
if event.Enabled {
p, exists := du.pluginStore.Plugin(ctx, event.PluginId)
if !exists {
return fmt.Errorf("plugin %s not found. Could not sync plugin dashboards", event.PluginId)
}
du.syncPluginDashboards(ctx, p, event.OrgId)
} else {
query := models.GetDashboardsByPluginIdQuery{PluginId: event.PluginId, OrgId: event.OrgId}
if err := du.dashboardPluginService.GetDashboardsByPluginID(ctx, &query); err != nil {
return err
}
for _, dash := range query.Result {
du.logger.Info("Deleting plugin dashboard", "pluginId", event.PluginId, "dashboard", dash.Slug)
if err := du.dashboardService.DeleteDashboard(ctx, dash.Id, dash.OrgId); err != nil {
return err
}
}
}
return nil
}
func (du *DashboardUpdater) autoUpdateAppDashboard(ctx context.Context, pluginDashInfo *plugindashboards.PluginDashboard, orgID int64) error {
req := &plugindashboards.LoadPluginDashboardRequest{
PluginID: pluginDashInfo.PluginId,
Reference: pluginDashInfo.Reference,
}
resp, err := du.pluginDashboardService.LoadPluginDashboard(ctx, req)
if err != nil {
return err
}
du.logger.Info("Auto updating App dashboard", "dashboard", resp.Dashboard.Title, "newRev",
pluginDashInfo.Revision, "oldRev", pluginDashInfo.ImportedRevision)
_, err = du.dashboardImportService.ImportDashboard(ctx, &dashboardimport.ImportDashboardRequest{
PluginId: pluginDashInfo.PluginId,
User: &models.SignedInUser{UserId: 0, OrgRole: models.ROLE_ADMIN, OrgId: orgID},
Path: pluginDashInfo.Reference,
FolderId: 0,
Dashboard: resp.Dashboard.Data,
Overwrite: true,
Inputs: nil,
})
return err
}

View File

@ -1,4 +1,4 @@
package plugindashboards
package service
import (
"context"
@ -9,19 +9,21 @@ import (
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/dashboardimport"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/services/pluginsettings/service"
"github.com/stretchr/testify/require"
)
func TestService(t *testing.T) {
func TestDashboardUpdater(t *testing.T) {
t.Run("updateAppDashboards", func(t *testing.T) {
scenario(t, "Without any stored plugin settings shouldn't delete/import any dashboards",
scenarioInput{}, func(ctx *scenarioContext) {
ctx.s.updateAppDashboards()
ctx.dashboardUpdater.updateAppDashboards()
require.Len(t, ctx.pluginSettingsService.getPluginSettingsArgs, 1)
require.Equal(t, int64(0), ctx.pluginSettingsService.getPluginSettingsArgs[0])
require.Empty(t, ctx.deleteDashboardArgs)
require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
require.Empty(t, ctx.importDashboardArgs)
})
@ -33,17 +35,17 @@ func TestService(t *testing.T) {
Enabled: false,
},
},
pluginDashboards: []*plugins.PluginDashboardInfoDTO{
pluginDashboards: []*plugindashboards.PluginDashboard{
{
PluginId: "test",
Path: "dashboard.json",
PluginId: "test",
Reference: "dashboard.json",
},
},
}, func(ctx *scenarioContext) {
ctx.s.updateAppDashboards()
ctx.dashboardUpdater.updateAppDashboards()
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
require.Empty(t, ctx.deleteDashboardArgs)
require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
require.Empty(t, ctx.importDashboardArgs)
})
@ -55,17 +57,17 @@ func TestService(t *testing.T) {
Enabled: true,
},
},
pluginDashboards: []*plugins.PluginDashboardInfoDTO{
pluginDashboards: []*plugindashboards.PluginDashboard{
{
PluginId: "test",
Path: "dashboard.json",
PluginId: "test",
Reference: "dashboard.json",
},
},
}, func(ctx *scenarioContext) {
ctx.s.updateAppDashboards()
ctx.dashboardUpdater.updateAppDashboards()
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
require.Empty(t, ctx.deleteDashboardArgs)
require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
require.Empty(t, ctx.importDashboardArgs)
})
@ -87,17 +89,17 @@ func TestService(t *testing.T) {
},
},
},
pluginDashboards: []*plugins.PluginDashboardInfoDTO{
pluginDashboards: []*plugindashboards.PluginDashboard{
{
PluginId: "test",
Path: "dashboard.json",
PluginId: "test",
Reference: "dashboard.json",
},
},
}, func(ctx *scenarioContext) {
ctx.s.updateAppDashboards()
ctx.dashboardUpdater.updateAppDashboards()
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
require.Empty(t, ctx.deleteDashboardArgs)
require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
require.Empty(t, ctx.importDashboardArgs)
})
@ -119,20 +121,20 @@ func TestService(t *testing.T) {
},
},
},
pluginDashboards: []*plugins.PluginDashboardInfoDTO{
pluginDashboards: []*plugindashboards.PluginDashboard{
{
PluginId: "test",
Path: "dashboard.json",
Reference: "dashboard.json",
Removed: false,
Revision: 1,
ImportedRevision: 1,
},
},
}, func(ctx *scenarioContext) {
ctx.s.updateAppDashboards()
ctx.dashboardUpdater.updateAppDashboards()
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
require.Empty(t, ctx.deleteDashboardArgs)
require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
require.Empty(t, ctx.importDashboardArgs)
})
@ -156,33 +158,33 @@ func TestService(t *testing.T) {
},
},
},
pluginDashboards: []*plugins.PluginDashboardInfoDTO{
pluginDashboards: []*plugindashboards.PluginDashboard{
{
DashboardId: 3,
PluginId: "test",
Path: "removed.json",
Reference: "removed.json",
Removed: true,
},
{
DashboardId: 4,
PluginId: "test",
Path: "not-updated.json",
Reference: "not-updated.json",
},
{
DashboardId: 5,
PluginId: "test",
Path: "updated.json",
Reference: "updated.json",
Revision: 1,
ImportedRevision: 2,
},
},
}, func(ctx *scenarioContext) {
ctx.s.updateAppDashboards()
ctx.dashboardUpdater.updateAppDashboards()
require.NotEmpty(t, ctx.pluginSettingsService.getPluginSettingsArgs)
require.Len(t, ctx.deleteDashboardArgs, 1)
require.Equal(t, int64(2), ctx.deleteDashboardArgs[0].OrgId)
require.Equal(t, int64(3), ctx.deleteDashboardArgs[0].Id)
require.Len(t, ctx.dashboardService.deleteDashboardArgs, 1)
require.Equal(t, int64(2), ctx.dashboardService.deleteDashboardArgs[0].orgId)
require.Equal(t, int64(3), ctx.dashboardService.deleteDashboardArgs[0].dashboardId)
require.Len(t, ctx.importDashboardArgs, 1)
require.Equal(t, "test", ctx.importDashboardArgs[0].PluginId)
@ -204,10 +206,10 @@ func TestService(t *testing.T) {
})
require.NoError(t, err)
require.Len(t, ctx.getDashboardsByPluginIdQueryArgs, 1)
require.Equal(t, int64(2), ctx.getDashboardsByPluginIdQueryArgs[0].OrgId)
require.Equal(t, "test", ctx.getDashboardsByPluginIdQueryArgs[0].PluginId)
require.Empty(t, ctx.deleteDashboardArgs)
require.Len(t, ctx.dashboardPluginService.args, 1)
require.Equal(t, int64(2), ctx.dashboardPluginService.args[0].OrgId)
require.Equal(t, "test", ctx.dashboardPluginService.args[0].PluginId)
require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
})
})
@ -227,21 +229,21 @@ func TestService(t *testing.T) {
},
},
},
pluginDashboards: []*plugins.PluginDashboardInfoDTO{
pluginDashboards: []*plugindashboards.PluginDashboard{
{
DashboardId: 3,
PluginId: "test",
Path: "dashboard1.json",
Reference: "dashboard1.json",
},
{
DashboardId: 4,
PluginId: "test",
Path: "dashboard2.json",
Reference: "dashboard2.json",
},
{
DashboardId: 5,
PluginId: "test",
Path: "dashboard3.json",
Reference: "dashboard3.json",
},
},
}, func(ctx *scenarioContext) {
@ -252,10 +254,10 @@ func TestService(t *testing.T) {
})
require.NoError(t, err)
require.Len(t, ctx.getDashboardsByPluginIdQueryArgs, 1)
require.Equal(t, int64(2), ctx.getDashboardsByPluginIdQueryArgs[0].OrgId)
require.Equal(t, "test", ctx.getDashboardsByPluginIdQueryArgs[0].PluginId)
require.Len(t, ctx.deleteDashboardArgs, 3)
require.Len(t, ctx.dashboardPluginService.args, 1)
require.Equal(t, int64(2), ctx.dashboardPluginService.args[0].OrgId)
require.Equal(t, "test", ctx.dashboardPluginService.args[0].PluginId)
require.Len(t, ctx.dashboardService.deleteDashboardArgs, 3)
})
scenario(t, "When app plugin is enabled, stored disabled plugin and with dashboard updates should import dashboards",
@ -278,25 +280,25 @@ func TestService(t *testing.T) {
},
},
},
pluginDashboards: []*plugins.PluginDashboardInfoDTO{
pluginDashboards: []*plugindashboards.PluginDashboard{
{
DashboardId: 3,
PluginId: "test",
Path: "dashboard1.json",
Reference: "dashboard1.json",
Revision: 1,
ImportedRevision: 0,
},
{
DashboardId: 4,
PluginId: "test",
Path: "dashboard2.json",
Reference: "dashboard2.json",
Revision: 1,
ImportedRevision: 0,
},
{
DashboardId: 5,
PluginId: "test",
Path: "dashboard3.json",
Reference: "dashboard3.json",
Revision: 1,
ImportedRevision: 0,
},
@ -309,7 +311,7 @@ func TestService(t *testing.T) {
})
require.NoError(t, err)
require.Empty(t, ctx.deleteDashboardArgs)
require.Empty(t, ctx.dashboardService.deleteDashboardArgs)
require.Len(t, ctx.importDashboardArgs, 3)
require.Equal(t, "test", ctx.importDashboardArgs[0].PluginId)
@ -348,23 +350,24 @@ func (m *pluginStoreMock) Plugin(ctx context.Context, pluginID string) (plugins.
return plugins.PluginDTO{}, false
}
type pluginDashboardManagerMock struct {
plugins.PluginDashboardManager
getPluginDashboardsFunc func(ctx context.Context, orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error)
loadPluginDashboardFunc func(ctx context.Context, pluginID, path string) (*models.Dashboard, error)
type pluginDashboardServiceMock struct {
listPluginDashboardsFunc func(ctx context.Context, req *plugindashboards.ListPluginDashboardsRequest) (*plugindashboards.ListPluginDashboardsResponse, error)
loadPluginDashboardfunc func(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error)
}
func (m *pluginDashboardManagerMock) GetPluginDashboards(ctx context.Context, orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) {
if m.getPluginDashboardsFunc != nil {
return m.getPluginDashboardsFunc(ctx, orgID, pluginID)
func (m *pluginDashboardServiceMock) ListPluginDashboards(ctx context.Context, req *plugindashboards.ListPluginDashboardsRequest) (*plugindashboards.ListPluginDashboardsResponse, error) {
if m.listPluginDashboardsFunc != nil {
return m.listPluginDashboardsFunc(ctx, req)
}
return []*plugins.PluginDashboardInfoDTO{}, nil
return &plugindashboards.ListPluginDashboardsResponse{
Items: []*plugindashboards.PluginDashboard{},
}, nil
}
func (m *pluginDashboardManagerMock) LoadPluginDashboard(ctx context.Context, pluginID, path string) (*models.Dashboard, error) {
if m.loadPluginDashboardFunc != nil {
return m.loadPluginDashboardFunc(ctx, pluginID, path)
func (m *pluginDashboardServiceMock) LoadPluginDashboard(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error) {
if m.loadPluginDashboardfunc != nil {
return m.loadPluginDashboardfunc(ctx, req)
}
return nil, nil
@ -418,38 +421,55 @@ func (s *pluginsSettingsServiceMock) UpdatePluginSetting(_ context.Context, _ *m
return s.err
}
type dashboardServiceMock struct {
dashboards.DashboardService
deleteDashboardArgs []struct {
orgId int64
dashboardId int64
}
}
func (s *dashboardServiceMock) DeleteDashboard(_ context.Context, dashboardId int64, orgId int64) error {
s.deleteDashboardArgs = append(s.deleteDashboardArgs, struct {
orgId int64
dashboardId int64
}{
orgId: orgId,
dashboardId: dashboardId,
})
return nil
}
type scenarioInput struct {
storedPluginSettings []*models.PluginSettingInfoDTO
installedPlugins []plugins.PluginDTO
pluginDashboards []*plugins.PluginDashboardInfoDTO
pluginDashboards []*plugindashboards.PluginDashboard
}
type scenarioContext struct {
t *testing.T
bus bus.Bus
pluginSettingsService *pluginsSettingsServiceMock
pluginStore plugins.Store
pluginDashboardManager plugins.PluginDashboardManager
importDashboardService dashboardimport.Service
importDashboardArgs []*dashboardimport.ImportDashboardRequest
deleteDashboardArgs []*models.DeleteDashboardCommand
getPluginSettingsByIdArgs []*models.GetPluginSettingByIdQuery
updatePluginSettingVersionArgs []*models.UpdatePluginSettingVersionCmd
getDashboardsByPluginIdQueryArgs []*models.GetDashboardsByPluginIdQuery
s *Service
t *testing.T
bus bus.Bus
pluginSettingsService *pluginsSettingsServiceMock
pluginStore plugins.Store
pluginDashboardService plugindashboards.Service
importDashboardService dashboardimport.Service
dashboardPluginService *dashboardPluginServiceMock
dashboardService *dashboardServiceMock
importDashboardArgs []*dashboardimport.ImportDashboardRequest
getPluginSettingsByIdArgs []*models.GetPluginSettingByIdQuery
updatePluginSettingVersionArgs []*models.UpdatePluginSettingVersionCmd
dashboardUpdater *DashboardUpdater
}
func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenarioContext)) {
t.Helper()
sCtx := &scenarioContext{
t: t,
bus: bus.New(),
importDashboardArgs: []*dashboardimport.ImportDashboardRequest{},
deleteDashboardArgs: []*models.DeleteDashboardCommand{},
getPluginSettingsByIdArgs: []*models.GetPluginSettingByIdQuery{},
updatePluginSettingVersionArgs: []*models.UpdatePluginSettingVersionCmd{},
getDashboardsByPluginIdQueryArgs: []*models.GetDashboardsByPluginIdQuery{},
t: t,
bus: bus.New(),
importDashboardArgs: []*dashboardimport.ImportDashboardRequest{},
getPluginSettingsByIdArgs: []*models.GetPluginSettingByIdQuery{},
updatePluginSettingVersionArgs: []*models.UpdatePluginSettingVersionCmd{},
}
getPlugin := func(ctx context.Context, pluginID string) (plugins.PluginDTO, bool) {
@ -470,31 +490,57 @@ func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenar
pluginFunc: getPlugin,
}
getPluginDashboards := func(ctx context.Context, orgID int64, pluginID string) ([]*plugins.PluginDashboardInfoDTO, error) {
dashboards := []*plugins.PluginDashboardInfoDTO{}
pluginDashboards := map[string][]*models.Dashboard{}
for _, pluginDashboard := range input.pluginDashboards {
if _, exists := pluginDashboards[pluginDashboard.PluginId]; !exists {
pluginDashboards[pluginDashboard.PluginId] = []*models.Dashboard{}
}
pluginDashboards[pluginDashboard.PluginId] = append(pluginDashboards[pluginDashboard.PluginId], &models.Dashboard{
PluginId: pluginDashboard.PluginId,
})
}
sCtx.dashboardPluginService = &dashboardPluginServiceMock{
pluginDashboards: pluginDashboards,
}
sCtx.dashboardService = &dashboardServiceMock{
deleteDashboardArgs: []struct {
orgId int64
dashboardId int64
}{},
}
listPluginDashboards := func(ctx context.Context, req *plugindashboards.ListPluginDashboardsRequest) (*plugindashboards.ListPluginDashboardsResponse, error) {
dashboards := []*plugindashboards.PluginDashboard{}
for _, d := range input.pluginDashboards {
if d.PluginId == pluginID {
if d.PluginId == req.PluginID {
dashboards = append(dashboards, d)
}
}
return dashboards, nil
return &plugindashboards.ListPluginDashboardsResponse{
Items: dashboards,
}, nil
}
loadPluginDashboard := func(ctx context.Context, pluginID, path string) (*models.Dashboard, error) {
loadPluginDashboard := func(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error) {
for _, d := range input.pluginDashboards {
if d.PluginId == pluginID && path == d.Path {
return &models.Dashboard{}, nil
if d.PluginId == req.PluginID && req.Reference == d.Reference {
return &plugindashboards.LoadPluginDashboardResponse{
Dashboard: &models.Dashboard{},
}, nil
}
}
return nil, fmt.Errorf("no match for loading plugin dashboard")
}
sCtx.pluginDashboardManager = &pluginDashboardManagerMock{
getPluginDashboardsFunc: getPluginDashboards,
loadPluginDashboardFunc: loadPluginDashboard,
sCtx.pluginDashboardService = &pluginDashboardServiceMock{
listPluginDashboardsFunc: listPluginDashboards,
loadPluginDashboardfunc: loadPluginDashboard,
}
importDashboard := func(ctx context.Context, req *dashboardimport.ImportDashboardRequest) (*dashboardimport.ImportDashboardResponse, error) {
@ -509,43 +555,15 @@ func scenario(t *testing.T, desc string, input scenarioInput, f func(ctx *scenar
importDashboardFunc: importDashboard,
}
sCtx.bus.AddHandler(func(ctx context.Context, cmd *models.DeleteDashboardCommand) error {
sCtx.deleteDashboardArgs = append(sCtx.deleteDashboardArgs, cmd)
return nil
})
sCtx.bus.AddHandler(func(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
sCtx.getDashboardsByPluginIdQueryArgs = append(sCtx.getDashboardsByPluginIdQueryArgs, query)
dashboards := []*models.Dashboard{}
var plugin *models.PluginSettingInfoDTO
for _, p := range input.storedPluginSettings {
if p.PluginId == query.PluginId {
plugin = p
}
}
if plugin == nil {
return nil
}
for _, d := range input.pluginDashboards {
if d.PluginId == plugin.PluginId {
dashboards = append(dashboards, &models.Dashboard{
Id: d.DashboardId,
OrgId: plugin.OrgId,
})
}
}
query.Result = dashboards
return nil
})
sCtx.s = newService(sCtx.bus, sCtx.pluginStore, sCtx.pluginDashboardManager, sCtx.importDashboardService, sCtx.pluginSettingsService)
sCtx.dashboardUpdater = newDashboardUpdater(
sCtx.bus,
sCtx.pluginStore,
sCtx.pluginDashboardService,
sCtx.importDashboardService,
sCtx.pluginSettingsService,
sCtx.dashboardPluginService,
sCtx.dashboardService,
)
t.Cleanup(bus.ClearBusHandlers)

View File

@ -0,0 +1,134 @@
package service
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/plugindashboards"
)
func ProvideService(pluginDashboardStore plugins.DashboardFileStore, dashboardPluginService dashboards.PluginService) *Service {
return &Service{
pluginDashboardStore: pluginDashboardStore,
dashboardPluginService: dashboardPluginService,
logger: log.New("plugindashboards"),
}
}
type Service struct {
pluginDashboardStore plugins.DashboardFileStore
dashboardPluginService dashboards.PluginService
logger log.Logger
}
func (s Service) ListPluginDashboards(ctx context.Context, req *plugindashboards.ListPluginDashboardsRequest) (*plugindashboards.ListPluginDashboardsResponse, error) {
if req == nil {
return nil, fmt.Errorf("req cannot be nil")
}
listArgs := &plugins.ListPluginDashboardFilesArgs{
PluginID: req.PluginID,
}
listResp, err := s.pluginDashboardStore.ListPluginDashboardFiles(ctx, listArgs)
if err != nil {
return nil, err
}
result := make([]*plugindashboards.PluginDashboard, 0)
// load current dashboards
query := models.GetDashboardsByPluginIdQuery{OrgId: req.OrgID, PluginId: req.PluginID}
if err := s.dashboardPluginService.GetDashboardsByPluginID(ctx, &query); err != nil {
return nil, err
}
existingMatches := make(map[int64]bool)
for _, reference := range listResp.FileReferences {
loadReq := &plugindashboards.LoadPluginDashboardRequest{
PluginID: req.PluginID,
Reference: reference,
}
loadResp, err := s.LoadPluginDashboard(ctx, loadReq)
if err != nil {
return nil, err
}
dashboard := loadResp.Dashboard
res := &plugindashboards.PluginDashboard{}
res.UID = dashboard.Uid
res.Reference = reference
res.PluginId = req.PluginID
res.Title = dashboard.Title
res.Revision = dashboard.Data.Get("revision").MustInt64(1)
// find existing dashboard
for _, existingDash := range query.Result {
if existingDash.Slug == dashboard.Slug {
res.UID = existingDash.Uid
res.DashboardId = existingDash.Id
res.Imported = true
res.ImportedUri = "db/" + existingDash.Slug
res.ImportedUrl = existingDash.GetUrl()
res.ImportedRevision = existingDash.Data.Get("revision").MustInt64(1)
existingMatches[existingDash.Id] = true
break
}
}
result = append(result, res)
}
// find deleted dashboards
for _, dash := range query.Result {
if _, exists := existingMatches[dash.Id]; !exists {
result = append(result, &plugindashboards.PluginDashboard{
UID: dash.Uid,
Slug: dash.Slug,
DashboardId: dash.Id,
Removed: true,
})
}
}
return &plugindashboards.ListPluginDashboardsResponse{
Items: result,
}, nil
}
func (s Service) LoadPluginDashboard(ctx context.Context, req *plugindashboards.LoadPluginDashboardRequest) (*plugindashboards.LoadPluginDashboardResponse, error) {
if req == nil {
return nil, fmt.Errorf("req cannot be nil")
}
args := &plugins.GetPluginDashboardFileContentsArgs{
PluginID: req.PluginID,
FileReference: req.Reference,
}
resp, err := s.pluginDashboardStore.GetPluginDashboardFileContents(ctx, args)
if err != nil {
return nil, err
}
defer func() {
if err := resp.Content.Close(); err != nil {
s.logger.Warn("Failed to close plugin dashboard file", "reference", req.Reference, "err", err)
}
}()
data, err := simplejson.NewFromReader(resp.Content)
if err != nil {
return nil, err
}
return &plugindashboards.LoadPluginDashboardResponse{
Dashboard: models.NewDashboardFromJson(data),
}, nil
}
var _ plugindashboards.Service = &Service{}

View File

@ -0,0 +1,224 @@
package service
import (
"bytes"
"context"
"fmt"
"io"
"sort"
"testing"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/stretchr/testify/require"
)
func TestGetPluginDashboards(t *testing.T) {
testDashboardOld := simplejson.New()
testDashboardOld.Set("title", "Nginx Connections")
testDashboardOld.Set("revision", 22)
testDashboardNew := simplejson.New()
testDashboardNew.Set("title", "Nginx Connections")
testDashboardNew.Set("revision", 23)
testDashboardNewBytes, err := testDashboardNew.MarshalJSON()
require.NoError(t, err)
testDashboardDeleted := simplejson.New()
testDashboardDeleted.Set("title", "test")
testDashboardDeleted.Set("id", 4)
pluginDashboardStore := &pluginDashboardStoreMock{
pluginDashboardFiles: map[string]map[string][]byte{
"test-app": {
"nginx-connections": testDashboardNewBytes,
},
},
}
dashboardPluginService := &dashboardPluginServiceMock{
pluginDashboards: map[string][]*models.Dashboard{
"test-app": {
models.NewDashboardFromJson(testDashboardOld),
models.NewDashboardFromJson(testDashboardDeleted),
},
},
}
s := ProvideService(pluginDashboardStore, dashboardPluginService)
require.NotNil(t, s)
t.Run("LoadPluginDashboard", func(t *testing.T) {
testCases := []struct {
desc string
req *plugindashboards.LoadPluginDashboardRequest
errorFn require.ErrorAssertionFunc
respValueFn require.ValueAssertionFunc
validateFn func(tt *testing.T, resp *plugindashboards.LoadPluginDashboardResponse)
}{
{
desc: "Should return error for nil req",
req: nil,
errorFn: require.Error,
respValueFn: require.Nil,
},
{
desc: "Should return error for non-existing plugin",
req: &plugindashboards.LoadPluginDashboardRequest{
PluginID: "non-existing",
},
errorFn: require.Error,
respValueFn: require.Nil,
},
{
desc: "Should return error for non-existing file reference",
req: &plugindashboards.LoadPluginDashboardRequest{
PluginID: "test-app",
Reference: "non-existing",
},
errorFn: require.Error,
respValueFn: require.Nil,
},
{
desc: "Should return expected loaded dashboard model",
req: &plugindashboards.LoadPluginDashboardRequest{
PluginID: "test-app",
Reference: "nginx-connections",
},
errorFn: require.NoError,
respValueFn: require.NotNil,
validateFn: func(tt *testing.T, resp *plugindashboards.LoadPluginDashboardResponse) {
require.NotNil(tt, resp.Dashboard)
require.Equal(tt, testDashboardNew.Get("title").MustString(), resp.Dashboard.Title)
require.Equal(tt, testDashboardNew.Get("revision").MustInt64(1), resp.Dashboard.Data.Get("revision").MustInt64(1))
},
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
resp, err := s.LoadPluginDashboard(context.Background(), tc.req)
tc.errorFn(t, err)
tc.respValueFn(t, resp)
if resp != nil {
tc.validateFn(t, resp)
}
})
}
})
t.Run("ListPluginDashboards", func(t *testing.T) {
testCases := []struct {
desc string
req *plugindashboards.ListPluginDashboardsRequest
errorFn require.ErrorAssertionFunc
respValueFn require.ValueAssertionFunc
validateFn func(tt *testing.T, resp *plugindashboards.ListPluginDashboardsResponse)
}{
{
desc: "Should return error for nil req",
req: nil,
errorFn: require.Error,
respValueFn: require.Nil,
},
{
desc: "Should return error for non-existing plugin",
req: &plugindashboards.ListPluginDashboardsRequest{
PluginID: "non-existing",
},
errorFn: require.Error,
respValueFn: require.Nil,
},
{
desc: "Should return updated nginx dashboard revision and removed title dashboard",
req: &plugindashboards.ListPluginDashboardsRequest{
PluginID: "test-app",
},
errorFn: require.NoError,
respValueFn: require.NotNil,
validateFn: func(tt *testing.T, resp *plugindashboards.ListPluginDashboardsResponse) {
require.Len(tt, resp.Items, 2)
nginx := resp.Items[0]
require.True(tt, nginx.Imported)
require.Equal(t, int64(23), nginx.Revision)
require.Equal(t, int64(22), nginx.ImportedRevision)
require.Equal(tt, testDashboardOld.Get("title").MustString(), nginx.Title)
test := resp.Items[1]
require.True(tt, test.Removed)
},
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
resp, err := s.ListPluginDashboards(context.Background(), tc.req)
tc.errorFn(t, err)
tc.respValueFn(t, resp)
if resp != nil {
tc.validateFn(t, resp)
}
})
}
})
}
type pluginDashboardStoreMock struct {
pluginDashboardFiles map[string]map[string][]byte
}
func (m pluginDashboardStoreMock) ListPluginDashboardFiles(ctx context.Context, args *plugins.ListPluginDashboardFilesArgs) (*plugins.ListPluginDashboardFilesResult, error) {
if dashboardFiles, exists := m.pluginDashboardFiles[args.PluginID]; exists {
references := []string{}
for ref := range dashboardFiles {
references = append(references, ref)
}
sort.Strings(references)
return &plugins.ListPluginDashboardFilesResult{
FileReferences: references,
}, nil
}
return nil, plugins.NotFoundError{PluginID: args.PluginID}
}
func (m pluginDashboardStoreMock) GetPluginDashboardFileContents(ctx context.Context, args *plugins.GetPluginDashboardFileContentsArgs) (*plugins.GetPluginDashboardFileContentsResult, error) {
if dashboardFiles, exists := m.pluginDashboardFiles[args.PluginID]; exists {
if content, exists := dashboardFiles[args.FileReference]; exists {
r := bytes.NewReader(content)
return &plugins.GetPluginDashboardFileContentsResult{
Content: io.NopCloser(r),
}, nil
}
} else if !exists {
return nil, plugins.NotFoundError{PluginID: args.PluginID}
}
return nil, fmt.Errorf("plugin dashboard file not found")
}
type dashboardPluginServiceMock struct {
pluginDashboards map[string][]*models.Dashboard
args []*models.GetDashboardsByPluginIdQuery
}
func (d *dashboardPluginServiceMock) GetDashboardsByPluginID(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
query.Result = []*models.Dashboard{}
if dashboards, exists := d.pluginDashboards[query.PluginId]; exists {
query.Result = dashboards
}
if d.args == nil {
d.args = []*models.GetDashboardsByPluginIdQuery{}
}
d.args = append(d.args, query)
return nil
}

View File

@ -39,7 +39,6 @@ func (ss *SQLStore) addDashboardQueryAndCommandHandlers() {
bus.AddHandler("sql", ss.GetDashboards)
bus.AddHandler("sql", ss.HasEditPermissionInFolders)
bus.AddHandler("sql", ss.GetDashboardPermissionsForUser)
bus.AddHandler("sql", ss.GetDashboardsByPluginId)
bus.AddHandler("sql", ss.GetDashboardSlugById)
bus.AddHandler("sql", ss.HasAdminPermissionInFolders)
}
@ -441,17 +440,6 @@ func (ss *SQLStore) GetDashboardPermissionsForUser(ctx context.Context, query *m
})
}
func (ss *SQLStore) GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
return ss.WithDbSession(ctx, func(dbSession *DBSession) error {
var dashboards = make([]*models.Dashboard, 0)
whereExpr := "org_id=? AND plugin_id=? AND is_folder=" + dialect.BooleanStr(false)
err := dbSession.Where(whereExpr, query.OrgId, query.PluginId).Find(&dashboards)
query.Result = dashboards
return err
})
}
type DashboardSlugDTO struct {
Slug string
}

View File

@ -660,10 +660,6 @@ func (m *SQLStoreMock) GetDashboardPermissionsForUser(ctx context.Context, query
return m.ExpectedError
}
func (m *SQLStoreMock) GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error {
return m.ExpectedError
}
func (m *SQLStoreMock) GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error {
return m.ExpectedError
}

View File

@ -150,6 +150,5 @@ type Store interface {
SearchOrgs(ctx context.Context, query *models.SearchOrgsQuery) error
HasAdminPermissionInFolders(ctx context.Context, query *models.HasAdminPermissionInFoldersQuery) error
GetDashboardPermissionsForUser(ctx context.Context, query *models.GetDashboardPermissionsForUserQuery) error
GetDashboardsByPluginId(ctx context.Context, query *models.GetDashboardsByPluginIdQuery) error
GetDashboardSlugById(ctx context.Context, query *models.GetDashboardSlugByIdQuery) error
}

View File

@ -14,8 +14,8 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/dashboardimport"
"github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/tests/testinfra"
@ -61,7 +61,7 @@ func TestDashboardQuota(t *testing.T) {
})
b, err := ioutil.ReadAll(resp.Body)
require.NoError(t, err)
dashboardDTO := &plugins.PluginDashboardInfoDTO{}
dashboardDTO := &plugindashboards.PluginDashboard{}
err = json.Unmarshal(b, dashboardDTO)
require.NoError(t, err)
require.EqualValues(t, 1, dashboardDTO.DashboardId)

View File

@ -49,6 +49,16 @@ func NewServer(t testing.TB, routeRegister routing.RouteRegister) *Server {
}
}
// NewGetRequest creates a new GET request setup for test.
func (s *Server) NewGetRequest(target string) *http.Request {
return s.NewRequest(http.MethodGet, target, nil)
}
// NewPostRequest creates a new POST request setup for test.
func (s *Server) NewPostRequest(target string, body io.Reader) *http.Request {
return s.NewRequest(http.MethodPost, target, body)
}
// NewRequest creates a new request setup for test.
func (s *Server) NewRequest(method string, target string, body io.Reader) *http.Request {
s.t.Helper()
@ -65,11 +75,19 @@ func (s *Server) NewRequest(method string, target string, body io.Reader) *http.
return req
}
// Send sends an HTTP request to the test server and returns an HTTP response
// Send sends a HTTP request to the test server and returns an HTTP response.
func (s *Server) Send(req *http.Request) (*http.Response, error) {
return http.DefaultClient.Do(req)
}
// SendJSON sets the Content-Type header to application/json and sends
// a HTTP request to the test server and returns an HTTP response.
// Suitable for POST/PUT/PATCH requests that sends request body as JSON.
func (s *Server) SendJSON(req *http.Request) (*http.Response, error) {
req.Header.Add("Content-Type", "application/json")
return s.Send(req)
}
func generateRequestIdentifier() string {
return uuid.NewString()
}
@ -91,7 +109,7 @@ func RequestWithWebContext(req *http.Request, c *models.ReqContext) *http.Reques
func RequestWithSignedInUser(req *http.Request, user *models.SignedInUser) *http.Request {
return RequestWithWebContext(req, &models.ReqContext{
SignedInUser: &models.SignedInUser{},
SignedInUser: user,
IsSignedIn: true,
})
}

View File

@ -4,6 +4,7 @@ import (
"encoding/json"
"io/ioutil"
"net/http"
"strings"
"testing"
"github.com/grafana/grafana/pkg/api/response"
@ -12,6 +13,69 @@ import (
"github.com/stretchr/testify/require"
)
func TestServer(t *testing.T) {
routeRegister := routing.NewRouteRegister()
var actualRequest *http.Request
routeRegister.Post("/api", routing.Wrap(func(c *models.ReqContext) response.Response {
actualRequest = c.Req
return response.JSON(http.StatusOK, c.SignedInUser)
}))
s := NewServer(t, routeRegister)
require.NotNil(t, s)
t.Run("NewRequest: GET api should set expected properties", func(t *testing.T) {
req := s.NewRequest(http.MethodGet, "api", nil)
verifyRequest(t, s, req, "")
})
t.Run("NewGetRequest: GET /api should set expected properties", func(t *testing.T) {
req := s.NewGetRequest("/api")
verifyRequest(t, s, req, "")
})
t.Run("NewPostRequest: POST api should set expected properties", func(t *testing.T) {
payload := strings.NewReader("test")
req := s.NewPostRequest("api", payload)
verifyRequest(t, s, req, "test")
t.Run("SendJSON should set expected Content-Type header", func(t *testing.T) {
payload.Reset("test")
resp, err := s.SendJSON(req)
require.NoError(t, err)
require.NotNil(t, resp)
require.NoError(t, resp.Body.Close())
require.NotNil(t, actualRequest)
require.Equal(t, "application/json", actualRequest.Header.Get("Content-Type"))
})
})
}
func verifyRequest(t *testing.T, s *Server, req *http.Request, expectedBody string) {
require.NotNil(t, req)
require.Equal(t, s.TestServer.URL+"/api", req.URL.String())
if expectedBody == "" {
require.Equal(t, http.MethodGet, req.Method)
require.Equal(t, http.NoBody, req.Body)
} else {
require.Equal(t, http.MethodPost, req.Method)
require.NotNil(t, req.Body)
bytes, err := ioutil.ReadAll(req.Body)
require.NoError(t, err)
require.Equal(t, expectedBody, string(bytes))
}
require.NotEmpty(t, requestIdentifierFromRequest(req))
req = RequestWithWebContext(req, &models.ReqContext{
IsSignedIn: true,
})
require.NotNil(t, req)
ctx := requestContextFromRequest(req)
require.NotNil(t, ctx)
require.True(t, ctx.IsSignedIn)
}
func TestServerClient(t *testing.T) {
routeRegister := routing.NewRouteRegister()
routeRegister.Get("/test", routing.Wrap(func(c *models.ReqContext) response.Response {