Plugin Extensions: Support core plugins (#108685)

* feat(extensions): allow core plugins to use core grafana extension points

* fix: don't validate the plugin.json for core plugins
This commit is contained in:
Levente Balogh
2025-08-01 13:42:07 +02:00
committed by GitHub
parent 63093dfb26
commit d96cd46272
7 changed files with 203 additions and 9 deletions
@@ -1,7 +1,13 @@
import { act, render, renderHook, screen } from '@testing-library/react';
import React from 'react';
import { PluginContextProvider, PluginExtensionPoints, PluginMeta, PluginType } from '@grafana/data';
import {
PluginContextProvider,
PluginExtensionPoints,
PluginLoadingStrategy,
PluginMeta,
PluginType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
@@ -105,6 +111,32 @@ describe('usePluginComponents()', () => {
},
};
config.apps[pluginId] = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider meta={pluginMeta}>
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
@@ -459,6 +491,49 @@ describe('usePluginComponents()', () => {
expect(log.error).not.toHaveBeenCalled();
});
// It can happen that core Grafana plugins (e.g. traces) reuse core components which implement extension points.
it('should not validate the extension point meta-info for core plugins', () => {
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const componentConfig = {
targets: extensionPointId,
title: '1',
description: '1',
component: () => <div>Component</div>,
};
// The `AddedComponentsRegistry` is validating if the link is registered in the plugin metadata (config.apps).
config.apps[pluginId].extensions.addedComponents = [componentConfig];
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider
meta={{
...pluginMeta,
// The module tells if it is a core plugin
module: 'core:plugin/traces',
extensions: {
...pluginMeta.extensions!,
// Empty list of extension points in the plugin meta (from plugin.json)
extensionPoints: [],
},
}}
>
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
</PluginContextProvider>
);
registries.addedComponentsRegistry.register({
pluginId,
configs: [componentConfig],
});
// Trying to render an extension point that is not defined in the plugin meta
// (No restrictions due to being a core plugin)
let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
expect(result.current.components.length).toBe(1);
expect(log.error).not.toHaveBeenCalled();
});
it('should not validate the extension point id in production mode', () => {
// Empty list of extension points in the plugin meta (from plugin.json)
wrapper = ({ children }: { children: React.ReactNode }) => (
@@ -29,6 +29,7 @@ export function usePluginComponents<Props extends object = {}>({
return useMemo(() => {
const isInsidePlugin = Boolean(pluginContext);
const isCoreGrafanaPlugin = pluginContext?.meta.module.startsWith('core:') ?? false;
const components: Array<ComponentTypeWithExtensionMeta<Props>> = [];
const extensionsByPlugin: Record<string, number> = {};
const pluginId = pluginContext?.meta.id ?? '';
@@ -38,7 +39,10 @@ export function usePluginComponents<Props extends object = {}>({
});
// Don't show extensions if the extension-point id is invalid in DEV mode
if (isGrafanaDevMode() && !isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, log: pointLog })) {
if (
isGrafanaDevMode() &&
!isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, isCoreGrafanaPlugin, log: pointLog })
) {
return {
isLoading: false,
components: [],
@@ -46,7 +50,12 @@ export function usePluginComponents<Props extends object = {}>({
}
// Don't show extensions if the extension-point misses meta info (plugin.json) in DEV mode
if (isGrafanaDevMode() && pluginContext && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
if (
isGrafanaDevMode() &&
!isCoreGrafanaPlugin &&
pluginContext &&
isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)
) {
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
return {
isLoading: false,
@@ -24,6 +24,7 @@ export function usePluginFunctions<Signature>({
return useMemo(() => {
const isInsidePlugin = Boolean(pluginContext);
const isCoreGrafanaPlugin = pluginContext?.meta.module.startsWith('core:') ?? false;
const results: Array<PluginExtensionFunction<Signature>> = [];
const extensionsByPlugin: Record<string, number> = {};
const pluginId = pluginContext?.meta.id ?? '';
@@ -32,14 +33,22 @@ export function usePluginFunctions<Signature>({
extensionPointId,
});
if (isGrafanaDevMode() && !isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, log: pointLog })) {
if (
isGrafanaDevMode() &&
!isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, isCoreGrafanaPlugin, log: pointLog })
) {
return {
isLoading: false,
functions: [],
};
}
if (isGrafanaDevMode() && pluginContext && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
if (
isGrafanaDevMode() &&
!isCoreGrafanaPlugin &&
pluginContext &&
isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)
) {
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
return {
isLoading: false,
@@ -1,6 +1,13 @@
import { act, renderHook } from '@testing-library/react';
import { PluginContextProvider, PluginExtensionPoints, PluginMeta, PluginType } from '@grafana/data';
import {
PluginContextProvider,
PluginExtensionPoints,
PluginLoadingStrategy,
PluginMeta,
PluginType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
@@ -98,6 +105,32 @@ describe('usePluginLinks()', () => {
},
};
config.apps[pluginId] = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
};
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider meta={pluginMeta}>
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
@@ -219,6 +252,49 @@ describe('usePluginLinks()', () => {
expect(log.warning).not.toHaveBeenCalled();
});
// It can happen that core Grafana plugins (e.g. traces) reuse core components which implement extension points.
it('should not validate the extension point meta-info for core plugins', () => {
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const linkConfig = {
targets: extensionPointId,
title: '1',
description: '1',
path: `/a/${pluginId}/2`,
};
// The `AddedLinksRegistry` is validating if the link is registered in the plugin metadata (config.apps).
config.apps[pluginId].extensions.addedLinks = [linkConfig];
wrapper = ({ children }: { children: React.ReactNode }) => (
<PluginContextProvider
meta={{
...pluginMeta,
// The module tells if it is a core plugin
module: 'core:plugin/traces',
extensions: {
...pluginMeta.extensions!,
// Empty list of extension points in the plugin meta (from plugin.json)
extensionPoints: [],
},
}}
>
<ExtensionRegistriesProvider registries={registries}>{children}</ExtensionRegistriesProvider>
</PluginContextProvider>
);
registries.addedLinksRegistry.register({
pluginId,
configs: [linkConfig],
});
// Trying to render an extension point that is not defined in the plugin meta
// (No restrictions due to being a core plugin)
let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
expect(result.current.links.length).toBe(1);
expect(log.warning).not.toHaveBeenCalled();
});
it('should not validate the extension point id in production mode', () => {
// Empty list of extension points in the plugin meta (from plugin.json)
wrapper = ({ children }: { children: React.ReactNode }) => (
@@ -34,19 +34,28 @@ export function usePluginLinks({
return useMemo(() => {
const isInsidePlugin = Boolean(pluginContext);
const pluginId = pluginContext?.meta.id ?? '';
const isCoreGrafanaPlugin = pluginContext?.meta.module.startsWith('core:') ?? false;
const pointLog = log.child({
pluginId,
extensionPointId,
});
if (isGrafanaDevMode() && !isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, log: pointLog })) {
if (
isGrafanaDevMode() &&
!isExtensionPointIdValid({ extensionPointId, pluginId, isInsidePlugin, isCoreGrafanaPlugin, log: pointLog })
) {
return {
isLoading: false,
links: [],
};
}
if (isGrafanaDevMode() && pluginContext && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
if (
isGrafanaDevMode() &&
!isCoreGrafanaPlugin &&
pluginContext &&
isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)
) {
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
return {
isLoading: false,
@@ -217,6 +217,7 @@ describe('Plugin Extension Validators', () => {
extensionPointId,
pluginId,
isInsidePlugin: pluginId !== 'grafana' && pluginId !== '',
isCoreGrafanaPlugin: false,
log: createLogMock(),
})
).toBe(true);
@@ -244,10 +245,23 @@ describe('Plugin Extension Validators', () => {
extensionPointId,
pluginId,
isInsidePlugin: pluginId !== 'grafana' && pluginId !== '',
isCoreGrafanaPlugin: false,
log: createLogMock(),
})
).toBe(false);
});
it('should return FALSE true if the extension point id is set by a core plugin', () => {
expect(
isExtensionPointIdValid({
extensionPointId: 'traces',
pluginId: 'traces',
isInsidePlugin: true,
isCoreGrafanaPlugin: true,
log: createLogMock(),
})
).toBe(true);
});
});
describe('isAddedLinkMetaInfoMissing()', () => {
@@ -69,17 +69,19 @@ export function isExtensionPointIdValid({
extensionPointId,
pluginId,
isInsidePlugin,
isCoreGrafanaPlugin,
log,
}: {
extensionPointId: string;
pluginId: string;
isInsidePlugin: boolean;
isCoreGrafanaPlugin: boolean;
log: ExtensionsLog;
}) {
const startsWithPluginId =
extensionPointId.startsWith(`${pluginId}/`) || extensionPointId.startsWith(`plugins/${pluginId}/`);
if (isInsidePlugin && !startsWithPluginId) {
if (isInsidePlugin && !isCoreGrafanaPlugin && !startsWithPluginId) {
log.error(errors.INVALID_EXTENSION_POINT_ID_PLUGIN(pluginId, extensionPointId));
return false;
}