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
pull/97195/head
Levente Balogh 6 months ago committed by GitHub
parent a2c407854f
commit cce943b3af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      e2e/test-plugins/grafana-extensionstest-app/README.md
  2. 3
      public/app/AppWrapper.tsx
  3. 25
      public/app/app.ts
  4. 10
      public/app/features/plugins/components/AppRootPage.test.tsx
  5. 26
      public/app/features/plugins/extensions/registry/setup.ts
  6. 19
      public/app/features/plugins/extensions/useLoadAppPlugins.tsx
  7. 13
      public/app/features/plugins/extensions/usePluginComponent.test.tsx
  8. 13
      public/app/features/plugins/extensions/usePluginComponent.tsx
  9. 14
      public/app/features/plugins/extensions/usePluginComponents.test.tsx
  10. 13
      public/app/features/plugins/extensions/usePluginComponents.tsx
  11. 4
      public/app/features/plugins/extensions/usePluginExtensions.test.tsx
  12. 30
      public/app/features/plugins/extensions/usePluginExtensions.tsx
  13. 13
      public/app/features/plugins/extensions/usePluginLinks.test.tsx
  14. 12
      public/app/features/plugins/extensions/usePluginLinks.tsx
  15. 434
      public/app/features/plugins/extensions/utils.test.tsx
  16. 75
      public/app/features/plugins/extensions/utils.tsx
  17. 71
      public/app/features/plugins/pluginPreloader.ts
  18. 66
      public/app/features/plugins/plugin_loader.ts
  19. 35
      public/app/features/plugins/tests/plugin_loader.test.ts

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

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

@ -85,12 +85,12 @@ import { PanelDataErrorView } from './features/panel/components/PanelDataErrorVi
import { PanelRenderer } from './features/panel/components/PanelRenderer';
import { DatasourceSrv } from './features/plugins/datasource_srv';
import { createPluginExtensionsGetter } from './features/plugins/extensions/getPluginExtensions';
import { setupPluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
import { PluginExtensionRegistries } from './features/plugins/extensions/registry/types';
import { pluginExtensionRegistries } from './features/plugins/extensions/registry/setup';
import { usePluginComponent } from './features/plugins/extensions/usePluginComponent';
import { usePluginComponents } from './features/plugins/extensions/usePluginComponents';
import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions';
import { usePluginLinks } from './features/plugins/extensions/usePluginLinks';
import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils';
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
import { preloadPlugins } from './features/plugins/pluginPreloader';
import { QueryRunner } from './features/query/state/QueryRunner';
@ -127,7 +127,6 @@ if (process.env.NODE_ENV === 'development') {
export class GrafanaApp {
context!: GrafanaContextType;
pluginExtensionsRegistries!: PluginExtensionRegistries;
async init() {
try {
@ -217,22 +216,16 @@ export class GrafanaApp {
setDataSourceSrv(dataSourceSrv);
initWindowRuntime();
// Initialize plugin extensions
this.pluginExtensionsRegistries = setupPluginExtensionRegistries();
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.
// TODO: remove the following exception once the issue mentioned above is fixed.
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);
await preloadPlugins(awaitedAppPlugins, this.pluginExtensionsRegistries, 'frontend_awaited_plugins_preload');
const appPluginsToAwait = getAppPluginsToAwait();
const appPluginsToPreload = getAppPluginsToPreload();
preloadPlugins(appPluginsToPreload);
await preloadPlugins(appPluginsToAwait);
}
setPluginExtensionGetter(createPluginExtensionsGetter(this.pluginExtensionsRegistries));
setPluginExtensionsHook(createUsePluginExtensions(this.pluginExtensionsRegistries));
setPluginExtensionGetter(createPluginExtensionsGetter(pluginExtensionRegistries));
setPluginExtensionsHook(createUsePluginExtensions(pluginExtensionRegistries));
setPluginLinksHook(usePluginLinks);
setPluginComponentHook(usePluginComponent);
setPluginComponentsHook(usePluginComponents);

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

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

@ -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 };
}

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

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

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

@ -10,7 +10,8 @@ import {
import { useAddedComponentsRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
import { isGrafanaDevMode } from './utils';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
// 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 registryState = useObservable(registry.asObservable());
const pluginContext = usePluginContext();
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
return useMemo(() => {
// 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] ?? []) {
const { pluginId } = registryItem;
@ -65,5 +74,5 @@ export function usePluginComponents<Props extends object = {}>({
isLoading: false,
components,
};
}, [extensionPointId, limitPerPlugin, pluginContext, registryState]);
}, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]);
}

@ -5,8 +5,11 @@ import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { createUsePluginExtensions } from './usePluginExtensions';
jest.mock('./useLoadAppPlugins');
describe('usePluginExtensions()', () => {
let registries: PluginExtensionRegistries;
const pluginId = 'myorg-extensions-app';
@ -18,6 +21,7 @@ describe('usePluginExtensions()', () => {
addedLinksRegistry: new AddedLinksRegistry(),
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', () => {

@ -8,7 +8,8 @@ import * as errors from './errors';
import { getPluginExtensions } from './getPluginExtensions';
import { log } from './logs/log';
import { PluginExtensionRegistries } from './registry/types';
import { isGrafanaDevMode } from './utils';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
export function createUsePluginExtensions(registries: PluginExtensionRegistries) {
@ -20,8 +21,9 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries)
const addedComponentsRegistry = useObservable(observableAddedComponentsRegistry);
const addedLinksRegistry = useObservable(observableAddedLinksRegistry);
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.
const enableRestrictions = isGrafanaDevMode() && pluginContext !== null;
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,
context,
limitPerPlugin,
addedComponentsRegistry,
addedLinksRegistry,
});
return { extensions, isLoading: false };
// 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.
// 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
}, [addedLinksRegistry, addedComponentsRegistry, extensionPointId, context, limitPerPlugin, pluginContext]);
return { extensions, isLoading: false };
}, [
addedLinksRegistry,
addedComponentsRegistry,
extensionPointId,
context,
limitPerPlugin,
pluginContext,
isLoadingAppPlugins,
]);
};
}

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

@ -11,8 +11,10 @@ import {
import { useAddedLinksRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import {
generateExtensionId,
getExtensionPointPluginDependencies,
getLinkExtensionOnClick,
getLinkExtensionOverrides,
getLinkExtensionPathWithTracking,
@ -30,6 +32,7 @@ export function usePluginLinks({
const registry = useAddedLinksRegistry();
const pluginContext = usePluginContext();
const registryState = useObservable(registry.asObservable());
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId));
return useMemo(() => {
// 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]) {
return {
isLoading: false,
@ -117,5 +127,5 @@ export function usePluginLinks({
isLoading: false,
links: extensions,
};
}, [context, extensionPointId, limitPerPlugin, registryState, pluginContext]);
}, [context, extensionPointId, limitPerPlugin, registryState, pluginContext, isLoadingAppPlugins]);
}

@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react';
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 { ShowModalReactEvent } from 'app/types/events';
@ -12,6 +13,10 @@ import {
getReadOnlyProxy,
createOpenModalFunction,
wrapWithPluginContext,
getExtensionPointPluginDependencies,
getExposedComponentPluginDependencies,
getAppPluginConfigs,
getAppPluginIdFromExposedComponentId,
} from './utils';
jest.mock('app/features/plugins/pluginSettings', () => ({
@ -447,4 +452,431 @@ describe('Plugin Extensions / Utils', () => {
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']);
});
});
});

@ -16,8 +16,9 @@ import {
PanelMenuItem,
PluginExtensionAddedLinkConfig,
urlUtil,
PluginExtensionPoints,
} from '@grafana/data';
import { reportInteraction, config } from '@grafana/runtime';
import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime';
import { Modal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
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")
// Can be set with the `GF_DEFAULT_APP_MODE` environment variable
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));
});
};

@ -1,11 +1,9 @@
import type { PluginExtensionAddedLinkConfig, PluginExtensionExposedComponentConfig } from '@grafana/data';
import { PluginExtensionAddedComponentConfig } from '@grafana/data/src/types/pluginExtensions';
import type { AppPluginConfig } from '@grafana/runtime';
import { startMeasure, stopMeasure } from 'app/core/utils/metrics';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { PluginExtensionRegistries } from './extensions/registry/types';
import { importPluginModule } from './plugin_loader';
import { importAppPlugin } from './plugin_loader';
export type PluginPreloadResult = {
pluginId: string;
@ -15,67 +13,28 @@ export type PluginPreloadResult = {
addedLinkConfigs?: PluginExtensionAddedLinkConfig[];
};
export async function preloadPlugins(
apps: AppPluginConfig[] = [],
registries: PluginExtensionRegistries,
eventName = 'frontend_plugins_preload'
) {
startMeasure(eventName);
const promises = apps.filter((config) => config.preload).map((config) => preload(config));
const preloadedPlugins = await Promise.all(promises);
const preloadedAppPlugins = new Set<string>();
const isNotYetPreloaded = ({ id }: AppPluginConfig) => !preloadedAppPlugins.has(id);
const markAsPreloaded = (apps: AppPluginConfig[]) => apps.forEach(({ id }) => preloadedAppPlugins.add(id));
for (const preloadedPlugin of preloadedPlugins) {
if (preloadedPlugin.error) {
console.error(`[Plugins] Skip loading extensions for "${preloadedPlugin.pluginId}" due to an error.`);
continue;
}
export async function preloadPlugins(apps: AppPluginConfig[] = []) {
const appPluginsToPreload = apps.filter(isNotYetPreloaded);
registries.exposedComponentsRegistry.register({
pluginId: preloadedPlugin.pluginId,
configs: preloadedPlugin.exposedComponentConfigs,
});
registries.addedComponentsRegistry.register({
pluginId: preloadedPlugin.pluginId,
configs: preloadedPlugin.addedComponentConfigs || [],
});
registries.addedLinksRegistry.register({
pluginId: preloadedPlugin.pluginId,
configs: preloadedPlugin.addedLinkConfigs || [],
});
if (appPluginsToPreload.length === 0) {
return;
}
stopMeasure(eventName);
markAsPreloaded(apps);
await Promise.all(appPluginsToPreload.map(preload));
}
async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {
const { path, version, id: pluginId, loadingStrategy } = config;
async function preload(config: AppPluginConfig) {
try {
startMeasure(`frontend_plugin_preload_${pluginId}`);
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.
// (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);
const meta = await getPluginSettings(config.id);
return { pluginId, exposedComponentConfigs, addedComponentConfigs, addedLinkConfigs };
await importAppPlugin(meta);
} catch (error) {
console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error);
return {
pluginId,
error,
exposedComponentConfigs: [],
addedComponentConfigs: [],
addedLinkConfigs: [],
};
} finally {
stopMeasure(`frontend_plugin_preload_${pluginId}`);
console.error(`[Plugins] Failed to preload plugin: ${config.path} (version: ${config.version})`, error);
}
}

@ -13,6 +13,7 @@ import { DataQuery } from '@grafana/schema';
import { GenericDataSourcePlugin } from '../datasources/types';
import builtInPlugins from './built_in_plugins';
import { addedComponentsRegistry, addedLinksRegistry, exposedComponentsRegistry } from './extensions/registry/setup';
import { getPluginFromCache, registerPluginInCache } from './loader/cache';
// SystemJS has to be imported before the sharedDependenciesMap
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.
systemJSPrototype.onload = decorateSystemJsOnload;
type PluginImportInfo = {
path: string;
pluginId: string;
loadingStrategy: PluginLoadingStrategy;
version?: string;
isAngular?: boolean;
moduleHash?: string;
};
export async function importPluginModule({
path,
pluginId,
@ -76,14 +86,7 @@ export async function importPluginModule({
version,
isAngular,
moduleHash,
}: {
path: string;
pluginId: string;
loadingStrategy: PluginLoadingStrategy;
version?: string;
isAngular?: boolean;
moduleHash?: string;
}): Promise<System.Module> {
}: PluginImportInfo): Promise<System.Module> {
if (version) {
registerPluginInCache({ path, version, loadingStrategy });
}
@ -166,21 +169,44 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise<Gene
});
}
export function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> {
const isAngular = meta.angular?.detected ?? meta.angularDetected;
const fallbackLoadingStrategy = meta.loadingStrategy ?? PluginLoadingStrategy.fetch;
return importPluginModule({
// Only successfully loaded plugins are cached
const importedAppPlugins: Record<string, AppPlugin> = {};
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,
version: meta.info?.version,
isAngular,
loadingStrategy: fallbackLoadingStrategy,
pluginId: meta.id,
isAngular: meta.angular?.detected ?? meta.angularDetected,
loadingStrategy: meta.loadingStrategy ?? PluginLoadingStrategy.fetch,
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;
}

@ -12,27 +12,25 @@ jest.mock('app/core/core', () => {
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 '../plugin_loader';
class MyCustomApp extends AppPlugin {
initWasCalled = false;
calledTwice = false;
init(meta: AppPluginMeta) {
this.initWasCalled = true;
this.calledTwice = this.meta === meta;
}
}
jest.mock('../extensions/registry/setup');
describe('Load App', () => {
const app = new MyCustomApp();
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 });
});
@ -55,14 +53,17 @@ describe('Load App', () => {
const m = await SystemJS.import(modulePath);
expect(m.plugin).toBe(app);
const loaded = await importAppPlugin(meta);
expect(loaded).toBe(app);
// Importing the app should initialise the meta
const importedApp = await importAppPlugin(meta);
expect(importedApp).toBe(app);
expect(app.meta).toBe(meta);
expect(app.initWasCalled).toBeTruthy();
expect(app.calledTwice).toBeFalsy();
const again = await importAppPlugin(meta);
expect(again).toBe(app);
expect(app.calledTwice).toBeTruthy();
// 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);
});
});

Loading…
Cancel
Save