PluginExtensions: Added support for sharing functions (#98888)

* feat: add generic plugin extension functions

* updated betterer.

* Fixed type issues after sync with main.

* Remved extensions from datasource and panel.

* Added validation for extension function registry.

* Added tests and validation logic for function extensions registry.

* removed prop already existing on base.

* fixed lint error.

---------

Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
This commit is contained in:
Dominik Süß 2025-02-13 10:18:55 +01:00 committed by GitHub
parent fbf96916aa
commit 8a8e47fcea
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1182 additions and 69 deletions

View File

@ -491,6 +491,9 @@ exports[`better eslint`] = {
"packages/grafana-runtime/src/services/pluginExtensions/usePluginExtensions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-runtime/src/services/pluginExtensions/usePluginFunctions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"packages/grafana-runtime/src/utils/DataSourceWithBackend.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
@ -5602,6 +5605,9 @@ exports[`better eslint`] = {
[0, 0, 0, "\'@grafana/runtime/src/services/pluginExtensions/getPluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/plugins/extensions/usePluginFunctions.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/plugins/extensions/usePluginLinks.tsx:5381": [
[0, 0, 0, "\'@grafana/runtime/src/services/pluginExtensions/getPluginExtensions\' import is restricted from being used by a pattern. Import from the public export instead.", "0"]
],

View File

@ -549,6 +549,7 @@ export {
type PluginExtensionLink,
type PluginExtensionComponent,
type PluginExtensionConfig,
type PluginExtensionFunction,
type PluginExtensionLinkConfig,
type PluginExtensionComponentConfig,
type PluginExtensionEventHelpers,
@ -559,6 +560,7 @@ export {
type PluginExtensionExposedComponentConfig,
type PluginExtensionAddedComponentConfig,
type PluginExtensionAddedLinkConfig,
type PluginExtensionAddedFunctionConfig,
} from './types/pluginExtensions';
export {
type ScopeDashboardBindingSpec,

View File

@ -9,6 +9,7 @@ import {
PluginExtensionExposedComponentConfig,
PluginExtensionAddedComponentConfig,
PluginExtensionAddedLinkConfig,
PluginExtensionAddedFunctionConfig,
} from './pluginExtensions';
/**
@ -60,6 +61,7 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
private _exposedComponentConfigs: PluginExtensionExposedComponentConfig[] = [];
private _addedComponentConfigs: PluginExtensionAddedComponentConfig[] = [];
private _addedLinkConfigs: PluginExtensionAddedLinkConfig[] = [];
private _addedFunctionConfigs: PluginExtensionAddedFunctionConfig[] = [];
// Content under: /a/${plugin-id}/*
root?: ComponentType<AppRootProps<T>>;
@ -113,6 +115,10 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
return this._addedLinkConfigs;
}
get addedFunctionConfigs() {
return this._addedFunctionConfigs;
}
addLink<Context extends object>(linkConfig: PluginExtensionAddedLinkConfig<Context>) {
this._addedLinkConfigs.push(linkConfig as PluginExtensionAddedLinkConfig);
@ -125,6 +131,12 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
return this;
}
addFunction<Signature>(addedFunctionConfig: PluginExtensionAddedFunctionConfig<Signature>) {
this._addedFunctionConfigs.push(addedFunctionConfig);
return this;
}
exposeComponent<Props = {}>(componentConfig: PluginExtensionExposedComponentConfig<Props>) {
this._exposedComponentConfigs.push(componentConfig as PluginExtensionExposedComponentConfig);

View File

@ -130,6 +130,8 @@ export interface PluginExtensions {
// The component extensions that the plugin registers
addedComponents: ExtensionInfo[];
addedFunctions: ExtensionInfo[];
// The link extensions that the plugin registers
addedLinks: ExtensionInfo[];

View File

@ -14,6 +14,7 @@ import { RawTimeRange, TimeZone } from './time';
export enum PluginExtensionTypes {
link = 'link',
component = 'component',
function = 'function',
}
type PluginExtensionBase = {
@ -36,7 +37,12 @@ export type PluginExtensionComponent<Props = {}> = PluginExtensionBase & {
component: React.ComponentType<Props>;
};
export type PluginExtension = PluginExtensionLink | PluginExtensionComponent;
export type PluginExtensionFunction<Signature = () => void> = PluginExtensionBase & {
type: PluginExtensionTypes.function;
fn: Signature;
};
export type PluginExtension = PluginExtensionLink | PluginExtensionComponent | PluginExtensionFunction;
// Objects used for registering extensions (in app plugins)
// --------------------------------------------------------
@ -74,6 +80,17 @@ export type PluginExtensionAddedComponentConfig<Props = {}> = PluginExtensionCon
*/
component: React.ComponentType<Props>;
};
export type PluginExtensionAddedFunctionConfig<Signature = unknown> = PluginExtensionConfigBase & {
/**
* The target extension points where the component will be added
*/
targets: string | string[];
/**
* The function to be executed
*/
fn: Signature;
};
export type PluginAddedLinksConfigureFunc<Context extends object> = (context: Readonly<Context> | undefined) =>
| Partial<{

View File

@ -22,6 +22,8 @@ export {
type UsePluginExtensions,
type UsePluginExtensionsResult,
type UsePluginComponentResult,
type UsePluginFunctionsOptions,
type UsePluginFunctionsResult,
} from './pluginExtensions/getPluginExtensions';
export {
setPluginExtensionsHook,
@ -33,6 +35,7 @@ export {
export { setPluginComponentHook, usePluginComponent } from './pluginExtensions/usePluginComponent';
export { setPluginComponentsHook, usePluginComponents } from './pluginExtensions/usePluginComponents';
export { setPluginLinksHook, usePluginLinks } from './pluginExtensions/usePluginLinks';
export { setPluginFunctionsHook, usePluginFunctions } from './pluginExtensions/usePluginFunctions';
export { isPluginExtensionLink, isPluginExtensionComponent } from './pluginExtensions/utils';
export { setCurrentUser } from './user';

View File

@ -1,4 +1,9 @@
import type { PluginExtension, PluginExtensionLink, PluginExtensionComponent } from '@grafana/data';
import type {
PluginExtension,
PluginExtensionLink,
PluginExtensionComponent,
PluginExtensionFunction,
} from '@grafana/data';
import { isPluginExtensionComponent, isPluginExtensionLink } from './utils';
@ -52,6 +57,16 @@ export type UsePluginLinksResult = {
links: PluginExtensionLink[];
};
export type UsePluginFunctionsOptions = {
extensionPointId: string;
limitPerPlugin?: number;
};
export type UsePluginFunctionsResult<Signature> = {
isLoading: boolean;
functions: Array<PluginExtensionFunction<Signature>>;
};
let singleton: GetPluginExtensions | undefined;
export function setPluginExtensionGetter(instance: GetPluginExtensions): void {

View File

@ -0,0 +1,20 @@
import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from './getPluginExtensions';
export type UsePluginFunctions<T> = (options: UsePluginFunctionsOptions) => UsePluginFunctionsResult<T>;
let singleton: UsePluginFunctions<unknown> | undefined;
export function setPluginFunctionsHook(hook: UsePluginFunctions<unknown>): void {
// We allow overriding the registry in tests
if (singleton && process.env.NODE_ENV !== 'test') {
throw new Error('setUsePluginFunctionsHook() function should only be called once, when Grafana is starting.');
}
singleton = hook;
}
export function usePluginFunctions<T>(options: UsePluginFunctionsOptions): UsePluginFunctionsResult<T> {
if (!singleton) {
throw new Error('usePluginFunctions(options) can only be used after the Grafana instance has started.');
}
return singleton(options) as UsePluginFunctionsResult<T>;
}

View File

@ -57,6 +57,7 @@ func TestFinder_Find(t *testing.T) {
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -96,8 +97,10 @@ func TestFinder_Find(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -127,8 +130,10 @@ func TestFinder_Find(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -200,8 +205,10 @@ func TestFinder_Find(t *testing.T) {
{Name: "Nginx Datasource", Type: "datasource", Role: "Viewer", Action: "plugins.app:access"},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -238,8 +245,10 @@ func TestFinder_Find(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -269,8 +278,10 @@ func TestFinder_Find(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -300,8 +311,10 @@ func TestFinder_Find(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -340,8 +353,10 @@ func TestFinder_Find(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},

View File

@ -106,6 +106,7 @@ func TestLoader_Load(t *testing.T) {
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -201,8 +202,10 @@ func TestLoader_Load(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -249,8 +252,10 @@ func TestLoader_Load(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -304,8 +309,10 @@ func TestLoader_Load(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -398,8 +405,10 @@ func TestLoader_Load(t *testing.T) {
{Name: "Root Page (react)", Type: "page", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},

View File

@ -63,6 +63,7 @@ type ExtensionsV2 struct {
AddedComponents []AddedComponent `json:"addedComponents"`
ExposedComponents []ExposedComponent `json:"exposedComponents"`
ExtensionPoints []ExtensionPoint `json:"extensionPoints"`
AddedFunctions []AddedFunction `json:"addedFunctions"`
}
type Extensions ExtensionsV2
@ -76,6 +77,7 @@ func (e *Extensions) UnmarshalJSON(data []byte) error {
e.AddedLinks = extensionsV2.AddedLinks
e.ExposedComponents = extensionsV2.ExposedComponents
e.ExtensionPoints = extensionsV2.ExtensionPoints
e.AddedFunctions = extensionsV2.AddedFunctions
return nil
}
@ -123,6 +125,11 @@ type AddedComponent struct {
Description string `json:"description"`
}
type AddedFunction struct {
Targets []string `json:"targets"`
Title string `json:"title"`
}
type ExposedComponent struct {
Id string `json:"id"`
Title string `json:"title"`
@ -267,6 +274,7 @@ type PluginMetaDTO struct {
Angular AngularMeta `json:"angular"`
MultiValueFilterOperators bool `json:"multiValueFilterOperators"`
LoadingStrategy LoadingStrategy `json:"loadingStrategy"`
Extensions Extensions `json:"extensions"`
}
type DataSourceDTO struct {

View File

@ -167,6 +167,10 @@ func ReadPluginJSON(reader io.Reader) (JSONData, error) {
plugin.Extensions.AddedComponents = []AddedComponent{}
}
if plugin.Extensions.AddedFunctions == nil {
plugin.Extensions.AddedFunctions = []AddedFunction{}
}
if plugin.Extensions.ExposedComponents == nil {
plugin.Extensions.ExposedComponents = []ExposedComponent{}
}

View File

@ -56,6 +56,7 @@ func Test_ReadPluginJSON(t *testing.T) {
Extensions: Extensions{
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
AddedFunctions: []AddedFunction{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
@ -108,8 +109,10 @@ func Test_ReadPluginJSON(t *testing.T) {
Name: "Pie Chart (old)",
Extensions: Extensions{
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
AddedFunctions: []AddedFunction{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
@ -143,8 +146,10 @@ func Test_ReadPluginJSON(t *testing.T) {
Type: TypeDataSource,
Extensions: Extensions{
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
AddedFunctions: []AddedFunction{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
@ -188,6 +193,9 @@ func Test_ReadPluginJSON(t *testing.T) {
"id": "myorg-extensions-app/component-1/v1"
}
],
"addedFunctions": [
{"targets": ["foo/bar"], "title":"some hook"}
],
"extensionPoints": [
{
"title": "Extension point 1",
@ -209,6 +217,7 @@ func Test_ReadPluginJSON(t *testing.T) {
{Title: "Added link 1", Description: "Added link 1 description", Targets: []string{"grafana/dashboard/panel/menu"}},
},
AddedComponents: []AddedComponent{
{Title: "Added component 1", Description: "Added component 1 description", Targets: []string{"grafana/user/profile/tab"}},
},
ExposedComponents: []ExposedComponent{
@ -217,6 +226,9 @@ func Test_ReadPluginJSON(t *testing.T) {
ExtensionPoints: []ExtensionPoint{
{Id: "myorg-extensions-app/extensions-point-1/v1", Title: "Extension point 1", Description: "Extension points 1 description"},
},
AddedFunctions: []AddedFunction{
{Targets: []string{"foo/bar"}, Title: "some hook"},
},
},
Dependencies: Dependencies{
@ -271,6 +283,7 @@ func Test_ReadPluginJSON(t *testing.T) {
AddedComponents: []AddedComponent{
{Title: "Added component 1", Description: "Added component 1 description", Targets: []string{"grafana/user/profile/tab"}},
},
AddedFunctions: []AddedFunction{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
@ -301,8 +314,10 @@ func Test_ReadPluginJSON(t *testing.T) {
Type: TypeApp,
Extensions: Extensions{
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
AddedFunctions: []AddedFunction{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
@ -332,8 +347,10 @@ func Test_ReadPluginJSON(t *testing.T) {
Type: TypeApp,
Extensions: Extensions{
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
AddedFunctions: []AddedFunction{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},
@ -371,6 +388,7 @@ func Test_ReadPluginJSON(t *testing.T) {
Extensions: Extensions{
AddedLinks: []AddedLink{},
AddedComponents: []AddedComponent{},
AddedFunctions: []AddedFunction{},
ExposedComponents: []ExposedComponent{},
ExtensionPoints: []ExtensionPoint{},
},

View File

@ -105,6 +105,7 @@ func TestLoader_Load(t *testing.T) {
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -200,8 +201,10 @@ func TestLoader_Load(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -248,8 +251,10 @@ func TestLoader_Load(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -309,8 +314,10 @@ func TestLoader_Load(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -423,8 +430,10 @@ func TestLoader_Load(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -504,8 +513,10 @@ func TestLoader_Load_ExternalRegistration(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -615,8 +626,10 @@ func TestLoader_Load_CustomSource(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -696,8 +709,10 @@ func TestLoader_Load_MultiplePlugins(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -801,8 +816,10 @@ func TestLoader_Load_RBACReady(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -883,8 +900,10 @@ func TestLoader_Load_Signature_RootURL(t *testing.T) {
ExposedComponents: []string{},
}},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -964,8 +983,10 @@ func TestLoader_Load_DuplicatePlugins(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -1060,8 +1081,10 @@ func TestLoader_Load_SkipUninitializedPlugins(t *testing.T) {
{Name: "Nginx Datasource", Type: "datasource", Role: org.RoleViewer, Action: plugins.ActionAppAccess, Slug: "nginx-datasource"},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -1272,8 +1295,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -1314,8 +1339,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -1463,8 +1490,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},
@ -1512,8 +1541,10 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
},
Extensions: plugins.Extensions{
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedLinks: []plugins.AddedLink{},
AddedComponents: []plugins.AddedComponent{},
AddedFunctions: []plugins.AddedFunction{},
ExposedComponents: []plugins.ExposedComponent{},
ExtensionPoints: []plugins.ExtensionPoint{},
},

View File

@ -40,6 +40,7 @@ import {
setChromeHeaderHeightHook,
setPluginLinksHook,
setCorrelationsService,
setPluginFunctionsHook,
} from '@grafana/runtime';
import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView';
import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer';
@ -89,6 +90,7 @@ import { pluginExtensionRegistries } from './features/plugins/extensions/registr
import { usePluginComponent } from './features/plugins/extensions/usePluginComponent';
import { usePluginComponents } from './features/plugins/extensions/usePluginComponents';
import { createUsePluginExtensions } from './features/plugins/extensions/usePluginExtensions';
import { usePluginFunctions } from './features/plugins/extensions/usePluginFunctions';
import { usePluginLinks } from './features/plugins/extensions/usePluginLinks';
import { getAppPluginsToAwait, getAppPluginsToPreload } from './features/plugins/extensions/utils';
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
@ -229,6 +231,7 @@ export class GrafanaApp {
setPluginLinksHook(usePluginLinks);
setPluginComponentHook(usePluginComponent);
setPluginComponentsHook(usePluginComponents);
setPluginFunctionsHook(usePluginFunctions);
// initialize chrome service
const queryParams = locationService.getSearchObject();

View File

@ -24,6 +24,7 @@ export const getPluginsHandler = (pluginsArray: PluginMeta[] = plugins) => {
addedComponents: [],
extensionPoints: [],
exposedComponents: [],
addedFunctions: [],
},
dependencies: {
grafanaVersion: '',

View File

@ -163,6 +163,7 @@ export function pluginMetaToPluginConfig(pluginMeta: PluginMeta): AppPluginConfi
addedComponents: [],
extensionPoints: [],
exposedComponents: [],
addedFunctions: [],
},
};
}

View File

@ -55,6 +55,7 @@ describe('getRuleOrigin', () => {
addedComponents: [],
extensionPoints: [],
exposedComponents: [],
addedFunctions: [],
},
dependencies: {
grafanaVersion: '',

View File

@ -12,6 +12,7 @@ import { Echo } from 'app/core/services/echo/Echo';
import { ExtensionRegistriesProvider } from '../extensions/ExtensionRegistriesContext';
import { AddedComponentsRegistry } from '../extensions/registry/AddedComponentsRegistry';
import { AddedFunctionsRegistry } from '../extensions/registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from '../extensions/registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from '../extensions/registry/ExposedComponentsRegistry';
import { getPluginSettings } from '../pluginSettings';
@ -93,6 +94,7 @@ function renderUnderRouter(page = '') {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
const pagePath = page ? `/${page}` : '';
const route = {

View File

@ -29,6 +29,7 @@ import {
useAddedLinksRegistry,
useAddedComponentsRegistry,
useExposedComponentsRegistry,
useAddedFunctionsRegistry,
} from '../extensions/ExtensionRegistriesContext';
import { getPluginSettings } from '../pluginSettings';
import { importAppPlugin } from '../plugin_loader';
@ -60,6 +61,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
const addedLinksRegistry = useAddedLinksRegistry();
const addedComponentsRegistry = useAddedComponentsRegistry();
const exposedComponentsRegistry = useExposedComponentsRegistry();
const addedFunctionsRegistry = useAddedFunctionsRegistry();
const location = useLocation();
const [state, dispatch] = useReducer(stateSlice.reducer, initialState);
const currentUrl = config.appSubUrl + location.pathname + location.search;
@ -104,6 +106,7 @@ export function AppRootPage({ pluginId, pluginNavSection }: Props) {
addedLinksRegistry: addedLinksRegistry.readOnly(),
addedComponentsRegistry: addedComponentsRegistry.readOnly(),
exposedComponentsRegistry: exposedComponentsRegistry.readOnly(),
addedFunctionsRegistry: addedFunctionsRegistry.readOnly(),
}}
>
<plugin.root

View File

@ -1,6 +1,7 @@
import { PropsWithChildren, createContext, useContext } from 'react';
import { AddedComponentsRegistry } from 'app/features/plugins/extensions/registry/AddedComponentsRegistry';
import { AddedFunctionsRegistry } from 'app/features/plugins/extensions/registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from 'app/features/plugins/extensions/registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from 'app/features/plugins/extensions/registry/ExposedComponentsRegistry';
@ -13,6 +14,7 @@ export interface ExtensionRegistriesContextType {
// Using a different context for each registry to avoid unnecessary re-renders
export const AddedLinksRegistryContext = createContext<AddedLinksRegistry | undefined>(undefined);
export const AddedComponentsRegistryContext = createContext<AddedComponentsRegistry | undefined>(undefined);
export const AddedFunctionsRegistryContext = createContext<AddedFunctionsRegistry | undefined>(undefined);
export const ExposedComponentsRegistryContext = createContext<ExposedComponentsRegistry | undefined>(undefined);
export function useAddedLinksRegistry(): AddedLinksRegistry {
@ -31,6 +33,14 @@ export function useAddedComponentsRegistry(): AddedComponentsRegistry {
return context;
}
export function useAddedFunctionsRegistry(): AddedFunctionsRegistry {
const context = useContext(AddedFunctionsRegistryContext);
if (!context) {
throw new Error('No `AddedFunctionsRegistry` found.');
}
return context;
}
export function useExposedComponentsRegistry(): ExposedComponentsRegistry {
const context = useContext(ExposedComponentsRegistryContext);
if (!context) {
@ -46,9 +56,11 @@ export const ExtensionRegistriesProvider = ({
return (
<AddedLinksRegistryContext.Provider value={registries.addedLinksRegistry}>
<AddedComponentsRegistryContext.Provider value={registries.addedComponentsRegistry}>
<ExposedComponentsRegistryContext.Provider value={registries.exposedComponentsRegistry}>
{children}
</ExposedComponentsRegistryContext.Provider>
<AddedFunctionsRegistryContext.Provider value={registries.addedFunctionsRegistry}>
<ExposedComponentsRegistryContext.Provider value={registries.exposedComponentsRegistry}>
{children}
</ExposedComponentsRegistryContext.Provider>
</AddedFunctionsRegistryContext.Provider>
</AddedComponentsRegistryContext.Provider>
</AddedLinksRegistryContext.Provider>
);

View File

@ -8,6 +8,8 @@ export const TITLE_MISSING = 'Title is missing.';
export const DESCRIPTION_MISSING = 'Description is missing.';
export const INVALID_EXTENSION_FUNCTION = 'The "fn" argument is invalid, it should be a function.';
export const INVALID_CONFIGURE_FUNCTION = 'The "configure" function is invalid. It should be a function.';
export const INVALID_PATH_OR_ON_CLICK = 'Either "path" or "onClick" is required.';
@ -33,6 +35,9 @@ export const TITLE_NOT_MATCHING_META_INFO = 'The "title" doesn\'t match the titl
export const ADDED_LINK_META_INFO_MISSING =
'The extension was not recorded in the plugin.json. Added link extensions must be listed in the section "extensions.addedLinks[]". Currently, this is only required in development but will be enforced also in production builds in the future.';
export const ADDED_FUNCTION_META_INFO_MISSING =
'The extension was not recorded in the plugin.json. Added function extensions must be listed in the section "extensions.addedFunction[]". Currently, this is only required in development but will be enforced also in production builds in the future.';
export const DESCRIPTION_NOT_MATCHING_META_INFO =
'The "description" doesn\'t match the description recorded in plugin.json.';

View File

@ -1,8 +1,8 @@
import { isString } from 'lodash';
import {
type PluginExtension,
PluginExtensionTypes,
type PluginExtension,
type PluginExtensionLink,
type PluginExtensionComponent,
} from '@grafana/data';

View File

@ -52,6 +52,7 @@ describe('AddedComponentsRegistry', () => {
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},

View File

@ -0,0 +1,677 @@
import { firstValueFrom } from 'rxjs';
import { PluginLoadingStrategy } from '@grafana/data';
import { config } from '@grafana/runtime';
import { log } from '../logs/log';
import { resetLogMock } from '../logs/testUtils';
import { isGrafanaDevMode } from '../utils';
import { AddedFunctionsRegistry } from './AddedFunctionsRegistry';
import { MSG_CANNOT_REGISTER_READ_ONLY } from './Registry';
jest.mock('../utils', () => ({
...jest.requireActual('../utils'),
// Manually set the dev mode to false
// (to make sure that by default we are testing a production scneario)
isGrafanaDevMode: jest.fn().mockReturnValue(false),
}));
jest.mock('../logs/log', () => {
const { createLogMock } = jest.requireActual('../logs/testUtils');
const original = jest.requireActual('../logs/log');
return {
...original,
log: createLogMock(),
};
});
describe('addedFunctionsRegistry', () => {
const originalApps = config.apps;
const pluginId = 'grafana-basic-app';
const appPluginConfig = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedFunctions: [],
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
},
};
beforeEach(() => {
resetLogMock(log);
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return empty registry when no extensions registered', async () => {
const addedFunctionsRegistry = new AddedFunctionsRegistry();
const observable = addedFunctionsRegistry.asObservable();
const registry = await firstValueFrom(observable);
expect(registry).toEqual({});
});
it('should be possible to register function extensions in the registry', async () => {
const addedFunctionsRegistry = new AddedFunctionsRegistry();
addedFunctionsRegistry.register({
pluginId,
configs: [
{
title: 'Function 1',
description: 'Function 1 description',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn(),
},
{
title: 'Function 2',
description: 'Function 2 description',
targets: 'plugins/myorg-basic-app/start',
fn: jest.fn(),
},
],
});
const registry = await addedFunctionsRegistry.getState();
expect(registry).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
title: 'Function 1',
description: 'Function 1 description',
extensionPointId: 'grafana/dashboard/panel/menu',
fn: expect.any(Function),
},
],
'plugins/myorg-basic-app/start': [
{
pluginId: pluginId,
title: 'Function 2',
description: 'Function 2 description',
extensionPointId: 'plugins/myorg-basic-app/start',
fn: expect.any(Function),
},
],
});
});
it('should be possible to asynchronously register function extensions for the same placement (different plugins)', async () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'grafana-basic-app2';
const reactiveRegistry = new AddedFunctionsRegistry();
// Register extensions for the first plugin
reactiveRegistry.register({
pluginId: pluginId1,
configs: [
{
title: 'Function 1',
description: 'Function 1 description',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn().mockReturnValue({}),
},
],
});
const registry1 = await reactiveRegistry.getState();
expect(registry1).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId1,
title: 'Function 1',
description: 'Function 1 description',
extensionPointId: 'grafana/dashboard/panel/menu',
fn: expect.any(Function),
},
],
});
// Register extensions for the second plugin to a different placement
reactiveRegistry.register({
pluginId: pluginId2,
configs: [
{
title: 'Function 2',
description: 'Function 2 description',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn().mockReturnValue({}),
},
],
});
const registry2 = await reactiveRegistry.getState();
expect(registry2).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId1,
title: 'Function 1',
description: 'Function 1 description',
extensionPointId: 'grafana/dashboard/panel/menu',
fn: expect.any(Function),
},
{
pluginId: pluginId2,
title: 'Function 2',
description: 'Function 2 description',
extensionPointId: 'grafana/dashboard/panel/menu',
fn: expect.any(Function),
},
],
});
});
it('should be possible to asynchronously register function extensions for a different placement (different plugin)', async () => {
const pluginId1 = 'grafana-basic-app';
const pluginId2 = 'grafana-basic-app2';
const reactiveRegistry = new AddedFunctionsRegistry();
// Register extensions for the first plugin
reactiveRegistry.register({
pluginId: pluginId1,
configs: [
{
title: 'Function 1',
description: 'Function 1 description',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn().mockReturnValue({}),
},
],
});
const registry1 = await reactiveRegistry.getState();
expect(registry1).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId1,
title: 'Function 1',
description: 'Function 1 description',
extensionPointId: 'grafana/dashboard/panel/menu',
fn: expect.any(Function),
},
],
});
// Register extensions for the second plugin to a different placement
reactiveRegistry.register({
pluginId: pluginId2,
configs: [
{
title: 'Function 2',
description: 'Function 2 description',
targets: 'plugins/myorg-basic-app/start',
fn: jest.fn().mockReturnValue({}),
},
],
});
const registry2 = await reactiveRegistry.getState();
expect(registry2).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId1,
title: 'Function 1',
description: 'Function 1 description',
extensionPointId: 'grafana/dashboard/panel/menu',
fn: expect.any(Function),
},
],
'plugins/myorg-basic-app/start': [
{
pluginId: pluginId2,
title: 'Function 2',
description: 'Function 2 description',
extensionPointId: 'plugins/myorg-basic-app/start',
fn: expect.any(Function),
},
],
});
});
it('should be possible to asynchronously register function extensions for the same placement (same plugin)', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedFunctionsRegistry();
// Register extensions for the first extension point
reactiveRegistry.register({
pluginId: pluginId,
configs: [
{
title: 'Function 1',
description: 'Function 1 description',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn().mockReturnValue({}),
},
],
});
// Register extensions to a different extension point
reactiveRegistry.register({
pluginId: pluginId,
configs: [
{
title: 'Function 2',
description: 'Function 2 description',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn().mockReturnValue({}),
},
],
});
const registry2 = await reactiveRegistry.getState();
expect(registry2).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
title: 'Function 1',
description: 'Function 1 description',
extensionPointId: 'grafana/dashboard/panel/menu',
fn: expect.any(Function),
},
{
pluginId: pluginId,
title: 'Function 2',
description: 'Function 2 description',
extensionPointId: 'grafana/dashboard/panel/menu',
fn: expect.any(Function),
},
],
});
});
it('should be possible to asynchronously register function extensions for a different placement (same plugin)', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedFunctionsRegistry();
// Register extensions for the first extension point
reactiveRegistry.register({
pluginId: pluginId,
configs: [
{
title: 'Function 1',
description: 'Function 1 description',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn().mockReturnValue({}),
},
],
});
// Register extensions to a different extension point
reactiveRegistry.register({
pluginId: pluginId,
configs: [
{
title: 'Function 2',
description: 'Function 2 description',
targets: 'plugins/myorg-basic-app/start',
fn: jest.fn().mockReturnValue({}),
},
],
});
const registry2 = await reactiveRegistry.getState();
expect(registry2).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
title: 'Function 1',
description: 'Function 1 description',
extensionPointId: 'grafana/dashboard/panel/menu',
fn: expect.any(Function),
},
],
'plugins/myorg-basic-app/start': [
{
pluginId: pluginId,
title: 'Function 2',
description: 'Function 2 description',
extensionPointId: 'plugins/myorg-basic-app/start',
fn: expect.any(Function),
},
],
});
});
it('should notify subscribers when the registry changes', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedFunctionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
observable.subscribe(subscribeCallback);
// Register extensions for the first plugin
reactiveRegistry.register({
pluginId: pluginId,
configs: [
{
title: 'Function 1',
description: 'Function 1 description',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn().mockReturnValue({}),
},
],
});
expect(subscribeCallback).toHaveBeenCalledTimes(2);
// Register extensions for the first plugin
reactiveRegistry.register({
pluginId: 'another-plugin',
configs: [
{
title: 'Function 1',
description: 'Function 1 description',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn().mockReturnValue({}),
},
],
});
expect(subscribeCallback).toHaveBeenCalledTimes(3);
const registry = subscribeCallback.mock.calls[2][0];
expect(registry).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
title: 'Function 1',
description: 'Function 1 description',
extensionPointId: 'grafana/dashboard/panel/menu',
fn: expect.any(Function),
},
{
pluginId: 'another-plugin',
title: 'Function 1',
description: 'Function 1 description',
extensionPointId: 'grafana/dashboard/panel/menu',
fn: expect.any(Function),
},
],
});
});
it('should give the last version of the registry for new subscribers', async () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedFunctionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
reactiveRegistry.register({
pluginId: pluginId,
configs: [
{
title: 'Function 1',
description: 'Function 1 description',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn().mockReturnValue({}),
},
],
});
observable.subscribe(subscribeCallback);
expect(subscribeCallback).toHaveBeenCalledTimes(1);
const registry = subscribeCallback.mock.calls[0][0];
expect(registry).toEqual({
'grafana/dashboard/panel/menu': [
{
pluginId: pluginId,
title: 'Function 1',
description: 'Function 1 description',
extensionPointId: 'grafana/dashboard/panel/menu',
fn: expect.any(Function),
},
],
});
});
it('should not register a function extension if it has an invalid fn function', () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedFunctionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
reactiveRegistry.register({
pluginId: pluginId,
configs: [
{
title: 'Function 1',
description: 'Function 1 description',
targets: 'grafana/dashboard/panel/menu',
//@ts-ignore
fn: '...',
},
],
});
expect(log.error).toHaveBeenCalled();
observable.subscribe(subscribeCallback);
expect(subscribeCallback).toHaveBeenCalledTimes(1);
const registry = subscribeCallback.mock.calls[0][0];
expect(registry).toEqual({});
});
it('should not register a function extension if it has invalid properties (empty title)', () => {
const pluginId = 'grafana-basic-app';
const reactiveRegistry = new AddedFunctionsRegistry();
const observable = reactiveRegistry.asObservable();
const subscribeCallback = jest.fn();
reactiveRegistry.register({
pluginId: pluginId,
configs: [
{
title: '',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn().mockReturnValue({}),
},
],
});
expect(log.error).toHaveBeenCalled();
observable.subscribe(subscribeCallback);
expect(subscribeCallback).toHaveBeenCalledTimes(1);
const registry = subscribeCallback.mock.calls[0][0];
expect(registry).toEqual({});
});
it('should not be possible to register a function on a read-only registry', async () => {
const pluginId = 'grafana-basic-app';
const registry = new AddedFunctionsRegistry();
const readOnlyRegistry = registry.readOnly();
expect(() => {
readOnlyRegistry.register({
pluginId,
configs: [
{
title: 'Function 2',
description: 'Function 2 description',
targets: 'plugins/myorg-basic-app/start',
fn: jest.fn().mockReturnValue({}),
},
],
});
}).toThrow(MSG_CANNOT_REGISTER_READ_ONLY);
const currentState = await readOnlyRegistry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
});
it('should pass down fresh registrations to the read-only version of the registry', async () => {
const pluginId = 'grafana-basic-app';
const registry = new AddedFunctionsRegistry();
const readOnlyRegistry = registry.readOnly();
const subscribeCallback = jest.fn();
let readOnlyState;
// Should have no extensions registered in the beginning
readOnlyState = await readOnlyRegistry.getState();
expect(Object.keys(readOnlyState)).toHaveLength(0);
readOnlyRegistry.asObservable().subscribe(subscribeCallback);
// Register an extension to the original (writable) registry
registry.register({
pluginId,
configs: [
{
title: 'Function 2',
description: 'Function 2 description',
targets: 'plugins/myorg-basic-app/start',
fn: jest.fn().mockReturnValue({}),
},
],
});
// The read-only registry should have received the new extension
readOnlyState = await readOnlyRegistry.getState();
expect(Object.keys(readOnlyState)).toHaveLength(1);
expect(subscribeCallback).toHaveBeenCalledTimes(2);
expect(Object.keys(subscribeCallback.mock.calls[1][0])).toEqual(['plugins/myorg-basic-app/start']);
});
it('should not register a function added by a plugin in dev-mode if the meta-info is missing from the plugin.json', async () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedFunctionsRegistry();
const fnConfig = {
title: 'Function 1',
description: 'Function 1 description',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn().mockReturnValue({}),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedFunctions = [];
registry.register({
pluginId,
configs: [fnConfig],
});
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
expect(log.error).toHaveBeenCalled();
});
it('should register a function added by core Grafana in dev-mode even if the meta-info is missing', async () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedFunctionsRegistry();
const fnConfig = {
title: 'Function 1',
description: 'Function 1 description',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn().mockReturnValue({}),
};
registry.register({
pluginId: 'grafana',
configs: [fnConfig],
});
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.error).not.toHaveBeenCalled();
});
it('should register a function added by a plugin in production mode even if the meta-info is missing', async () => {
// Production mode
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
const registry = new AddedFunctionsRegistry();
const fnConfig = {
title: 'Function 1',
description: 'Function 1 description',
targets: 'grafana/dashboard/panel/menu',
fn: jest.fn().mockReturnValue({}),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedFunctions = [];
registry.register({
pluginId,
configs: [fnConfig],
});
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.error).not.toHaveBeenCalled();
});
it('should register a function added by a plugin in dev-mode if the meta-info is present', async () => {
// Enabling dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
const registry = new AddedFunctionsRegistry();
const fnConfig = {
title: 'Function 1',
description: 'Function 1 description',
targets: ['grafana/dashboard/panel/menu'],
fn: jest.fn().mockReturnValue({}),
};
// Make sure that the meta-info is empty
config.apps[pluginId].extensions.addedFunctions = [fnConfig];
registry.register({
pluginId,
configs: [fnConfig],
});
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.error).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,87 @@
import { isFunction } from 'lodash';
import { ReplaySubject } from 'rxjs';
import { PluginExtensionAddedFunctionConfig } from '@grafana/data';
import * as errors from '../errors';
import { isGrafanaDevMode } from '../utils';
import { isAddedFunctionMetaInfoMissing } from '../validators';
import { PluginExtensionConfigs, Registry, RegistryType } from './Registry';
const logPrefix = 'Could not register function extension. Reason:';
export type AddedFunctionsRegistryItem = {
pluginId: string;
title: string;
fn: unknown;
description?: string;
};
export class AddedFunctionsRegistry extends Registry<AddedFunctionsRegistryItem[], PluginExtensionAddedFunctionConfig> {
constructor(
options: {
registrySubject?: ReplaySubject<RegistryType<AddedFunctionsRegistryItem[]>>;
initialState?: RegistryType<AddedFunctionsRegistryItem[]>;
} = {}
) {
super(options);
}
mapToRegistry(
registry: RegistryType<AddedFunctionsRegistryItem[]>,
item: PluginExtensionConfigs<PluginExtensionAddedFunctionConfig>
): RegistryType<AddedFunctionsRegistryItem[]> {
const { pluginId, configs } = item;
for (const config of configs) {
const configLog = this.logger.child({
title: config.title,
pluginId,
});
if (!config.title) {
configLog.error(`${logPrefix} ${errors.TITLE_MISSING}`);
continue;
}
if (!isFunction(config.fn)) {
configLog.error(`${logPrefix} ${errors.INVALID_EXTENSION_FUNCTION}`);
continue;
}
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedFunctionMetaInfoMissing(pluginId, config, configLog)) {
continue;
}
const extensionPointIds = Array.isArray(config.targets) ? config.targets : [config.targets];
for (const extensionPointId of extensionPointIds) {
const pointIdLog = configLog.child({ extensionPointId });
const result = {
pluginId,
fn: config.fn,
description: config.description,
title: config.title,
extensionPointId,
};
pointIdLog.debug('Added function extension successfully registered');
if (!(extensionPointId in registry)) {
registry[extensionPointId] = [result];
} else {
registry[extensionPointId].push(result);
}
}
}
return registry;
}
// Returns a read-only version of the registry.
readOnly() {
return new AddedFunctionsRegistry({
registrySubject: this.registrySubject,
});
}
}

View File

@ -51,6 +51,7 @@ describe('AddedLinksRegistry', () => {
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},

View File

@ -52,6 +52,7 @@ describe('ExposedComponentsRegistry', () => {
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},

View File

@ -1,6 +1,7 @@
import { getCoreExtensionConfigurations } from '../getCoreExtensionConfigurations';
import { AddedComponentsRegistry } from './AddedComponentsRegistry';
import { AddedFunctionsRegistry } from './AddedFunctionsRegistry';
import { AddedLinksRegistry } from './AddedLinksRegistry';
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './types';
@ -8,10 +9,12 @@ import { PluginExtensionRegistries } from './types';
export const addedComponentsRegistry = new AddedComponentsRegistry();
export const exposedComponentsRegistry = new ExposedComponentsRegistry();
export const addedLinksRegistry = new AddedLinksRegistry();
export const addedFunctionsRegistry = new AddedFunctionsRegistry();
export const pluginExtensionRegistries: PluginExtensionRegistries = {
addedComponentsRegistry,
exposedComponentsRegistry,
addedLinksRegistry,
addedFunctionsRegistry,
};
// Registering core extensions

View File

@ -1,9 +1,11 @@
import { AddedComponentsRegistry } from './AddedComponentsRegistry';
import { AddedFunctionsRegistry } from './AddedFunctionsRegistry';
import { AddedLinksRegistry } from './AddedLinksRegistry';
import { ExposedComponentsRegistry } from './ExposedComponentsRegistry';
export type PluginExtensionRegistries = {
addedComponentsRegistry: AddedComponentsRegistry;
exposedComponentsRegistry: ExposedComponentsRegistry;
addedFunctionsRegistry: AddedFunctionsRegistry;
addedLinksRegistry: AddedLinksRegistry;
};

View File

@ -7,6 +7,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
@ -78,6 +79,7 @@ describe('usePluginComponent()', () => {
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
// This is necessary, so we can register exposed components to the registry during the tests
// (Otherwise the registry would reject it in the imitated production mode)
exposedComponents: [exposedComponentConfig],
@ -90,6 +92,7 @@ describe('usePluginComponent()', () => {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
jest.mocked(isGrafanaDevMode).mockReturnValue(false);
@ -122,6 +125,7 @@ describe('usePluginComponent()', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
dependencies: {
grafanaVersion: '8.0.0',

View File

@ -6,6 +6,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
@ -60,6 +61,7 @@ describe('usePluginComponents()', () => {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
jest.mocked(wrapWithPluginContext).mockClear();
@ -89,6 +91,7 @@ describe('usePluginComponents()', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
dependencies: {
grafanaVersion: '8.0.0',

View File

@ -1,6 +1,7 @@
import { act, renderHook } from '@testing-library/react';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
@ -19,6 +20,7 @@ describe('usePluginExtensions()', () => {
addedComponentsRegistry: new AddedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
jest.mocked(useLoadAppPlugins).mockReturnValue({ isLoading: false });
});

View File

@ -0,0 +1,82 @@
import { useMemo } from 'react';
import { useObservable } from 'react-use';
import { usePluginContext, PluginExtensionFunction, PluginExtensionTypes } from '@grafana/data';
import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from '@grafana/runtime';
import { useAddedFunctionsRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
import { useLoadAppPlugins } from './useLoadAppPlugins';
import { generateExtensionId, getExtensionPointPluginDependencies, isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
// Returns an array of component extensions for the given extension point
export function usePluginFunctions<Signature>({
limitPerPlugin,
extensionPointId,
}: UsePluginFunctionsOptions): UsePluginFunctionsResult<Signature> {
const registry = useAddedFunctionsRegistry();
const registryState = useObservable(registry.asObservable());
const pluginContext = usePluginContext();
const deps = getExtensionPointPluginDependencies(extensionPointId);
const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps);
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;
const results: Array<PluginExtensionFunction<Signature>> = [];
const extensionsByPlugin: Record<string, number> = {};
const pluginId = pluginContext?.meta.id ?? '';
const pointLog = log.child({
pluginId,
extensionPointId,
});
if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) {
pointLog.error(errors.INVALID_EXTENSION_POINT_ID);
}
if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
return {
isLoading: false,
functions: [],
};
}
if (isLoadingAppPlugins) {
return {
isLoading: true,
functions: [],
};
}
for (const registryItem of registryState?.[extensionPointId] ?? []) {
const { pluginId } = registryItem;
// Only limit if the `limitPerPlugin` is set
if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) {
continue;
}
if (extensionsByPlugin[pluginId] === undefined) {
extensionsByPlugin[pluginId] = 0;
}
results.push({
id: generateExtensionId(pluginId, extensionPointId, registryItem.title),
type: PluginExtensionTypes.function,
title: registryItem.title,
description: registryItem.description ?? '',
pluginId: pluginId,
fn: registryItem.fn as Signature,
});
extensionsByPlugin[pluginId] += 1;
}
return {
isLoading: false,
functions: results,
};
}, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]);
}

View File

@ -6,6 +6,7 @@ import { ExtensionRegistriesProvider } from './ExtensionRegistriesContext';
import { log } from './logs/log';
import { resetLogMock } from './logs/testUtils';
import { AddedComponentsRegistry } from './registry/AddedComponentsRegistry';
import { AddedFunctionsRegistry } from './registry/AddedFunctionsRegistry';
import { AddedLinksRegistry } from './registry/AddedLinksRegistry';
import { ExposedComponentsRegistry } from './registry/ExposedComponentsRegistry';
import { PluginExtensionRegistries } from './registry/types';
@ -57,6 +58,7 @@ describe('usePluginLinks()', () => {
addedComponentsRegistry: new AddedComponentsRegistry(),
exposedComponentsRegistry: new ExposedComponentsRegistry(),
addedLinksRegistry: new AddedLinksRegistry(),
addedFunctionsRegistry: new AddedFunctionsRegistry(),
};
resetLogMock(log);
@ -85,6 +87,7 @@ describe('usePluginLinks()', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
dependencies: {
grafanaVersion: '8.0.0',

View File

@ -475,6 +475,7 @@ describe('Plugin Extensions / Utils', () => {
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
@ -553,6 +554,7 @@ describe('Plugin Extensions / Utils', () => {
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},
@ -584,6 +586,7 @@ describe('Plugin Extensions / Utils', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
},
'myorg-third-app': {
@ -623,6 +626,7 @@ describe('Plugin Extensions / Utils', () => {
],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
},
};
@ -679,6 +683,7 @@ describe('Plugin Extensions / Utils', () => {
],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
@ -705,6 +710,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
addedFunctions: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
@ -726,6 +732,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
addedFunctions: [],
},
},
'myorg-sixth-app': {
@ -763,6 +770,7 @@ describe('Plugin Extensions / Utils', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
};
@ -791,6 +799,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
addedFunctions: [],
},
},
'myorg-third-app': {
@ -825,6 +834,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
addedFunctions: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
@ -850,6 +860,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
addedFunctions: [],
},
dependencies: {
...genereicAppPluginConfig.dependencies,
@ -871,6 +882,7 @@ describe('Plugin Extensions / Utils', () => {
},
],
extensionPoints: [],
addedFunctions: [],
},
},
};
@ -902,6 +914,7 @@ describe('Plugin Extensions / Utils', () => {
extensions: {
addedLinks: [],
addedComponents: [],
addedFunctions: [],
exposedComponents: [],
extensionPoints: [],
},

View File

@ -271,6 +271,7 @@ describe('Plugin Extension Validators', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
};
const extensionConfig = {
@ -387,6 +388,7 @@ describe('Plugin Extension Validators', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
};
const extensionConfig = {
@ -503,6 +505,7 @@ describe('Plugin Extension Validators', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
};
const exposedComponentConfig = {
@ -688,6 +691,7 @@ describe('Plugin Extension Validators', () => {
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
addedFunctions: [],
},
dependencies: {
grafanaVersion: '8.0.0',

View File

@ -5,6 +5,7 @@ import type {
PluginContextType,
PluginExtensionAddedComponentConfig,
PluginExtensionExposedComponentConfig,
PluginExtensionAddedFunctionConfig,
} from '@grafana/data';
import { PluginAddedLinksConfigureFunc, PluginExtensionPoints } from '@grafana/data/src/types/pluginExtensions';
import { config, isPluginExtensionLink } from '@grafana/runtime';
@ -160,6 +161,38 @@ export const isAddedLinkMetaInfoMissing = (
return false;
};
export const isAddedFunctionMetaInfoMissing = (
pluginId: string,
metaInfo: PluginExtensionAddedFunctionConfig,
log: ExtensionsLog
) => {
const logPrefix = 'Could not register function extension. Reason:';
const app = config.apps[pluginId];
const pluginJsonMetaInfo = app ? app.extensions.addedFunctions.find(({ title }) => title === metaInfo.title) : null;
if (!app) {
log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`);
return true;
}
if (!pluginJsonMetaInfo) {
log.error(`${logPrefix} ${errors.ADDED_FUNCTION_META_INFO_MISSING}`);
return true;
}
const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets];
if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) {
log.error(`${logPrefix} ${errors.TARGET_NOT_MATCHING_META_INFO}`);
return true;
}
if (pluginJsonMetaInfo.description !== metaInfo.description) {
log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO);
}
return false;
};
export const isAddedComponentMetaInfoMissing = (
pluginId: string,
metaInfo: PluginExtensionAddedComponentConfig,

View File

@ -82,7 +82,6 @@ function getPanelPlugin(meta: PanelPluginMeta): Promise<PanelPlugin> {
if (!plugin.panel && plugin.angularPanelCtrl) {
plugin.panel = getAngularPanelReactWrapper(plugin);
}
return plugin;
})
.catch((err) => {

View File

@ -13,7 +13,12 @@ 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 {
addedComponentsRegistry,
addedFunctionsRegistry,
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';
@ -153,7 +158,6 @@ export function importDataSourcePlugin(meta: DataSourcePluginMeta): Promise<Gene
dsPlugin.meta = meta;
return dsPlugin;
}
if (pluginExports.Datasource) {
const dsPlugin = new DataSourcePlugin<
DataSourceApi<DataQuery, DataSourceJsonData>,
@ -205,6 +209,10 @@ export async function importAppPlugin(meta: PluginMeta): Promise<AppPlugin> {
pluginId,
configs: plugin.addedLinkConfigs || [],
});
addedFunctionsRegistry.register({
pluginId,
configs: plugin.addedFunctionConfigs || [],
});
importedAppPlugins[pluginId] = plugin;