Plugins: Refactor plugin dashboards (#44315)
Moves/refactor Grafana specific functionality related to plugin dashboards out to specific services for importing dashboards and keep app plugin dashboards up-to-date. Fixes #44257
This commit is contained in:
@@ -1,18 +0,0 @@
|
||||
package plugins
|
||||
|
||||
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"`
|
||||
}
|
||||
+17
-12
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/models"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin"
|
||||
)
|
||||
@@ -76,20 +75,26 @@ 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"`
|
||||
}
|
||||
|
||||
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)
|
||||
// ImportDashboard imports a dashboard.
|
||||
ImportDashboard(ctx context.Context, pluginID, path string, orgID, folderID int64, dashboardModel *simplejson.Json,
|
||||
overwrite bool, inputs []ImportDashboardInput, user *models.SignedInUser) (PluginDashboardInfoDTO,
|
||||
*models.Dashboard, error)
|
||||
}
|
||||
|
||||
type ImportDashboardInput struct {
|
||||
Type string `json:"type"`
|
||||
PluginId string `json:"pluginId"`
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/expr"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
)
|
||||
|
||||
var varRegex = regexp.MustCompile(`(\$\{.+?\})`)
|
||||
|
||||
type DashboardInputMissingError struct {
|
||||
VariableName string
|
||||
}
|
||||
|
||||
func (e DashboardInputMissingError) Error() string {
|
||||
return fmt.Sprintf("Dashboard input variable: %v missing from import command", e.VariableName)
|
||||
}
|
||||
|
||||
type DashTemplateEvaluator struct {
|
||||
template *simplejson.Json
|
||||
inputs []plugins.ImportDashboardInput
|
||||
variables map[string]string
|
||||
result *simplejson.Json
|
||||
}
|
||||
|
||||
func (e *DashTemplateEvaluator) findInput(varName string, varType string) *plugins.ImportDashboardInput {
|
||||
for _, input := range e.inputs {
|
||||
if varType == input.Type && (input.Name == varName || input.Name == "*") {
|
||||
return &input
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *DashTemplateEvaluator) Eval() (*simplejson.Json, error) {
|
||||
e.result = simplejson.New()
|
||||
e.variables = make(map[string]string)
|
||||
|
||||
// check that we have all inputs we need
|
||||
for _, inputDef := range e.template.Get("__inputs").MustArray() {
|
||||
inputDefJson := simplejson.NewFromAny(inputDef)
|
||||
inputName := inputDefJson.Get("name").MustString()
|
||||
inputType := inputDefJson.Get("type").MustString()
|
||||
input := e.findInput(inputName, inputType)
|
||||
|
||||
// force expressions value to `__expr__`
|
||||
if inputDefJson.Get("pluginId").MustString() == expr.DatasourceType {
|
||||
input = &plugins.ImportDashboardInput{
|
||||
Value: expr.DatasourceType,
|
||||
}
|
||||
}
|
||||
|
||||
if input == nil {
|
||||
return nil, &DashboardInputMissingError{VariableName: inputName}
|
||||
}
|
||||
|
||||
e.variables["${"+inputName+"}"] = input.Value
|
||||
}
|
||||
|
||||
return simplejson.NewFromAny(e.evalObject(e.template)), nil
|
||||
}
|
||||
|
||||
func (e *DashTemplateEvaluator) evalValue(source *simplejson.Json) interface{} {
|
||||
sourceValue := source.Interface()
|
||||
|
||||
switch v := sourceValue.(type) {
|
||||
case string:
|
||||
interpolated := varRegex.ReplaceAllStringFunc(v, func(match string) string {
|
||||
replacement, exists := e.variables[match]
|
||||
if exists {
|
||||
return replacement
|
||||
}
|
||||
return match
|
||||
})
|
||||
return interpolated
|
||||
case bool:
|
||||
return v
|
||||
case json.Number:
|
||||
return v
|
||||
case map[string]interface{}:
|
||||
return e.evalObject(source)
|
||||
case []interface{}:
|
||||
array := make([]interface{}, 0)
|
||||
for _, item := range v {
|
||||
array = append(array, e.evalValue(simplejson.NewFromAny(item)))
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *DashTemplateEvaluator) evalObject(source *simplejson.Json) interface{} {
|
||||
result := make(map[string]interface{})
|
||||
|
||||
for key, value := range source.MustMap() {
|
||||
if key == "__inputs" {
|
||||
continue
|
||||
}
|
||||
result[key] = e.evalValue(simplejson.NewFromAny(value))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/ioutil"
|
||||
"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/plugins/backendplugin/provider"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/loader"
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/signature"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDashboardImport(t *testing.T) {
|
||||
pluginScenario(t, "When importing a plugin dashboard", func(t *testing.T, pm *PluginManager) {
|
||||
origNewDashboardService := dashboards.NewService
|
||||
t.Cleanup(func() {
|
||||
dashboards.NewService = origNewDashboardService
|
||||
})
|
||||
mock := &dashboards.FakeDashboardService{}
|
||||
dashboards.MockDashboardService(mock)
|
||||
|
||||
info, dash, err := pm.ImportDashboard(context.Background(), "test-app", "dashboards/connections.json", 1, 0, nil, false,
|
||||
[]plugins.ImportDashboardInput{
|
||||
{Name: "*", Type: "datasource", Value: "graphite"},
|
||||
}, &models.SignedInUser{UserId: 1, OrgRole: models.ROLE_ADMIN})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, info)
|
||||
require.NotNil(t, dash)
|
||||
|
||||
resultStr, err := mock.SavedDashboards[0].Dashboard.Data.EncodePretty()
|
||||
require.NoError(t, err)
|
||||
expectedBytes, err := ioutil.ReadFile("testdata/test-app/dashboards/connections_result.json")
|
||||
require.NoError(t, err)
|
||||
expectedJson, err := simplejson.NewJson(expectedBytes)
|
||||
require.NoError(t, err)
|
||||
expectedStr, err := expectedJson.EncodePretty()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, expectedStr, resultStr)
|
||||
|
||||
panel := mock.SavedDashboards[0].Dashboard.Data.Get("rows").GetIndex(0).Get("panels").GetIndex(0)
|
||||
require.Equal(t, "graphite", panel.Get("datasource").MustString())
|
||||
})
|
||||
|
||||
t.Run("When evaling dashboard template", func(t *testing.T) {
|
||||
template, err := simplejson.NewJson([]byte(`{
|
||||
"__inputs": [
|
||||
{
|
||||
"name": "DS_NAME",
|
||||
"type": "datasource"
|
||||
}
|
||||
],
|
||||
"test": {
|
||||
"prop": "${DS_NAME}_${DS_NAME}"
|
||||
}
|
||||
}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
evaluator := &DashTemplateEvaluator{
|
||||
template: template,
|
||||
inputs: []plugins.ImportDashboardInput{
|
||||
{Name: "*", Type: "datasource", Value: "my-server"},
|
||||
},
|
||||
}
|
||||
|
||||
res, err := evaluator.Eval()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "my-server_my-server", res.GetPath("test", "prop").MustString())
|
||||
|
||||
inputs := res.Get("__inputs")
|
||||
require.Nil(t, inputs.Interface())
|
||||
})
|
||||
}
|
||||
|
||||
func pluginScenario(t *testing.T, desc string, fn func(*testing.T, *PluginManager)) {
|
||||
t.Helper()
|
||||
|
||||
t.Run("Given a plugin", func(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{}), &sqlstore.SQLStore{})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run(desc, func(t *testing.T) {
|
||||
fn(t, pm)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -11,7 +11,6 @@ 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/dashboards"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
@@ -134,63 +133,3 @@ func (m *PluginManager) LoadPluginDashboard(ctx context.Context, pluginID, path
|
||||
|
||||
return models.NewDashboardFromJson(data), nil
|
||||
}
|
||||
|
||||
func (m *PluginManager) ImportDashboard(ctx context.Context, pluginID, path string, orgID, folderID int64, dashboardModel *simplejson.Json,
|
||||
overwrite bool, inputs []plugins.ImportDashboardInput, user *models.SignedInUser) (plugins.PluginDashboardInfoDTO,
|
||||
*models.Dashboard, error) {
|
||||
var dashboard *models.Dashboard
|
||||
if pluginID != "" {
|
||||
var err error
|
||||
if dashboard, err = m.LoadPluginDashboard(ctx, pluginID, path); err != nil {
|
||||
return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err
|
||||
}
|
||||
} else {
|
||||
dashboard = models.NewDashboardFromJson(dashboardModel)
|
||||
}
|
||||
|
||||
evaluator := &DashTemplateEvaluator{
|
||||
template: dashboard.Data,
|
||||
inputs: inputs,
|
||||
}
|
||||
|
||||
generatedDash, err := evaluator.Eval()
|
||||
if err != nil {
|
||||
return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err
|
||||
}
|
||||
|
||||
saveCmd := models.SaveDashboardCommand{
|
||||
Dashboard: generatedDash,
|
||||
OrgId: orgID,
|
||||
UserId: user.UserId,
|
||||
Overwrite: overwrite,
|
||||
PluginId: pluginID,
|
||||
FolderId: folderID,
|
||||
}
|
||||
|
||||
dto := &dashboards.SaveDashboardDTO{
|
||||
OrgId: orgID,
|
||||
Dashboard: saveCmd.GetDashboardModel(),
|
||||
Overwrite: saveCmd.Overwrite,
|
||||
User: user,
|
||||
}
|
||||
|
||||
savedDash, err := dashboards.NewService(m.sqlStore).ImportDashboard(ctx, dto)
|
||||
if err != nil {
|
||||
return plugins.PluginDashboardInfoDTO{}, &models.Dashboard{}, err
|
||||
}
|
||||
|
||||
return plugins.PluginDashboardInfoDTO{
|
||||
UID: savedDash.Uid,
|
||||
PluginId: pluginID,
|
||||
Title: savedDash.Title,
|
||||
Path: path,
|
||||
Revision: savedDash.Data.Get("revision").MustInt64(1),
|
||||
FolderId: savedDash.FolderId,
|
||||
ImportedUri: "db/" + savedDash.Slug,
|
||||
ImportedUrl: savedDash.GetUrl(),
|
||||
ImportedRevision: dashboard.Data.Get("revision").MustInt64(1),
|
||||
Imported: true,
|
||||
DashboardId: savedDash.Id,
|
||||
Slug: savedDash.Slug,
|
||||
}, savedDash, nil
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
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/sqlstore"
|
||||
)
|
||||
|
||||
func ProvideService(pluginStore plugins.Store, pluginDashboardManager plugins.PluginDashboardManager,
|
||||
sqlStore *sqlstore.SQLStore) *Service {
|
||||
s := &Service{
|
||||
sqlStore: sqlStore,
|
||||
pluginStore: pluginStore,
|
||||
pluginDashboardManager: pluginDashboardManager,
|
||||
logger: log.New("plugindashboards"),
|
||||
}
|
||||
bus.AddEventListener(s.handlePluginStateChanged)
|
||||
s.updateAppDashboards()
|
||||
return s
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
sqlStore *sqlstore.SQLStore
|
||||
pluginStore plugins.Store
|
||||
pluginDashboardManager plugins.PluginDashboardManager
|
||||
|
||||
logger log.Logger
|
||||
}
|
||||
|
||||
func (s *Service) updateAppDashboards() {
|
||||
s.logger.Debug("Looking for app dashboard updates")
|
||||
|
||||
pluginSettings, err := s.sqlStore.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 := 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 := bus.Dispatch(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 := bus.Dispatch(ctx, &cmd); err != nil {
|
||||
s.logger.Error("Failed to update plugin setting version", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) handlePluginStateChanged(ctx context.Context, event *models.PluginStateChangedEvent) error {
|
||||
s.logger.Info("Plugin state changed", "pluginId", event.PluginId, "enabled", event.Enabled)
|
||||
|
||||
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 := 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 := 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)
|
||||
user := &models.SignedInUser{UserId: 0, OrgRole: models.ROLE_ADMIN}
|
||||
_, _, err = s.pluginDashboardManager.ImportDashboard(ctx, pluginDashInfo.PluginId, pluginDashInfo.Path, orgID, 0, dash.Data, true,
|
||||
nil, user)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user