i18n: wires up translations for plugins (#102853)

* i18n: consolidate i18n types & runtime services

* Chore: updates after PR feedback

* Chore: updates after feedback

* Chore: updates after feedback

* Chore: adds feature toggle

* Chore: adds locale to backend

* Chore: adds locales to i18n instance

* Chore: fix missing path in CODEOWNERS

* Chore: fix go lint issues

* Chore: fix missing path in CODEOWNERS

* Chore: updates after PR feedback

* Trigger build

* Chore: updates after PR feedback

* Chore: use resolved language for lookup

* Chore: updates after PR feedback

* Update pkg/plugins/plugins.go

Co-authored-by: Will Browne <wbrowne@users.noreply.github.com>

* Chore: updates after PR feedback

* Chore: updates after PR feedback

---------

Co-authored-by: Will Browne <wbrowne@users.noreply.github.com>
This commit is contained in:
Hugo Häggmark
2025-03-31 06:38:38 +02:00
committed by GitHub
co-authored by Will Browne
parent 7ea0fab606
commit 18ae5d7f0c
29 changed files with 759 additions and 46 deletions
@@ -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
@@ -1055,4 +1055,8 @@ export interface FeatureToggles {
* @default true
*/
alertingRuleRecoverDeleted?: boolean;
/**
* Enables localization for plugins
*/
localizationForPlugins?: boolean;
}
@@ -100,6 +100,9 @@ export interface PluginMeta<T extends KeyValue = {}> {
loadingStrategy?: PluginLoadingStrategy;
extensions?: PluginExtensions;
moduleHash?: string;
// Paths to the translations for the plugin
translations?: Record<string, string>;
}
interface PluginDependencyInfo {
+6 -2
View File
@@ -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 {
+234
View File
@@ -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{})
}
+1
View File
@@ -35,6 +35,7 @@ type Features struct {
SkipHostEnvVarsEnabled bool
SriChecksEnabled bool
PluginsCDNSyncLoaderEnabled bool
LocalizationForPlugins bool
}
// NewPluginManagementCfg returns a new PluginManagementCfg.
@@ -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
}
@@ -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)
})
})
}
}
@@ -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 {
@@ -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
}
@@ -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"),
}
+34 -31
View File
@@ -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 (
+5
View File
@@ -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) {
+102
View File
@@ -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 {
@@ -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,
}
@@ -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{
+7
View File
@@ -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,
},
}
)
+1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
239 unifiedStorageHistoryPruner experimental @grafana/search-and-storage false false false
240 unifiedStorageGrpcConnectionPool experimental @grafana/search-and-storage false false false
241 alertingRuleRecoverDeleted GA @grafana/alerting-squad false false true
242 localizationForPlugins experimental @grafana/plugins-platform-backend false false false
+4
View File
@@ -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"
)
+12
View File
@@ -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",
@@ -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),
})
}
@@ -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,
@@ -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 {
@@ -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)
})
}
@@ -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' },
@@ -64,6 +64,7 @@ function getPanelPlugin(meta: PanelPluginMeta): Promise<PanelPlugin> {
loadingStrategy: fallbackLoadingStrategy,
pluginId: meta.id,
moduleHash: meta.moduleHash,
translations: meta.translations,
})
.then((pluginExports) => {
if (pluginExports.plugin) {
@@ -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 };
@@ -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',
});
});
});
});
@@ -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<string, string>;
};
export async function importPluginModule({
@@ -91,11 +94,22 @@ export async function importPluginModule({
version,
isAngular,
moduleHash,
translations,
}: PluginImportInfo): Promise<System.Module> {
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<Gene
loadingStrategy: fallbackLoadingStrategy,
pluginId: meta.id,
moduleHash: meta.moduleHash,
translations: meta.translations,
}).then((pluginExports) => {
if (pluginExports.plugin) {
const dsPlugin: GenericDataSourcePlugin = pluginExports.plugin;
@@ -190,6 +205,7 @@ export async function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> {
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<AppPlugin> {
return plugin;
}
interface AddTranslationsToI18nOptions {
resolvedLanguage: string;
fallbackLanguage: string;
pluginId: string;
translations: Record<string, string>;
}
// exported for testing purposes only
export async function addTranslationsToI18n({
resolvedLanguage,
fallbackLanguage,
pluginId,
translations,
}: AddTranslationsToI18nOptions): Promise<void> {
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,
});
}
}