diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 6ec593c6761..1832b95666f 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1021,4 +1021,9 @@ export interface FeatureToggles { * @default false */ foldersAppPlatformAPI?: boolean; + /** + * Set this to true to use the new PluginImporter functionality + * @default false + */ + enablePluginImporter?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 3370a9c1d68..a82c3a77dbd 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1755,6 +1755,16 @@ var ( FrontendOnly: true, Expression: "false", }, + { + Name: "enablePluginImporter", + Description: "Set this to true to use the new PluginImporter functionality", + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + HideFromAdminPage: true, + HideFromDocs: true, + FrontendOnly: true, + Expression: "false", + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index f5b845f0b97..81d6bf376f7 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -228,3 +228,4 @@ tabularNumbers,GA,@grafana/grafana-frontend-platform,false,false,false newInfluxDSConfigPageDesign,privatePreview,@grafana/partner-datasources,false,false,false enableAppChromeExtensions,experimental,@grafana/plugins-platform-backend,false,false,true foldersAppPlatformAPI,experimental,@grafana/grafana-search-navigate-organise,false,false,true +enablePluginImporter,experimental,@grafana/plugins-platform-backend,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 0f50eac6a1b..6069be22df0 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -922,4 +922,8 @@ const ( // FlagFoldersAppPlatformAPI // Enables use of app platform API for folders FlagFoldersAppPlatformAPI = "foldersAppPlatformAPI" + + // FlagEnablePluginImporter + // Set this to true to use the new PluginImporter functionality + FlagEnablePluginImporter = "enablePluginImporter" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index ab148e849ac..793dd264e1c 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1003,6 +1003,22 @@ "hideFromAdminPage": true } }, + { + "metadata": { + "name": "enablePluginImporter", + "resourceVersion": "1752591728321", + "creationTimestamp": "2025-07-15T15:02:08Z" + }, + "spec": { + "description": "Set this to true to use the new PluginImporter functionality", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true, + "hideFromAdminPage": true, + "hideFromDocs": true, + "expression": "false" + } + }, { "metadata": { "name": "enableSCIM", @@ -2317,6 +2333,23 @@ "requiresRestart": true } }, + { + "metadata": { + "name": "pluginLoadingRefactor", + "resourceVersion": "1752218524617", + "creationTimestamp": "2025-07-11T07:22:04Z", + "deletionTimestamp": "2025-07-11T07:23:34Z" + }, + "spec": { + "description": "Set this to true to use the new plugin loading functionality", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true, + "hideFromAdminPage": true, + "hideFromDocs": true, + "expression": "false" + } + }, { "metadata": { "name": "pluginProxyPreserveTrailingSlash", diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts index 3afee6fa4c3..2cf402cb2b6 100644 --- a/public/app/features/plugins/importPanelPlugin.ts +++ b/public/app/features/plugins/importPanelPlugin.ts @@ -3,7 +3,8 @@ import config from 'app/core/config'; import { getPanelPluginLoadError } from '../panel/components/PanelPluginError'; -import { importPluginModule } from './pluginLoader'; +import { importPluginModule } from './importer/importPluginModule'; +import { pluginImporter } from './importer/pluginImporter'; const promiseCache: Record> = {}; const panelPluginCache: Record = {}; @@ -50,10 +51,18 @@ export function importPanelPluginFromMeta(meta: PanelPluginMeta): Promise { + if (config.featureToggles.enablePluginImporter) { + return pluginImporter.importPanel(meta); + } + throwIfAngular(meta); const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch; diff --git a/public/app/features/plugins/importer/addTranslationsToI18n.test.ts b/public/app/features/plugins/importer/addTranslationsToI18n.test.ts new file mode 100644 index 00000000000..becc58c0ca8 --- /dev/null +++ b/public/app/features/plugins/importer/addTranslationsToI18n.test.ts @@ -0,0 +1,142 @@ +import { i18n } from 'i18next'; + +import * as i18nModule from '@grafana/i18n/internal'; + +import { server } from '../loader/pluginLoader.mock'; +import { SystemJS } from '../loader/systemjs'; +import { SystemJSWithLoaderHooks } from '../loader/types'; + +import { addTranslationsToI18n } from './addTranslationsToI18n'; + +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(i18nModule, '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' }); + }); + + 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' }); + }); + + 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/importer/addTranslationsToI18n.ts b/public/app/features/plugins/importer/addTranslationsToI18n.ts new file mode 100644 index 00000000000..177e6ba78b8 --- /dev/null +++ b/public/app/features/plugins/importer/addTranslationsToI18n.ts @@ -0,0 +1,49 @@ +import { addResourceBundle } from '@grafana/i18n/internal'; + +import { SystemJS } from '../loader/systemjs'; +import { resolveModulePath } from '../loader/utils'; + +interface AddTranslationsToI18nOptions { + resolvedLanguage: string; + fallbackLanguage: string; + pluginId: string; + translations: Record; +} + +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; + addResourceBundle(language, pluginId, module.default); + } catch (error) { + console.warn(`Could not load translation for plugin ${pluginId}`, { + resolvedLanguage, + fallbackLanguage, + error, + path, + }); + } +} diff --git a/public/app/features/plugins/importer/importPluginModule.ts b/public/app/features/plugins/importer/importPluginModule.ts new file mode 100644 index 00000000000..9b220c97d6f --- /dev/null +++ b/public/app/features/plugins/importer/importPluginModule.ts @@ -0,0 +1,83 @@ +import { DEFAULT_LANGUAGE } from '@grafana/i18n'; +import { getResolvedLanguage } from '@grafana/i18n/internal'; +import { config } from '@grafana/runtime'; + +import builtInPlugins from '../built_in_plugins'; +import { registerPluginInCache } from '../loader/cache'; +import { SystemJS } from '../loader/systemjs'; +import { resolveModulePath } from '../loader/utils'; +import { importPluginModuleInSandbox } from '../sandbox/sandboxPluginLoader'; +import { shouldLoadPluginInFrontendSandbox } from '../sandbox/sandboxPluginLoaderRegistry'; +import { pluginsLogger } from '../utils'; + +import { addTranslationsToI18n } from './addTranslationsToI18n'; +import { PluginImportInfo } from './types'; + +export async function importPluginModule({ + path, + pluginId, + loadingStrategy, + version, + 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: getResolvedLanguage(), + fallbackLanguage: DEFAULT_LANGUAGE, + pluginId, + translations, + }); + } + + const builtIn = builtInPlugins[path]; + if (builtIn) { + // for handling dynamic imports + if (typeof builtIn === 'function') { + return await builtIn(); + } else { + return builtIn; + } + } + + const modulePath = resolveModulePath(path); + + // inject integrity hash into SystemJS import map + if (config.featureToggles.pluginsSriChecks) { + const resolvedModule = System.resolve(modulePath); + const integrityMap = System.getImportMap().integrity; + + if (moduleHash && integrityMap && !integrityMap[resolvedModule]) { + SystemJS.addImportMap({ + integrity: { + [resolvedModule]: moduleHash, + }, + }); + } + } + + // the sandboxing environment code cannot work in nodejs and requires a real browser + if (await shouldLoadPluginInFrontendSandbox({ pluginId })) { + return importPluginModuleInSandbox({ pluginId }); + } + + return SystemJS.import(modulePath).catch((e) => { + let error = new Error('Could not load plugin: ' + e); + console.error(error); + pluginsLogger.logError(error, { + path, + pluginId, + pluginVersion: version ?? '', + expectedHash: moduleHash ?? '', + loadingStrategy: loadingStrategy.toString(), + sriChecksEnabled: String(Boolean(config.featureToggles.pluginsSriChecks)), + newPluginLoadingEnabled: String(Boolean(config.featureToggles.enablePluginImporter)), + }); + throw error; + }); +} diff --git a/public/app/features/plugins/importer/pluginImporter.test.ts b/public/app/features/plugins/importer/pluginImporter.test.ts new file mode 100644 index 00000000000..a8dd2254b59 --- /dev/null +++ b/public/app/features/plugins/importer/pluginImporter.test.ts @@ -0,0 +1,415 @@ +import { + AppPlugin, + AppPluginMeta, + DataSourcePlugin, + DataSourcePluginMeta, + PanelPlugin, + PanelPluginMeta, + PluginLoadingStrategy, + PluginMeta, + PluginType, +} from '@grafana/data'; + +import { + addedComponentsRegistry, + addedFunctionsRegistry, + addedLinksRegistry, + exposedComponentsRegistry, +} from '../extensions/registry/setup'; +import { pluginsLogger } from '../utils'; + +import * as importPluginModule from './importPluginModule'; +import { pluginImporter, clearCaches } from './pluginImporter'; + +describe('pluginImporter', () => { + beforeEach(() => { + jest.clearAllMocks(); + clearCaches(); + }); + + describe('importPanel', () => { + it('should import a panel plugin successfully with fallbackLoadingStrategy', async () => { + const spy = jest + .spyOn(importPluginModule, 'importPluginModule') + .mockResolvedValue({ plugin: { ...panelPlugin } }); + + const result = await pluginImporter.importPanel({ ...panelPlugin }); + + expect(spy).toHaveBeenCalledWith({ + path: 'public/plugins/test-plugin/module.js', + version: '1.0.0', + loadingStrategy: 'fetch', + pluginId: 'test-plugin', + moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' }, + }); + + expect(result).toEqual({ ...panelPlugin, meta: { ...panelPlugin } }); + }); + + it('should set correct loading strategy', async () => { + const spy = jest + .spyOn(importPluginModule, 'importPluginModule') + .mockResolvedValue({ plugin: { ...panelPlugin } }); + const meta = { ...panelPlugin, loadingStrategy: PluginLoadingStrategy.script }; + + const result = await pluginImporter.importPanel({ ...meta }); + + expect(spy).toHaveBeenCalledWith({ + path: 'public/plugins/test-plugin/module.js', + version: '1.0.0', + loadingStrategy: 'script', + pluginId: 'test-plugin', + moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' }, + }); + + expect(result).toEqual({ ...panelPlugin, meta: { ...panelPlugin, loadingStrategy: 'script' } }); + }); + + it('should log a warning and return a error component if module is missing exported plugin', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const spy = jest.spyOn(importPluginModule, 'importPluginModule').mockResolvedValue({}); + + const result = await pluginImporter.importPanel({ ...panelPlugin }); + + expect(spy).toHaveBeenCalledWith({ + path: 'public/plugins/test-plugin/module.js', + version: '1.0.0', + loadingStrategy: 'fetch', + pluginId: 'test-plugin', + moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' }, + }); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Error loading panel plugin: test-plugin', + new Error('missing export: plugin') + ); + + expect(result).toBeInstanceOf(PanelPlugin); + expect(result.loadError).toBe(true); + }); + }); + + describe('importDataSource', () => { + it('should import a data source plugin successfully with fallbackLoadingStrategy', async () => { + const spy = jest + .spyOn(importPluginModule, 'importPluginModule') + .mockResolvedValue({ plugin: { ...dataSourcePlugin } }); + + const result = await pluginImporter.importDataSource({ ...dataSourcePlugin }); + + expect(spy).toHaveBeenCalledWith({ + path: 'public/plugins/test-plugin/module.js', + version: '1.0.0', + loadingStrategy: 'fetch', + pluginId: 'test-plugin', + moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' }, + }); + + expect(result).toEqual({ ...dataSourcePlugin, meta: { ...dataSourcePlugin } }); + }); + + it('should import a data source plugin with Datasource prop', async () => { + const spy = jest + .spyOn(importPluginModule, 'importPluginModule') + .mockResolvedValue({ Datasource: { ...dataSourcePlugin } }); + + const result = await pluginImporter.importDataSource({ ...dataSourcePlugin }); + + expect(spy).toHaveBeenCalledWith({ + path: 'public/plugins/test-plugin/module.js', + version: '1.0.0', + loadingStrategy: 'fetch', + pluginId: 'test-plugin', + moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const DataSourceClass: any = { ...dataSourcePlugin }; + const expected = new DataSourcePlugin(DataSourceClass); + expect(result).toEqual({ + ...expected, + components: { + AnnotationsQueryCtrl: undefined, + ExploreQueryField: undefined, + QueryCtrl: undefined, + QueryEditor: undefined, + QueryEditorHelp: undefined, + VariableQueryEditor: undefined, + }, + meta: { ...dataSourcePlugin }, + }); + }); + + it('should set correct loading strategy', async () => { + const spy = jest + .spyOn(importPluginModule, 'importPluginModule') + .mockResolvedValue({ plugin: { ...dataSourcePlugin } }); + const meta = { ...dataSourcePlugin, loadingStrategy: PluginLoadingStrategy.script }; + + const result = await pluginImporter.importDataSource({ ...meta }); + + expect(spy).toHaveBeenCalledWith({ + path: 'public/plugins/test-plugin/module.js', + version: '1.0.0', + loadingStrategy: 'script', + pluginId: 'test-plugin', + moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' }, + }); + + expect(result).toEqual({ ...dataSourcePlugin, meta: { ...dataSourcePlugin, loadingStrategy: 'script' } }); + }); + + it('should throw error if module is missing exported plugin', async () => { + const spy = jest.spyOn(importPluginModule, 'importPluginModule').mockResolvedValue({}); + + expect(async () => { + await pluginImporter.importDataSource({ ...dataSourcePlugin }); + }).rejects.toThrow(new Error('Plugin module is missing DataSourcePlugin or Datasource constructor export')); + + expect(spy).toHaveBeenCalledWith({ + path: 'public/plugins/test-plugin/module.js', + version: '1.0.0', + loadingStrategy: 'fetch', + pluginId: 'test-plugin', + moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' }, + }); + }); + }); + + describe('importApp', () => { + it('should import a app plugin successfully with fallbackLoadingStrategy', async () => { + const init = jest.fn(); + const setComponentsFromLegacyExports = jest.fn(); + const plugin = { + plugin: { + ...appPlugin, + init, + setComponentsFromLegacyExports, + exposedComponentConfigs: [{}], + addedComponentConfigs: [{}], + addedLinkConfigs: [{}], + addedFunctionConfigs: [{}], + }, + }; + const exposedComponentsRegistrySpy = jest + .spyOn(exposedComponentsRegistry, 'register') + .mockImplementation(() => {}); + const addedComponentsRegistrySpy = jest.spyOn(addedComponentsRegistry, 'register').mockImplementation(() => {}); + const addedLinksRegistrySpy = jest.spyOn(addedLinksRegistry, 'register').mockImplementation(() => {}); + const addedFunctionsRegistrySpy = jest.spyOn(addedFunctionsRegistry, 'register').mockImplementation(() => {}); + const spy = jest.spyOn(importPluginModule, 'importPluginModule').mockResolvedValue({ ...plugin }); + + const result = await pluginImporter.importApp({ ...appPlugin }); + + expect(spy).toHaveBeenCalledWith({ + path: 'public/plugins/test-plugin/module.js', + version: '1.0.0', + loadingStrategy: 'fetch', + pluginId: 'test-plugin', + moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' }, + }); + expect(init).toHaveBeenCalledWith({ ...appPlugin }); + expect(setComponentsFromLegacyExports).toHaveBeenCalledWith({ ...plugin }); + expect(exposedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] }); + expect(addedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] }); + expect(addedLinksRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] }); + expect(addedFunctionsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] }); + + expect(result).toEqual({ + ...appPlugin, + meta: { ...appPlugin }, + init, + setComponentsFromLegacyExports, + exposedComponentConfigs: [{}], + addedComponentConfigs: [{}], + addedLinkConfigs: [{}], + addedFunctionConfigs: [{}], + }); + }); + + it('should set correct loading strategy', async () => { + const init = jest.fn(); + const setComponentsFromLegacyExports = jest.fn(); + const plugin = { + plugin: { + ...appPlugin, + init, + setComponentsFromLegacyExports, + exposedComponentConfigs: [{}], + addedComponentConfigs: [{}], + addedLinkConfigs: [{}], + addedFunctionConfigs: [{}], + }, + }; + const exposedComponentsRegistrySpy = jest + .spyOn(exposedComponentsRegistry, 'register') + .mockImplementation(() => {}); + const addedComponentsRegistrySpy = jest.spyOn(addedComponentsRegistry, 'register').mockImplementation(() => {}); + const addedLinksRegistrySpy = jest.spyOn(addedLinksRegistry, 'register').mockImplementation(() => {}); + const addedFunctionsRegistrySpy = jest.spyOn(addedFunctionsRegistry, 'register').mockImplementation(() => {}); + const spy = jest.spyOn(importPluginModule, 'importPluginModule').mockResolvedValue({ ...plugin }); + const meta = { ...appPlugin, loadingStrategy: PluginLoadingStrategy.script }; + + const result = await pluginImporter.importApp({ ...meta }); + + expect(spy).toHaveBeenCalledWith({ + path: 'public/plugins/test-plugin/module.js', + version: '1.0.0', + loadingStrategy: 'script', + pluginId: 'test-plugin', + moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' }, + }); + expect(init).toHaveBeenCalledWith({ ...meta }); + expect(setComponentsFromLegacyExports).toHaveBeenCalledWith({ ...plugin }); + expect(exposedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] }); + expect(addedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] }); + expect(addedLinksRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] }); + expect(addedFunctionsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [{}] }); + + expect(result).toEqual({ + ...appPlugin, + meta: { ...appPlugin, loadingStrategy: 'script' }, + init, + setComponentsFromLegacyExports, + exposedComponentConfigs: [{}], + addedComponentConfigs: [{}], + addedLinkConfigs: [{}], + addedFunctionConfigs: [{}], + }); + }); + + it('should import an empty app plugin if missing exported plugin', async () => { + const exposedComponentsRegistrySpy = jest + .spyOn(exposedComponentsRegistry, 'register') + .mockImplementation(() => {}); + const addedComponentsRegistrySpy = jest.spyOn(addedComponentsRegistry, 'register').mockImplementation(() => {}); + const addedLinksRegistrySpy = jest.spyOn(addedLinksRegistry, 'register').mockImplementation(() => {}); + const addedFunctionsRegistrySpy = jest.spyOn(addedFunctionsRegistry, 'register').mockImplementation(() => {}); + const spy = jest.spyOn(importPluginModule, 'importPluginModule').mockResolvedValue({}); + + const result = await pluginImporter.importApp({ ...appPlugin }); + + expect(spy).toHaveBeenCalledWith({ + path: 'public/plugins/test-plugin/module.js', + version: '1.0.0', + loadingStrategy: 'fetch', + pluginId: 'test-plugin', + moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' }, + }); + expect(exposedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [] }); + expect(addedComponentsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [] }); + expect(addedLinksRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [] }); + expect(addedFunctionsRegistrySpy).toHaveBeenCalledWith({ pluginId: 'test-plugin', configs: [] }); + + expect(result).toEqual({ ...new AppPlugin(), meta: { ...appPlugin } }); + }); + }); + + describe('caches', () => { + it('should return a cached plugin if it exsits', async () => { + const logSpy = jest.spyOn(pluginsLogger, 'logDebug').mockImplementation(() => {}); + const spy = jest + .spyOn(importPluginModule, 'importPluginModule') + .mockResolvedValue({ plugin: { ...panelPlugin } }); + + const original = await pluginImporter.importPanel({ ...panelPlugin }); + const cached = await pluginImporter.importPanel({ ...panelPlugin }); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ + path: 'public/plugins/test-plugin/module.js', + version: '1.0.0', + loadingStrategy: 'fetch', + pluginId: 'test-plugin', + moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' }, + }); + expect(logSpy).toHaveBeenCalledWith(`Retrieving plugin from cache`, { + expectedHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + loadingStrategy: 'fetch', + newPluginLoadingEnabled: 'false', + path: 'public/plugins/test-plugin/module.js', + pluginId: 'test-plugin', + pluginVersion: '1.0.0', + sriChecksEnabled: 'false', + }); + expect(cached).toBe(original); + }); + + it('should return an inflight plugin load if it exsits', async () => { + const logSpy = jest.spyOn(pluginsLogger, 'logDebug').mockImplementation(() => {}); + const spy = jest + .spyOn(importPluginModule, 'importPluginModule') + .mockResolvedValue({ plugin: { ...panelPlugin } }); + + const original = pluginImporter.importPanel({ ...panelPlugin }); + const cached = pluginImporter.importPanel({ ...panelPlugin }); + await Promise.all([original, cached]); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ + path: 'public/plugins/test-plugin/module.js', + version: '1.0.0', + loadingStrategy: 'fetch', + pluginId: 'test-plugin', + moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' }, + }); + expect(logSpy).toHaveBeenCalledWith(`Retrieving plugin from inflight plugin load request`, { + expectedHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + loadingStrategy: 'fetch', + newPluginLoadingEnabled: 'false', + path: 'public/plugins/test-plugin/module.js', + pluginId: 'test-plugin', + pluginVersion: '1.0.0', + sriChecksEnabled: 'false', + }); + expect(cached).toBe(original); + }); + }); +}); + +const baseMeta: PluginMeta = { + id: 'test-plugin', + name: 'Test Plugin', + type: '' as PluginType, + module: 'public/plugins/test-plugin/module.js', + baseUrl: 'public/plugins/test-plugin', + moduleHash: 'cc3e6f370520e1efc6043f1874d735fabc710d4b', + translations: { 'en-US': 'public/plugins/test-plugin/locales/en-US/test-plugin.json' }, + info: { + author: { name: 'Test Author' }, + description: 'Test Description', + links: [], + logos: { large: '', small: '' }, + screenshots: [], + updated: '2023-01-01', + version: '1.0.0', + }, +}; + +const panelPlugin: PanelPluginMeta = { + ...baseMeta, + type: PluginType.panel, + sort: 0, +}; + +const dataSourcePlugin: DataSourcePluginMeta = { + ...baseMeta, + type: PluginType.datasource, +}; + +const appPlugin: AppPluginMeta = { + ...baseMeta, + type: PluginType.app, +}; diff --git a/public/app/features/plugins/importer/pluginImporter.ts b/public/app/features/plugins/importer/pluginImporter.ts new file mode 100644 index 00000000000..742a6739463 --- /dev/null +++ b/public/app/features/plugins/importer/pluginImporter.ts @@ -0,0 +1,185 @@ +import { + AppPlugin, + AppPluginMeta, + DataQuery, + DataSourceApi, + DataSourceJsonData, + DataSourcePlugin, + DataSourcePluginMeta, + PanelPlugin, + PanelPluginMeta, + PluginLoadingStrategy, + PluginMeta, + throwIfAngular, +} from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { GenericDataSourcePlugin } from 'app/features/datasources/types'; +import { getPanelPluginLoadError } from 'app/features/panel/components/PanelPluginError'; + +import { + addedComponentsRegistry, + addedFunctionsRegistry, + addedLinksRegistry, + exposedComponentsRegistry, +} from '../extensions/registry/setup'; +import { pluginsLogger } from '../utils'; + +import { importPluginModule } from './importPluginModule'; +import { PluginImporter, PostImportStrategy, PreImportStrategy } from './types'; + +const defaultPreImport: PreImportStrategy = (plugin) => { + throwIfAngular(plugin); + const fallbackLoadingStrategy = plugin.loadingStrategy ?? PluginLoadingStrategy.fetch; + + const args = { + path: plugin.module, + version: plugin.info?.version, + loadingStrategy: fallbackLoadingStrategy, + pluginId: plugin.id, + moduleHash: plugin.moduleHash, + translations: plugin.translations, + }; + + return args; +}; + +const panelPluginPostImport: PostImportStrategy = async (meta, module) => { + try { + const pluginExports = await module; + + if (pluginExports.plugin) { + const plugin: PanelPlugin = pluginExports.plugin; + plugin.meta = meta; + pluginsCache.set(meta.id, plugin); + return plugin; + } + + throwIfAngular(pluginExports); + throw new Error('missing export: plugin'); + } catch (error) { + // TODO, maybe a different error plugin + console.warn('Error loading panel plugin: ' + meta.id, error); + return getPanelPluginLoadError(meta, error); + } +}; + +const datasourcePluginPostImport: PostImportStrategy = async ( + meta, + module +) => { + const pluginExports = await module; + + if (pluginExports.plugin) { + const dsPlugin: GenericDataSourcePlugin = pluginExports.plugin; + dsPlugin.meta = meta; + pluginsCache.set(meta.id, dsPlugin); + return dsPlugin; + } + + if (pluginExports.Datasource) { + const dsPlugin = new DataSourcePlugin, DataQuery, DataSourceJsonData>( + pluginExports.Datasource + ); + dsPlugin.setComponentsFromLegacyExports(pluginExports); + dsPlugin.meta = meta; + pluginsCache.set(meta.id, dsPlugin); + return dsPlugin; + } + + throw new Error('Plugin module is missing DataSourcePlugin or Datasource constructor export'); +}; + +const appPluginPostImport: PostImportStrategy = async (meta, module) => { + const pluginExports = await module; + + const { plugin = new AppPlugin() } = pluginExports; + plugin.init(meta); + plugin.meta = meta; + plugin.setComponentsFromLegacyExports(pluginExports); + + exposedComponentsRegistry.register({ pluginId: meta.id, configs: plugin.exposedComponentConfigs || [] }); + addedComponentsRegistry.register({ pluginId: meta.id, configs: plugin.addedComponentConfigs || [] }); + addedLinksRegistry.register({ pluginId: meta.id, configs: plugin.addedLinkConfigs || [] }); + addedFunctionsRegistry.register({ pluginId: meta.id, configs: plugin.addedFunctionConfigs || [] }); + + pluginsCache.set(meta.id, plugin); + return plugin; +}; + +const promisesCache: Map> = new Map(); + +const getPromiseFromCache = ( + meta: M +): Promise

=> { + const cached = promisesCache.get(meta.id); + if (cached) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return cached as Promise

; + } + + throw new Error(`Trying to get unknown plugin type ${meta.type} from cache for plugin ${meta.id}`); +}; + +const pluginsCache: Map = new Map(); + +const getPluginFromCache =

(id: string): P | undefined => { + const cached = pluginsCache.get(id); + if (cached) { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return cached as P; + } + + return undefined; +}; + +const importPlugin = ( + meta: M, + postImportStrategy: PostImportStrategy, + preImportStrategy: PreImportStrategy = defaultPreImport +): Promise

=> { + const cached = getPluginFromCache

(meta.id); + if (cached) { + pluginsLogger.logDebug(`Retrieving plugin from cache`, { + path: meta.module, + pluginId: meta.id, + pluginVersion: meta.info?.version ?? '', + expectedHash: meta.moduleHash ?? '', + loadingStrategy: meta.loadingStrategy ?? PluginLoadingStrategy.fetch, + sriChecksEnabled: String(Boolean(config.featureToggles.pluginsSriChecks)), + newPluginLoadingEnabled: String(Boolean(config.featureToggles.enablePluginImporter)), + }); + return Promise.resolve(cached); + } + + if (promisesCache.has(meta.id)) { + pluginsLogger.logDebug(`Retrieving plugin from inflight plugin load request`, { + path: meta.module, + pluginId: meta.id, + pluginVersion: meta.info?.version ?? '', + expectedHash: meta.moduleHash ?? '', + loadingStrategy: meta.loadingStrategy ?? PluginLoadingStrategy.fetch, + sriChecksEnabled: String(Boolean(config.featureToggles.pluginsSriChecks)), + newPluginLoadingEnabled: String(Boolean(config.featureToggles.enablePluginImporter)), + }); + return getPromiseFromCache(meta); + } + + const args = preImportStrategy(meta); + const module = importPluginModule(args); + const plugin = postImportStrategy(meta, module); + promisesCache.set(meta.id, plugin); + + return getPromiseFromCache(meta); +}; + +export const pluginImporter: PluginImporter = { + importPanel: (meta: PanelPluginMeta) => importPlugin(meta, panelPluginPostImport), + importDataSource: (meta: DataSourcePluginMeta) => importPlugin(meta, datasourcePluginPostImport), + importApp: (meta: AppPluginMeta) => importPlugin(meta, appPluginPostImport), + getPanel: (id: string) => getPluginFromCache(id), // we need this sync because how the panel plugins are loaded in PanelRenderer +}; + +export const clearCaches = () => { + promisesCache.clear(); + pluginsCache.clear(); +}; diff --git a/public/app/features/plugins/importer/types.ts b/public/app/features/plugins/importer/types.ts new file mode 100644 index 00000000000..01fcb78452e --- /dev/null +++ b/public/app/features/plugins/importer/types.ts @@ -0,0 +1,53 @@ +import { + AppPlugin, + AppPluginMeta, + DataSourcePluginMeta, + PanelPlugin, + PanelPluginMeta, + PluginLoadingStrategy, + PluginMeta, +} from '@grafana/data'; +import { GenericDataSourcePlugin } from 'app/features/datasources/types'; + +export interface PluginImporter { + /** + * Imports a panel plugin from module.js + * @param meta - The plugin meta + * @returns a Promise + */ + importPanel: (meta: PanelPluginMeta) => Promise; + /** + * Imports a datasource plugin from module.js + * @param meta - The plugin meta + * @returns a Promise + */ + importDataSource: (meta: DataSourcePluginMeta) => Promise; + /** + * Imports an app plugin from module.js + * @param meta - The plugin meta + * @returns a Promise + */ + importApp: (meta: AppPluginMeta) => Promise; + /** + * Retrieves a panel plugin from the cache, if it doesn't exist in the cache it returns undefined + * @param id - The plugin id + * @returns a PanelPlugin or undefined + */ + getPanel: (id: string) => PanelPlugin | undefined; +} + +export interface PluginImportInfo { + path: string; + pluginId: string; + loadingStrategy: PluginLoadingStrategy; + version?: string; + moduleHash?: string; + translations?: Record; +} + +export type PreImportStrategy = (meta: M) => PluginImportInfo; + +export type PostImportStrategy< + P extends PanelPlugin | GenericDataSourcePlugin | AppPlugin, + M extends PluginMeta = PluginMeta, +> = (meta: M, module: Promise) => Promise

; diff --git a/public/app/features/plugins/pluginLoader.test.ts b/public/app/features/plugins/pluginLoader.test.ts index e715f1b9d35..ea91ffb7d3e 100644 --- a/public/app/features/plugins/pluginLoader.test.ts +++ b/public/app/features/plugins/pluginLoader.test.ts @@ -1,143 +1,66 @@ -import { i18n } from 'i18next'; +jest.mock('app/core/core', () => { + return { + coreModule: { + directive: jest.fn(), + }, + }; +}); -import * as i18nModule from '@grafana/i18n/internal'; +import { AppPluginMeta, PluginMetaInfo, PluginType, AppPlugin } from '@grafana/data'; -import { server } from './loader/pluginLoader.mock'; +// Loaded after the `unmock` above +import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from './extensions/registry/setup'; import { SystemJS } from './loader/systemjs'; -import { SystemJSWithLoaderHooks } from './loader/types'; -import { addTranslationsToI18n } from './pluginLoader'; +import { importAppPlugin } from './pluginLoader'; -describe('pluginLoader', () => { - describe('addTranslationsToI18n', () => { - const systemJSPrototype: SystemJSWithLoaderHooks = SystemJS.constructor.prototype; - const originalFetch = systemJSPrototype.fetch; - const originalResolve = systemJSPrototype.resolve; - let addResourceBundleSpy: jest.SpyInstance; +jest.mock('./extensions/registry/setup'); - 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; - }); +describe('Load App', () => { + const app = new AppPlugin(); + const modulePath = 'http://localhost:3000/public/plugins/my-app-plugin/module.js'; + // Hook resolver for tests + const originalResolve = SystemJS.constructor.prototype.resolve; + SystemJS.constructor.prototype.resolve = (x: unknown) => x; - beforeEach(() => { - addResourceBundleSpy = jest - .spyOn(i18nModule, 'addResourceBundle') - .mockImplementation(() => ({}) as unknown as i18n); - }); + beforeAll(() => { + app.init = jest.fn(); + addedComponentsRegistry.register = jest.fn(); + addedLinksRegistry.register = jest.fn(); + exposedComponentsRegistry.register = jest.fn(); - afterEach(() => { - server.resetHandlers(); - jest.clearAllMocks(); - }); + SystemJS.set(modulePath, { plugin: app }); + }); - afterAll(() => { - SystemJS.constructor.prototype.resolve = originalResolve; - SystemJS.constructor.prototype.fetch = originalFetch; - server.close(); - }); + afterAll(() => { + SystemJS.delete(modulePath); + SystemJS.constructor.prototype.resolve = originalResolve; + }); - 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', - }; + it('should call init and set meta', async () => { + const meta: AppPluginMeta = { + id: 'test-app', + module: modulePath, + baseUrl: 'xxx', + info: {} as PluginMetaInfo, + type: PluginType.app, + name: 'test', + }; - await addTranslationsToI18n({ - resolvedLanguage: 'pt-BR', - fallbackLanguage: 'en-US', - pluginId: 'test-panel', - translations, - }); + // Check that we mocked the import OK + const m = await SystemJS.import(modulePath); + expect(m.plugin).toBe(app); - expect(addResourceBundleSpy).toHaveBeenCalledTimes(1); - expect(addResourceBundleSpy).toHaveBeenNthCalledWith(1, 'pt-BR', 'test-panel', { testKey: 'valorDeTeste' }); - }); + // Importing the app should initialise the meta + const importedApp = await importAppPlugin(meta); + expect(importedApp).toBe(app); + expect(app.meta).toBe(meta); - 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' }); - }); - - 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', - }); - }); + // Importing the same app again doesn't initialise it twice + const importedAppAgain = await importAppPlugin(meta); + expect(importedAppAgain).toBe(app); + expect(app.init).toHaveBeenCalledTimes(1); + expect(addedComponentsRegistry.register).toHaveBeenCalledTimes(1); + expect(addedLinksRegistry.register).toHaveBeenCalledTimes(1); + expect(exposedComponentsRegistry.register).toHaveBeenCalledTimes(1); }); }); diff --git a/public/app/features/plugins/pluginLoader.ts b/public/app/features/plugins/pluginLoader.ts index 15f00ac400f..2a5e50a93e5 100644 --- a/public/app/features/plugins/pluginLoader.ts +++ b/public/app/features/plugins/pluginLoader.ts @@ -8,31 +8,27 @@ import { PluginMeta, throwIfAngular, } from '@grafana/data'; -import { DEFAULT_LANGUAGE } from '@grafana/i18n'; -import { addResourceBundle, getResolvedLanguage } from '@grafana/i18n/internal'; import { config } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; import { GenericDataSourcePlugin } from '../datasources/types'; -import builtInPlugins from './built_in_plugins'; import { addedComponentsRegistry, addedFunctionsRegistry, addedLinksRegistry, exposedComponentsRegistry, } from './extensions/registry/setup'; -import { getPluginFromCache, registerPluginInCache } from './loader/cache'; +import { importPluginModule } from './importer/importPluginModule'; +import { pluginImporter } from './importer/pluginImporter'; +import { getPluginFromCache } from './loader/cache'; // SystemJS has to be imported before the sharedDependenciesMap import { SystemJS } from './loader/systemjs'; // eslint-disable-next-line import/order import { sharedDependenciesMap } from './loader/sharedDependencies'; import { decorateSystemJSFetch, decorateSystemJSResolve, decorateSystemJsOnload } from './loader/systemjsHooks'; import { SystemJSWithLoaderHooks } from './loader/types'; -import { buildImportMap, resolveModulePath } from './loader/utils'; -import { importPluginModuleInSandbox } from './sandbox/sandboxPluginLoader'; -import { shouldLoadPluginInFrontendSandbox } from './sandbox/sandboxPluginLoaderRegistry'; -import { pluginsLogger } from './utils'; +import { buildImportMap } from './loader/utils'; const imports = buildImportMap(sharedDependenciesMap); @@ -78,84 +74,11 @@ systemJSPrototype.resolve = decorateSystemJSResolve.bind(systemJSPrototype, syst // Any css files loaded via SystemJS have their styles applied onload. systemJSPrototype.onload = decorateSystemJsOnload; -type PluginImportInfo = { - path: string; - pluginId: string; - loadingStrategy: PluginLoadingStrategy; - version?: string; - moduleHash?: string; - translations?: Record; -}; - -export async function importPluginModule({ - path, - pluginId, - loadingStrategy, - version, - 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: getResolvedLanguage(), - fallbackLanguage: DEFAULT_LANGUAGE, - pluginId, - translations, - }); - } - - const builtIn = builtInPlugins[path]; - if (builtIn) { - // for handling dynamic imports - if (typeof builtIn === 'function') { - return await builtIn(); - } else { - return builtIn; - } - } - - const modulePath = resolveModulePath(path); - - // inject integrity hash into SystemJS import map - if (config.featureToggles.pluginsSriChecks) { - const resolvedModule = System.resolve(modulePath); - const integrityMap = System.getImportMap().integrity; - - if (moduleHash && integrityMap && !integrityMap[resolvedModule]) { - SystemJS.addImportMap({ - integrity: { - [resolvedModule]: moduleHash, - }, - }); - } - } - - // the sandboxing environment code cannot work in nodejs and requires a real browser - if (await shouldLoadPluginInFrontendSandbox({ pluginId })) { - return importPluginModuleInSandbox({ pluginId }); - } - - return SystemJS.import(modulePath).catch((e) => { - let error = new Error('Could not load plugin: ' + e); - console.error(error); - pluginsLogger.logError(error, { - path, - pluginId, - pluginVersion: version ?? '', - expectedHash: moduleHash ?? '', - loadingStrategy: loadingStrategy.toString(), - sriChecksEnabled: (config.featureToggles.pluginsSriChecks ?? false).toString(), - }); - throw error; - }); -} - export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise { + if (config.featureToggles.enablePluginImporter) { + return pluginImporter.importDataSource(meta); + } + throwIfAngular(meta); const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch; @@ -191,6 +114,10 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise> = {}; export async function importAppPlugin(meta: PluginMeta): Promise { + if (config.featureToggles.enablePluginImporter) { + return pluginImporter.importApp(meta); + } + const pluginId = meta.id; // We are caching the import promises to prevent duplicate imports @@ -237,49 +164,3 @@ async function doImportAppPlugin(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; - addResourceBundle(language, pluginId, module.default); - } catch (error) { - console.warn(`Could not load translation for plugin ${pluginId}`, { - resolvedLanguage, - fallbackLanguage, - error, - path, - }); - } -} diff --git a/public/app/features/plugins/tests/plugin_loader.test.ts b/public/app/features/plugins/tests/plugin_loader.test.ts deleted file mode 100644 index 860d509bdcd..00000000000 --- a/public/app/features/plugins/tests/plugin_loader.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -jest.mock('app/core/core', () => { - return { - coreModule: { - directive: jest.fn(), - }, - }; -}); - -import { AppPluginMeta, PluginMetaInfo, PluginType, AppPlugin } from '@grafana/data'; - -// Loaded after the `unmock` above -import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from '../extensions/registry/setup'; -import { SystemJS } from '../loader/systemjs'; -import { importAppPlugin } from '../pluginLoader'; - -jest.mock('../extensions/registry/setup'); - -describe('Load App', () => { - const app = new AppPlugin(); - const modulePath = 'http://localhost:3000/public/plugins/my-app-plugin/module.js'; - // Hook resolver for tests - const originalResolve = SystemJS.constructor.prototype.resolve; - SystemJS.constructor.prototype.resolve = (x: unknown) => x; - - beforeAll(() => { - app.init = jest.fn(); - addedComponentsRegistry.register = jest.fn(); - addedLinksRegistry.register = jest.fn(); - exposedComponentsRegistry.register = jest.fn(); - - SystemJS.set(modulePath, { plugin: app }); - }); - - afterAll(() => { - SystemJS.delete(modulePath); - SystemJS.constructor.prototype.resolve = originalResolve; - }); - - it('should call init and set meta', async () => { - const meta: AppPluginMeta = { - id: 'test-app', - module: modulePath, - baseUrl: 'xxx', - info: {} as PluginMetaInfo, - type: PluginType.app, - name: 'test', - }; - - // Check that we mocked the import OK - const m = await SystemJS.import(modulePath); - expect(m.plugin).toBe(app); - - // Importing the app should initialise the meta - const importedApp = await importAppPlugin(meta); - expect(importedApp).toBe(app); - expect(app.meta).toBe(meta); - - // Importing the same app again doesn't initialise it twice - const importedAppAgain = await importAppPlugin(meta); - expect(importedAppAgain).toBe(app); - expect(app.init).toHaveBeenCalledTimes(1); - expect(addedComponentsRegistry.register).toHaveBeenCalledTimes(1); - expect(addedLinksRegistry.register).toHaveBeenCalledTimes(1); - expect(exposedComponentsRegistry.register).toHaveBeenCalledTimes(1); - }); -});