Plugins: Introduce `LoadingStrategy` for frontend loading logic (#92392)

* do it all

* feat(plugins): move loadingStrategy to ds pluginMeta and add to plugin settings endpoint

* support child plugins and update tests

* use relative path for nested plugins

* feat(plugins): support nested plugins in the plugin loader cache by extracting pluginId from path

* feat(grafana-data): add plugin loading strategy to plugin meta and export

* feat(plugins): pass down loadingStrategy to fe plugin loader

* refactor(plugins): make PluginLoadingStrategy an enum

* feat(plugins): add the loading strategy to the fe plugin loader cache

* feat(plugins): load fe plugin js assets as script tags based on be loadingStrategy

* add more tests

* feat(plugins): add loading strategy to plugin preloader

* feat(plugins): make loadingStrategy a maybe and provide fetch fallback

* test(alerting): update config.apps mocks to include loadingStrategy

* fix format

---------

Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com>
pull/93100/head
Will Browne 10 months ago committed by GitHub
parent d61530941a
commit 2c47d246fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      packages/grafana-data/src/index.ts
  2. 7
      packages/grafana-data/src/types/plugin.ts
  3. 2
      packages/grafana-runtime/src/config.ts
  4. 1
      pkg/api/dtos/plugins.go
  5. 42
      pkg/api/frontendsettings.go
  6. 157
      pkg/api/frontendsettings_test.go
  7. 5
      pkg/api/http_server.go
  8. 1
      pkg/api/plugins.go
  9. 4
      pkg/api/plugins_test.go
  10. 22
      pkg/plugins/manager/loader/assetpath/assetpath.go
  11. 150
      pkg/plugins/manager/loader/assetpath/assetpath_test.go
  12. 46
      pkg/plugins/models.go
  13. 12
      pkg/services/pluginsintegration/loader/loader_test.go
  14. 81
      pkg/services/pluginsintegration/pluginassets/pluginassets.go
  15. 168
      pkg/services/pluginsintegration/pluginassets/pluginassets_test.go
  16. 2
      pkg/services/pluginsintegration/pluginsintegration.go
  17. 13
      pkg/services/pluginsintegration/pluginstore/plugins.go
  18. 3
      public/app/features/alerting/unified/mocks/server/handlers/plugins.ts
  19. 2
      public/app/features/alerting/unified/utils/rules.test.ts
  20. 4
      public/app/features/plugins/importPanelPlugin.ts
  21. 64
      public/app/features/plugins/loader/cache.test.ts
  22. 26
      public/app/features/plugins/loader/cache.ts
  23. 7
      public/app/features/plugins/pluginPreloader.ts
  24. 23
      public/app/features/plugins/plugin_loader.ts

@ -579,6 +579,7 @@ export {
PluginSignatureType, PluginSignatureType,
PluginErrorCode, PluginErrorCode,
PluginIncludeType, PluginIncludeType,
PluginLoadingStrategy,
GrafanaPlugin, GrafanaPlugin,
type PluginError, type PluginError,
type AngularMeta, type AngularMeta,

@ -59,6 +59,12 @@ export interface AngularMeta {
hideDeprecation: boolean; hideDeprecation: boolean;
} }
// Signals to SystemJS how to load frontend js assets.
export enum PluginLoadingStrategy {
fetch = 'fetch',
script = 'script',
}
export interface PluginMeta<T extends KeyValue = {}> { export interface PluginMeta<T extends KeyValue = {}> {
id: string; id: string;
name: string; name: string;
@ -91,6 +97,7 @@ export interface PluginMeta<T extends KeyValue = {}> {
live?: boolean; live?: boolean;
angular?: AngularMeta; angular?: AngularMeta;
angularDetected?: boolean; angularDetected?: boolean;
loadingStrategy?: PluginLoadingStrategy;
} }
interface PluginDependencyInfo { interface PluginDependencyInfo {

@ -17,6 +17,7 @@ import {
SystemDateFormatSettings, SystemDateFormatSettings,
getThemeById, getThemeById,
AngularMeta, AngularMeta,
PluginLoadingStrategy,
} from '@grafana/data'; } from '@grafana/data';
export interface AzureSettings { export interface AzureSettings {
@ -40,6 +41,7 @@ export type AppPluginConfig = {
version: string; version: string;
preload: boolean; preload: boolean;
angular: AngularMeta; angular: AngularMeta;
loadingStrategy: PluginLoadingStrategy;
}; };
export type PreinstalledPlugin = { export type PreinstalledPlugin = {

@ -28,6 +28,7 @@ type PluginSetting struct {
SignatureType plugins.SignatureType `json:"signatureType"` SignatureType plugins.SignatureType `json:"signatureType"`
SignatureOrg string `json:"signatureOrg"` SignatureOrg string `json:"signatureOrg"`
AngularDetected bool `json:"angularDetected"` AngularDetected bool `json:"angularDetected"`
LoadingStrategy plugins.LoadingStrategy `json:"loadingStrategy"`
} }
type PluginListItem struct { type PluginListItem struct {

@ -110,7 +110,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
apps := make(map[string]*plugins.AppDTO, 0) apps := make(map[string]*plugins.AppDTO, 0)
for _, ap := range availablePlugins[plugins.TypeApp] { for _, ap := range availablePlugins[plugins.TypeApp] {
apps[ap.Plugin.ID] = newAppDTO( apps[ap.Plugin.ID] = hs.newAppDTO(
c.Req.Context(),
ap.Plugin, ap.Plugin,
ap.Settings, ap.Settings,
) )
@ -140,18 +141,19 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
} }
panels[panel.ID] = plugins.PanelDTO{ panels[panel.ID] = plugins.PanelDTO{
ID: panel.ID, ID: panel.ID,
Name: panel.Name, Name: panel.Name,
AliasIDs: panel.AliasIDs, AliasIDs: panel.AliasIDs,
Info: panel.Info, Info: panel.Info,
Module: panel.Module, Module: panel.Module,
BaseURL: panel.BaseURL, BaseURL: panel.BaseURL,
SkipDataQuery: panel.SkipDataQuery, SkipDataQuery: panel.SkipDataQuery,
HideFromList: panel.HideFromList, HideFromList: panel.HideFromList,
ReleaseState: string(panel.State), ReleaseState: string(panel.State),
Signature: string(panel.Signature), Signature: string(panel.Signature),
Sort: getPanelSort(panel.ID), Sort: getPanelSort(panel.ID),
Angular: panel.Angular, Angular: panel.Angular,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), panel),
} }
} }
@ -455,6 +457,7 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
BaseURL: plugin.BaseURL, BaseURL: plugin.BaseURL,
Angular: plugin.Angular, Angular: plugin.Angular,
MultiValueFilterOperators: plugin.MultiValueFilterOperators, MultiValueFilterOperators: plugin.MultiValueFilterOperators,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), plugin),
} }
if ds.JsonData == nil { if ds.JsonData == nil {
@ -551,13 +554,14 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug
return dataSources, nil return dataSources, nil
} }
func newAppDTO(plugin pluginstore.Plugin, settings pluginsettings.InfoDTO) *plugins.AppDTO { func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin, settings pluginsettings.InfoDTO) *plugins.AppDTO {
app := &plugins.AppDTO{ app := &plugins.AppDTO{
ID: plugin.ID, ID: plugin.ID,
Version: plugin.Info.Version, Version: plugin.Info.Version,
Path: plugin.Module, Path: plugin.Module,
Preload: false, Preload: false,
Angular: plugin.Angular, Angular: plugin.Angular,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(ctx, plugin),
} }
if settings.Enabled { if settings.Enabled {

@ -25,6 +25,7 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/rendering"
@ -35,7 +36,7 @@ import (
"github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web"
) )
func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.FeatureToggles, pstore pluginstore.Store, psettings pluginsettings.Service) (*web.Mux, *HTTPServer) { func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.FeatureToggles, pstore pluginstore.Store, psettings pluginsettings.Service, passets *pluginassets.Service) (*web.Mux, *HTTPServer) {
t.Helper() t.Helper()
db.InitTestDB(t) db.InitTestDB(t)
// nolint:staticcheck // nolint:staticcheck
@ -50,6 +51,11 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
}) })
} }
pluginsCDN := pluginscdn.ProvideService(&config.PluginManagementCfg{
PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate,
PluginSettings: cfg.PluginSettings,
})
var pluginStore = pstore var pluginStore = pstore
if pluginStore == nil { if pluginStore == nil {
pluginStore = &pluginstore.FakePluginStore{} pluginStore = &pluginstore.FakePluginStore{}
@ -60,6 +66,11 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
pluginsSettings = &pluginsettings.FakePluginSettings{} pluginsSettings = &pluginsettings.FakePluginSettings{}
} }
var pluginsAssets = passets
if pluginsAssets == nil {
pluginsAssets = pluginassets.ProvideService(cfg, pluginsCDN)
}
hs := &HTTPServer{ hs := &HTTPServer{
authnService: &authntest.FakeService{}, authnService: &authntest.FakeService{},
Cfg: cfg, Cfg: cfg,
@ -69,16 +80,14 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F
Cfg: cfg, Cfg: cfg,
RendererPluginManager: &fakeRendererPluginManager{}, RendererPluginManager: &fakeRendererPluginManager{},
}, },
SQLStore: db.InitTestDB(t), SQLStore: db.InitTestDB(t),
SettingsProvider: setting.ProvideProvider(cfg), SettingsProvider: setting.ProvideProvider(cfg),
pluginStore: pluginStore, pluginStore: pluginStore,
grafanaUpdateChecker: &updatechecker.GrafanaService{}, grafanaUpdateChecker: &updatechecker.GrafanaService{},
AccessControl: accesscontrolmock.New(), AccessControl: accesscontrolmock.New(),
PluginSettings: pluginsSettings, PluginSettings: pluginsSettings,
pluginsCDNService: pluginscdn.ProvideService(&config.PluginManagementCfg{ pluginsCDNService: pluginsCDN,
PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate, pluginAssets: pluginsAssets,
PluginSettings: cfg.PluginSettings,
}),
namespacer: request.GetNamespaceMapper(cfg), namespacer: request.GetNamespaceMapper(cfg),
SocialService: socialimpl.ProvideService(cfg, features, &usagestats.UsageStatsMock{}, supportbundlestest.NewFakeBundleService(), remotecache.NewFakeCacheStorage(), nil, &ssosettingstests.MockService{}), SocialService: socialimpl.ProvideService(cfg, features, &usagestats.UsageStatsMock{}, supportbundlestest.NewFakeBundleService(), remotecache.NewFakeCacheStorage(), nil, &ssosettingstests.MockService{}),
managedPluginsService: managedplugins.NewNoop(), managedPluginsService: managedplugins.NewNoop(),
@ -108,7 +117,7 @@ func TestHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) {
cfg.BuildVersion = "7.8.9" cfg.BuildVersion = "7.8.9"
cfg.BuildCommit = "01234567" cfg.BuildCommit = "01234567"
m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil) m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil) req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
@ -198,7 +207,7 @@ func TestHTTPServer_GetFrontendSettings_pluginsCDNBaseURL(t *testing.T) {
if test.mutateCfg != nil { if test.mutateCfg != nil {
test.mutateCfg(cfg) test.mutateCfg(cfg)
} }
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil) m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil) req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
@ -221,6 +230,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
desc string desc string
pluginStore func() pluginstore.Store pluginStore func() pluginstore.Store
pluginSettings func() pluginsettings.Service pluginSettings func() pluginsettings.Service
pluginAssets func() *pluginassets.Service
expected settings expected settings
}{ }{
{ {
@ -245,13 +255,17 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", false), Plugins: newAppSettings("test-app", false),
} }
}, },
pluginAssets: func() *pluginassets.Service {
return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{}))
},
expected: settings{ expected: settings{
Apps: map[string]*plugins.AppDTO{ Apps: map[string]*plugins.AppDTO{
"test-app": { "test-app": {
ID: "test-app", ID: "test-app",
Preload: false, Preload: false,
Path: "/test-app/module.js", Path: "/test-app/module.js",
Version: "0.5.0", Version: "0.5.0",
LoadingStrategy: plugins.LoadingStrategyScript,
}, },
}, },
}, },
@ -278,13 +292,17 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true), Plugins: newAppSettings("test-app", true),
} }
}, },
pluginAssets: func() *pluginassets.Service {
return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{}))
},
expected: settings{ expected: settings{
Apps: map[string]*plugins.AppDTO{ Apps: map[string]*plugins.AppDTO{
"test-app": { "test-app": {
ID: "test-app", ID: "test-app",
Preload: true, Preload: true,
Path: "/test-app/module.js", Path: "/test-app/module.js",
Version: "0.5.0", Version: "0.5.0",
LoadingStrategy: plugins.LoadingStrategyScript,
}, },
}, },
}, },
@ -312,14 +330,99 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Plugins: newAppSettings("test-app", true), Plugins: newAppSettings("test-app", true),
} }
}, },
pluginAssets: func() *pluginassets.Service {
return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{}))
},
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
ID: "test-app",
Preload: true,
Path: "/test-app/module.js",
Version: "0.5.0",
Angular: plugins.AngularMeta{Detected: true},
LoadingStrategy: plugins.LoadingStrategyFetch,
},
},
},
},
{
desc: "app plugin with create plugin version compatible with script loading strategy",
pluginStore: func() pluginstore.Store {
return &pluginstore.FakePluginStore{
PluginList: []pluginstore.Plugin{
{
Module: fmt.Sprintf("/%s/module.js", "test-app"),
JSONData: plugins.JSONData{
ID: "test-app",
Info: plugins.Info{Version: "0.5.0"},
Type: plugins.TypeApp,
Preload: true,
},
},
},
}
},
pluginSettings: func() pluginsettings.Service {
return &pluginsettings.FakePluginSettings{
Plugins: newAppSettings("test-app", true),
}
},
pluginAssets: func() *pluginassets.Service {
return pluginassets.ProvideService(&setting.Cfg{
PluginSettings: map[string]map[string]string{
"test-app": {
pluginassets.CreatePluginVersionCfgKey: pluginassets.CreatePluginVersionScriptSupportEnabled,
},
},
}, pluginscdn.ProvideService(&config.PluginManagementCfg{}))
},
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
ID: "test-app",
Preload: true,
Path: "/test-app/module.js",
Version: "0.5.0",
LoadingStrategy: plugins.LoadingStrategyScript,
},
},
},
},
{
desc: "app plugin with CDN class",
pluginStore: func() pluginstore.Store {
return &pluginstore.FakePluginStore{
PluginList: []pluginstore.Plugin{
{
Class: plugins.ClassCDN,
Module: fmt.Sprintf("/%s/module.js", "test-app"),
JSONData: plugins.JSONData{
ID: "test-app",
Info: plugins.Info{Version: "0.5.0"},
Type: plugins.TypeApp,
Preload: true,
},
},
},
}
},
pluginSettings: func() pluginsettings.Service {
return &pluginsettings.FakePluginSettings{
Plugins: newAppSettings("test-app", true),
}
},
pluginAssets: func() *pluginassets.Service {
return pluginassets.ProvideService(setting.NewCfg(), pluginscdn.ProvideService(&config.PluginManagementCfg{}))
},
expected: settings{ expected: settings{
Apps: map[string]*plugins.AppDTO{ Apps: map[string]*plugins.AppDTO{
"test-app": { "test-app": {
ID: "test-app", ID: "test-app",
Preload: true, Preload: true,
Path: "/test-app/module.js", Path: "/test-app/module.js",
Version: "0.5.0", Version: "0.5.0",
Angular: plugins.AngularMeta{Detected: true}, LoadingStrategy: plugins.LoadingStrategyFetch,
}, },
}, },
}, },
@ -329,7 +432,7 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.desc, func(t *testing.T) { t.Run(test.desc, func(t *testing.T) {
cfg := setting.NewCfg() cfg := setting.NewCfg()
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), test.pluginSettings()) m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), test.pluginSettings(), test.pluginAssets())
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil) req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()

@ -78,6 +78,7 @@ import (
"github.com/grafana/grafana/pkg/services/playlist" "github.com/grafana/grafana/pkg/services/playlist"
"github.com/grafana/grafana/pkg/services/plugindashboards" "github.com/grafana/grafana/pkg/services/plugindashboards"
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
@ -146,6 +147,7 @@ type HTTPServer struct {
pluginDashboardService plugindashboards.Service pluginDashboardService plugindashboards.Service
pluginStaticRouteResolver plugins.StaticRouteResolver pluginStaticRouteResolver plugins.StaticRouteResolver
pluginErrorResolver plugins.ErrorResolver pluginErrorResolver plugins.ErrorResolver
pluginAssets *pluginassets.Service
SearchService search.Service SearchService search.Service
ShortURLService shorturls.Service ShortURLService shorturls.Service
QueryHistoryService queryhistory.Service QueryHistoryService queryhistory.Service
@ -247,7 +249,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
encryptionService encryption.Internal, grafanaUpdateChecker *updatechecker.GrafanaService, encryptionService encryption.Internal, grafanaUpdateChecker *updatechecker.GrafanaService,
pluginsUpdateChecker *updatechecker.PluginsService, searchUsersService searchusers.Service, pluginsUpdateChecker *updatechecker.PluginsService, searchUsersService searchusers.Service,
dataSourcesService datasources.DataSourceService, queryDataService query.Service, pluginFileStore plugins.FileStore, dataSourcesService datasources.DataSourceService, queryDataService query.Service, pluginFileStore plugins.FileStore,
serviceaccountsService serviceaccounts.Service, serviceaccountsService serviceaccounts.Service, pluginAssets *pluginassets.Service,
authInfoService login.AuthInfoService, storageService store.StorageService, authInfoService login.AuthInfoService, storageService store.StorageService,
notificationService notifications.Service, dashboardService dashboards.DashboardService, notificationService notifications.Service, dashboardService dashboards.DashboardService,
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService folder.Service, dashboardProvisioningService dashboards.DashboardProvisioningService, folderService folder.Service,
@ -286,6 +288,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
pluginStore: pluginStore, pluginStore: pluginStore,
pluginStaticRouteResolver: pluginStaticRouteResolver, pluginStaticRouteResolver: pluginStaticRouteResolver,
pluginDashboardService: pluginDashboardService, pluginDashboardService: pluginDashboardService,
pluginAssets: pluginAssets,
pluginErrorResolver: pluginErrorResolver, pluginErrorResolver: pluginErrorResolver,
pluginFileStore: pluginFileStore, pluginFileStore: pluginFileStore,
grafanaUpdateChecker: grafanaUpdateChecker, grafanaUpdateChecker: grafanaUpdateChecker,

@ -208,6 +208,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
SignatureOrg: plugin.SignatureOrg, SignatureOrg: plugin.SignatureOrg,
SecureJsonFields: map[string]bool{}, SecureJsonFields: map[string]bool{},
AngularDetected: plugin.Angular.Detected, AngularDetected: plugin.Angular.Detected,
LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), plugin),
} }
if plugin.IsApp() { if plugin.IsApp() {

@ -41,6 +41,7 @@ import (
"github.com/grafana/grafana/pkg/services/org/orgtest" "github.com/grafana/grafana/pkg/services/org/orgtest"
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
@ -817,6 +818,7 @@ func Test_PluginsSettings(t *testing.T) {
Version: "1.0.0", Version: "1.0.0",
}, },
SecureJsonFields: map[string]bool{}, SecureJsonFields: map[string]bool{},
LoadingStrategy: plugins.LoadingStrategyScript,
}, },
}, },
{ {
@ -841,6 +843,8 @@ func Test_PluginsSettings(t *testing.T) {
ErrorCode: tc.errCode, ErrorCode: tc.errCode,
}) })
} }
pluginCDN := pluginscdn.ProvideService(&config.PluginManagementCfg{})
hs.pluginAssets = pluginassets.ProvideService(hs.Cfg, pluginCDN)
hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker) hs.pluginErrorResolver = pluginerrs.ProvideStore(errTracker)
var err error var err error
hs.pluginsUpdateChecker, err = updatechecker.ProvidePluginsService(hs.Cfg, nil, tracing.InitializeTracerForTest()) hs.pluginsUpdateChecker, err = updatechecker.ProvidePluginsService(hs.Cfg, nil, tracing.InitializeTracerForTest())

@ -57,13 +57,14 @@ func (s *Service) Base(n PluginInfo) (string, error) {
return s.cdn.AssetURL(n.pluginJSON.ID, n.pluginJSON.Info.Version, "") return s.cdn.AssetURL(n.pluginJSON.ID, n.pluginJSON.Info.Version, "")
} }
if n.parent != nil { if n.parent != nil {
relPath, err := n.parent.fs.Rel(n.fs.Base())
if err != nil {
return "", err
}
if s.cdn.PluginSupported(n.parent.pluginJSON.ID) { if s.cdn.PluginSupported(n.parent.pluginJSON.ID) {
relPath, err := n.parent.fs.Rel(n.fs.Base())
if err != nil {
return "", err
}
return s.cdn.AssetURL(n.parent.pluginJSON.ID, n.parent.pluginJSON.Info.Version, relPath) return s.cdn.AssetURL(n.parent.pluginJSON.ID, n.parent.pluginJSON.Info.Version, relPath)
} }
return path.Join("public/plugins", n.parent.pluginJSON.ID, relPath), nil
} }
return path.Join("public/plugins", n.pluginJSON.ID), nil return path.Join("public/plugins", n.pluginJSON.ID), nil
@ -87,13 +88,14 @@ func (s *Service) Module(n PluginInfo) (string, error) {
return s.cdn.AssetURL(n.pluginJSON.ID, n.pluginJSON.Info.Version, "module.js") return s.cdn.AssetURL(n.pluginJSON.ID, n.pluginJSON.Info.Version, "module.js")
} }
if n.parent != nil { if n.parent != nil {
relPath, err := n.parent.fs.Rel(n.fs.Base())
if err != nil {
return "", err
}
if s.cdn.PluginSupported(n.parent.pluginJSON.ID) { if s.cdn.PluginSupported(n.parent.pluginJSON.ID) {
relPath, err := n.parent.fs.Rel(n.fs.Base())
if err != nil {
return "", err
}
return s.cdn.AssetURL(n.parent.pluginJSON.ID, n.parent.pluginJSON.Info.Version, path.Join(relPath, "module.js")) return s.cdn.AssetURL(n.parent.pluginJSON.ID, n.parent.pluginJSON.Info.Version, path.Join(relPath, "module.js"))
} }
return path.Join("public/plugins", n.parent.pluginJSON.ID, relPath, "module.js"), nil
} }
return path.Join("public/plugins", n.pluginJSON.ID, "module.js"), nil return path.Join("public/plugins", n.pluginJSON.ID, "module.js"), nil
@ -117,10 +119,6 @@ func (s *Service) RelativeURL(n PluginInfo, pathStr string) (string, error) {
return s.cdn.AssetURL(n.parent.pluginJSON.ID, n.parent.pluginJSON.Info.Version, path.Join(relPath, pathStr)) return s.cdn.AssetURL(n.parent.pluginJSON.ID, n.parent.pluginJSON.Info.Version, path.Join(relPath, pathStr))
} }
} }
if s.cdn.PluginSupported(n.pluginJSON.ID) {
return s.cdn.NewCDNURLConstructor(n.pluginJSON.ID, n.pluginJSON.Info.Version).StringPath(pathStr)
}
// Local // Local
u, err := url.Parse(pathStr) u, err := url.Parse(pathStr)
if err != nil { if err != nil {

@ -187,3 +187,153 @@ func TestService(t *testing.T) {
}) })
} }
} }
func TestService_ChildPlugins(t *testing.T) {
type expected struct {
module string
baseURL string
relURL string
}
tcs := []struct {
name string
cfg *config.PluginManagementCfg
pluginInfo func() PluginInfo
expected expected
}{
{
name: "Local FS external plugin",
cfg: &config.PluginManagementCfg{},
pluginInfo: func() PluginInfo {
return NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassExternal, plugins.NewLocalFS("/plugins/parent"), nil)
},
expected: expected{
module: "public/plugins/parent/module.js",
baseURL: "public/plugins/parent",
relURL: "public/plugins/parent/path/to/file.txt",
},
},
{
name: "Local FS external plugin with child",
cfg: &config.PluginManagementCfg{},
pluginInfo: func() PluginInfo {
parentInfo := NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassExternal, plugins.NewLocalFS("/plugins/parent"), nil)
childInfo := NewPluginInfo(plugins.JSONData{ID: "child", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassExternal, plugins.NewLocalFS("/plugins/parent/child"), &parentInfo)
return childInfo
},
expected: expected{
module: "public/plugins/parent/child/module.js",
baseURL: "public/plugins/parent/child",
relURL: "public/plugins/parent/child/path/to/file.txt",
},
},
{
name: "Local FS core plugin",
cfg: &config.PluginManagementCfg{},
pluginInfo: func() PluginInfo {
return NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassCore, plugins.NewLocalFS("/plugins/parent"), nil)
},
expected: expected{
module: "core:plugin/parent",
baseURL: "public/app/plugins/parent",
relURL: "public/app/plugins/parent/path/to/file.txt",
},
},
{
name: "Externally-built Local FS core plugin",
cfg: &config.PluginManagementCfg{},
pluginInfo: func() PluginInfo {
return NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassCore, plugins.NewLocalFS("/plugins/parent/dist"), nil)
},
expected: expected{
module: "public/plugins/parent/module.js",
baseURL: "public/app/plugins/parent",
relURL: "public/app/plugins/parent/path/to/file.txt",
},
},
{
name: "CDN Class plugin",
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
},
pluginInfo: func() PluginInfo {
return NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassCDN, pluginFS("https://cdn.example.com/plugins/parent"), nil)
},
expected: expected{
module: "https://cdn.example.com/plugins/parent/module.js",
baseURL: "https://cdn.example.com/plugins/parent",
relURL: "https://cdn.example.com/plugins/parent/path/to/file.txt",
},
},
{
name: "CDN Class plugin with child",
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
},
pluginInfo: func() PluginInfo {
// Note: fake plugin FS is the most convenient way to mock the plugin FS for CDN plugins
parentInfo := NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassCDN, pluginFS("https://cdn.example.com/parent"), nil)
childInfo := NewPluginInfo(plugins.JSONData{ID: "child", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassCDN, pluginFS("https://cdn.example.com/parent/some/other/dir/child"), &parentInfo)
return childInfo
},
expected: expected{
module: "https://cdn.example.com/parent/some/other/dir/child/module.js",
baseURL: "https://cdn.example.com/parent/some/other/dir/child",
relURL: "https://cdn.example.com/parent/some/other/dir/child/path/to/file.txt",
},
},
{
name: "CDN supported plugin",
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
PluginSettings: map[string]map[string]string{
"parent": {"cdn": "true"},
},
},
pluginInfo: func() PluginInfo {
return NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassExternal, plugins.NewLocalFS("/plugins/parent"), nil)
},
expected: expected{
module: "https://cdn.example.com/parent/1.0.0/public/plugins/parent/module.js",
baseURL: "https://cdn.example.com/parent/1.0.0/public/plugins/parent",
relURL: "https://cdn.example.com/parent/1.0.0/public/plugins/parent/path/to/file.txt",
},
},
{
name: "CDN supported plugin with child",
cfg: &config.PluginManagementCfg{
PluginsCDNURLTemplate: "https://cdn.example.com",
PluginSettings: map[string]map[string]string{
"parent": {"cdn": "true"},
},
},
pluginInfo: func() PluginInfo {
parentInfo := NewPluginInfo(plugins.JSONData{ID: "parent", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassExternal, plugins.NewLocalFS("/plugins/parent"), nil)
childInfo := NewPluginInfo(plugins.JSONData{ID: "child", Info: plugins.Info{Version: "1.0.0"}}, plugins.ClassExternal, plugins.NewLocalFS("/plugins/parent/child"), &parentInfo)
return childInfo
},
expected: expected{
module: "https://cdn.example.com/parent/1.0.0/public/plugins/parent/child/module.js",
baseURL: "https://cdn.example.com/parent/1.0.0/public/plugins/parent/child",
relURL: "https://cdn.example.com/parent/1.0.0/public/plugins/parent/child/path/to/file.txt",
},
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
svc := ProvideService(tc.cfg, pluginscdn.ProvideService(tc.cfg))
module, err := svc.Module(tc.pluginInfo())
require.NoError(t, err)
require.Equal(t, tc.expected.module, module)
baseURL, err := svc.Base(tc.pluginInfo())
require.NoError(t, err)
require.Equal(t, tc.expected.baseURL, baseURL)
relURL, err := svc.RelativeURL(tc.pluginInfo(), "path/to/file.txt")
require.NoError(t, err)
require.Equal(t, tc.expected.relURL, relURL)
})
}
}

@ -176,6 +176,7 @@ type PluginMetaDTO struct {
BaseURL string `json:"baseUrl"` BaseURL string `json:"baseUrl"`
Angular AngularMeta `json:"angular"` Angular AngularMeta `json:"angular"`
MultiValueFilterOperators bool `json:"multiValueFilterOperators"` MultiValueFilterOperators bool `json:"multiValueFilterOperators"`
LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
} }
type DataSourceDTO struct { type DataSourceDTO struct {
@ -211,28 +212,28 @@ type DataSourceDTO struct {
} }
type PanelDTO struct { type PanelDTO struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
AliasIDs []string `json:"aliasIds,omitempty"` AliasIDs []string `json:"aliasIds,omitempty"`
Info Info `json:"info"` Info Info `json:"info"`
HideFromList bool `json:"hideFromList"` HideFromList bool `json:"hideFromList"`
Sort int `json:"sort"` Sort int `json:"sort"`
SkipDataQuery bool `json:"skipDataQuery"` SkipDataQuery bool `json:"skipDataQuery"`
ReleaseState string `json:"state"` ReleaseState string `json:"state"`
BaseURL string `json:"baseUrl"` BaseURL string `json:"baseUrl"`
Signature string `json:"signature"` Signature string `json:"signature"`
Module string `json:"module"` Module string `json:"module"`
Angular AngularMeta `json:"angular"`
Angular AngularMeta `json:"angular"` LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
} }
type AppDTO struct { type AppDTO struct {
ID string `json:"id"` ID string `json:"id"`
Path string `json:"path"` Path string `json:"path"`
Version string `json:"version"` Version string `json:"version"`
Preload bool `json:"preload"` Preload bool `json:"preload"`
Angular AngularMeta `json:"angular"`
Angular AngularMeta `json:"angular"` LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
} }
const ( const (
@ -252,6 +253,13 @@ type Error struct {
message string `json:"-"` message string `json:"-"`
} }
type LoadingStrategy string
const (
LoadingStrategyFetch LoadingStrategy = "fetch"
LoadingStrategyScript LoadingStrategy = "script"
)
func (e Error) Error() string { func (e Error) Error() string {
if e.message != "" { if e.message != "" {
return e.message return e.message

@ -1243,8 +1243,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
Plugins: []plugins.Dependency{}, Plugins: []plugins.Dependency{},
}, },
}, },
Module: "public/plugins/test-panel/module.js", Module: "public/plugins/test-datasource/nested/module.js",
BaseURL: "public/plugins/test-panel", BaseURL: "public/plugins/test-datasource/nested",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")), FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "nested-plugins/parent/nested")),
Signature: plugins.SignatureStatusValid, Signature: plugins.SignatureStatusValid,
SignatureType: plugins.SignatureTypeGrafana, SignatureType: plugins.SignatureTypeGrafana,
@ -1408,8 +1408,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
{Name: "License", URL: "https://github.com/grafana/grafana-starter-panel/blob/master/LICENSE"}, {Name: "License", URL: "https://github.com/grafana/grafana-starter-panel/blob/master/LICENSE"},
}, },
Logos: plugins.Logos{ Logos: plugins.Logos{
Small: "public/plugins/myorgid-simple-panel/img/logo.svg", Small: "public/plugins/myorgid-simple-app/child/img/logo.svg",
Large: "public/plugins/myorgid-simple-panel/img/logo.svg", Large: "public/plugins/myorgid-simple-app/child/img/logo.svg",
}, },
Screenshots: []plugins.Screenshots{}, Screenshots: []plugins.Screenshots{},
Description: "Grafana Panel Plugin Template", Description: "Grafana Panel Plugin Template",
@ -1423,8 +1423,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
Plugins: []plugins.Dependency{}, Plugins: []plugins.Dependency{},
}, },
}, },
Module: "public/plugins/myorgid-simple-panel/module.js", Module: "public/plugins/myorgid-simple-app/child/module.js",
BaseURL: "public/plugins/myorgid-simple-panel", BaseURL: "public/plugins/myorgid-simple-app/child",
FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist/child")), FS: mustNewStaticFSForTests(t, filepath.Join(testDataDir(t), "app-with-child/dist/child")),
IncludedInAppID: parent.ID, IncludedInAppID: parent.ID,
Signature: plugins.SignatureStatusValid, Signature: plugins.SignatureStatusValid,

@ -0,0 +1,81 @@
package pluginassets
import (
"context"
"github.com/Masterminds/semver/v3"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/setting"
)
const (
CreatePluginVersionCfgKey = "create_plugin_version"
CreatePluginVersionScriptSupportEnabled = "4.15.0"
)
var (
scriptLoadingMinSupportedVersion = semver.MustParse(CreatePluginVersionScriptSupportEnabled)
)
func ProvideService(cfg *setting.Cfg, cdn *pluginscdn.Service) *Service {
return &Service{
cfg: cfg,
cdn: cdn,
log: log.New("pluginassets"),
}
}
type Service struct {
cfg *setting.Cfg
cdn *pluginscdn.Service
log log.Logger
}
// LoadingStrategy calculates the loading strategy for a plugin.
// If a plugin has plugin setting `create_plugin_version` >= 4.15.0, set loadingStrategy to "script".
// If a plugin is not loaded via the CDN and is not Angular, set loadingStrategy to "script".
// Otherwise, set loadingStrategy to "fetch".
func (s *Service) LoadingStrategy(_ context.Context, p pluginstore.Plugin) plugins.LoadingStrategy {
if pCfg, ok := s.cfg.PluginSettings[p.ID]; ok {
if s.compatibleCreatePluginVersion(pCfg) {
return plugins.LoadingStrategyScript
}
}
// If the plugin has a parent, check the parent's create_plugin_version setting
if p.Parent != nil {
if pCfg, ok := s.cfg.PluginSettings[p.Parent.ID]; ok {
if s.compatibleCreatePluginVersion(pCfg) {
return plugins.LoadingStrategyScript
}
}
}
if !s.cndEnabled(p) && !p.Angular.Detected {
return plugins.LoadingStrategyScript
}
return plugins.LoadingStrategyFetch
}
func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool {
if cpv, ok := ps[CreatePluginVersionCfgKey]; ok {
createPluginVer, err := semver.NewVersion(cpv)
if err != nil {
s.log.Warn("Failed to parse create plugin version setting as semver", "version", cpv, "error", err)
} else {
if !createPluginVer.LessThan(scriptLoadingMinSupportedVersion) {
return true
}
}
}
return false
}
func (s *Service) cndEnabled(p pluginstore.Plugin) bool {
return s.cdn.PluginSupported(p.ID) || p.Class == plugins.ClassCDN
}

@ -0,0 +1,168 @@
package pluginassets
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/setting"
)
func TestService_Calculate(t *testing.T) {
const pluginID = "grafana-test-datasource"
const (
incompatVersion = "4.14.0"
compatVersion = CreatePluginVersionScriptSupportEnabled
futureVersion = "5.0.0"
)
tcs := []struct {
name string
pluginSettings setting.PluginSettings
plugin pluginstore.Plugin
expected plugins.LoadingStrategy
}{
{
name: "Expected LoadingStrategyScript when create-plugin version is compatible and plugin is not angular",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: compatVersion,
}),
plugin: newPlugin(pluginID, false),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when parent create-plugin version is compatible and plugin is not angular",
pluginSettings: newPluginSettings("parent-datasource", map[string]string{
CreatePluginVersionCfgKey: compatVersion,
}),
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"}
return p
}),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when create-plugin version is future compatible and plugin is not angular",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: futureVersion,
}),
plugin: newPlugin(pluginID, false),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when create-plugin version is not provided, plugin is not angular and is not configured as CDN enabled",
pluginSettings: newPluginSettings(pluginID, map[string]string{
// NOTE: cdn key is not set
}),
plugin: newPlugin(pluginID, false),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyScript when create-plugin version is not compatible, plugin is not angular, is not configured as CDN enabled and does not have the CDN class",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: incompatVersion,
// NOTE: cdn key is not set
}),
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
p.Class = plugins.ClassExternal
return p
}),
expected: plugins.LoadingStrategyScript,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular, is configured as CDN enabled and does not have the CDN class",
pluginSettings: newPluginSettings(pluginID, map[string]string{
"cdn": "true",
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
p.Class = plugins.ClassExternal
return p
}),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible and plugin is angular",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPlugin(pluginID, true),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular and plugin is configured as CDN enabled",
pluginSettings: newPluginSettings(pluginID, map[string]string{
"cdn": "true",
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPlugin(pluginID, false),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular and has the CDN class",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: incompatVersion,
}),
plugin: newPlugin(pluginID, false, func(p pluginstore.Plugin) pluginstore.Plugin {
p.Class = plugins.ClassCDN
return p
}),
expected: plugins.LoadingStrategyFetch,
},
{
name: "Expected LoadingStrategyScript when plugin setting create-plugin version is badly formatted, plugin is not configured as CDN enabled and does not have the CDN class",
pluginSettings: newPluginSettings(pluginID, map[string]string{
CreatePluginVersionCfgKey: "invalidSemver",
}),
plugin: newPlugin(pluginID, false),
expected: plugins.LoadingStrategyScript,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
s := &Service{
cfg: newCfg(tc.pluginSettings),
cdn: pluginscdn.ProvideService(&config.PluginManagementCfg{
PluginsCDNURLTemplate: "http://cdn.example.com", // required for cdn.PluginSupported check
PluginSettings: tc.pluginSettings,
}),
log: log.NewNopLogger(),
}
got := s.LoadingStrategy(context.Background(), tc.plugin)
assert.Equal(t, tc.expected, got, "unexpected loading strategy")
})
}
}
func newPlugin(pluginID string, angular bool, cbs ...func(p pluginstore.Plugin) pluginstore.Plugin) pluginstore.Plugin {
p := pluginstore.Plugin{
JSONData: plugins.JSONData{
ID: pluginID,
},
Angular: plugins.AngularMeta{Detected: angular},
}
for _, cb := range cbs {
p = cb(p)
}
return p
}
func newCfg(ps setting.PluginSettings) *setting.Cfg {
return &setting.Cfg{
PluginSettings: ps,
}
}
func newPluginSettings(pluginID string, kv map[string]string) setting.PluginSettings {
return setting.PluginSettings{
pluginID: kv,
}
}

@ -43,6 +43,7 @@ import (
"github.com/grafana/grafana/pkg/services/pluginsintegration/loader" "github.com/grafana/grafana/pkg/services/pluginsintegration/loader"
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins" "github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline" "github.com/grafana/grafana/pkg/services/pluginsintegration/pipeline"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginconfig"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext" "github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs"
@ -126,6 +127,7 @@ var WireSet = wire.NewSet(
plugincontext.ProvideBaseService, plugincontext.ProvideBaseService,
wire.Bind(new(plugincontext.BasePluginContextProvider), new(*plugincontext.BaseProvider)), wire.Bind(new(plugincontext.BasePluginContextProvider), new(*plugincontext.BaseProvider)),
plugininstaller.ProvideService, plugininstaller.ProvideService,
pluginassets.ProvideService,
) )
// WireExtensionSet provides a wire.ProviderSet of plugin providers that can be // WireExtensionSet provides a wire.ProviderSet of plugin providers that can be

@ -16,6 +16,7 @@ type Plugin struct {
Class plugins.Class Class plugins.Class
// App fields // App fields
Parent *ParentPlugin
IncludedInAppID string IncludedInAppID string
DefaultNavURL string DefaultNavURL string
Pinned bool Pinned bool
@ -59,7 +60,7 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin {
supportsStreaming = true supportsStreaming = true
} }
return Plugin{ dto := Plugin{
fs: p.FS, fs: p.FS,
supportsStreaming: supportsStreaming, supportsStreaming: supportsStreaming,
Class: p.Class, Class: p.Class,
@ -76,4 +77,14 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin {
ExternalService: p.ExternalService, ExternalService: p.ExternalService,
Angular: p.Angular, Angular: p.Angular,
} }
if p.Parent != nil {
dto.Parent = &ParentPlugin{ID: p.Parent.ID}
}
return dto
}
type ParentPlugin struct {
ID string
} }

@ -1,6 +1,6 @@
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { PluginMeta } from '@grafana/data'; import { PluginLoadingStrategy, PluginMeta } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { plugins } from 'app/features/alerting/unified/testSetup/plugins'; import { plugins } from 'app/features/alerting/unified/testSetup/plugins';
@ -18,6 +18,7 @@ export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => {
preload: true, preload: true,
version: info.version, version: info.version,
angular: angular ?? { detected: false, hideDeprecation: false }, angular: angular ?? { detected: false, hideDeprecation: false },
loadingStrategy: PluginLoadingStrategy.script,
}; };
}); });

@ -1,3 +1,4 @@
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { RuleGroupIdentifier } from 'app/types/unified-alerting'; import { RuleGroupIdentifier } from 'app/types/unified-alerting';
@ -47,6 +48,7 @@ describe('getRuleOrigin', () => {
path: '', path: '',
preload: true, preload: true,
angular: { detected: false, hideDeprecation: false }, angular: { detected: false, hideDeprecation: false },
loadingStrategy: PluginLoadingStrategy.script,
}, },
}; };
const rule = mockCombinedRule({ const rule = mockCombinedRule({

@ -1,6 +1,6 @@
import { ComponentType } from 'react'; import { ComponentType } from 'react';
import { PanelPlugin, PanelPluginMeta, PanelProps } from '@grafana/data'; import { PanelPlugin, PanelPluginMeta, PanelProps, PluginLoadingStrategy } from '@grafana/data';
import config from 'app/core/config'; import config from 'app/core/config';
import { getPanelPluginLoadError } from '../panel/components/PanelPluginError'; import { getPanelPluginLoadError } from '../panel/components/PanelPluginError';
@ -56,10 +56,12 @@ export function syncGetPanelPlugin(id: string): PanelPlugin | undefined {
} }
function getPanelPlugin(meta: PanelPluginMeta): Promise<PanelPlugin> { function getPanelPlugin(meta: PanelPluginMeta): Promise<PanelPlugin> {
const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch;
return importPluginModule({ return importPluginModule({
path: meta.module, path: meta.module,
version: meta.info?.version, version: meta.info?.version,
isAngular: meta.angular?.detected, isAngular: meta.angular?.detected,
loadingStrategy: fallbackLoadingStrategy,
pluginId: meta.id, pluginId: meta.id,
}) })
.then((pluginExports) => { .then((pluginExports) => {

@ -1,4 +1,12 @@
import { registerPluginInCache, invalidatePluginInCache, resolveWithCache, getPluginFromCache } from './cache'; import { PluginLoadingStrategy } from '@grafana/data';
import {
registerPluginInCache,
invalidatePluginInCache,
resolveWithCache,
getPluginFromCache,
extractCacheKeyFromPath,
} from './cache';
jest.mock('./constants', () => ({ jest.mock('./constants', () => ({
CACHE_INITIALISED_AT: 123456, CACHE_INITIALISED_AT: 123456,
@ -7,28 +15,28 @@ jest.mock('./constants', () => ({
describe('Cache Functions', () => { describe('Cache Functions', () => {
describe('registerPluginInCache', () => { describe('registerPluginInCache', () => {
it('should register a plugin in the cache', () => { it('should register a plugin in the cache', () => {
const plugin = { pluginId: 'plugin1', version: '1.0.0', isAngular: false }; const plugin = { version: '1.0.0', loadingStrategy: PluginLoadingStrategy.script };
registerPluginInCache(plugin); registerPluginInCache({ path: 'public/plugins/plugin1/module.js', ...plugin });
expect(getPluginFromCache('plugin1')).toEqual(plugin); expect(getPluginFromCache('plugin1')).toEqual(plugin);
}); });
it('should not register a plugin if it already exists in the cache', () => { it('should not register a plugin if it already exists in the cache', () => {
const pluginId = 'plugin2'; const path = 'public/plugins/plugin2/module.js';
const plugin = { pluginId, version: '2.0.0' }; const plugin = { path, version: '2.0.0', loadingStrategy: PluginLoadingStrategy.script };
registerPluginInCache(plugin); registerPluginInCache(plugin);
const plugin2 = { pluginId, version: '2.5.0' }; const plugin2 = { path, version: '2.5.0', loadingStrategy: PluginLoadingStrategy.script };
registerPluginInCache(plugin2); registerPluginInCache(plugin2);
expect(getPluginFromCache(pluginId)?.version).toBe('2.0.0'); expect(getPluginFromCache(path)?.version).toBe('2.0.0');
}); });
}); });
describe('invalidatePluginInCache', () => { describe('invalidatePluginInCache', () => {
it('should invalidate a plugin in the cache', () => { it('should invalidate a plugin in the cache', () => {
const pluginId = 'plugin3'; const path = 'public/plugins/plugin2/module.js';
const plugin = { pluginId, version: '3.0.0' }; const plugin = { path, version: '3.0.0', loadingStrategy: PluginLoadingStrategy.script };
registerPluginInCache(plugin); registerPluginInCache(plugin);
invalidatePluginInCache(pluginId); invalidatePluginInCache('plugin2');
expect(getPluginFromCache(pluginId)).toBeUndefined(); expect(getPluginFromCache('plugin2')).toBeUndefined();
}); });
it('should not throw an error if the plugin does not exist in the cache', () => { it('should not throw an error if the plugin does not exist in the cache', () => {
@ -43,17 +51,43 @@ describe('Cache Functions', () => {
}); });
it('should resolve URL with plugin version as cache bust parameter if available', () => { it('should resolve URL with plugin version as cache bust parameter if available', () => {
const plugin = { pluginId: 'plugin5', version: '5.0.0' };
registerPluginInCache(plugin);
const url = 'http://localhost:3000/public/plugins/plugin5/module.js'; const url = 'http://localhost:3000/public/plugins/plugin5/module.js';
const plugin = { path: url, version: '5.0.0', loadingStrategy: PluginLoadingStrategy.script };
registerPluginInCache(plugin);
expect(resolveWithCache(url)).toContain('_cache=5.0.0'); expect(resolveWithCache(url)).toContain('_cache=5.0.0');
}); });
}); });
describe('extractCacheKeyFromPath', () => {
it('should extract plugin ID from a path', () => {
expect(extractCacheKeyFromPath('public/plugins/plugin6/module.js')).toBe('plugin6');
});
it('should extract plugin ID from a path', () => {
expect(extractCacheKeyFromPath('public/plugins/plugin6/datasource/module.js')).toBe('plugin6');
});
it('should extract plugin ID from a url', () => {
expect(extractCacheKeyFromPath('https://my-url.com/plugin6/1.0.0/public/plugins/plugin6/module.js')).toBe(
'plugin6'
);
});
it('should extract plugin ID from a nested plugin url', () => {
expect(
extractCacheKeyFromPath('https://my-url.com/plugin6/1.0.0/public/plugins/plugin6/datasource/module.js')
).toBe('plugin6');
});
it('should return null if the path does not match the pattern', () => {
expect(extractCacheKeyFromPath('public/plugins/plugin7')).toBeNull();
});
});
describe('getPluginFromCache', () => { describe('getPluginFromCache', () => {
it('should return plugin from cache if exists', () => { it('should return plugin from cache if exists', () => {
const plugin = { pluginId: 'plugin6', version: '6.0.0' }; const plugin = { version: '6.0.0', loadingStrategy: PluginLoadingStrategy.script };
registerPluginInCache(plugin); registerPluginInCache({ path: 'public/plugins/plugin6/module.js', ...plugin });
expect(getPluginFromCache('plugin6')).toEqual(plugin); expect(getPluginFromCache('plugin6')).toEqual(plugin);
}); });

@ -1,22 +1,26 @@
import { PluginLoadingStrategy } from '@grafana/data';
import { clearPluginSettingsCache } from '../pluginSettings'; import { clearPluginSettingsCache } from '../pluginSettings';
import { CACHE_INITIALISED_AT } from './constants'; import { CACHE_INITIALISED_AT } from './constants';
const cache: Record<string, CacheablePlugin> = {}; const cache: Record<string, CachedPlugin> = {};
type CacheablePlugin = { type CacheablePlugin = {
pluginId: string; path: string;
version: string; version: string;
isAngular?: boolean; loadingStrategy: PluginLoadingStrategy;
}; };
export function registerPluginInCache({ pluginId, version, isAngular }: CacheablePlugin): void { type CachedPlugin = Omit<CacheablePlugin, 'path'>;
const key = pluginId;
export function registerPluginInCache({ path, version, loadingStrategy }: CacheablePlugin): void {
const key = extractCacheKeyFromPath(path);
if (key && !cache[key]) { if (key && !cache[key]) {
cache[key] = { cache[key] = {
version: encodeURI(version), version: encodeURI(version),
isAngular, loadingStrategy,
pluginId,
}; };
} }
} }
@ -39,7 +43,7 @@ export function resolveWithCache(url: string, defaultBust = CACHE_INITIALISED_AT
return `${url}?_cache=${bust}`; return `${url}?_cache=${bust}`;
} }
export function getPluginFromCache(path: string): CacheablePlugin | undefined { export function getPluginFromCache(path: string): CachedPlugin | undefined {
const key = getCacheKey(path); const key = getCacheKey(path);
if (!key) { if (!key) {
return; return;
@ -47,6 +51,12 @@ export function getPluginFromCache(path: string): CacheablePlugin | undefined {
return cache[key]; return cache[key];
} }
export function extractCacheKeyFromPath(path: string) {
const regex = /\/?public\/plugins\/([^\/]+)\//;
const match = path.match(regex);
return match ? match[1] : null;
}
function getCacheKey(address: string): string | undefined { function getCacheKey(address: string): string | undefined {
const key = Object.keys(cache).find((key) => address.includes(key)); const key = Object.keys(cache).find((key) => address.includes(key));
if (!key) { if (!key) {

@ -5,7 +5,7 @@ import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import { getPluginSettings } from 'app/features/plugins/pluginSettings'; import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { PluginExtensionRegistries } from './extensions/registry/types'; import { PluginExtensionRegistries } from './extensions/registry/types';
import * as pluginLoader from './plugin_loader'; import { importPluginModule } from './plugin_loader';
export type PluginPreloadResult = { export type PluginPreloadResult = {
pluginId: string; pluginId: string;
@ -48,14 +48,15 @@ export async function preloadPlugins(
} }
async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> { async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {
const { path, version, id: pluginId } = config; const { path, version, id: pluginId, loadingStrategy } = config;
try { try {
startMeasure(`frontend_plugin_preload_${pluginId}`); startMeasure(`frontend_plugin_preload_${pluginId}`);
const { plugin } = await pluginLoader.importPluginModule({ const { plugin } = await importPluginModule({
path, path,
version, version,
isAngular: config.angular.detected, isAngular: config.angular.detected,
pluginId, pluginId,
loadingStrategy,
}); });
const { exposedComponentConfigs = [], addedComponentConfigs = [], addedLinkConfigs = [] } = plugin; const { exposedComponentConfigs = [], addedComponentConfigs = [], addedLinkConfigs = [] } = plugin;

@ -4,6 +4,7 @@ import {
DataSourceJsonData, DataSourceJsonData,
DataSourcePlugin, DataSourcePlugin,
DataSourcePluginMeta, DataSourcePluginMeta,
PluginLoadingStrategy,
PluginMeta, PluginMeta,
} from '@grafana/data'; } from '@grafana/data';
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
@ -18,7 +19,7 @@ import { SystemJS } from './loader/systemjs';
import { sharedDependenciesMap } from './loader/sharedDependencies'; import { sharedDependenciesMap } from './loader/sharedDependencies';
import { decorateSystemJSFetch, decorateSystemJSResolve, decorateSystemJsOnload } from './loader/systemjsHooks'; import { decorateSystemJSFetch, decorateSystemJSResolve, decorateSystemJsOnload } from './loader/systemjsHooks';
import { SystemJSWithLoaderHooks } from './loader/types'; import { SystemJSWithLoaderHooks } from './loader/types';
import { buildImportMap, isHostedOnCDN, resolveModulePath } from './loader/utils'; import { buildImportMap, resolveModulePath } from './loader/utils';
import { importPluginModuleInSandbox } from './sandbox/sandbox_plugin_loader'; import { importPluginModuleInSandbox } from './sandbox/sandbox_plugin_loader';
import { isFrontendSandboxSupported } from './sandbox/utils'; import { isFrontendSandboxSupported } from './sandbox/utils';
@ -29,13 +30,17 @@ SystemJS.addImportMap({ imports });
const systemJSPrototype: SystemJSWithLoaderHooks = SystemJS.constructor.prototype; const systemJSPrototype: SystemJSWithLoaderHooks = SystemJS.constructor.prototype;
// This instructs SystemJS to load plugin assets using fetch and eval if it returns a truthy value, otherwise // This instructs SystemJS to load plugin assets using fetch and eval if it returns a truthy value, otherwise
// it will load the plugin using a script tag. We only want to fetch and eval files that are // it will load the plugin using a script tag. The logic that sets loadingStrategy comes from the backend.
// hosted on a CDN, are related to Angular plugins or are not js files. // See: pkg/services/pluginsintegration/pluginassets/pluginassets.go
systemJSPrototype.shouldFetch = function (url) { systemJSPrototype.shouldFetch = function (url) {
const pluginInfo = getPluginFromCache(url); const pluginInfo = getPluginFromCache(url);
const jsTypeRegEx = /^[^#?]+\.(js)([?#].*)?$/; const jsTypeRegEx = /^[^#?]+\.(js)([?#].*)?$/;
return isHostedOnCDN(url) || Boolean(pluginInfo?.isAngular) || !jsTypeRegEx.test(url); if (!jsTypeRegEx.test(url)) {
return true;
}
return Boolean(pluginInfo?.loadingStrategy !== PluginLoadingStrategy.script);
}; };
const originalImport = systemJSPrototype.import; const originalImport = systemJSPrototype.import;
@ -64,17 +69,19 @@ systemJSPrototype.onload = decorateSystemJsOnload;
export async function importPluginModule({ export async function importPluginModule({
path, path,
pluginId,
loadingStrategy,
version, version,
isAngular, isAngular,
pluginId,
}: { }: {
path: string; path: string;
pluginId: string; pluginId: string;
loadingStrategy: PluginLoadingStrategy;
version?: string; version?: string;
isAngular?: boolean; isAngular?: boolean;
}): Promise<System.Module> { }): Promise<System.Module> {
if (version) { if (version) {
registerPluginInCache({ pluginId, version, isAngular }); registerPluginInCache({ path, version, loadingStrategy });
} }
const builtIn = builtInPlugins[path]; const builtIn = builtInPlugins[path];
@ -99,10 +106,12 @@ export async function importPluginModule({
export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise<GenericDataSourcePlugin> { export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise<GenericDataSourcePlugin> {
const isAngular = meta.angular?.detected ?? meta.angularDetected; const isAngular = meta.angular?.detected ?? meta.angularDetected;
const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch;
return importPluginModule({ return importPluginModule({
path: meta.module, path: meta.module,
version: meta.info?.version, version: meta.info?.version,
isAngular, isAngular,
loadingStrategy: fallbackLoadingStrategy,
pluginId: meta.id, pluginId: meta.id,
}).then((pluginExports) => { }).then((pluginExports) => {
if (pluginExports.plugin) { if (pluginExports.plugin) {
@ -128,10 +137,12 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise<Gene
export function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> { export function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> {
const isAngular = meta.angular?.detected ?? meta.angularDetected; const isAngular = meta.angular?.detected ?? meta.angularDetected;
const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch;
return importPluginModule({ return importPluginModule({
path: meta.module, path: meta.module,
version: meta.info?.version, version: meta.info?.version,
isAngular, isAngular,
loadingStrategy: fallbackLoadingStrategy,
pluginId: meta.id, pluginId: meta.id,
}).then((pluginExports) => { }).then((pluginExports) => {
const plugin: AppPlugin = pluginExports.plugin ? pluginExports.plugin : new AppPlugin(); const plugin: AppPlugin = pluginExports.plugin ? pluginExports.plugin : new AppPlugin();

Loading…
Cancel
Save