Plugins: replaces various plugin imports with pluginImporter (#108002)
* Plugins: renames plugin_loader * Wip * chore: adds pluginImporter * chore: some small refactors * chore: better typings * chore: merge functions * chore: create a generic plugin cache * chore: adds comments * chore: change to const * chore: remove unused change * chore: put everything behind feature toggle * chore: rename test file and props * chore: adds sync cache as well * chore: fix the typings * chore: fix broken unit test * chore: small rename * chore: adds tests * chore: updates after PR feedback * chore: updates after PR feedback
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, Promise<PanelPlugin>> = {};
|
||||
const panelPluginCache: Record<string, PanelPlugin> = {};
|
||||
@@ -50,10 +51,18 @@ export function importPanelPluginFromMeta(meta: PanelPluginMeta): Promise<PanelP
|
||||
}
|
||||
|
||||
export function syncGetPanelPlugin(id: string): PanelPlugin | undefined {
|
||||
if (config.featureToggles.enablePluginImporter) {
|
||||
return pluginImporter.getPanel(id);
|
||||
}
|
||||
|
||||
return panelPluginCache[id];
|
||||
}
|
||||
|
||||
function getPanelPlugin(meta: PanelPluginMeta): Promise<PanelPlugin> {
|
||||
if (config.featureToggles.enablePluginImporter) {
|
||||
return pluginImporter.importPanel(meta);
|
||||
}
|
||||
|
||||
throwIfAngular(meta);
|
||||
|
||||
const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch;
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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;
|
||||
addResourceBundle(language, pluginId, module.default);
|
||||
} catch (error) {
|
||||
console.warn(`Could not load translation for plugin ${pluginId}`, {
|
||||
resolvedLanguage,
|
||||
fallbackLanguage,
|
||||
error,
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<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: 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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<PanelPlugin, PanelPluginMeta> = 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<GenericDataSourcePlugin, DataSourcePluginMeta> = 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<DataSourceApi<DataQuery, DataSourceJsonData>, 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<AppPlugin, AppPluginMeta> = 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<string, Promise<PanelPlugin | GenericDataSourcePlugin | AppPlugin>> = new Map();
|
||||
|
||||
const getPromiseFromCache = <M extends PluginMeta, P extends PanelPlugin | GenericDataSourcePlugin | AppPlugin>(
|
||||
meta: M
|
||||
): Promise<P> => {
|
||||
const cached = promisesCache.get(meta.id);
|
||||
if (cached) {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return cached as Promise<P>;
|
||||
}
|
||||
|
||||
throw new Error(`Trying to get unknown plugin type ${meta.type} from cache for plugin ${meta.id}`);
|
||||
};
|
||||
|
||||
const pluginsCache: Map<string, PanelPlugin | GenericDataSourcePlugin | AppPlugin> = new Map();
|
||||
|
||||
const getPluginFromCache = <P extends PanelPlugin | GenericDataSourcePlugin | AppPlugin>(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 = <M extends PluginMeta, P extends PanelPlugin | GenericDataSourcePlugin | AppPlugin>(
|
||||
meta: M,
|
||||
postImportStrategy: PostImportStrategy<P, M>,
|
||||
preImportStrategy: PreImportStrategy<M> = defaultPreImport
|
||||
): Promise<P> => {
|
||||
const cached = getPluginFromCache<P>(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<PanelPlugin>(id), // we need this sync because how the panel plugins are loaded in PanelRenderer
|
||||
};
|
||||
|
||||
export const clearCaches = () => {
|
||||
promisesCache.clear();
|
||||
pluginsCache.clear();
|
||||
};
|
||||
@@ -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<PanelPlugin>
|
||||
*/
|
||||
importPanel: (meta: PanelPluginMeta) => Promise<PanelPlugin>;
|
||||
/**
|
||||
* Imports a datasource plugin from module.js
|
||||
* @param meta - The plugin meta
|
||||
* @returns a Promise<GenericDataSourcePlugin>
|
||||
*/
|
||||
importDataSource: (meta: DataSourcePluginMeta) => Promise<GenericDataSourcePlugin>;
|
||||
/**
|
||||
* Imports an app plugin from module.js
|
||||
* @param meta - The plugin meta
|
||||
* @returns a Promise<AppPlugin>
|
||||
*/
|
||||
importApp: (meta: AppPluginMeta) => Promise<AppPlugin>;
|
||||
/**
|
||||
* 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<string, string>;
|
||||
}
|
||||
|
||||
export type PreImportStrategy<M extends PluginMeta = PluginMeta> = (meta: M) => PluginImportInfo;
|
||||
|
||||
export type PostImportStrategy<
|
||||
P extends PanelPlugin | GenericDataSourcePlugin | AppPlugin,
|
||||
M extends PluginMeta = PluginMeta,
|
||||
> = (meta: M, module: Promise<System.Module>) => Promise<P>;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
export async function importPluginModule({
|
||||
path,
|
||||
pluginId,
|
||||
loadingStrategy,
|
||||
version,
|
||||
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: 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<GenericDataSourcePlugin> {
|
||||
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<Gene
|
||||
const importPromises: Record<string, Promise<AppPlugin>> = {};
|
||||
|
||||
export async function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> {
|
||||
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<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;
|
||||
addResourceBundle(language, pluginId, module.default);
|
||||
} catch (error) {
|
||||
console.warn(`Could not load translation for plugin ${pluginId}`, {
|
||||
resolvedLanguage,
|
||||
fallbackLanguage,
|
||||
error,
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user