Plugin Extensions: Only load app plugins when necessary (#86624)

* feat(plugins): automatically preload plugins

This PR enables auto-preloading for plugins when they are used
by an extension or extension-point. Once this change is merged plugins
that were only using "preload: true" in their plugin.json for using extensions
can remove it.

* fix: remove unused types

* fix: call `setComponentsFromLegacyExports()` after meta is initialised
This commit is contained in:
Levente Balogh 2024-11-29 14:05:55 +01:00 committed by GitHub
parent a2c407854f
commit cce943b3af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 732 additions and 144 deletions

View File

@ -32,4 +32,4 @@ Note that this plugin extends the `@grafana/plugin-configs` configs which is why
## Run Playwright tests ## Run Playwright tests
- `yarn playwright --project extensions-test-app` - `yarn playwright test --project extensions-test-app`

View File

@ -22,6 +22,7 @@ import { RouteDescriptor } from './core/navigation/types';
import { ThemeProvider } from './core/utils/ConfigProvider'; import { ThemeProvider } from './core/utils/ConfigProvider';
import { LiveConnectionWarning } from './features/live/LiveConnectionWarning'; import { LiveConnectionWarning } from './features/live/LiveConnectionWarning';
import { ExtensionRegistriesProvider } from './features/plugins/extensions/ExtensionRegistriesContext'; import { ExtensionRegistriesProvider } from './features/plugins/extensions/ExtensionRegistriesContext';
import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
import { ExperimentalSplitPaneRouterWrapper, RouterWrapper } from './routes/RoutesWrapper'; import { ExperimentalSplitPaneRouterWrapper, RouterWrapper } from './routes/RoutesWrapper';
interface AppWrapperProps { interface AppWrapperProps {
@ -104,7 +105,7 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
<GlobalStyles /> <GlobalStyles />
<MaybeTimeRangeProvider> <MaybeTimeRangeProvider>
<SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}> <SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}>
<ExtensionRegistriesProvider registries={app.pluginExtensionsRegistries}> <ExtensionRegistriesProvider registries={pluginExtensionRegistries}>
<div className="grafana-app"> <div className="grafana-app">
{config.featureToggles.appSidecar ? ( {config.featureToggles.appSidecar ? (
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} /> <ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />

View File

@ -85,12 +85,12 @@ import { PanelDataErrorView } from './features/panel/components/PanelDataErrorVi
import { PanelRenderer } from './features/panel/components/PanelRenderer'; import { PanelRenderer } from './features/panel/components/PanelRenderer';
import { DatasourceSrv } from './features/plugins/datasource_srv'; import { DatasourceSrv } from './features/plugins/datasource_srv';
import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions'; import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions';
import { setupPluginExtensionRegistries } from './features/plugins/extensions/registry/setup'; import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
import { PluginExtensionRegistries } from './features/plugins/extensions/registry/types';
import { usePluginComponent } from './features/plugins/extensions/usePluginComponent'; import { usePluginComponent } from './features/plugins/extensions/usePluginComponent';
import { usePluginComponents } from './features/plugins/extensions/usePluginComponents'; import { usePluginComponents } from './features/plugins/extensions/usePluginComponents';
import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions'; import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions';
import { usePluginLinks } from './features/plugins/extensions/usePluginLinks'; import { usePluginLinks } from './features/plugins/extensions/usePluginLinks';
import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils';
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin'; import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
import { preloadPlugins } from './features/plugins/pluginPreloader'; import { preloadPlugins } from './features/plugins/pluginPreloader';
import { QueryRunner } from './features/query/state/QueryRunner'; import { QueryRunner } from './features/query/state/QueryRunner';
@ -127,7 +127,6 @@ if (process.env.NODE_ENV === 'development') {
export class GrafanaApp { export class GrafanaApp {
context!: GrafanaContextType; context!: GrafanaContextType;
pluginExtensionsRegistries!: PluginExtensionRegistries;
async init() { async init() {
try { try {
@ -217,22 +216,16 @@ export class GrafanaApp {
setDataSourceSrv(dataSourceSrv); setDataSourceSrv(dataSourceSrv);
initWindowRuntime(); initWindowRuntime();
// Initialize plugin extensions
this.pluginExtensionsRegistries = setupPluginExtensionRegistries();
if (contextSrv.user.orgRole !== '') { if (contextSrv.user.orgRole !== '') {
// The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init. const appPluginsToAwait = getAppPluginsToAwait();
// TODO: remove the following exception once the issue mentioned above is fixed. const appPluginsToPreload = getAppPluginsToPreload();
const awaitedAppPluginIds = ['cloud-home-app'];
const awaitedAppPlugins = Object.values(config.apps).filter((app) => awaitedAppPluginIds.includes(app.id));
const appPlugins = Object.values(config.apps).filter((app) => !awaitedAppPluginIds.includes(app.id));
preloadPlugins(appPlugins, this.pluginExtensionsRegistries); preloadPlugins(appPluginsToPreload);
await preloadPlugins(awaitedAppPlugins, this.pluginExtensionsRegistries, 'frontend_awaited_plugins_preload'); await preloadPlugins(appPluginsToAwait);
} }
setPluginExtensionGetter(createPluginExtensionsGetter(this.pluginExtensionsRegistries)); setPluginExtensionGetter(createPluginExtensionsGetter(pluginExtensionRegistries));
setPluginExtensionsHook(createUsePluginExtensions(this.pluginExtensionsRegistries)); setPluginExtensionsHook(createUsePluginExtensions(pluginExtensionRegistries));
setPluginLinksHook(usePluginLinks); setPluginLinksHook(usePluginLinks);
setPluginComponentHook(usePluginComponent); setPluginComponentHook(usePluginComponent);
setPluginComponentsHook(usePluginComponents); setPluginComponentsHook(usePluginComponents);

View File

@ -11,7 +11,9 @@ import { contextSrv } from 'app/core/services/context_srv';
import { Echo } from 'app/core/services/echo/Echo'; import { Echo } from 'app/core/services/echo/Echo';
import { ExtensionRegistriesProvider } from '../extensions/ExtensionRegistriesContext'; import { ExtensionRegistriesProvider } from '../extensions/ExtensionRegistriesContext';
import { setupPluginExtensionRegistries } from '../extensions/registry/setup'; import { AddedComponentsRegistry } from '../extensions/registry/AddedComponentsRegistry';
import { AddedLinksRegistry } from '../extensions/registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from '../extensions/registry/ExposedComponentsRegistry';
import { getPluginSettings } from '../pluginSettings'; import { getPluginSettings } from '../pluginSettings';
import { importAppPlugin } from '../plugin_loader'; import { importAppPlugin } from '../plugin_loader';
@ -86,7 +88,11 @@ function renderUnderRouter(page = '') {
appPluginNavItem.parentItem = appsSection; appPluginNavItem.parentItem = appsSection;
const registries = setupPluginExtensionRegistries(); const registries = {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
};
const pagePath = page ? `/${page}` : ''; const pagePath = page ? `/${page}` : '';
const route = { const route = {
path: `/a/:pluginId/*`, path: `/a/:pluginId/*`,

View File

@ -5,17 +5,17 @@ import { AddedLinksRegistry } from './AddedLinksRegistry';
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './types'; import { PluginExtensionRegistries } from './types';
export function setupPluginExtensionRegistries(): PluginExtensionRegistries { export const addedComponentsRegistry = new AddedComponentsRegistry();
const pluginExtensionsRegistries = { export const exposedComponentsRegistry = new ExposedComponentsRegistry();
addedComponentsRegistry: new AddedComponentsRegistry(), export const addedLinksRegistry = new AddedLinksRegistry();
exposedComponentsRegistry: new ExposedComponentsRegistry(), export const pluginExtensionRegistries: PluginExtensionRegistries = {
addedLinksRegistry: new AddedLinksRegistry(), addedComponentsRegistry,
}; exposedComponentsRegistry,
addedLinksRegistry,
};
pluginExtensionsRegistries.addedLinksRegistry.register({ // Registering core extensions
pluginId: 'grafana', addedLinksRegistry.register({
configs: getCoreExtensionConfigurations(), pluginId: 'grafana',
}); configs: getCoreExtensionConfigurations(),
});
return pluginExtensionsRegistries;
}

View File

@ -0,0 +1,19 @@
import { useAsync } from 'react-use';
import { preloadPlugins } from '../pluginPreloader';
import { getAppPluginConfigs } from './utils';
export function useLoadAppPlugins(pluginIds: string[] = []): { isLoading: boolean } {
const { loading: isLoading } = useAsync(async () => {
const appConfigs = getAppPluginConfigs(pluginIds);
if (!appConfigs.length) {
return;
}
await preloadPlugins(appConfigs);
});
return { isLoading };
}

View File

@ -7,11 +7,15 @@ import { config } from '@grafana/runtime';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log'; import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils'; import { resetLogMock } from './logs/testUtils';
import { setupPluginExtensionRegistries } from './registry/setup'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types'; import { PluginExtensionRegistries } from './registry/types';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { usePluginComponent } from './usePluginComponent'; import { usePluginComponent } from './usePluginComponent';
import { isGrafanaDevMode, wrapWithPluginContext } from './utils'; import { isGrafanaDevMode, wrapWithPluginContext } from './utils';
jest.mock('./useLoadAppPlugins');
jest.mock('app/features/plugins/pluginSettings', () => ({ jest.mock('app/features/plugins/pluginSettings', () => ({
getPluginSettings: jest.fn().mockResolvedValue({ getPluginSettings: jest.fn().mockResolvedValue({
id: 'my-app-plugin', id: 'my-app-plugin',
@ -83,7 +87,12 @@ describe('usePluginComponent()', () => {
}; };
beforeEach(() => { beforeEach(() => {
registries = setupPluginExtensionRegistries(); registries = {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
};
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
jest.mocked(isGrafanaDevMode).mockReturnValue(false); jest.mocked(isGrafanaDevMode).mockReturnValue(false);
resetLogMock(log); resetLogMock(log);

View File

@ -7,7 +7,8 @@ import { UsePluginComponentResult } from '@grafana/runtime';
import { useExposedComponentsRegistry } from './ExtensionRegistriesContext'; import { useExposedComponentsRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors'; import * as errors from './errors';
import { log } from './logs/log'; import { log } from './logs/log';
import { isGrafanaDevMode, wrapWithPluginContext } from './utils'; import { useLoadAppPlugins } from './useLoadAppPlugins';
import { getExposedComponentPluginDependencies, isGrafanaDevMode, wrapWithPluginContext } from './utils';
import { isExposedComponentDependencyMissing } from './validators'; import { isExposedComponentDependencyMissing } from './validators';
// Returns a component exposed by a plugin. // Returns a component exposed by a plugin.
@ -16,11 +17,19 @@ export function usePluginComponent<Props extends object = {}>(id: string): UsePl
const registry = useExposedComponentsRegistry(); const registry = useExposedComponentsRegistry();
const registryState = useObservable(registry.asObservable()); const registryState = useObservable(registry.asObservable());
const pluginContext = usePluginContext(); const pluginContext = usePluginContext();
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExposedComponentPluginDependencies(id));
return useMemo(() => { return useMemo(() => {
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana. // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
const enableRestrictions = isGrafanaDevMode() && pluginContext; const enableRestrictions = isGrafanaDevMode() && pluginContext;
if (isLoadingAppPlugins) {
return {
isLoading: true,
component: null,
};
}
if (!registryState?.[id]) { if (!registryState?.[id]) {
return { return {
isLoading: false, isLoading: false,
@ -47,5 +56,5 @@ export function usePluginComponent<Props extends object = {}>(id: string): UsePl
isLoading: false, isLoading: false,
component: wrapWithPluginContext(registryItem.pluginId, registryItem.component, componentLog), component: wrapWithPluginContext(registryItem.pluginId, registryItem.component, componentLog),
}; };
}, [id, pluginContext, registryState]); }, [id, pluginContext, registryState, isLoadingAppPlugins]);
} }

View File

@ -6,11 +6,15 @@ import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log'; import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils'; import { resetLogMock } from './logs/testUtils';
import { setupPluginExtensionRegistries } from './registry/setup'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types'; import { PluginExtensionRegistries } from './registry/types';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { usePluginComponents } from './usePluginComponents'; import { usePluginComponents } from './usePluginComponents';
import { isGrafanaDevMode, wrapWithPluginContext } from './utils'; import { isGrafanaDevMode, wrapWithPluginContext } from './utils';
jest.mock('./useLoadAppPlugins');
jest.mock('app/features/plugins/pluginSettings', () => ({ jest.mock('app/features/plugins/pluginSettings', () => ({
getPluginSettings: jest.fn().mockResolvedValue({ getPluginSettings: jest.fn().mockResolvedValue({
id: 'my-app-plugin', id: 'my-app-plugin',
@ -50,8 +54,14 @@ describe('usePluginComponents()', () => {
beforeEach(() => { beforeEach(() => {
jest.mocked(isGrafanaDevMode).mockReturnValue(false); jest.mocked(isGrafanaDevMode).mockReturnValue(false);
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
resetLogMock(log); resetLogMock(log);
registries = setupPluginExtensionRegistries(); registries = {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
};
jest.mocked(wrapWithPluginContext).mockClear(); jest.mocked(wrapWithPluginContext).mockClear();

View File

@ -10,7 +10,8 @@ import {
import { useAddedComponentsRegistry } from './ExtensionRegistriesContext'; import { useAddedComponentsRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors'; import * as errors from './errors';
import { log } from './logs/log'; import { log } from './logs/log';
import { isGrafanaDevMode } from './utils'; import { useLoadAppPlugins } from './useLoadAppPlugins';
import { getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators'; import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
// Returns an array of component extensions for the given extension point // Returns an array of component extensions for the given extension point
@ -21,6 +22,7 @@ export function usePluginComponents<Props extends object = {}>({
const registry = useAddedComponentsRegistry(); const registry = useAddedComponentsRegistry();
const registryState = useObservable(registry.asObservable()); const registryState = useObservable(registry.asObservable());
const pluginContext = usePluginContext(); const pluginContext = usePluginContext();
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
return useMemo(() => { return useMemo(() => {
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana. // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
@ -45,6 +47,13 @@ export function usePluginComponents<Props extends object = {}>({
}; };
} }
if (isLoadingAppPlugins) {
return {
isLoading: true,
components: [],
};
}
for (const registryItem of registryState?.[extensionPointId] ?? []) { for (const registryItem of registryState?.[extensionPointId] ?? []) {
const { pluginId } = registryItem; const { pluginId } = registryItem;
@ -65,5 +74,5 @@ export function usePluginComponents<Props extends object = {}>({
isLoading: false, isLoading: false,
components, components,
}; };
}, [extensionPointId, limitPerPlugin, pluginContext, registryState]); }, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]);
} }

View File

@ -5,8 +5,11 @@ import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry'; import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry'; import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types'; import { PluginExtensionRegistries } from './registry/types';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { createUsePluginExtensions } from './usePluginExtensions'; import { createUsePluginExtensions } from './usePluginExtensions';
jest.mock('./useLoadAppPlugins');
describe('usePluginExtensions()', () => { describe('usePluginExtensions()', () => {
let registries: PluginExtensionRegistries; let registries: PluginExtensionRegistries;
const pluginId = 'myorg-extensions-app'; const pluginId = 'myorg-extensions-app';
@ -18,6 +21,7 @@ describe('usePluginExtensions()', () => {
addedLinksRegistry: new AddedLinksRegistry(), addedLinksRegistry: new AddedLinksRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(), exposedComponentsRegistry: new ExposedComponentsRegistry(),
}; };
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
}); });
it('should return an empty array if there are no extensions registered for the extension point', () => { it('should return an empty array if there are no extensions registered for the extension point', () => {

View File

@ -8,7 +8,8 @@ import * as errors from './errors';
import { getPluginExtensions } from './getPluginExtensions'; import { getPluginExtensions } from './getPluginExtensions';
import { log } from './logs/log'; import { log } from './logs/log';
import { PluginExtensionRegistries } from './registry/types'; import { PluginExtensionRegistries } from './registry/types';
import { isGrafanaDevMode } from './utils'; import { useLoadAppPlugins } from './useLoadAppPlugins';
import { getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators'; import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
export function createUsePluginExtensions(registries: PluginExtensionRegistries) { export function createUsePluginExtensions(registries: PluginExtensionRegistries) {
@ -20,8 +21,9 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries)
const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry); const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry);
const addedLinksRegistry = useObservable(observableAddedLinksRegistry); const addedLinksRegistry = useObservable(observableAddedLinksRegistry);
const { extensionPointId, context, limitPerPlugin } = options; const { extensionPointId, context, limitPerPlugin } = options;
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
const { extensions } = useMemo(() => { return useMemo(() => {
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana. // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
const enableRestrictions = isGrafanaDevMode() && pluginContext !== null; const enableRestrictions = isGrafanaDevMode() && pluginContext !== null;
const pluginId = pluginContext?.meta.id ?? ''; const pluginId = pluginContext?.meta.id ?? '';
@ -50,19 +52,35 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries)
}; };
} }
return getPluginExtensions({ if (isLoadingAppPlugins) {
return {
isLoading: true,
extensions: [],
};
}
const { extensions } = getPluginExtensions({
extensionPointId, extensionPointId,
context, context,
limitPerPlugin, limitPerPlugin,
addedComponentsRegistry, addedComponentsRegistry,
addedLinksRegistry, addedLinksRegistry,
}); });
return { extensions, isLoading: false };
// Doing the deps like this instead of just `option` because users probably aren't going to memoize the // Doing the deps like this instead of just `option` because users probably aren't going to memoize the
// options object so we are checking it's simple value attributes. // options object so we are checking it's simple value attributes.
// The context though still has to be memoized though and not mutated. // The context though still has to be memoized though and not mutated.
// eslint-disable-next-line react-hooks/exhaustive-deps -- TODO: refactor `getPluginExtensions` to accept service dependencies as arguments instead of relying on the sidecar singleton under the hood // eslint-disable-next-line react-hooks/exhaustive-deps -- TODO: refactor `getPluginExtensions` to accept service dependencies as arguments instead of relying on the sidecar singleton under the hood
}, [addedLinksRegistry, addedComponentsRegistry, extensionPointId, context, limitPerPlugin, pluginContext]); }, [
addedLinksRegistry,
return { extensions, isLoading: false }; addedComponentsRegistry,
extensionPointId,
context,
limitPerPlugin,
pluginContext,
isLoadingAppPlugins,
]);
}; };
} }

View File

@ -6,11 +6,15 @@ import { PluginContextProvider, PluginMeta, PluginType } from '@grafana/data';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext'; import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log'; import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils'; import { resetLogMock } from './logs/testUtils';
import { setupPluginExtensionRegistries } from './registry/setup'; import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types'; import { PluginExtensionRegistries } from './registry/types';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { usePluginLinks } from './usePluginLinks'; import { usePluginLinks } from './usePluginLinks';
import { isGrafanaDevMode } from './utils'; import { isGrafanaDevMode } from './utils';
jest.mock('./useLoadAppPlugins');
jest.mock('app/features/plugins/pluginSettings', () => ({ jest.mock('app/features/plugins/pluginSettings', () => ({
getPluginSettings: jest.fn().mockResolvedValue({ getPluginSettings: jest.fn().mockResolvedValue({
id: 'my-app-plugin', id: 'my-app-plugin',
@ -48,8 +52,13 @@ describe('usePluginLinks()', () => {
const extensionPointId = `${pluginId}/extension-point/v1`; const extensionPointId = `${pluginId}/extension-point/v1`;
beforeEach(() => { beforeEach(() => {
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
jest.mocked(isGrafanaDevMode).mockReturnValue(false); jest.mocked(isGrafanaDevMode).mockReturnValue(false);
registries = setupPluginExtensionRegistries(); registries = {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
};
resetLogMock(log); resetLogMock(log);
pluginMeta = { pluginMeta = {

View File

@ -11,8 +11,10 @@ import {
import { useAddedLinksRegistry } from './ExtensionRegistriesContext'; import { useAddedLinksRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors'; import * as errors from './errors';
import { log } from './logs/log'; import { log } from './logs/log';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { import {
generateExtensionId, generateExtensionId,
getExtensionPointPluginDependencies,
getLinkExtensionOnClick, getLinkExtensionOnClick,
getLinkExtensionOverrides, getLinkExtensionOverrides,
getLinkExtensionPathWithTracking, getLinkExtensionPathWithTracking,
@ -30,6 +32,7 @@ export function usePluginLinks({
const registry = useAddedLinksRegistry(); const registry = useAddedLinksRegistry();
const pluginContext = usePluginContext(); const pluginContext = usePluginContext();
const registryState = useObservable(registry.asObservable()); const registryState = useObservable(registry.asObservable());
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
return useMemo(() => { return useMemo(() => {
// For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana. // For backwards compatibility we don't enable restrictions in production or when the hook is used in core Grafana.
@ -56,6 +59,13 @@ export function usePluginLinks({
}; };
} }
if (isLoadingAppPlugins) {
return {
isLoading: true,
links: [],
};
}
if (!registryState || !registryState[extensionPointId]) { if (!registryState || !registryState[extensionPointId]) {
return { return {
isLoading: false, isLoading: false,
@ -117,5 +127,5 @@ export function usePluginLinks({
isLoading: false, isLoading: false,
links: extensions, links: extensions,
}; };
}, [context, extensionPointId, limitPerPlugin, registryState, pluginContext]); }, [context, extensionPointId, limitPerPlugin, registryState, pluginContext, isLoadingAppPlugins]);
} }

View File

@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { type Unsubscribable } from 'rxjs'; import { type Unsubscribable } from 'rxjs';
import { dateTime, usePluginContext } from '@grafana/data'; import { dateTime, usePluginContext, PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { ShowModalReactEvent } from 'app/types/events'; import { ShowModalReactEvent } from 'app/types/events';
@ -12,6 +13,10 @@ import {
getReadOnlyProxy, getReadOnlyProxy,
createOpenModalFunction, createOpenModalFunction,
wrapWithPluginContext, wrapWithPluginContext,
getExtensionPointPluginDependencies,
getExposedComponentPluginDependencies,
getAppPluginConfigs,
getAppPluginIdFromExposedComponentId,
} from './utils'; } from './utils';
jest.mock('app/features/plugins/pluginSettings', () => ({ jest.mock('app/features/plugins/pluginSettings', () => ({
@ -447,4 +452,431 @@ describe('Plugin Extensions / Utils', () => {
expect(screen.getByText('Version: 1.0.0')).toBeVisible(); expect(screen.getByText('Version: 1.0.0')).toBeVisible();
}); });
}); });
describe('getAppPluginConfigs()', () => {
const originalApps = config.apps;
const genereicAppPluginConfig = {
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
},
};
afterEach(() => {
config.apps = originalApps;
});
test('should return the app plugin configs based on the provided plugin ids', () => {
config.apps = {
'myorg-first-app': {
...genereicAppPluginConfig,
id: 'myorg-first-app',
},
'myorg-second-app': {
...genereicAppPluginConfig,
id: 'myorg-second-app',
},
'myorg-third-app': {
...genereicAppPluginConfig,
id: 'myorg-third-app',
},
};
expect(getAppPluginConfigs(['myorg-first-app', 'myorg-third-app'])).toEqual([
config.apps['myorg-first-app'],
config.apps['myorg-third-app'],
]);
});
test('should simply ignore the app plugin ids that do not belong to a config', () => {
config.apps = {
'myorg-first-app': {
...genereicAppPluginConfig,
id: 'myorg-first-app',
},
'myorg-second-app': {
...genereicAppPluginConfig,
id: 'myorg-second-app',
},
'myorg-third-app': {
...genereicAppPluginConfig,
id: 'myorg-third-app',
},
};
expect(getAppPluginConfigs(['myorg-first-app', 'unknown-app-id'])).toEqual([config.apps['myorg-first-app']]);
});
});
describe('getAppPluginIdFromExposedComponentId()', () => {
test('should return the app plugin id from an extension point id', () => {
expect(getAppPluginIdFromExposedComponentId('myorg-extensions-app/component/v1')).toBe('myorg-extensions-app');
});
});
describe('getExtensionPointPluginDependencies()', () => {
const originalApps = config.apps;
const genereicAppPluginConfig = {
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
},
};
afterEach(() => {
config.apps = originalApps;
});
test('should return the app plugin ids that register extensions to a link extension point', () => {
const extensionPointId = 'myorg-first-app/link/v1';
config.apps = {
'myorg-first-app': {
...genereicAppPluginConfig,
id: 'myorg-first-app',
},
// This plugin is registering a link extension to the extension point
'myorg-second-app': {
...genereicAppPluginConfig,
id: 'myorg-second-app',
extensions: {
addedLinks: [
{
targets: [extensionPointId],
title: 'Link title',
},
],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
},
},
'myorg-third-app': {
...genereicAppPluginConfig,
id: 'myorg-third-app',
},
};
const appPluginIds = getExtensionPointPluginDependencies(extensionPointId);
expect(appPluginIds).toEqual(['myorg-second-app']);
});
test('should return the app plugin ids that register extensions to a component extension point', () => {
const extensionPointId = 'myorg-first-app/component/v1';
config.apps = {
'myorg-first-app': {
...genereicAppPluginConfig,
id: 'myorg-first-app',
},
'myorg-second-app': {
...genereicAppPluginConfig,
id: 'myorg-second-app',
},
// This plugin is registering a component extension to the extension point
'myorg-third-app': {
...genereicAppPluginConfig,
id: 'myorg-third-app',
extensions: {
addedLinks: [],
addedComponents: [
{
targets: [extensionPointId],
title: 'Component title',
},
],
exposedComponents: [],
extensionPoints: [],
},
},
};
const appPluginIds = getExtensionPointPluginDependencies(extensionPointId);
expect(appPluginIds).toEqual(['myorg-third-app']);
});
test('should return an empty array if there are no apps that that extend the extension point', () => {
const extensionPointId = 'myorg-first-app/component/v1';
// None of the apps are extending the extension point
config.apps = {
'myorg-first-app': {
...genereicAppPluginConfig,
id: 'myorg-first-app',
},
'myorg-second-app': {
...genereicAppPluginConfig,
id: 'myorg-second-app',
},
'myorg-third-app': {
...genereicAppPluginConfig,
id: 'myorg-third-app',
},
};
const appPluginIds = getExtensionPointPluginDependencies(extensionPointId);
expect(appPluginIds).toEqual([]);
});
test('should also return (recursively) the app plugin ids that the apps which extend the extension-point depend on', () => {
const extensionPointId = 'myorg-first-app/component/v1';
config.apps = {
'myorg-first-app': {
...genereicAppPluginConfig,
id: 'myorg-first-app',
},
// This plugin is registering a component extension to the extension point.
// It is also depending on the 'myorg-fourth-app' plugin.
'myorg-second-app': {
...genereicAppPluginConfig,
id: 'myorg-second-app',
extensions: {
addedLinks: [],
addedComponents: [
{
targets: [extensionPointId],
title: 'Component title',
},
],
exposedComponents: [],
extensionPoints: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
extensions: {
exposedComponents: ['myorg-fourth-app/component/v1'],
},
},
},
'myorg-third-app': {
...genereicAppPluginConfig,
id: 'myorg-third-app',
},
// This plugin exposes a component, but is also depending on the 'myorg-fifth-app'.
'myorg-fourth-app': {
...genereicAppPluginConfig,
id: 'myorg-fourth-app',
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [
{
id: 'myorg-fourth-app/component/v1',
title: 'Exposed component',
},
],
extensionPoints: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
extensions: {
exposedComponents: ['myorg-fifth-app/component/v1'],
},
},
},
'myorg-fifth-app': {
...genereicAppPluginConfig,
id: 'myorg-fifth-app',
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [
{
id: 'myorg-fifth-app/component/v1',
title: 'Exposed component',
},
],
extensionPoints: [],
},
},
'myorg-sixth-app': {
...genereicAppPluginConfig,
id: 'myorg-sixth-app',
},
};
const appPluginIds = getExtensionPointPluginDependencies(extensionPointId);
expect(appPluginIds).toEqual(['myorg-second-app', 'myorg-fourth-app', 'myorg-fifth-app']);
});
});
describe('getExposedComponentPluginDependencies()', () => {
const originalApps = config.apps;
const genereicAppPluginConfig = {
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
},
};
afterEach(() => {
config.apps = originalApps;
});
test('should only return the app plugin id that exposes the component, if that component does not depend on anything', () => {
const exposedComponentId = 'myorg-second-app/component/v1';
config.apps = {
'myorg-first-app': {
...genereicAppPluginConfig,
id: 'myorg-first-app',
},
'myorg-second-app': {
...genereicAppPluginConfig,
id: 'myorg-second-app',
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [
{
id: exposedComponentId,
title: 'Component title',
},
],
extensionPoints: [],
},
},
'myorg-third-app': {
...genereicAppPluginConfig,
id: 'myorg-third-app',
},
};
const appPluginIds = getExposedComponentPluginDependencies(exposedComponentId);
expect(appPluginIds).toEqual(['myorg-second-app']);
});
test('should also return the list of app plugin ids that the plugin - which exposes the component - is depending on', () => {
const exposedComponentId = 'myorg-second-app/component/v1';
config.apps = {
'myorg-first-app': {
...genereicAppPluginConfig,
id: 'myorg-first-app',
},
'myorg-second-app': {
...genereicAppPluginConfig,
id: 'myorg-second-app',
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [
{
id: exposedComponentId,
title: 'Component title',
},
],
extensionPoints: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
extensions: {
exposedComponents: ['myorg-fourth-app/component/v1'],
},
},
},
'myorg-third-app': {
...genereicAppPluginConfig,
id: 'myorg-third-app',
},
'myorg-fourth-app': {
...genereicAppPluginConfig,
id: 'myorg-fourth-app',
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [
{
id: 'myorg-fourth-app/component/v1',
title: 'Component title',
},
],
extensionPoints: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
extensions: {
exposedComponents: ['myorg-fifth-app/component/v1'],
},
},
},
'myorg-fifth-app': {
...genereicAppPluginConfig,
id: 'myorg-fifth-app',
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [
{
id: 'myorg-fifth-app/component/v1',
title: 'Component title',
},
],
extensionPoints: [],
},
},
};
const appPluginIds = getExposedComponentPluginDependencies(exposedComponentId);
expect(appPluginIds).toEqual(['myorg-second-app', 'myorg-fourth-app', 'myorg-fifth-app']);
});
});
}); });

View File

@ -16,8 +16,9 @@ import {
PanelMenuItem, PanelMenuItem,
PluginExtensionAddedLinkConfig, PluginExtensionAddedLinkConfig,
urlUtil, urlUtil,
PluginExtensionPoints,
} from '@grafana/data'; } from '@grafana/data';
import { reportInteraction, config } from '@grafana/runtime'; import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime';
import { Modal } from '@grafana/ui'; import { Modal } from '@grafana/ui';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { getPluginSettings } from 'app/features/plugins/pluginSettings'; import { getPluginSettings } from 'app/features/plugins/pluginSettings';
@ -421,3 +422,75 @@ export function getLinkExtensionPathWithTracking(pluginId: string, path: string,
// Comes from the `app_mode` setting in the Grafana config (defaults to "development") // Comes from the `app_mode` setting in the Grafana config (defaults to "development")
// Can be set with the `GF_DEFAULT_APP_MODE` environment variable // Can be set with the `GF_DEFAULT_APP_MODE` environment variable
export const isGrafanaDevMode = () => config.buildInfo.env === 'development'; export const isGrafanaDevMode = () => config.buildInfo.env === 'development';
export const getAppPluginConfigs = (pluginIds: string[] = []) =>
Object.values(config.apps).filter((app) => pluginIds.includes(app.id));
export const getAppPluginIdFromExposedComponentId = (exposedComponentId: string) => {
return exposedComponentId.split('/')[0];
};
// Returns a list of app plugin ids that are registering extensions to this extension point.
// (These plugins are necessary to be loaded to use the extension point.)
// (The function also returns the plugin ids that the plugins - that extend the extension point - depend on.)
export const getExtensionPointPluginDependencies = (extensionPointId: string): string[] => {
return Object.values(config.apps)
.filter(
(app) =>
app.extensions.addedLinks.some((link) => link.targets.includes(extensionPointId)) ||
app.extensions.addedComponents.some((component) => component.targets.includes(extensionPointId))
)
.map((app) => app.id)
.reduce((acc: string[], id: string) => {
return [...acc, id, ...getAppPluginDependencies(id)];
}, []);
};
// Returns a list of app plugin ids that are necessary to be loaded to use the exposed component.
// (It is first the plugin that exposes the component, and then the ones that it depends on.)
export const getExposedComponentPluginDependencies = (exposedComponentId: string) => {
const pluginId = getAppPluginIdFromExposedComponentId(exposedComponentId);
return [pluginId].reduce((acc: string[], pluginId: string) => {
return [...acc, pluginId, ...getAppPluginDependencies(pluginId)];
}, []);
};
// Returns a list of app plugin ids that are necessary to be loaded, based on the `dependencies.extensions`
// metadata field. (For example the plugins that expose components that the app depends on.)
// Heads up! This is a recursive function.
export const getAppPluginDependencies = (pluginId: string): string[] => {
if (!config.apps[pluginId]) {
return [];
}
const pluginIdDependencies = config.apps[pluginId].dependencies.extensions.exposedComponents.map(
getAppPluginIdFromExposedComponentId
);
return pluginIdDependencies.reduce((acc, pluginId) => {
return [...acc, ...getAppPluginDependencies(pluginId)];
}, pluginIdDependencies);
};
// Returns a list of app plugins that has to be loaded before core Grafana could finish the initialization.
export const getAppPluginsToAwait = () => {
const pluginIds = [
// The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init.
'cloud-home-app',
];
return Object.values(config.apps).filter((app) => pluginIds.includes(app.id));
};
// Returns a list of app plugins that has to be preloaded in parallel with the core Grafana initialization.
export const getAppPluginsToPreload = () => {
// The DashboardPanelMenu extension point is using the `getPluginExtensions()` API in scenes at the moment, which means that it cannot yet benefit from dynamic plugin loading.
const dashboardPanelMenuPluginIds = getExtensionPointPluginDependencies(PluginExtensionPoints.DashboardPanelMenu);
const awaitedPluginIds = getAppPluginsToAwait().map((app) => app.id);
const isNotAwaited = (app: AppPluginConfig) => !awaitedPluginIds.includes(app.id);
return Object.values(config.apps).filter((app) => {
return isNotAwaited(app) && (app.preload || dashboardPanelMenuPluginIds.includes(app.id));
});
};

View File

@ -1,11 +1,9 @@
import type { PluginExtensionAddedLinkConfig, PluginExtensionExposedComponentConfig } from '@grafana/data'; import type { PluginExtensionAddedLinkConfig, PluginExtensionExposedComponentConfig } from '@grafana/data';
import { PluginExtensionAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions'; import { PluginExtensionAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions';
import type { AppPluginConfig } from '@grafana/runtime'; import type { AppPluginConfig } from '@grafana/runtime';
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import { getPluginSettings } from 'app/features/plugins/pluginSettings'; import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { PluginExtensionRegistries } from './extensions/registry/types'; import { importAppPlugin } from './plugin_loader';
import { importPluginModule } from './plugin_loader';
export type PluginPreloadResult = { export type PluginPreloadResult = {
pluginId: string; pluginId: string;
@ -15,67 +13,28 @@ export type PluginPreloadResult = {
addedLinkConfigs?: PluginExtensionAddedLinkConfig[]; addedLinkConfigs?: PluginExtensionAddedLinkConfig[];
}; };
export async function preloadPlugins( const preloadedAppPlugins = new Set<string>();
apps: AppPluginConfig[] = [], const isNotYetPreloaded = ({ id }: AppPluginConfig) => !preloadedAppPlugins.has(id);
registries: PluginExtensionRegistries, const markAsPreloaded = (apps: AppPluginConfig[]) => apps.forEach(({ id }) => preloadedAppPlugins.add(id));
eventName = 'frontend_plugins_preload'
) {
startMeasure(eventName);
const promises = apps.filter((config) => config.preload).map((config) => preload(config));
const preloadedPlugins = await Promise.all(promises);
for (const preloadedPlugin of preloadedPlugins) { export async function preloadPlugins(apps: AppPluginConfig[] = []) {
if (preloadedPlugin.error) { const appPluginsToPreload = apps.filter(isNotYetPreloaded);
console.error(`[Plugins] Skip loading extensions for "${preloadedPlugin.pluginId}" due to an error.`);
continue;
}
registries.exposedComponentsRegistry.register({ if (appPluginsToPreload.length === 0) {
pluginId: preloadedPlugin.pluginId, return;
configs: preloadedPlugin.exposedComponentConfigs,
});
registries.addedComponentsRegistry.register({
pluginId: preloadedPlugin.pluginId,
configs: preloadedPlugin.addedComponentConfigs || [],
});
registries.addedLinksRegistry.register({
pluginId: preloadedPlugin.pluginId,
configs: preloadedPlugin.addedLinkConfigs || [],
});
} }
stopMeasure(eventName); markAsPreloaded(apps);
await Promise.all(appPluginsToPreload.map(preload));
} }
async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> { async function preload(config: AppPluginConfig) {
const { path, version, id: pluginId, loadingStrategy } = config;
try { try {
startMeasure(`frontend_plugin_preload_${pluginId}`); const meta = await getPluginSettings(config.id);
const { plugin } = await importPluginModule({
path,
version,
isAngular: config.angular.detected,
pluginId,
loadingStrategy,
moduleHash: config.moduleHash,
});
const { exposedComponentConfigs = [], addedComponentConfigs = [], addedLinkConfigs = [] } = plugin;
// Fetching meta-information for the preloaded app plugin and caching it for later. await importAppPlugin(meta);
// (The function below returns a promise, but it's not awaited for a reason: we don't want to block the preload process, we would only like to cache the result for later.)
getPluginSettings(pluginId);
return { pluginId, exposedComponentConfigs, addedComponentConfigs, addedLinkConfigs };
} catch (error) { } catch (error) {
console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error); console.error(`[Plugins] Failed to preload plugin: ${config.path} (version: ${config.version})`, error);
return {
pluginId,
error,
exposedComponentConfigs: [],
addedComponentConfigs: [],
addedLinkConfigs: [],
};
} finally {
stopMeasure(`frontend_plugin_preload_${pluginId}`);
} }
} }

View File

@ -13,6 +13,7 @@ import { DataQuery } from '@grafana/schema';
import { GenericDataSourcePlugin } from '../datasources/types'; import { GenericDataSourcePlugin } from '../datasources/types';
import builtInPlugins from './built_in_plugins'; import builtInPlugins from './built_in_plugins';
import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from './extensions/registry/setup';
import { getPluginFromCache, registerPluginInCache } from './loader/cache'; import { getPluginFromCache, registerPluginInCache } from './loader/cache';
// SystemJS has to be imported before the sharedDependenciesMap // SystemJS has to be imported before the sharedDependenciesMap
import { SystemJS } from './loader/systemjs'; import { SystemJS } from './loader/systemjs';
@ -69,6 +70,15 @@ systemJSPrototype.resolve = decorateSystemJSResolve.bind(systemJSPrototype, syst
// Any css files loaded via SystemJS have their styles applied onload. // Any css files loaded via SystemJS have their styles applied onload.
systemJSPrototype.onload = decorateSystemJsOnload; systemJSPrototype.onload = decorateSystemJsOnload;
type PluginImportInfo = {
path: string;
pluginId: string;
loadingStrategy: PluginLoadingStrategy;
version?: string;
isAngular?: boolean;
moduleHash?: string;
};
export async function importPluginModule({ export async function importPluginModule({
path, path,
pluginId, pluginId,
@ -76,14 +86,7 @@ export async function importPluginModule({
version, version,
isAngular, isAngular,
moduleHash, moduleHash,
}: { }: PluginImportInfo): Promise<System.Module> {
path: string;
pluginId: string;
loadingStrategy: PluginLoadingStrategy;
version?: string;
isAngular?: boolean;
moduleHash?: string;
}): Promise<System.Module> {
if (version) { if (version) {
registerPluginInCache({ path, version, loadingStrategy }); registerPluginInCache({ path, version, loadingStrategy });
} }
@ -166,21 +169,44 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise<Gene
}); });
} }
export function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> { // Only successfully loaded plugins are cached
const isAngular = meta.angular?.detected ?? meta.angularDetected; const importedAppPlugins: Record<string, AppPlugin> = {};
const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch;
return importPluginModule({ export async function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> {
const pluginId = meta.id;
if (importedAppPlugins[pluginId]) {
return importedAppPlugins[pluginId];
}
const pluginExports = await importPluginModule({
path: meta.module, path: meta.module,
version: meta.info?.version, version: meta.info?.version,
isAngular,
loadingStrategy: fallbackLoadingStrategy,
pluginId: meta.id, pluginId: meta.id,
isAngular: meta.angular?.detected ?? meta.angularDetected,
loadingStrategy: meta.loadingStrategy ?? PluginLoadingStrategy.fetch,
moduleHash: meta.moduleHash, moduleHash: meta.moduleHash,
}).then((pluginExports) => {
const plugin: AppPlugin = pluginExports.plugin ? pluginExports.plugin : new AppPlugin();
plugin.init(meta);
plugin.meta = meta;
plugin.setComponentsFromLegacyExports(pluginExports);
return plugin;
}); });
const { plugin = new AppPlugin() } = pluginExports;
plugin.init(meta);
plugin.meta = meta;
plugin.setComponentsFromLegacyExports(pluginExports);
exposedComponentsRegistry.register({
pluginId,
configs: plugin.exposedComponentConfigs || [],
});
addedComponentsRegistry.register({
pluginId,
configs: plugin.addedComponentConfigs || [],
});
addedLinksRegistry.register({
pluginId,
configs: plugin.addedLinkConfigs || [],
});
importedAppPlugins[pluginId] = plugin;
return plugin;
} }

View File

@ -12,27 +12,25 @@ jest.mock('app/core/core', () => {
import { AppPluginMeta, PluginMetaInfo, PluginType, AppPlugin } from '@grafana/data'; import { AppPluginMeta, PluginMetaInfo, PluginType, AppPlugin } from '@grafana/data';
// Loaded after the `unmock` above // Loaded after the `unmock` above
import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from '../extensions/registry/setup';
import { SystemJS } from '../loader/systemjs'; import { SystemJS } from '../loader/systemjs';
import { importAppPlugin } from '../plugin_loader'; import { importAppPlugin } from '../plugin_loader';
class MyCustomApp extends AppPlugin { jest.mock('../extensions/registry/setup');
initWasCalled = false;
calledTwice = false;
init(meta: AppPluginMeta) {
this.initWasCalled = true;
this.calledTwice = this.meta === meta;
}
}
describe('Load App', () => { describe('Load App', () => {
const app = new MyCustomApp(); const app = new AppPlugin();
const modulePath = 'http://localhost:3000/public/plugins/my-app-plugin/module.js'; const modulePath = 'http://localhost:3000/public/plugins/my-app-plugin/module.js';
// Hook resolver for tests // Hook resolver for tests
const originalResolve = SystemJS.constructor.prototype.resolve; const originalResolve = SystemJS.constructor.prototype.resolve;
SystemJS.constructor.prototype.resolve = (x: unknown) => x; SystemJS.constructor.prototype.resolve = (x: unknown) => x;
beforeAll(() => { beforeAll(() => {
app.init = jest.fn();
addedComponentsRegistry.register = jest.fn();
addedLinksRegistry.register = jest.fn();
exposedComponentsRegistry.register = jest.fn();
SystemJS.set(modulePath, { plugin: app }); SystemJS.set(modulePath, { plugin: app });
}); });
@ -55,14 +53,17 @@ describe('Load App', () => {
const m = await SystemJS.import(modulePath); const m = await SystemJS.import(modulePath);
expect(m.plugin).toBe(app); expect(m.plugin).toBe(app);
const loaded = await importAppPlugin(meta); // Importing the app should initialise the meta
expect(loaded).toBe(app); const importedApp = await importAppPlugin(meta);
expect(importedApp).toBe(app);
expect(app.meta).toBe(meta); expect(app.meta).toBe(meta);
expect(app.initWasCalled).toBeTruthy();
expect(app.calledTwice).toBeFalsy();
const again = await importAppPlugin(meta); // Importing the same app again doesn't initialise it twice
expect(again).toBe(app); const importedAppAgain = await importAppPlugin(meta);
expect(app.calledTwice).toBeTruthy(); expect(importedAppAgain).toBe(app);
expect(app.init).toHaveBeenCalledTimes(1);
expect(addedComponentsRegistry.register).toHaveBeenCalledTimes(1);
expect(addedLinksRegistry.register).toHaveBeenCalledTimes(1);
expect(exposedComponentsRegistry.register).toHaveBeenCalledTimes(1);
}); });
}); });