diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index 1f7b6158896..79015b76444 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -222,6 +222,7 @@ Experimental features might be changed or removed without prior notice. | `newLogsPanel` | Enables the new logs panel in Explore | | `pluginsCDNSyncLoader` | Load plugins from CDN synchronously | | `assetSriChecks` | Enables SRI checks for Grafana JavaScript assets | +| `localizationForPlugins` | Enables localization for plugins | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index ca5ec946364..fcaa74dd7c4 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1055,4 +1055,8 @@ export interface FeatureToggles { * @default true */ alertingRuleRecoverDeleted?: boolean; + /** + * Enables localization for plugins + */ + localizationForPlugins?: boolean; } diff --git a/packages/grafana-data/src/types/plugin.ts b/packages/grafana-data/src/types/plugin.ts index 7770eabe2bc..b279d885db6 100644 --- a/packages/grafana-data/src/types/plugin.ts +++ b/packages/grafana-data/src/types/plugin.ts @@ -100,6 +100,9 @@ export interface PluginMeta { loadingStrategy?: PluginLoadingStrategy; extensions?: PluginExtensions; moduleHash?: string; + + // Paths to the translations for the plugin + translations?: Record; } interface PluginDependencyInfo { diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 75defb7cf21..8d3fa8e10ce 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -154,6 +154,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro Sort: getPanelSort(panel.ID), Angular: panel.Angular, LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), panel), + Translations: panel.Translations, } } @@ -489,6 +490,7 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug Angular: plugin.Angular, MultiValueFilterOperators: plugin.MultiValueFilterOperators, LoadingStrategy: hs.pluginAssets.LoadingStrategy(c.Req.Context(), plugin), + Translations: plugin.Translations, } if ds.JsonData == nil { @@ -571,8 +573,9 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlug Signature: ds.Signature, Module: ds.Module, // ModuleHash: hs.pluginAssets.ModuleHash(c.Req.Context(), ds), - BaseURL: ds.BaseURL, - Angular: ds.Angular, + BaseURL: ds.BaseURL, + Angular: ds.Angular, + Translations: ds.Translations, }, } if ds.Name == grafanads.DatasourceName { @@ -597,6 +600,7 @@ func (hs *HTTPServer) newAppDTO(ctx context.Context, plugin pluginstore.Plugin, Extensions: plugin.Extensions, Dependencies: plugin.Dependencies, ModuleHash: hs.pluginAssets.ModuleHash(ctx, plugin), + Translations: plugin.Translations, } if settings.Enabled { diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index 6a9663fe77f..62e09136af7 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "fmt" "net/http" @@ -24,6 +25,10 @@ import ( accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/authn/authntest" + "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/datasources" + datafakes "github.com/grafana/grafana/pkg/services/datasources/fakes" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/licensing" "github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins" @@ -34,6 +39,7 @@ import ( "github.com/grafana/grafana/pkg/services/ssosettings/ssosettingstests" "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest" "github.com/grafana/grafana/pkg/services/updatechecker" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" ) @@ -96,6 +102,7 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features featuremgmt.F SocialService: socialimpl.ProvideService(cfg, features, &usagestats.UsageStatsMock{}, supportbundlestest.NewFakeBundleService(), remotecache.NewFakeCacheStorage(), nil, &ssosettingstests.MockService{}), managedPluginsService: managedplugins.NewNoop(), tracer: tracing.InitializeTracerForTest(), + DataSourcesService: &datafakes.FakeDataSourceService{}, } m := web.New() @@ -455,6 +462,233 @@ func newAppSettings(id string, enabled bool) map[string]*pluginsettings.DTO { } } +func TestHTTPServer_GetFrontendSettings_translations(t *testing.T) { + type settings struct { + Datasources map[string]plugins.DataSourceDTO `json:"datasources"` + Panels map[string]*plugins.PanelDTO `json:"panels"` + Apps map[string]*plugins.AppDTO `json:"apps"` + } + + tests := []struct { + desc string + pluginStore func() pluginstore.Store + expected settings + signedInUser *user.SignedInUser + }{ + { + desc: "built in datasource plugin with translations", + 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.TypeDataSource, + BuiltIn: true, + }, + Translations: map[string]string{ + "en-US": "public/plugins/test-app/locales/en-US/test-app.json", + "pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json", + }, + }, + }, + } + }, + expected: settings{ + Datasources: map[string]plugins.DataSourceDTO{ + "": { + Type: string(plugins.TypeDataSource), + JSONData: make(map[string]any), + PluginMeta: &plugins.PluginMetaDTO{ + JSONData: plugins.JSONData{ + ID: "test-app", + Info: plugins.Info{Version: "0.5.0"}, + Type: plugins.TypeDataSource, + BuiltIn: true, + }, + Module: "/test-app/module.js", + Translations: map[string]string{ + "en-US": "public/plugins/test-app/locales/en-US/test-app.json", + "pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json", + }, + }, + }, + }, + Panels: map[string]*plugins.PanelDTO{}, + Apps: map[string]*plugins.AppDTO{}, + }, + }, + { + desc: "non-builtin datasource plugin with translations", + 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.TypeDataSource, + }, + Translations: map[string]string{ + "en-US": "public/plugins/test-app/locales/en-US/test-app.json", + "pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json", + }, + }, + }, + } + }, + signedInUser: &user.SignedInUser{ + OrgID: 1, + }, + expected: settings{ + Datasources: map[string]plugins.DataSourceDTO{ + "test-app": { + Type: "test-app", + Name: "test-app", + JSONData: make(map[string]any), + Module: "/test-app/module.js", + PluginMeta: &plugins.PluginMetaDTO{ + Module: "/test-app/module.js", + JSONData: plugins.JSONData{ + ID: "test-app", + Info: plugins.Info{Version: "0.5.0"}, + Type: plugins.TypeDataSource, + }, + LoadingStrategy: "script", + Translations: map[string]string{ + "en-US": "public/plugins/test-app/locales/en-US/test-app.json", + "pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json", + }, + }, + }, + }, + Panels: map[string]*plugins.PanelDTO{}, + Apps: map[string]*plugins.AppDTO{}, + }, + }, + { + desc: "panel plugin with translations", + 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.TypePanel, + }, + Translations: map[string]string{ + "en-US": "public/plugins/test-app/locales/en-US/test-app.json", + "pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json", + }, + }, + }, + } + }, + expected: settings{ + Datasources: map[string]plugins.DataSourceDTO{}, + Panels: map[string]*plugins.PanelDTO{ + "test-app": { + ID: "test-app", + Info: plugins.Info{Version: "0.5.0"}, + Sort: 100, + Module: "/test-app/module.js", + LoadingStrategy: "script", + ModuleHash: "", + Translations: map[string]string{ + "en-US": "public/plugins/test-app/locales/en-US/test-app.json", + "pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json", + }, + }, + }, + Apps: map[string]*plugins.AppDTO{}, + }, + }, + { + desc: "app plugin with translations", + 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, + }, + Translations: map[string]string{ + "en-US": "public/plugins/test-app/locales/en-US/test-app.json", + "pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json", + }, + }, + }, + } + }, + expected: settings{ + Datasources: map[string]plugins.DataSourceDTO{}, + Panels: map[string]*plugins.PanelDTO{}, + Apps: map[string]*plugins.AppDTO{ + "test-app": { + ID: "test-app", + LoadingStrategy: "script", + ModuleHash: "", + Path: "/test-app/module.js", + Version: "0.5.0", + Translations: map[string]string{ + "en-US": "public/plugins/test-app/locales/en-US/test-app.json", + "pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json", + }, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.desc, func(t *testing.T) { + cfg := setting.NewCfg() + m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), nil, nil) + + // Create a request with the appropriate context + req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil) + recorder := httptest.NewRecorder() + + if test.signedInUser != nil { + _, err := hs.DataSourcesService.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{ + Name: "test-app", + Type: "test-app", + OrgID: 1, + }) + require.NoError(t, err) + m.UseMiddleware(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + reqContext := &contextmodel.ReqContext{ + Context: web.FromContext(ctx), + SignedInUser: test.signedInUser, + PublicDashboardAccessToken: "test-token", + } + ctx = context.WithValue(ctx, ctxkey.Key{}, reqContext) + *reqContext.Req = *reqContext.Req.WithContext(ctx) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + }) + } + + m.ServeHTTP(recorder, req) + var got settings + err := json.Unmarshal(recorder.Body.Bytes(), &got) + require.NoError(t, err) + require.Equal(t, http.StatusOK, recorder.Code) + require.EqualValues(t, test.expected, got) + }) + } +} + func newPluginAssets() func() *pluginassets.Service { return newPluginAssetsWithConfig(&config.PluginManagementCfg{}) } diff --git a/pkg/plugins/config/config.go b/pkg/plugins/config/config.go index 67b5b9c15ee..9de22f18b8d 100644 --- a/pkg/plugins/config/config.go +++ b/pkg/plugins/config/config.go @@ -35,6 +35,7 @@ type Features struct { SkipHostEnvVarsEnabled bool SriChecksEnabled bool PluginsCDNSyncLoaderEnabled bool + LocalizationForPlugins bool } // NewPluginManagementCfg returns a new PluginManagementCfg. diff --git a/pkg/plugins/manager/loader/assetpath/assetpath.go b/pkg/plugins/manager/loader/assetpath/assetpath.go index 873e21feb60..b15f0dd867b 100644 --- a/pkg/plugins/manager/loader/assetpath/assetpath.go +++ b/pkg/plugins/manager/loader/assetpath/assetpath.go @@ -153,3 +153,22 @@ func getBaseDir(pluginDir string) string { } return baseDir } + +func (s *Service) GetTranslations(n PluginInfo) (map[string]string, error) { + pathToTranslations, err := s.RelativeURL(n, "locales") + if err != nil { + return nil, fmt.Errorf("get locales: %w", err) + } + + // loop through all the languages specified in the plugin.json and add them to the list + translations := map[string]string{} + for _, language := range n.pluginJSON.Languages { + file := fmt.Sprintf("%s.json", n.pluginJSON.ID) + translations[language], err = url.JoinPath(pathToTranslations, language, file) + if err != nil { + return nil, fmt.Errorf("join path: %w", err) + } + } + + return translations, nil +} diff --git a/pkg/plugins/manager/loader/assetpath/assetpath_test.go b/pkg/plugins/manager/loader/assetpath/assetpath_test.go index 816242cf877..e9cf4638298 100644 --- a/pkg/plugins/manager/loader/assetpath/assetpath_test.go +++ b/pkg/plugins/manager/loader/assetpath/assetpath_test.go @@ -190,6 +190,21 @@ func TestService(t *testing.T) { require.NoError(t, err) require.Equal(t, oneCDNRelativeURL, u) }) + + t.Run("GetTranslations", func(t *testing.T) { + pluginInfo := NewPluginInfo(jsonData["one"], plugins.ClassExternal, pluginFS("one"), nil) + pluginInfo.pluginJSON.Languages = []string{"en-US", "pt-BR"} + translations, err := svc.GetTranslations(pluginInfo) + require.NoError(t, err) + oneCDNURL, err := url.JoinPath(tc.cdnBaseURL, "one", "1.0.0", "public", "plugins", "one") + require.NoError(t, err) + enURL, err := url.JoinPath(oneCDNURL, "locales", "en-US", "one.json") + require.NoError(t, err) + ptBRURL, err := url.JoinPath(oneCDNURL, "locales", "pt-BR", "one.json") + require.NoError(t, err) + + require.Equal(t, map[string]string{"en-US": enURL, "pt-BR": ptBRURL}, translations) + }) }) } } diff --git a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go index 58599486b84..f69a0314f52 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go +++ b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go @@ -44,7 +44,7 @@ type Opts struct { // New returns a new Bootstrap stage. func New(cfg *config.PluginManagementCfg, opts Opts) *Bootstrap { if opts.ConstructFunc == nil { - opts.ConstructFunc = DefaultConstructFunc(signature.DefaultCalculator(cfg), assetpath.DefaultService(cfg)) + opts.ConstructFunc = DefaultConstructFunc(cfg, signature.DefaultCalculator(cfg), assetpath.DefaultService(cfg)) } if opts.DecorateFuncs == nil { diff --git a/pkg/plugins/manager/pipeline/bootstrap/factory.go b/pkg/plugins/manager/pipeline/bootstrap/factory.go index 9b6b5fd718b..65330259c22 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/factory.go +++ b/pkg/plugins/manager/pipeline/bootstrap/factory.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath" ) @@ -16,11 +17,12 @@ type pluginFactoryFunc func(p *plugins.FoundBundle, pluginClass plugins.Class, s // service to set the plugin's BaseURL, Module, Logos and Screenshots fields. type DefaultPluginFactory struct { assetPath *assetpath.Service + features *config.Features } // NewDefaultPluginFactory returns a new DefaultPluginFactory. -func NewDefaultPluginFactory(assetPath *assetpath.Service) *DefaultPluginFactory { - return &DefaultPluginFactory{assetPath: assetPath} +func NewDefaultPluginFactory(features *config.Features, assetPath *assetpath.Service) *DefaultPluginFactory { + return &DefaultPluginFactory{assetPath: assetPath, features: features} } func (f *DefaultPluginFactory) createPlugin(bundle *plugins.FoundBundle, class plugins.Class, @@ -74,6 +76,13 @@ func (f *DefaultPluginFactory) newPlugin(p plugins.FoundPlugin, class plugins.Cl if err = setImages(plugin, f.assetPath, info); err != nil { return nil, err } + + if f.features.LocalizationForPlugins { + if err := setTranslations(plugin, f.assetPath, info); err != nil { + return nil, err + } + } + return plugin, nil } @@ -99,3 +108,13 @@ func setImages(p *plugins.Plugin, assetPath *assetpath.Service, info assetpath.P } return nil } + +func setTranslations(p *plugins.Plugin, assetPath *assetpath.Service, info assetpath.PluginInfo) error { + translations, err := assetPath.GetTranslations(info) + if err != nil { + return fmt.Errorf("set translations: %w", err) + } + + p.Translations = translations + return nil +} diff --git a/pkg/plugins/manager/pipeline/bootstrap/steps.go b/pkg/plugins/manager/pipeline/bootstrap/steps.go index 9bd9c19767d..042a83e35e2 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/steps.go +++ b/pkg/plugins/manager/pipeline/bootstrap/steps.go @@ -22,8 +22,8 @@ type DefaultConstructor struct { } // DefaultConstructFunc is the default ConstructFunc used for the Construct step of the Bootstrap stage. -func DefaultConstructFunc(signatureCalculator plugins.SignatureCalculator, assetPath *assetpath.Service) ConstructFunc { - return NewDefaultConstructor(signatureCalculator, assetPath).Construct +func DefaultConstructFunc(cfg *config.PluginManagementCfg, signatureCalculator plugins.SignatureCalculator, assetPath *assetpath.Service) ConstructFunc { + return NewDefaultConstructor(cfg, signatureCalculator, assetPath).Construct } // DefaultDecorateFuncs are the default DecorateFuncs used for the Decorate step of the Bootstrap stage. @@ -37,9 +37,9 @@ func DefaultDecorateFuncs(cfg *config.PluginManagementCfg) []DecorateFunc { } // NewDefaultConstructor returns a new DefaultConstructor. -func NewDefaultConstructor(signatureCalculator plugins.SignatureCalculator, assetPath *assetpath.Service) *DefaultConstructor { +func NewDefaultConstructor(cfg *config.PluginManagementCfg, signatureCalculator plugins.SignatureCalculator, assetPath *assetpath.Service) *DefaultConstructor { return &DefaultConstructor{ - pluginFactoryFunc: NewDefaultPluginFactory(assetPath).createPlugin, + pluginFactoryFunc: NewDefaultPluginFactory(&cfg.Features, assetPath).createPlugin, signatureCalculator: signatureCalculator, log: log.New("plugins.construct"), } diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 8440cb37a11..7a8f8211610 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -267,14 +267,15 @@ type Signature struct { type PluginMetaDTO struct { JSONData - Signature SignatureStatus `json:"signature"` - Module string `json:"module"` - ModuleHash string `json:"moduleHash,omitempty"` - BaseURL string `json:"baseUrl"` - Angular AngularMeta `json:"angular"` - MultiValueFilterOperators bool `json:"multiValueFilterOperators"` - LoadingStrategy LoadingStrategy `json:"loadingStrategy"` - Extensions Extensions `json:"extensions"` + Signature SignatureStatus `json:"signature"` + Module string `json:"module"` + ModuleHash string `json:"moduleHash,omitempty"` + BaseURL string `json:"baseUrl"` + Angular AngularMeta `json:"angular"` + MultiValueFilterOperators bool `json:"multiValueFilterOperators"` + LoadingStrategy LoadingStrategy `json:"loadingStrategy"` + Extensions Extensions `json:"extensions"` + Translations map[string]string `json:"translations,omitempty"` } type DataSourceDTO struct { @@ -310,32 +311,34 @@ type DataSourceDTO struct { } type PanelDTO struct { - ID string `json:"id"` - Name string `json:"name"` - AliasIDs []string `json:"aliasIds,omitempty"` - Info Info `json:"info"` - HideFromList bool `json:"hideFromList"` - Sort int `json:"sort"` - SkipDataQuery bool `json:"skipDataQuery"` - ReleaseState string `json:"state"` - BaseURL string `json:"baseUrl"` - Signature string `json:"signature"` - Module string `json:"module"` - Angular AngularMeta `json:"angular"` - LoadingStrategy LoadingStrategy `json:"loadingStrategy"` - ModuleHash string `json:"moduleHash,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + AliasIDs []string `json:"aliasIds,omitempty"` + Info Info `json:"info"` + HideFromList bool `json:"hideFromList"` + Sort int `json:"sort"` + SkipDataQuery bool `json:"skipDataQuery"` + ReleaseState string `json:"state"` + BaseURL string `json:"baseUrl"` + Signature string `json:"signature"` + Module string `json:"module"` + Angular AngularMeta `json:"angular"` + LoadingStrategy LoadingStrategy `json:"loadingStrategy"` + ModuleHash string `json:"moduleHash,omitempty"` + Translations map[string]string `json:"translations,omitempty"` } type AppDTO struct { - ID string `json:"id"` - Path string `json:"path"` - Version string `json:"version"` - Preload bool `json:"preload"` - Angular AngularMeta `json:"angular"` - LoadingStrategy LoadingStrategy `json:"loadingStrategy"` - Extensions Extensions `json:"extensions"` - Dependencies Dependencies `json:"dependencies"` - ModuleHash string `json:"moduleHash,omitempty"` + ID string `json:"id"` + Path string `json:"path"` + Version string `json:"version"` + Preload bool `json:"preload"` + Angular AngularMeta `json:"angular"` + LoadingStrategy LoadingStrategy `json:"loadingStrategy"` + Extensions Extensions `json:"extensions"` + Dependencies Dependencies `json:"dependencies"` + ModuleHash string `json:"moduleHash,omitempty"` + Translations map[string]string `json:"translations,omitempty"` } const ( diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index cdbde14df77..9264fa37cd4 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -64,6 +64,8 @@ type Plugin struct { SkipHostEnvVars bool mu sync.Mutex + + Translations map[string]string } var ( @@ -129,6 +131,9 @@ type JSONData struct { // App Service Auth Registration IAM *auth.IAM `json:"iam,omitempty"` + + // List of languages supported by the plugin + Languages []string `json:"languages,omitempty"` } func ReadPluginJSON(reader io.Reader) (JSONData, error) { diff --git a/pkg/plugins/plugins_test.go b/pkg/plugins/plugins_test.go index 2a6f85dee07..59a07c114d1 100644 --- a/pkg/plugins/plugins_test.go +++ b/pkg/plugins/plugins_test.go @@ -403,6 +403,108 @@ func Test_ReadPluginJSON(t *testing.T) { }, }, }, + { + name: "can read languages in a datasource plugin", + pluginJSON: func(t *testing.T) io.ReadCloser { + pJSON := `{ + "id": "myorg-languages-datasource", + "name": "Languages Datasource", + "type": "datasource", + "languages": ["en-US", "pt-BR"] + }` + return io.NopCloser(strings.NewReader(pJSON)) + }, + expected: JSONData{ + ID: "myorg-languages-datasource", + Name: "Languages Datasource", + Type: TypeDataSource, + Languages: []string{"en-US", "pt-BR"}, + + Extensions: Extensions{ + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, + ExtensionPoints: []ExtensionPoint{}, + }, + + Dependencies: Dependencies{ + GrafanaVersion: "*", + Plugins: []Dependency{}, + Extensions: ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + }, + }, + { + name: "can read languages in a panel plugin", + pluginJSON: func(t *testing.T) io.ReadCloser { + pJSON := `{ + "id": "myorg-languages-panel", + "name": "Languages Panel", + "type": "panel", + "languages": ["en-US", "pt-BR"] + }` + return io.NopCloser(strings.NewReader(pJSON)) + }, + expected: JSONData{ + ID: "myorg-languages-panel", + Name: "Languages Panel", + Type: TypePanel, + Languages: []string{"en-US", "pt-BR"}, + + Extensions: Extensions{ + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, + ExtensionPoints: []ExtensionPoint{}, + }, + + Dependencies: Dependencies{ + GrafanaVersion: "*", + Plugins: []Dependency{}, + Extensions: ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + }, + }, + { + name: "can read languages in an app plugin", + pluginJSON: func(t *testing.T) io.ReadCloser { + pJSON := `{ + "id": "myorg-languages-app", + "name": "Languages App", + "type": "app", + "languages": ["en-US", "pt-BR"] + }` + return io.NopCloser(strings.NewReader(pJSON)) + }, + expected: JSONData{ + ID: "myorg-languages-app", + Name: "Languages App", + Type: TypeApp, + Languages: []string{"en-US", "pt-BR"}, + + Extensions: Extensions{ + AddedLinks: []AddedLink{}, + AddedComponents: []AddedComponent{}, + AddedFunctions: []AddedFunction{}, + ExposedComponents: []ExposedComponent{}, + ExtensionPoints: []ExtensionPoint{}, + }, + + Dependencies: Dependencies{ + GrafanaVersion: "*", + Plugins: []Dependency{}, + Extensions: ExtensionsDependencies{ + ExposedComponents: []string{}, + }, + }, + }, + }, } for _, tt := range tests { diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index fcfc00c048c..8930c2304cf 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -1977,7 +1977,7 @@ func TestGetDashboardUIDByID(t *testing.T) { func TestUnstructuredToLegacyDashboard(t *testing.T) { k8sCliMock := new(client.MockK8sHandler) - k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{"user:useruid": &user.User{ID: 10, UID: "useruid"}}, nil) + k8sCliMock.On("GetUsersFromMeta", mock.Anything, mock.Anything).Return(map[string]*user.User{"user:useruid": {ID: 10, UID: "useruid"}}, nil) dr := &DashboardServiceImpl{ k8sclient: k8sCliMock, } diff --git a/pkg/services/dashboardversion/dashverimpl/dashver_test.go b/pkg/services/dashboardversion/dashverimpl/dashver_test.go index 3511f03913e..d63a5199cb2 100644 --- a/pkg/services/dashboardversion/dashverimpl/dashver_test.go +++ b/pkg/services/dashboardversion/dashverimpl/dashver_test.go @@ -73,7 +73,7 @@ func TestDashboardVersionService(t *testing.T) { obj, err := utils.MetaAccessor(dash) require.NoError(t, err) obj.SetUpdatedTimestamp(&updatedTimestamp) - mockCli.On("GetUsersFromMeta", mock.Anything, []string{"user:1", ""}).Return(map[string]*user.User{"user:1": &user.User{ID: 1}}, nil) + mockCli.On("GetUsersFromMeta", mock.Anything, []string{"user:1", ""}).Return(map[string]*user.User{"user:1": {ID: 1}}, nil) mockCli.On("List", mock.Anything, int64(1), mock.Anything).Return(&unstructured.UnstructuredList{ Items: []unstructured.Unstructured{*dash}}, nil).Once() res, err := dashboardVersionService.Get(context.Background(), &dashver.GetDashboardVersionQuery{ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 1deace117b1..673de21e68a 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1818,6 +1818,13 @@ var ( HideFromDocs: true, Expression: "true", // enabled by default }, + { + Name: "localizationForPlugins", + Description: "Enables localization for plugins", + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + FrontendOnly: false, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 9836196c3de..dd179330523 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -239,3 +239,4 @@ alertingMigrationUI,experimental,@grafana/alerting-squad,false,false,true unifiedStorageHistoryPruner,experimental,@grafana/search-and-storage,false,false,false unifiedStorageGrpcConnectionPool,experimental,@grafana/search-and-storage,false,false,false alertingRuleRecoverDeleted,GA,@grafana/alerting-squad,false,false,true +localizationForPlugins,experimental,@grafana/plugins-platform-backend,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index bd6e9b20165..57457d8f214 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -966,4 +966,8 @@ const ( // FlagAlertingRuleRecoverDeleted // Enables the UI functionality to recover and view deleted alert rules FlagAlertingRuleRecoverDeleted = "alertingRuleRecoverDeleted" + + // FlagLocalizationForPlugins + // Enables localization for plugins + FlagLocalizationForPlugins = "localizationForPlugins" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 4fd62582f9e..1d9e64e0f7d 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2469,6 +2469,18 @@ "frontend": true } }, + { + "metadata": { + "name": "localizationForPlugins", + "resourceVersion": "1742561476144", + "creationTimestamp": "2025-03-21T12:51:16Z" + }, + "spec": { + "description": "Enables localization for plugins", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend" + } + }, { "metadata": { "name": "logQLScope", diff --git a/pkg/services/pluginsintegration/pipeline/pipeline.go b/pkg/services/pluginsintegration/pipeline/pipeline.go index f43f95c3268..c678f1f2bc0 100644 --- a/pkg/services/pluginsintegration/pipeline/pipeline.go +++ b/pkg/services/pluginsintegration/pipeline/pipeline.go @@ -44,7 +44,7 @@ func ProvideDiscoveryStage(cfg *config.PluginManagementCfg, pf finder.Finder, pr func ProvideBootstrapStage(cfg *config.PluginManagementCfg, sc plugins.SignatureCalculator, a *assetpath.Service) *bootstrap.Bootstrap { return bootstrap.New(cfg, bootstrap.Opts{ - ConstructFunc: bootstrap.DefaultConstructFunc(sc, a), + ConstructFunc: bootstrap.DefaultConstructFunc(cfg, sc, a), DecorateFuncs: bootstrap.DefaultDecorateFuncs(cfg), }) } diff --git a/pkg/services/pluginsintegration/pluginconfig/config.go b/pkg/services/pluginsintegration/pluginconfig/config.go index 47751b07711..31ad5b2bdea 100644 --- a/pkg/services/pluginsintegration/pluginconfig/config.go +++ b/pkg/services/pluginsintegration/pluginconfig/config.go @@ -34,6 +34,7 @@ func ProvidePluginManagementConfig(cfg *setting.Cfg, settingProvider setting.Pro SkipHostEnvVarsEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSkipHostEnvVars), SriChecksEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsSriChecks), PluginsCDNSyncLoaderEnabled: features.IsEnabledGlobally(featuremgmt.FlagPluginsCDNSyncLoader), + LocalizationForPlugins: features.IsEnabledGlobally(featuremgmt.FlagLocalizationForPlugins), }, cfg.AngularSupportEnabled, cfg.GrafanaComAPIURL, diff --git a/pkg/services/pluginsintegration/pluginstore/plugins.go b/pkg/services/pluginsintegration/pluginstore/plugins.go index 30321e69286..8dccc2c3522 100644 --- a/pkg/services/pluginsintegration/pluginstore/plugins.go +++ b/pkg/services/pluginsintegration/pluginstore/plugins.go @@ -35,6 +35,8 @@ type Plugin struct { Angular plugins.AngularMeta ExternalService *auth.ExternalService + + Translations map[string]string } func (p Plugin) SupportsStreaming() bool { @@ -76,6 +78,7 @@ func ToGrafanaDTO(p *plugins.Plugin) Plugin { BaseURL: p.BaseURL, ExternalService: p.ExternalService, Angular: p.Angular, + Translations: p.Translations, } if p.Parent != nil { diff --git a/pkg/services/pluginsintegration/pluginstore/plugins_test.go b/pkg/services/pluginsintegration/pluginstore/plugins_test.go new file mode 100644 index 00000000000..01b7e2782b8 --- /dev/null +++ b/pkg/services/pluginsintegration/pluginstore/plugins_test.go @@ -0,0 +1,22 @@ +package pluginstore + +import ( + "testing" + + "github.com/grafana/grafana/pkg/plugins" + "github.com/stretchr/testify/require" +) + +func TestToGrafanaDTO(t *testing.T) { + plugin := &plugins.Plugin{ + Translations: map[string]string{ + "en-US": "public/plugins/test-app/locales/en-US/test-app.json", + "pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json", + }, + } + + t.Run("Translations", func(t *testing.T) { + dto := ToGrafanaDTO(plugin) + require.Equal(t, plugin.Translations, dto.Translations) + }) +} diff --git a/public/app/core/internationalization/index.test.tsx b/public/app/core/internationalization/index.test.tsx index cb894181d24..cb0b3a37148 100644 --- a/public/app/core/internationalization/index.test.tsx +++ b/public/app/core/internationalization/index.test.tsx @@ -6,10 +6,10 @@ import { Trans as PluginTrans, setTransComponent, setUseTranslateHook, useTransl import { getI18next, Trans, useTranslateInternal } from './index'; -const id = 'frontend-test-locales-plugin'; +const id = 'frontend-test-languages-plugin'; const mockedMeta: PluginMeta = { id, - name: 'Frontend Test Locales Plugin', + name: 'Frontend Test Languages Plugin', type: PluginType.panel, info: { author: { name: 'Test Author' }, diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts index f6d9c1ac2a6..6db871596cc 100644 --- a/public/app/features/plugins/importPanelPlugin.ts +++ b/public/app/features/plugins/importPanelPlugin.ts @@ -64,6 +64,7 @@ function getPanelPlugin(meta: PanelPluginMeta): Promise { loadingStrategy: fallbackLoadingStrategy, pluginId: meta.id, moduleHash: meta.moduleHash, + translations: meta.translations, }) .then((pluginExports) => { if (pluginExports.plugin) { diff --git a/public/app/features/plugins/loader/pluginLoader.mock.ts b/public/app/features/plugins/loader/pluginLoader.mock.ts index ce4c6afa6d4..6015125a655 100644 --- a/public/app/features/plugins/loader/pluginLoader.mock.ts +++ b/public/app/features/plugins/loader/pluginLoader.mock.ts @@ -22,6 +22,11 @@ export const mockAmdModule = `define([], function() { } });`; +const mockTranslation = (value: string) => + `System.register([],function(e){return{execute:function(){e("default",{"testKey":"${value}"})}}})`; + +const mockTranslationWithNoDefaultExport = `System.register([],function(e){return{execute:function(){e({"testKey":"unknown"})}}})`; + const server = setupServer( http.get( '/public/plugins/mockAmdModule/module.js', @@ -49,7 +54,35 @@ const server = setupServer( 'Content-Type': 'text/javascript', }, }) - ) + ), + http.get( + '/public/plugins/test-panel/locales/en-US/test-panel.json', + () => + new HttpResponse(mockTranslation('testValue'), { + headers: { + 'Content-Type': 'text/javascript', + }, + }) + ), + http.get( + '/public/plugins/test-panel/locales/pt-BR/test-panel.json', + () => + new HttpResponse(mockTranslation('valorDeTeste'), { + headers: { + 'Content-Type': 'text/javascript', + }, + }) + ), + http.get( + '/public/plugins/test-panel/locales/en-US/no-default-export.json', + () => + new HttpResponse(mockTranslationWithNoDefaultExport, { + headers: { + 'Content-Type': 'text/javascript', + }, + }) + ), + http.get('/public/plugins/test-panel/locales/en-US/unknown.json', () => HttpResponse.error()) ); export { server }; diff --git a/public/app/features/plugins/plugin_loader.test.ts b/public/app/features/plugins/plugin_loader.test.ts new file mode 100644 index 00000000000..27f4006545a --- /dev/null +++ b/public/app/features/plugins/plugin_loader.test.ts @@ -0,0 +1,157 @@ +import { i18n } from 'i18next'; + +import { getI18next } from 'app/core/internationalization'; + +import { server } from './loader/pluginLoader.mock'; +import { SystemJS } from './loader/systemjs'; +import { SystemJSWithLoaderHooks } from './loader/types'; +import { addTranslationsToI18n } from './plugin_loader'; + +describe('plugin_loader', () => { + describe('addTranslationsToI18n', () => { + const systemJSPrototype: SystemJSWithLoaderHooks = SystemJS.constructor.prototype; + const originalFetch = systemJSPrototype.fetch; + const originalResolve = systemJSPrototype.resolve; + let addResourceBundleSpy: jest.SpyInstance; + + beforeAll(() => { + server.listen(); + systemJSPrototype.resolve = (moduleId: string) => moduleId; + systemJSPrototype.shouldFetch = () => true; + // because server.listen() patches fetch, we need to reassign this to the systemJSPrototype + // this is identical to what happens in the original code: https://github.com/systemjs/systemjs/blob/main/src/features/fetch-load.js#L12 + systemJSPrototype.fetch = window.fetch; + }); + + beforeEach(() => { + addResourceBundleSpy = jest + .spyOn(getI18next(), 'addResourceBundle') + .mockImplementation(() => ({}) as unknown as i18n); + }); + + afterEach(() => { + server.resetHandlers(); + jest.clearAllMocks(); + }); + + afterAll(() => { + SystemJS.constructor.prototype.resolve = originalResolve; + SystemJS.constructor.prototype.fetch = originalFetch; + server.close(); + }); + + it('should add translations that match the resolved language first', async () => { + const translations = { + 'en-US': '/public/plugins/test-panel/locales/en-US/test-panel.json', + 'pt-BR': '/public/plugins/test-panel/locales/pt-BR/test-panel.json', + }; + + await addTranslationsToI18n({ + resolvedLanguage: 'pt-BR', + fallbackLanguage: 'en-US', + pluginId: 'test-panel', + translations, + }); + + expect(addResourceBundleSpy).toHaveBeenCalledTimes(1); + expect(addResourceBundleSpy).toHaveBeenNthCalledWith( + 1, + 'pt-BR', + 'test-panel', + { testKey: 'valorDeTeste' }, + undefined, + true + ); + }); + + it('should add translations that match the fallback language if the resolved language is not in the translations', async () => { + const translations = { + 'en-US': '/public/plugins/test-panel/locales/en-US/test-panel.json', + 'pt-BR': '/public/plugins/test-panel/locales/pt-BR/test-panel.json', + }; + + await addTranslationsToI18n({ + resolvedLanguage: 'sv-SE', + fallbackLanguage: 'en-US', + pluginId: 'test-panel', + translations, + }); + + expect(addResourceBundleSpy).toHaveBeenCalledTimes(1); + expect(addResourceBundleSpy).toHaveBeenNthCalledWith( + 1, + 'en-US', + 'test-panel', + { testKey: 'testValue' }, + undefined, + true + ); + }); + + it('should warn if no translations are found', async () => { + const translations = { + 'en-US': '/public/plugins/test-panel/locales/en-US/test-panel.json', + 'pt-BR': '/public/plugins/test-panel/locales/pt-BR/test-panel.json', + }; + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await addTranslationsToI18n({ + resolvedLanguage: 'sv-SE', + fallbackLanguage: 'sv-SE', + pluginId: 'test-panel', + translations, + }); + + expect(consoleSpy).toHaveBeenCalledWith('Could not find any translation for plugin test-panel', { + resolvedLanguage: 'sv-SE', + fallbackLanguage: 'sv-SE', + }); + }); + + it('should warn if no exported default is found', async () => { + const translations = { + 'en-US': '/public/plugins/test-panel/locales/en-US/no-default-export.json', + 'pt-BR': '/public/plugins/test-panel/locales/pt-BR/no-default-export.json', + }; + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await addTranslationsToI18n({ + resolvedLanguage: 'en-US', + fallbackLanguage: 'en-US', + pluginId: 'test-panel', + translations, + }); + + expect(consoleSpy).toHaveBeenCalledWith('Could not find default export for plugin test-panel', { + resolvedLanguage: 'en-US', + fallbackLanguage: 'en-US', + path: '/public/plugins/test-panel/locales/en-US/no-default-export.json', + }); + }); + + it('should warn if translations cannot be loaded', async () => { + const translations = { + 'en-US': '/public/plugins/test-panel/locales/en-US/unknown.json', + 'pt-BR': '/public/plugins/test-panel/locales/pt-BR/unknown.json', + }; + + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + await addTranslationsToI18n({ + resolvedLanguage: 'en-US', + fallbackLanguage: 'pt-BR', + pluginId: 'test-panel', + translations, + }); + + expect(consoleSpy).toHaveBeenCalledWith('Could not load translation for plugin test-panel', { + resolvedLanguage: 'en-US', + fallbackLanguage: 'pt-BR', + error: new TypeError('Failed to fetch'), + path: '/public/plugins/test-panel/locales/en-US/unknown.json', + }); + }); + }); +}); diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index 98915c40f6d..d710e5de958 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -9,6 +9,8 @@ import { } from '@grafana/data'; import { config } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; +import { getI18next } from 'app/core/internationalization'; +import { DEFAULT_LANGUAGE } from 'app/core/internationalization/constants'; import { GenericDataSourcePlugin } from '../datasources/types'; @@ -82,6 +84,7 @@ type PluginImportInfo = { version?: string; isAngular?: boolean; moduleHash?: string; + translations?: Record; }; export async function importPluginModule({ @@ -91,11 +94,22 @@ export async function importPluginModule({ version, isAngular, moduleHash, + translations, }: PluginImportInfo): Promise { if (version) { registerPluginInCache({ path, version, loadingStrategy }); } + // Add locales to i18n for a plugin if the feature toggle is enabled and the plugin has locales + if (config.featureToggles.localizationForPlugins && translations) { + await addTranslationsToI18n({ + resolvedLanguage: getI18next().resolvedLanguage ?? DEFAULT_LANGUAGE, + fallbackLanguage: DEFAULT_LANGUAGE, + pluginId, + translations, + }); + } + const builtIn = builtInPlugins[path]; if (builtIn) { // for handling dynamic imports @@ -152,6 +166,7 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise { if (pluginExports.plugin) { const dsPlugin: GenericDataSourcePlugin = pluginExports.plugin; @@ -190,6 +205,7 @@ export async function importAppPlugin(meta: PluginMeta): Promise { isAngular: meta.angular?.detected ?? meta.angularDetected, loadingStrategy: meta.loadingStrategy ?? PluginLoadingStrategy.fetch, moduleHash: meta.moduleHash, + translations: meta.translations, }); const { plugin = new AppPlugin() } = pluginExports; @@ -218,3 +234,49 @@ export async function importAppPlugin(meta: PluginMeta): Promise { return plugin; } + +interface AddTranslationsToI18nOptions { + resolvedLanguage: string; + fallbackLanguage: string; + pluginId: string; + translations: Record; +} + +// exported for testing purposes only +export async function addTranslationsToI18n({ + resolvedLanguage, + fallbackLanguage, + pluginId, + translations, +}: AddTranslationsToI18nOptions): Promise { + const resolvedPath = translations[resolvedLanguage]; + const fallbackPath = translations[fallbackLanguage]; + const path = resolvedPath ?? fallbackPath; + + if (!path) { + console.warn(`Could not find any translation for plugin ${pluginId}`, { resolvedLanguage, fallbackLanguage }); + return; + } + + try { + const module = await SystemJS.import(resolveModulePath(path)); + if (!module.default) { + console.warn(`Could not find default export for plugin ${pluginId}`, { + resolvedLanguage, + fallbackLanguage, + path, + }); + return; + } + + const language = resolvedPath ? resolvedLanguage : fallbackLanguage; + getI18next().addResourceBundle(language, pluginId, module.default, undefined, true); + } catch (error) { + console.warn(`Could not load translation for plugin ${pluginId}`, { + resolvedLanguage, + fallbackLanguage, + error, + path, + }); + } +}