Plugins: Extend panel menu with links from plugins (#63089)

* feat(plugins): introduce dashboard panel menu placement for adding menu items

* test: add test for getPanelMenu()

* added an unique identifier for each extension.

* added context to getPluginExtensions.

* wip

* Wip

* wiwip

* Wip

* feat: WWWIIIIPPPP 🧨

* Wip

* Renamed some of the types to align a bit better.

* added limit to how many extensions a plugin can register per placement.

* decreased number of items to 2

* will trim the lenght of titles to max 25 chars.

* wrapping configure function with error handling.

* added error handling for all scenarios.

* moved extension menu items to the bottom of the more sub menu.

* added tests for configuring the title.

* minor refactorings.

* changed so you need to specify the full path in package.json.

* wip

* removed unused type.

* big refactor to make things simpler and to centralize all configure error/validation handling.

* added missing import.

* fixed failing tests.

* fixed tests.

* revert(extensions): remove static extensions config in favour of registering via AppPlugin APIs

* removed the compose that didn't work for some reason.

* added tests just to verify that validation and error handling is tied together in configuration function.

* adding some more values to the context.

* draft validation.

* added missing tests for getPanelMenu.

* added more tests.

* refactor(extensions): move logic for validating extension link config to function

* Fixed ts errors.

* Update packages/grafana-data/src/types/app.ts

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>

* Update packages/grafana-runtime/src/services/pluginExtensions/extensions.test.ts

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>

* refactor(extensions): rename limiter -> pluginPlacementCount

* refactor(getpanelmenu): remove redundant continue statement

---------

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
Co-authored-by: Marcus Andersson <marcus.andersson@grafana.com>
This commit is contained in:
Jack Westbrook 2023-03-02 15:42:00 +01:00 committed by GitHub
parent 5bd2fac9c8
commit 8c8f584b41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1382 additions and 713 deletions

View File

@ -333,7 +333,8 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
],
"packages/grafana-data/src/types/app.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"packages/grafana-data/src/types/config.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]

View File

@ -472,39 +472,6 @@
"default": false
}
}
},
"extensions": {
"type": "array",
"description": "Extends various parts of the Grafana UI with commands or links.",
"items": {
"type": "object",
"description": "Expose a page link that can be used by Grafana core or other plugins to navigate users to the plugin",
"additionalProperties": false,
"required": ["type", "title", "placement", "path"],
"properties": {
"type": {
"type": "string",
"enum": ["link"]
},
"title": {
"type": "string",
"minLength": 3,
"maxLength": 22
},
"placement": {
"type": "string",
"pattern": "^(plugins|grafana)/[a-z-/0-9]*$"
},
"description": {
"type": "string",
"maxLength": 200
},
"path": {
"type": "string",
"pattern": "^/.*"
}
}
}
}
}
}

View File

@ -3,6 +3,7 @@ import { ComponentType } from 'react';
import { KeyValue } from './data';
import { NavModel } from './navModel';
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
import { extensionLinkConfigIsValid, PluginExtensionLink } from './pluginExtensions';
/**
* @public
@ -49,7 +50,25 @@ export interface AppPluginMeta<T extends KeyValue = KeyValue> extends PluginMeta
// TODO anything specific to apps?
}
/**
* These types are towards the plugin developer when extending Grafana or other
* plugins from the module.ts
*/
export type AppConfigureExtension<T, C = object> = (extension: T, context: C) => Partial<T> | undefined;
export type AppPluginExtensionLink = Pick<PluginExtensionLink, 'description' | 'path' | 'title'>;
export type AppPluginExtensionLinkConfig<C extends object = object> = {
title: string;
description: string;
placement: string;
path: string;
configure?: AppConfigureExtension<AppPluginExtensionLink, C>;
};
export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppPluginMeta<T>> {
private linkExtensions: AppPluginExtensionLinkConfig[] = [];
// Content under: /a/${plugin-id}/*
root?: ComponentType<AppRootProps<T>>;
@ -58,7 +77,7 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
* This function may be called multiple times on the same instance.
* The first time, `this.meta` will be undefined
*/
init(meta: AppPluginMeta) {}
init(meta: AppPluginMeta<T>) {}
/**
* Set the component displayed under:
@ -89,6 +108,22 @@ export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppP
}
}
}
get extensionLinks(): AppPluginExtensionLinkConfig[] {
return this.linkExtensions;
}
configureExtensionLink<C extends object>(config: AppPluginExtensionLinkConfig<C>) {
const { path, description, title, placement } = config;
if (!extensionLinkConfigIsValid({ path, description, title, placement })) {
console.warn('[Plugins] Disabled extension because configureExtensionLink was called with an invalid object.');
return this;
}
this.linkExtensions.push(config as AppPluginExtensionLinkConfig);
return this;
}
}
/**

View File

@ -51,3 +51,9 @@ export * from './alerts';
export * from './slider';
export * from './accesscontrol';
export * from './icon';
export {
type PluginExtension,
type PluginExtensionLink,
isPluginExtensionLink,
PluginExtensionTypes,
} from './pluginExtensions';

View File

@ -0,0 +1,34 @@
/**
* These types are exposed when rendering extension points
*/
export enum PluginExtensionTypes {
link = 'link',
}
export type PluginExtension = {
type: PluginExtensionTypes;
title: string;
description: string;
key: number;
};
export type PluginExtensionLink = PluginExtension & {
type: PluginExtensionTypes.link;
path: string;
};
export function isPluginExtensionLink(extension: PluginExtension): extension is PluginExtensionLink {
return extension.type === PluginExtensionTypes.link && 'path' in extension;
}
export function extensionLinkConfigIsValid(props: {
path?: string;
description?: string;
title?: string;
placement?: string;
}) {
const valuesAreStrings = Object.values(props).every((val) => typeof val === 'string' && val.length);
const placementIsValid = props.placement?.startsWith('grafana/') || props.placement?.startsWith('plugins/');
return valuesAreStrings && placementIsValid;
}

View File

@ -24,24 +24,11 @@ export interface AzureSettings {
managedIdentityEnabled: boolean;
}
export enum PluginExtensionTypes {
link = 'link',
}
export type PluginsExtensionLinkConfig = {
placement: string;
type: PluginExtensionTypes.link;
title: string;
description: string;
path: string;
};
export type AppPluginConfig = {
id: string;
path: string;
version: string;
preload: boolean;
extensions?: PluginsExtensionLinkConfig[];
};
export class GrafanaBootConfig implements GrafanaConfig {

View File

@ -8,10 +8,15 @@ export * from './legacyAngularInjector';
export * from './live';
export * from './LocationService';
export * from './appEvents';
export { setPluginsExtensionRegistry } from './pluginExtensions/registry';
export type { PluginsExtensionRegistry, PluginsExtensionLink, PluginsExtension } from './pluginExtensions/registry';
export {
type GetPluginExtensionsOptions,
type PluginExtensionRegistry,
type PluginExtensionRegistryItem,
type RegistryConfigureExtension,
setPluginsExtensionRegistry,
} from './pluginExtensions/registry';
export {
type PluginExtensionsOptions,
type PluginExtensionsResult,
getPluginExtensions,
} from './pluginExtensions/extensions';
export { type PluginExtensionPanelContext } from './pluginExtensions/contexts';

View File

@ -0,0 +1,22 @@
import { RawTimeRange, TimeZone } from '@grafana/data';
type Dashboard = {
uid: string;
title: string;
tags: Readonly<Array<Readonly<string>>>;
};
type Target = {
pluginId: string;
refId: string;
};
export type PluginExtensionPanelContext = Readonly<{
pluginId: string;
id: number;
title: string;
timeRange: Readonly<RawTimeRange>;
timeZone: TimeZone;
dashboard: Readonly<Dashboard>;
targets: Readonly<Array<Readonly<Target>>>;
}>;

View File

@ -1,51 +1,88 @@
import { getPluginExtensions, PluginExtensionsMissingError } from './extensions';
import { setPluginsExtensionRegistry } from './registry';
import { isPluginExtensionLink, PluginExtension, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
import { getPluginExtensions } from './extensions';
import { PluginExtensionRegistryItem, setPluginsExtensionRegistry } from './registry';
describe('getPluginExtensions', () => {
describe('when getting a registered extension link', () => {
describe('when getting extensions for placement', () => {
const placement = 'grafana/dashboard/panel/menu';
const pluginId = 'grafana-basic-app';
const linkId = 'declare-incident';
beforeAll(() => {
setPluginsExtensionRegistry({
[`plugins/${pluginId}/${linkId}`]: [
{
type: 'link',
[placement]: [
createRegistryLinkItem({
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: `/a/${pluginId}/declare-incident`,
key: 1,
},
}),
],
'plugins/myorg-basic-app/start': [
createRegistryLinkItem({
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: `/a/${pluginId}/declare-incident`,
key: 2,
}),
],
});
});
it('should return a collection of extensions to the plugin', () => {
const { extensions, error } = getPluginExtensions({
placement: `plugins/${pluginId}/${linkId}`,
});
it('should return extensions with correct path', () => {
const { extensions } = getPluginExtensions({ placement });
const [extension] = extensions;
expect(extensions[0].path).toBe(`/a/${pluginId}/declare-incident`);
expect(error).toBeUndefined();
assertLinkExtension(extension);
expect(extension.path).toBe(`/a/${pluginId}/declare-incident`);
expect(extensions.length).toBe(1);
});
it('should return a description for the requested link', () => {
const { extensions, error } = getPluginExtensions({
placement: `plugins/${pluginId}/${linkId}`,
});
it('should return extensions with correct description', () => {
const { extensions } = getPluginExtensions({ placement });
const [extension] = extensions;
expect(extensions[0].path).toBe(`/a/${pluginId}/declare-incident`);
expect(extensions[0].description).toBe('Declaring an incident in the app');
expect(error).toBeUndefined();
assertLinkExtension(extension);
expect(extension.description).toBe('Declaring an incident in the app');
expect(extensions.length).toBe(1);
});
it('should return an empty array when no links can be found', () => {
const { extensions, error } = getPluginExtensions({
placement: `an-unknown-app/${linkId}`,
it('should return extensions with correct title', () => {
const { extensions } = getPluginExtensions({ placement });
const [extension] = extensions;
assertLinkExtension(extension);
expect(extension.title).toBe('Declare incident');
expect(extensions.length).toBe(1);
});
it('should return an empty array when extensions cannot be found', () => {
const { extensions } = getPluginExtensions({
placement: 'plugins/not-installed-app/news',
});
expect(extensions.length).toBe(0);
expect(error).toBeInstanceOf(PluginExtensionsMissingError);
});
});
});
function createRegistryLinkItem(
link: Omit<PluginExtensionLink, 'type'>
): PluginExtensionRegistryItem<PluginExtensionLink> {
return {
configure: undefined,
extension: {
...link,
type: PluginExtensionTypes.link,
},
};
}
function assertLinkExtension(extension: PluginExtension): asserts extension is PluginExtensionLink {
if (!isPluginExtensionLink(extension)) {
throw new Error(`extension is not a link extension`);
}
}

View File

@ -1,34 +1,37 @@
import { getPluginsExtensionRegistry, PluginsExtension } from './registry';
import { type PluginExtension } from '@grafana/data';
export type GetPluginExtensionsOptions = {
import { getPluginsExtensionRegistry } from './registry';
export type PluginExtensionsOptions<T extends object> = {
placement: string;
context?: T;
};
export type PluginExtensionsResult = {
extensions: PluginsExtension[];
error?: Error;
extensions: PluginExtension[];
};
export class PluginExtensionsMissingError extends Error {
readonly placement: string;
constructor(placement: string) {
super(`Could not find extensions for '${placement}'`);
this.placement = placement;
this.name = PluginExtensionsMissingError.name;
}
}
export function getPluginExtensions({ placement }: GetPluginExtensionsOptions): PluginExtensionsResult {
export function getPluginExtensions<T extends object = {}>(
options: PluginExtensionsOptions<T>
): PluginExtensionsResult {
const { placement, context } = options;
const registry = getPluginsExtensionRegistry();
const extensions = registry[placement];
const items = registry[placement] ?? [];
if (!Array.isArray(extensions)) {
return {
extensions: [],
error: new PluginExtensionsMissingError(placement),
};
}
const extensions = items.reduce<PluginExtension[]>((result, item) => {
if (!context || !item.configure) {
result.push(item.extension);
return result;
}
return { extensions };
const extension = item.configure(context);
if (extension) {
result.push(extension);
}
return result;
}, []);
return {
extensions: extensions,
};
}

View File

@ -1,25 +1,26 @@
export type PluginsExtensionLink = {
type: 'link';
title: string;
description: string;
path: string;
key: number;
import { PluginExtension } from '@grafana/data';
export type RegistryConfigureExtension<T extends PluginExtension = PluginExtension, C extends object = object> = (
context: C
) => T | undefined;
export type PluginExtensionRegistryItem<T extends PluginExtension = PluginExtension, C extends object = object> = {
extension: T;
configure?: RegistryConfigureExtension<T, C>;
};
export type PluginsExtension = PluginsExtensionLink;
export type PluginExtensionRegistry = Record<string, PluginExtensionRegistryItem[]>;
export type PluginsExtensionRegistry = Record<string, PluginsExtension[]>;
let registry: PluginExtensionRegistry | undefined;
let registry: PluginsExtensionRegistry | undefined;
export function setPluginsExtensionRegistry(instance: PluginsExtensionRegistry): void {
if (registry) {
export function setPluginsExtensionRegistry(instance: PluginExtensionRegistry): void {
if (registry && process.env.NODE_ENV !== 'test') {
throw new Error('setPluginsExtensionRegistry function should only be called once, when Grafana is starting.');
}
registry = instance;
}
export function getPluginsExtensionRegistry(): PluginsExtensionRegistry {
export function getPluginsExtensionRegistry(): PluginExtensionRegistry {
if (!registry) {
throw new Error('getPluginsExtensionRegistry can only be used after the Grafana instance has started.');
}

View File

@ -399,7 +399,6 @@ func newAppDTO(plugin plugins.PluginDTO, settings pluginsettings.InfoDTO) *plugi
}
if settings.Enabled {
app.Extensions = plugin.Extensions
app.Preload = plugin.Preload
}

View File

@ -16,7 +16,6 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/config"
"github.com/grafana/grafana/pkg/plugins/plugindef"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@ -215,47 +214,25 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
expected settings
}{
{
desc: "app without extensions",
desc: "disabled app with preload",
pluginStore: func() plugins.Store {
return &plugins.FakePluginStore{
PluginList: newPlugins("test-app", nil),
}
},
pluginSettings: func() pluginSettings.Service {
return &pluginSettings.FakePluginSettings{
Plugins: newAppSettings("test-app", true),
}
},
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
ID: "test-app",
Preload: false,
Path: "/test-app/module.js",
Version: "0.5.0",
Extensions: nil,
},
},
},
},
{
desc: "enabled app with link extensions",
pluginStore: func() plugins.Store {
return &plugins.FakePluginStore{
PluginList: newPlugins("test-app", []*plugindef.ExtensionsLink{
PluginList: []plugins.PluginDTO{
{
Placement: "core/home/menu",
Type: plugindef.ExtensionsLinkTypeLink,
Title: "Title",
Description: "Home route of app",
Path: "/home",
Module: fmt.Sprintf("/%s/module.js", "test-app"),
JSONData: plugins.JSONData{
ID: "test-app",
Info: plugins.Info{Version: "0.5.0"},
Type: plugins.App,
Preload: true,
},
},
}),
},
}
},
pluginSettings: func() pluginSettings.Service {
return &pluginSettings.FakePluginSettings{
Plugins: newAppSettings("test-app", true),
Plugins: newAppSettings("test-app", false),
}
},
expected: settings{
@ -265,82 +242,6 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
Preload: false,
Path: "/test-app/module.js",
Version: "0.5.0",
Extensions: []*plugindef.ExtensionsLink{
{
Placement: "core/home/menu",
Type: plugindef.ExtensionsLinkTypeLink,
Title: "Title",
Description: "Home route of app",
Path: "/home",
},
},
},
},
},
},
{
desc: "disabled app with link extensions",
pluginStore: func() plugins.Store {
return &plugins.FakePluginStore{
PluginList: newPlugins("test-app", []*plugindef.ExtensionsLink{
{
Placement: "core/home/menu",
Type: plugindef.ExtensionsLinkTypeLink,
Title: "Title",
Description: "Home route of app",
Path: "/home",
},
}),
}
},
pluginSettings: func() pluginSettings.Service {
return &pluginSettings.FakePluginSettings{
Plugins: newAppSettings("test-app", false),
}
},
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
ID: "test-app",
Preload: false,
Path: "/test-app/module.js",
Version: "0.5.0",
Extensions: nil,
},
},
},
},
{
desc: "disabled app with preload",
pluginStore: func() plugins.Store {
return &plugins.FakePluginStore{
PluginList: []plugins.PluginDTO{
{
Module: fmt.Sprintf("/%s/module.js", "test-app"),
JSONData: plugins.JSONData{
ID: "test-app",
Info: plugins.Info{Version: "0.5.0"},
Type: plugins.App,
Extensions: []*plugindef.ExtensionsLink{},
Preload: true,
},
},
},
}
},
pluginSettings: func() pluginSettings.Service {
return &pluginSettings.FakePluginSettings{
Plugins: newAppSettings("test-app", false),
}
},
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
ID: "test-app",
Preload: false,
Path: "/test-app/module.js",
Version: "0.5.0",
Extensions: nil,
},
},
},
@ -353,11 +254,10 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
{
Module: fmt.Sprintf("/%s/module.js", "test-app"),
JSONData: plugins.JSONData{
ID: "test-app",
Info: plugins.Info{Version: "0.5.0"},
Type: plugins.App,
Extensions: []*plugindef.ExtensionsLink{},
Preload: true,
ID: "test-app",
Info: plugins.Info{Version: "0.5.0"},
Type: plugins.App,
Preload: true,
},
},
},
@ -371,11 +271,10 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
expected: settings{
Apps: map[string]*plugins.AppDTO{
"test-app": {
ID: "test-app",
Preload: true,
Path: "/test-app/module.js",
Version: "0.5.0",
Extensions: nil,
ID: "test-app",
Preload: true,
Path: "/test-app/module.js",
Version: "0.5.0",
},
},
},
@ -409,17 +308,3 @@ func newAppSettings(id string, enabled bool) map[string]*pluginSettings.DTO {
},
}
}
func newPlugins(id string, extensions []*plugindef.ExtensionsLink) []plugins.PluginDTO {
return []plugins.PluginDTO{
{
Module: fmt.Sprintf("/%s/module.js", id),
JSONData: plugins.JSONData{
ID: id,
Info: plugins.Info{Version: "0.5.0"},
Type: plugins.App,
Extensions: extensions,
},
},
}
}

View File

@ -333,12 +333,6 @@ func (l *Loader) readPluginJSON(pluginJSONPath string) (plugins.JSONData, error)
}
}
for i, extension := range plugin.Extensions {
if !filepath.IsAbs(extension.Path) {
plugin.Extensions[i].Path = path.Join("/", extension.Path)
}
}
return plugin, nil
}

View File

@ -8,7 +8,6 @@ import (
"testing"
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
"github.com/grafana/grafana/pkg/plugins/plugindef"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
"github.com/google/go-cmp/cmp"
@ -463,72 +462,7 @@ func TestLoader_Load(t *testing.T) {
},
},
},
{
name: "Load an app with link extensions",
class: plugins.External,
cfg: &config.Cfg{
PluginsAllowUnsigned: []string{"test-app"},
},
pluginPaths: []string{"../testdata/test-app-with-link-extensions"},
want: []*plugins.Plugin{
{JSONData: plugins.JSONData{
ID: "test-app",
Type: "app",
Name: "Test App",
Info: plugins.Info{
Author: plugins.InfoLink{
Name: "Test Inc.",
URL: "http://test.com",
},
Description: "Official Grafana Test App & Dashboard bundle",
Version: "1.0.0",
Links: []plugins.InfoLink{
{Name: "Project site", URL: "http://project.com"},
{Name: "License & Terms", URL: "http://license.com"},
},
Logos: plugins.Logos{
Small: "public/img/icn-app.svg",
Large: "public/img/icn-app.svg",
},
Updated: "2015-02-10",
},
Dependencies: plugins.Dependencies{
GrafanaDependency: ">=8.0.0",
GrafanaVersion: "*",
Plugins: []plugins.Dependency{},
},
Includes: []*plugins.Includes{
{Name: "Root Page (react)", Type: "page", Role: "Viewer", Path: "/a/my-simple-app", DefaultNav: true, AddToNav: true, Slug: "root-page-react"},
},
Extensions: []*plugindef.ExtensionsLink{
{
Placement: "plugins/grafana-slo-app/slo-breach",
Title: "Declare incident",
Type: plugindef.ExtensionsLinkTypeLink,
Description: "Declares a new incident",
Path: "/incidents/declare",
},
{
Placement: "plugins/grafana-slo-app/slo-breach",
Title: "Declare incident",
Type: plugindef.ExtensionsLinkTypeLink,
Description: "Declares a new incident (path without backslash)",
Path: "/incidents/declare",
},
},
Backend: false,
},
DefaultNavURL: "/plugins/test-app/page/root-page-react",
PluginDir: filepath.Join(parentDir, "testdata/test-app-with-link-extensions"),
Class: plugins.External,
Signature: plugins.SignatureUnsigned,
Module: "plugins/test-app/module",
BaseURL: "public/plugins/test-app",
},
},
},
}
for _, tt := range tests {
reg := fakes.NewFakePluginRegistry()
storage := fakes.NewFakePluginStorage()

View File

@ -1,56 +0,0 @@
{
"type": "app",
"name": "Test App",
"id": "test-app",
"info": {
"description": "Official Grafana Test App & Dashboard bundle",
"author": {
"name": "Test Inc.",
"url": "http://test.com"
},
"keywords": [
"test"
],
"links": [
{
"name": "Project site",
"url": "http://project.com"
},
{
"name": "License & Terms",
"url": "http://license.com"
}
],
"version": "1.0.0",
"updated": "2015-02-10"
},
"includes": [
{
"type": "page",
"name": "Root Page (react)",
"path": "/a/my-simple-app",
"role": "Viewer",
"addToNav": true,
"defaultNav": true
}
],
"extensions": [
{
"placement": "plugins/grafana-slo-app/slo-breach",
"type": "link",
"title": "Declare incident",
"description": "Declares a new incident",
"path": "/incidents/declare"
},
{
"placement": "plugins/grafana-slo-app/slo-breach",
"type": "link",
"title": "Declare incident",
"description": "Declares a new incident (path without backslash)",
"path": "incidents/declare"
}
],
"dependencies": {
"grafanaDependency": ">=8.0.0"
}
}

View File

@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"github.com/grafana/grafana/pkg/plugins/plugindef"
"github.com/grafana/grafana/pkg/services/org"
)
@ -255,11 +254,10 @@ type PanelDTO struct {
}
type AppDTO struct {
ID string `json:"id"`
Path string `json:"path"`
Version string `json:"version"`
Preload bool `json:"preload"`
Extensions []*plugindef.ExtensionsLink `json:"extensions,omitempty"`
ID string `json:"id"`
Path string `json:"path"`
Version string `json:"version"`
Preload bool `json:"preload"`
}
const (

View File

@ -84,10 +84,6 @@ func TestParsePluginTestdata(t *testing.T) {
rootid: "test-app",
skip: "has a 'page'-type include which isn't a known part of spec",
},
"test-app-with-link-extensions": {
rootid: "test-app",
skip: "has a 'page'-type include which isn't a known part of spec",
},
"test-app-with-roles": {
rootid: "test-app",
},

View File

@ -3,7 +3,7 @@ package plugindef
import (
"regexp"
"strings"
"github.com/grafana/thema"
)
@ -122,23 +122,6 @@ seqs: [
...
}
#ExtensionsLink: {
// Target where the link will be rendered
placement: =~"^(plugins|grafana)\/[a-z-/0-9]*$"
// Type of extension
type: "link"
// Title that will be displayed for the rendered link
title: string & strings.MinRunes(3) & strings.MaxRunes(22)
// Description for the rendered link
description: string & strings.MaxRunes(200)
// Path relative to the extending plugin e.g. /incidents/declare
path: =~"^\/.*"
...
}
// Extensions made by the current plugin.
extensions?: [...#ExtensionsLink]
// For data source plugins, if the plugin supports logs.
logs?: bool

View File

@ -24,11 +24,6 @@ const (
DependencyTypePanel DependencyType = "panel"
)
// Defines values for ExtensionsLinkType.
const (
ExtensionsLinkTypeLink ExtensionsLinkType = "link"
)
// Defines values for IncludeRole.
const (
IncludeRoleAdmin IncludeRole = "Admin"
@ -126,27 +121,6 @@ type Dependency struct {
// DependencyType defines model for Dependency.Type.
type DependencyType string
// ExtensionsLink defines model for ExtensionsLink.
type ExtensionsLink struct {
// Description for the rendered link
Description string `json:"description"`
// Path relative to the extending plugin e.g. /incidents/declare
Path string `json:"path"`
// Target where the link will be rendered
Placement string `json:"placement"`
// Title that will be displayed for the rendered link
Title string `json:"title"`
// Type of extension
Type ExtensionsLinkType `json:"type"`
}
// Type of extension
type ExtensionsLinkType string
// Header describes an HTTP header that is forwarded with a proxied request for
// a plugin route.
type Header struct {
@ -313,9 +287,6 @@ type PluginDef struct {
// https://golang.org/doc/install/source#environment.
Executable *string `json:"executable,omitempty"`
// Extensions made by the current plugin.
Extensions []ExtensionsLink `json:"extensions,omitempty"`
// For data source plugins, include hidden queries in the data
// request.
HiddenQueries *bool `json:"hiddenQueries,omitempty"`

View File

@ -17,7 +17,6 @@ import (
"github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2"
"github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
"github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/plugins/plugindef"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/util"
)
@ -138,8 +137,7 @@ type JSONData struct {
SkipDataQuery bool `json:"skipDataQuery"`
// App settings
AutoEnabled bool `json:"autoEnabled"`
Extensions []*plugindef.ExtensionsLink `json:"extensions"`
AutoEnabled bool `json:"autoEnabled"`
// Datasource settings
Annotations bool `json:"annotations"`

View File

@ -70,7 +70,7 @@ import { getTimeSrv } from './features/dashboard/services/TimeSrv';
import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView';
import { PanelRenderer } from './features/panel/components/PanelRenderer';
import { DatasourceSrv } from './features/plugins/datasource_srv';
import { createPluginExtensionsRegistry } from './features/plugins/extensions/registry';
import { createPluginExtensionRegistry } from './features/plugins/extensions/registryFactory';
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
import { preloadPlugins } from './features/plugins/pluginPreloader';
import { QueryRunner } from './features/query/state/QueryRunner';
@ -174,15 +174,16 @@ export class GrafanaApp {
setDataSourceSrv(dataSourceSrv);
initWindowRuntime();
const pluginExtensionRegistry = createPluginExtensionsRegistry(config.apps);
setPluginsExtensionRegistry(pluginExtensionRegistry);
// init modal manager
const modalManager = new ModalManager();
modalManager.init();
// Preload selected app plugins
await preloadPlugins(config.apps);
const preloadResults = await preloadPlugins(config.apps);
// Create extension registry out of the preloaded plugins
const extensionsRegistry = createPluginExtensionRegistry(preloadResults);
setPluginsExtensionRegistry(extensionsRegistry);
// initialize chrome service
const queryParams = locationService.getSearchObject();

View File

@ -30,6 +30,7 @@ export class PanelHeaderMenu extends PureComponent<Props> {
iconClassName={menuItem.iconClassName}
onClick={menuItem.onClick}
shortcut={menuItem.shortcut}
href={menuItem.href}
>
{menuItem.subMenu && this.renderItems(menuItem.subMenu, true)}
</PanelHeaderMenuItem>

View File

@ -1,7 +1,14 @@
import { PanelMenuItem } from '@grafana/data';
import { PanelMenuItem, PluginExtension, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
import {
PluginExtensionPanelContext,
PluginExtensionRegistryItem,
RegistryConfigureExtension,
setPluginsExtensionRegistry,
} from '@grafana/runtime';
import { LoadingState } from '@grafana/schema';
import config from 'app/core/config';
import * as actions from 'app/features/explore/state/main';
import { GrafanaExtensions } from 'app/features/plugins/extensions/placements';
import { setStore } from 'app/store/store';
import { PanelModel } from '../state';
@ -15,7 +22,11 @@ jest.mock('app/core/services/context_srv', () => ({
},
}));
describe('getPanelMenu', () => {
describe('getPanelMenu()', () => {
beforeEach(() => {
setPluginsExtensionRegistry({});
});
it('should return the correct panel menu items', () => {
const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({});
@ -124,118 +135,390 @@ describe('getPanelMenu', () => {
])
);
});
});
describe('when panel is in view mode', () => {
it('should return the correct panel menu items', () => {
const getExtendedMenu = () => [{ text: 'Toggle legend', shortcut: 'p l', click: jest.fn() }];
const ctrl: any = { getExtendedMenu };
const scope: any = { $$childHead: { ctrl } };
const angularComponent: any = { getScope: () => scope };
const panel = new PanelModel({ isViewing: true });
const dashboard = createDashboardModelFixture({});
describe('when extending panel menu from plugins', () => {
it('should contain menu item from link extension', () => {
setPluginsExtensionRegistry({
[GrafanaExtensions.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>({
type: PluginExtensionTypes.link,
title: 'Declare incident',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
}),
],
});
const menuItems = getPanelMenu(dashboard, panel, undefined, angularComponent);
expect(menuItems).toMatchInlineSnapshot(`
[
{
"iconClassName": "eye",
"onClick": [Function],
"shortcut": "v",
"text": "View",
},
{
"iconClassName": "edit",
"onClick": [Function],
"shortcut": "e",
"text": "Edit",
},
{
"iconClassName": "share-alt",
"onClick": [Function],
"shortcut": "p s",
"text": "Share",
},
{
"iconClassName": "compass",
"onClick": [Function],
"shortcut": "x",
"text": "Explore",
},
{
"iconClassName": "info-circle",
"onClick": [Function],
"shortcut": "i",
"subMenu": [
const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel, LoadingState.Loading);
const moreSubMenu = menuItems.find((i) => i.text === 'More...')?.subMenu;
expect(moreSubMenu).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: 'Declare incident',
href: '/a/grafana-basic-app/declare-incident',
}),
])
);
});
it('should truncate menu item title to 25 chars', () => {
setPluginsExtensionRegistry({
[GrafanaExtensions.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>({
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
}),
],
});
const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel, LoadingState.Loading);
const moreSubMenu = menuItems.find((i) => i.text === 'More...')?.subMenu;
expect(moreSubMenu).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: 'Declare incident when...',
href: '/a/grafana-basic-app/declare-incident',
}),
])
);
});
it('should use extension for panel menu returned by configure function', () => {
const configure = () => ({
title: 'Wohoo',
type: PluginExtensionTypes.link,
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
});
setPluginsExtensionRegistry({
[GrafanaExtensions.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>(
{
"onClick": [Function],
"text": "Panel JSON",
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
},
],
"text": "Inspect",
"type": "submenu",
},
{
"iconClassName": "cube",
"onClick": [Function],
"subMenu": [
configure
),
],
});
const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel, LoadingState.Loading);
const moreSubMenu = menuItems.find((i) => i.text === 'More...')?.subMenu;
expect(moreSubMenu).toEqual(
expect.arrayContaining([
expect.objectContaining({
text: 'Wohoo',
href: '/a/grafana-basic-app/declare-incident',
}),
])
);
});
it('should hide menu item if configure function returns undefined', () => {
setPluginsExtensionRegistry({
[GrafanaExtensions.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>(
{
"href": undefined,
"onClick": [Function],
"shortcut": "p l",
"text": "Toggle legend",
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
},
],
"text": "More...",
"type": "submenu",
() => undefined
),
],
});
const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel, LoadingState.Loading);
const moreSubMenu = menuItems.find((i) => i.text === 'More...')?.subMenu;
expect(moreSubMenu).toEqual(
expect.not.arrayContaining([
expect.objectContaining({
text: 'Declare incident when...',
href: '/a/grafana-basic-app/declare-incident',
}),
])
);
});
it('should pass context with correct values when configuring extension', () => {
const configure = jest.fn();
setPluginsExtensionRegistry({
[GrafanaExtensions.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>(
{
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
},
configure
),
],
});
const panel = new PanelModel({
type: 'timeseries',
id: 1,
title: 'My panel',
targets: [
{
refId: 'A',
datasource: {
type: 'testdata',
},
},
],
});
const dashboard = createDashboardModelFixture({
timezone: 'utc',
time: {
from: 'now-5m',
to: 'now',
},
]
`);
tags: ['database', 'panel'],
uid: '123',
title: 'My dashboard',
});
getPanelMenu(dashboard, panel, LoadingState.Loading);
const context: PluginExtensionPanelContext = {
pluginId: 'timeseries',
id: 1,
title: 'My panel',
timeZone: 'utc',
timeRange: {
from: 'now-5m',
to: 'now',
},
targets: [
{
refId: 'A',
pluginId: 'testdata',
},
],
dashboard: {
tags: ['database', 'panel'],
uid: '123',
title: 'My dashboard',
},
};
expect(configure).toBeCalledWith(context);
});
it('should pass context that can not be edited in configure function', () => {
const configure = (context: PluginExtensionPanelContext) => {
// trying to change values in the context
// @ts-ignore
context.pluginId = 'changed';
return {
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
};
};
setPluginsExtensionRegistry({
[GrafanaExtensions.DashboardPanelMenu]: [
createRegistryItem<PluginExtensionLink>(
{
type: PluginExtensionTypes.link,
title: 'Declare incident when pressing this amazing menu item',
description: 'Declaring an incident in the app',
path: '/a/grafana-basic-app/declare-incident',
key: 1,
},
configure
),
],
});
const panel = new PanelModel({
type: 'timeseries',
id: 1,
title: 'My panel',
targets: [
{
refId: 'A',
datasource: {
type: 'testdata',
},
},
],
});
const dashboard = createDashboardModelFixture({
timezone: 'utc',
time: {
from: 'now-5m',
to: 'now',
},
tags: ['database', 'panel'],
uid: '123',
title: 'My dashboard',
});
expect(() => getPanelMenu(dashboard, panel, LoadingState.Loading)).toThrowError(TypeError);
});
});
describe('when panel is in view mode', () => {
it('should return the correct panel menu items', () => {
const getExtendedMenu = () => [{ text: 'Toggle legend', shortcut: 'p l', click: jest.fn() }];
const ctrl: any = { getExtendedMenu };
const scope: any = { $$childHead: { ctrl } };
const angularComponent: any = { getScope: () => scope };
const panel = new PanelModel({ isViewing: true });
const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel, undefined, angularComponent);
expect(menuItems).toMatchInlineSnapshot(`
[
{
"iconClassName": "eye",
"onClick": [Function],
"shortcut": "v",
"text": "View",
},
{
"iconClassName": "edit",
"onClick": [Function],
"shortcut": "e",
"text": "Edit",
},
{
"iconClassName": "share-alt",
"onClick": [Function],
"shortcut": "p s",
"text": "Share",
},
{
"iconClassName": "compass",
"onClick": [Function],
"shortcut": "x",
"text": "Explore",
},
{
"iconClassName": "info-circle",
"onClick": [Function],
"shortcut": "i",
"subMenu": [
{
"onClick": [Function],
"text": "Panel JSON",
},
],
"text": "Inspect",
"type": "submenu",
},
{
"iconClassName": "cube",
"onClick": [Function],
"subMenu": [
{
"href": undefined,
"onClick": [Function],
"shortcut": "p l",
"text": "Toggle legend",
},
],
"text": "More...",
"type": "submenu",
},
]
`);
});
});
describe('onNavigateToExplore', () => {
const testSubUrl = '/testSubUrl';
const testUrl = '/testUrl';
const windowOpen = jest.fn();
let event: any;
let explore: PanelMenuItem;
let navigateSpy: any;
beforeAll(() => {
const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel);
explore = menuItems.find((item) => item.text === 'Explore') as PanelMenuItem;
navigateSpy = jest.spyOn(actions, 'navigateToExplore');
window.open = windowOpen;
event = {
ctrlKey: true,
preventDefault: jest.fn(),
};
setStore({ dispatch: jest.fn() } as any);
});
it('should navigate to url without subUrl', () => {
explore.onClick!(event);
const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow;
openInNewWindow(testUrl);
expect(windowOpen).toHaveBeenLastCalledWith(testUrl);
});
it('should navigate to url with subUrl', () => {
config.appSubUrl = testSubUrl;
explore.onClick!(event);
const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow;
openInNewWindow(testUrl);
expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`);
});
});
});
describe('onNavigateToExplore', () => {
const testSubUrl = '/testSubUrl';
const testUrl = '/testUrl';
const windowOpen = jest.fn();
let event: any;
let explore: PanelMenuItem;
let navigateSpy: any;
beforeAll(() => {
const panel = new PanelModel({});
const dashboard = createDashboardModelFixture({});
const menuItems = getPanelMenu(dashboard, panel);
explore = menuItems.find((item) => item.text === 'Explore') as PanelMenuItem;
navigateSpy = jest.spyOn(actions, 'navigateToExplore');
window.open = windowOpen;
event = {
ctrlKey: true,
preventDefault: jest.fn(),
function createRegistryItem<T extends PluginExtension>(
extension: T,
configure?: (context: PluginExtensionPanelContext) => T | undefined
): PluginExtensionRegistryItem<T> {
if (!configure) {
return {
extension,
};
}
setStore({ dispatch: jest.fn() } as any);
});
it('should navigate to url without subUrl', () => {
explore.onClick!(event);
const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow;
openInNewWindow(testUrl);
expect(windowOpen).toHaveBeenLastCalledWith(testUrl);
});
it('should navigate to url with subUrl', () => {
config.appSubUrl = testSubUrl;
explore.onClick!(event);
const openInNewWindow = navigateSpy.mock.calls[0][1].openInNewWindow;
openInNewWindow(testUrl);
expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`);
});
});
return {
extension,
configure: configure as RegistryConfigureExtension<T>,
};
}

View File

@ -1,5 +1,12 @@
import { PanelMenuItem } from '@grafana/data';
import { AngularComponent, getDataSourceSrv, locationService, reportInteraction } from '@grafana/runtime';
import { isPluginExtensionLink, PanelMenuItem } from '@grafana/data';
import {
AngularComponent,
getDataSourceSrv,
getPluginExtensions,
locationService,
reportInteraction,
PluginExtensionPanelContext,
} from '@grafana/runtime';
import { LoadingState } from '@grafana/schema';
import { PanelCtrl } from 'app/angular/panel/panel_ctrl';
import config from 'app/core/config';
@ -19,6 +26,7 @@ import {
} from 'app/features/dashboard/utils/panel';
import { InspectTab } from 'app/features/inspector/types';
import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard';
import { GrafanaExtensions } from 'app/features/plugins/extensions/placements';
import { store } from 'app/store/store';
import { navigateToExplore } from '../../explore/state/main';
@ -278,5 +286,50 @@ export function getPanelMenu(
});
}
const { extensions } = getPluginExtensions({
placement: GrafanaExtensions.DashboardPanelMenu,
context: createExtensionContext(panel, dashboard),
});
for (const extension of extensions) {
if (isPluginExtensionLink(extension)) {
subMenu.push({
text: truncateTitle(extension.title, 25),
href: extension.path,
});
}
}
return menu;
}
function truncateTitle(title: string, length: number): string {
if (title.length < length) {
return title;
}
const part = title.slice(0, length - 3);
return `${part.trimEnd()}...`;
}
function createExtensionContext(panel: PanelModel, dashboard: DashboardModel): PluginExtensionPanelContext {
return Object.freeze({
id: panel.id,
pluginId: panel.type,
title: panel.title,
timeRange: Object.freeze(dashboard.time),
timeZone: dashboard.timezone,
dashboard: Object.freeze({
uid: dashboard.uid,
title: dashboard.title,
tags: Object.freeze(Array.from<string>(dashboard.tags)),
}),
targets: Object.freeze(
panel.targets.map((t) =>
Object.freeze({
refId: t.refId,
pluginId: t.datasource?.type ?? 'unknown',
})
)
),
});
}

View File

@ -0,0 +1,79 @@
import { AppConfigureExtension, AppPluginExtensionLink } from '@grafana/data';
import { createErrorHandling } from './errorHandling';
describe('extension error handling', () => {
const pluginId = 'grafana-basic-app';
const errorHandler = createErrorHandling<AppPluginExtensionLink>({
pluginId: pluginId,
title: 'Go to page one',
logger: jest.fn(),
});
const context = {};
const extension: AppPluginExtensionLink = {
title: 'Go to page one',
description: 'Will navigate the user to page one',
path: `/a/${pluginId}/one`,
};
it('should return configured link if configure is successful', () => {
const configureWithErrorHandling = errorHandler(() => {
return {
title: 'This is a new title',
};
});
const configured = configureWithErrorHandling(extension, context);
expect(configured).toEqual({
title: 'This is a new title',
});
});
it('should return undefined if configure throws error', () => {
const configureWithErrorHandling = errorHandler(() => {
throw new Error();
});
const configured = configureWithErrorHandling(extension, context);
expect(configured).toBeUndefined();
});
it('should return undefined if configure is promise/async-based', () => {
const promisebased = (async () => {}) as AppConfigureExtension<AppPluginExtensionLink>;
const configureWithErrorHandling = errorHandler(promisebased);
const configured = configureWithErrorHandling(extension, context);
expect(configured).toBeUndefined();
});
it('should return undefined if configure is not a function', () => {
const objectbased = {} as AppConfigureExtension<AppPluginExtensionLink>;
const configureWithErrorHandling = errorHandler(objectbased);
const configured = configureWithErrorHandling(extension, context);
expect(configured).toBeUndefined();
});
it('should return undefined if configure returns other than an object', () => {
const returnString = (() => '') as AppConfigureExtension<AppPluginExtensionLink>;
const configureWithErrorHandling = errorHandler(returnString);
const configured = configureWithErrorHandling(extension, context);
expect(configured).toBeUndefined();
});
it('should return undefined if configure returns undefined', () => {
const returnUndefined = () => undefined;
const configureWithErrorHandling = errorHandler(returnUndefined);
const configured = configureWithErrorHandling(extension, context);
expect(configured).toBeUndefined();
});
});

View File

@ -0,0 +1,43 @@
import { isFunction, isObject } from 'lodash';
import type { AppConfigureExtension } from '@grafana/data';
type Options = {
pluginId: string;
title: string;
logger: (msg: string, error?: unknown) => void;
};
export function createErrorHandling<T>(options: Options) {
const { pluginId, title, logger } = options;
return (configure: AppConfigureExtension<T>): AppConfigureExtension<T> => {
return function handleErrors(extension, context) {
try {
if (!isFunction(configure)) {
logger(`[Plugins] ${pluginId} provided invalid configuration function for extension '${title}'.`);
return;
}
const result = configure(extension, context);
if (result instanceof Promise) {
logger(
`[Plugins] ${pluginId} provided an unsupported async/promise-based configureation function for extension '${title}'.`
);
result.catch(() => {});
return;
}
if (!isObject(result) && typeof result !== 'undefined') {
logger(`[Plugins] ${pluginId} returned an inccorect object in configure function for extension '${title}'.`);
return;
}
return result;
} catch (error) {
logger(`[Plugins] ${pluginId} thow an error while configure extension '${title}'`, error);
return;
}
};
};
}

View File

@ -0,0 +1,3 @@
export enum GrafanaExtensions {
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
}

View File

@ -1,100 +0,0 @@
import { AppPluginConfig, PluginExtensionTypes, PluginsExtensionLinkConfig } from '@grafana/runtime';
import { createPluginExtensionsRegistry } from './registry';
describe('Plugin registry', () => {
describe('createPluginExtensionsRegistry function', () => {
const registry = createPluginExtensionsRegistry({
'belugacdn-app': createConfig([
{
placement: 'plugins/belugacdn-app/menu',
title: 'The title',
type: PluginExtensionTypes.link,
description: 'Incidents are occurring!',
path: '/incidents/declare',
},
]),
'strava-app': createConfig([
{
placement: 'plugins/strava-app/menu',
title: 'The title',
type: PluginExtensionTypes.link,
description: 'Incidents are occurring!',
path: '/incidents/declare',
},
]),
'duplicate-links-app': createConfig([
{
placement: 'plugins/duplicate-links-app/menu',
title: 'The title',
type: PluginExtensionTypes.link,
description: 'Incidents are occurring!',
path: '/incidents/declare',
},
{
placement: 'plugins/duplicate-links-app/menu',
title: 'The title',
type: PluginExtensionTypes.link,
description: 'Incidents are occurring!',
path: '/incidents/declare2',
},
]),
'no-extensions-app': createConfig(undefined),
});
it('should configure a registry link', () => {
const [link] = registry['plugins/belugacdn-app/menu'];
expect(link).toEqual({
title: 'The title',
type: 'link',
description: 'Incidents are occurring!',
path: '/a/belugacdn-app/incidents/declare',
key: 539074708,
});
});
it('should configure all registry targets', () => {
const numberOfTargets = Object.keys(registry).length;
expect(numberOfTargets).toBe(3);
});
it('should configure registry targets from multiple plugins', () => {
const [pluginALink] = registry['plugins/belugacdn-app/menu'];
const [pluginBLink] = registry['plugins/strava-app/menu'];
expect(pluginALink).toEqual({
title: 'The title',
type: 'link',
description: 'Incidents are occurring!',
path: '/a/belugacdn-app/incidents/declare',
key: 539074708,
});
expect(pluginBLink).toEqual({
title: 'The title',
type: 'link',
description: 'Incidents are occurring!',
path: '/a/strava-app/incidents/declare',
key: -1637066384,
});
});
it('should configure multiple links for a single target', () => {
const links = registry['plugins/duplicate-links-app/menu'];
expect(links.length).toBe(2);
});
});
});
function createConfig(extensions?: PluginsExtensionLinkConfig[]): AppPluginConfig {
return {
id: 'myorg-basic-app',
preload: false,
path: '',
version: '',
extensions,
};
}

View File

@ -1,54 +0,0 @@
import {
AppPluginConfig,
PluginExtensionTypes,
PluginsExtensionLinkConfig,
PluginsExtensionRegistry,
PluginsExtensionLink,
} from '@grafana/runtime';
export function createPluginExtensionsRegistry(apps: Record<string, AppPluginConfig> = {}): PluginsExtensionRegistry {
const registry: PluginsExtensionRegistry = {};
for (const [pluginId, config] of Object.entries(apps)) {
const extensions = config.extensions;
if (!Array.isArray(extensions)) {
continue;
}
for (const extension of extensions) {
const placement = extension.placement;
const item = createRegistryItem(pluginId, extension);
if (!Array.isArray(registry[placement])) {
registry[placement] = [item];
continue;
}
registry[placement].push(item);
continue;
}
}
for (const key of Object.keys(registry)) {
Object.freeze(registry[key]);
}
return Object.freeze(registry);
}
function createRegistryItem(pluginId: string, extension: PluginsExtensionLinkConfig): PluginsExtensionLink {
const path = `/a/${pluginId}${extension.path}`;
return Object.freeze({
type: PluginExtensionTypes.link,
title: extension.title,
description: extension.description,
path: path,
key: hashKey(`${extension.title}${path}`),
});
}
function hashKey(key: string): number {
return Array.from(key).reduce((s, c) => (Math.imul(31, s) + c.charCodeAt(0)) | 0, 0);
}

View File

@ -0,0 +1,326 @@
import { PluginExtensionTypes } from '@grafana/data';
import { createPluginExtensionRegistry } from './registryFactory';
const validateLink = jest.fn((configure, extension, context) => configure?.(extension, context));
const errorHandler = jest.fn((configure, extension, context) => configure?.(extension, context));
jest.mock('./errorHandling', () => ({
...jest.requireActual('./errorHandling'),
createErrorHandling: jest.fn(() => {
return jest.fn((configure) => {
return jest.fn((extension, context) => errorHandler(configure, extension, context));
});
}),
}));
jest.mock('./validateLink', () => ({
...jest.requireActual('./validateLink'),
createLinkValidator: jest.fn(() => {
return jest.fn((configure) => {
return jest.fn((extension, context) => validateLink(configure, extension, context));
});
}),
}));
describe('Creating extensions registry', () => {
beforeEach(() => {
validateLink.mockClear();
errorHandler.mockClear();
});
it('should register an extension', () => {
const registry = createPluginExtensionRegistry([
{
pluginId: 'belugacdn-app',
linkExtensions: [
{
placement: 'grafana/dashboard/panel/menu',
title: 'Open incident',
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
},
],
},
]);
const numberOfPlacements = Object.keys(registry).length;
const extensions = registry['grafana/dashboard/panel/menu'];
expect(numberOfPlacements).toBe(1);
expect(extensions).toEqual([
{
configure: undefined,
extension: {
title: 'Open incident',
type: PluginExtensionTypes.link,
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
key: -68154691,
},
},
]);
});
it('should register extensions from one plugin with multiple placements', () => {
const registry = createPluginExtensionRegistry([
{
pluginId: 'belugacdn-app',
linkExtensions: [
{
placement: 'grafana/dashboard/panel/menu',
title: 'Open incident',
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
},
{
placement: 'plugins/grafana-slo-app/slo-breached',
title: 'Open incident',
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
},
],
},
]);
const numberOfPlacements = Object.keys(registry).length;
const panelExtensions = registry['grafana/dashboard/panel/menu'];
const sloExtensions = registry['plugins/grafana-slo-app/slo-breached'];
expect(numberOfPlacements).toBe(2);
expect(panelExtensions).toEqual([
{
configure: undefined,
extension: {
title: 'Open incident',
type: PluginExtensionTypes.link,
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
key: -68154691,
},
},
]);
expect(sloExtensions).toEqual([
{
configure: undefined,
extension: {
title: 'Open incident',
type: PluginExtensionTypes.link,
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
key: -1638987831,
},
},
]);
});
it('should register extensions from multiple plugins with multiple placements', () => {
const registry = createPluginExtensionRegistry([
{
pluginId: 'belugacdn-app',
linkExtensions: [
{
placement: 'grafana/dashboard/panel/menu',
title: 'Open incident',
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
},
{
placement: 'plugins/grafana-slo-app/slo-breached',
title: 'Open incident',
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
},
],
},
{
pluginId: 'grafana-monitoring-app',
linkExtensions: [
{
placement: 'grafana/dashboard/panel/menu',
title: 'Open Incident',
description: 'You can create an incident from this context',
path: '/a/grafana-monitoring-app/incidents/declare',
},
],
},
]);
const numberOfPlacements = Object.keys(registry).length;
const panelExtensions = registry['grafana/dashboard/panel/menu'];
const sloExtensions = registry['plugins/grafana-slo-app/slo-breached'];
expect(numberOfPlacements).toBe(2);
expect(panelExtensions).toEqual([
{
configure: undefined,
extension: {
title: 'Open incident',
type: PluginExtensionTypes.link,
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
key: -68154691,
},
},
{
configure: undefined,
extension: {
title: 'Open Incident',
type: PluginExtensionTypes.link,
description: 'You can create an incident from this context',
path: '/a/grafana-monitoring-app/incidents/declare',
key: -540306829,
},
},
]);
expect(sloExtensions).toEqual([
{
configure: undefined,
extension: {
title: 'Open incident',
type: PluginExtensionTypes.link,
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
key: -1638987831,
},
},
]);
});
it('should register maximum 2 extensions per plugin and placement', () => {
const registry = createPluginExtensionRegistry([
{
pluginId: 'belugacdn-app',
linkExtensions: [
{
placement: 'grafana/dashboard/panel/menu',
title: 'Open incident',
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
},
{
placement: 'grafana/dashboard/panel/menu',
title: 'Open incident 2',
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
},
{
placement: 'grafana/dashboard/panel/menu',
title: 'Open incident 3',
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
},
],
},
]);
const numberOfPlacements = Object.keys(registry).length;
const panelExtensions = registry['grafana/dashboard/panel/menu'];
expect(numberOfPlacements).toBe(1);
expect(panelExtensions).toEqual([
{
configure: undefined,
extension: {
title: 'Open incident',
type: PluginExtensionTypes.link,
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
key: -68154691,
},
},
{
configure: undefined,
extension: {
title: 'Open incident 2',
type: PluginExtensionTypes.link,
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
key: -1072147569,
},
},
]);
});
it('should not register extensions with invalid path configured', () => {
const registry = createPluginExtensionRegistry([
{
pluginId: 'belugacdn-app',
linkExtensions: [
{
placement: 'grafana/dashboard/panel/menu',
title: 'Open incident',
description: 'You can create an incident from this context',
path: '/incidents/declare',
},
],
},
]);
const numberOfPlacements = Object.keys(registry).length;
expect(numberOfPlacements).toBe(0);
});
it('should wrap configure function with link extension validator', () => {
const registry = createPluginExtensionRegistry([
{
pluginId: 'belugacdn-app',
linkExtensions: [
{
placement: 'grafana/dashboard/panel/menu',
title: 'Open incident',
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
configure: () => ({}),
},
],
},
]);
const extensions = registry['grafana/dashboard/panel/menu'];
const [extension] = extensions;
const context = {};
const configurable = {
title: 'Open incident',
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
};
extension?.configure?.(context);
expect(validateLink).toBeCalledWith(expect.any(Function), configurable, context);
});
it('should wrap configure function with extension error handling', () => {
const registry = createPluginExtensionRegistry([
{
pluginId: 'belugacdn-app',
linkExtensions: [
{
placement: 'grafana/dashboard/panel/menu',
title: 'Open incident',
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
configure: () => ({}),
},
],
},
]);
const extensions = registry['grafana/dashboard/panel/menu'];
const [extension] = extensions;
const context = {};
const configurable = {
title: 'Open incident',
description: 'You can create an incident from this context',
path: '/a/belugacdn-app/incidents/declare',
};
extension?.configure?.(context);
expect(errorHandler).toBeCalledWith(expect.any(Function), configurable, context);
});
});

View File

@ -0,0 +1,132 @@
import {
AppConfigureExtension,
AppPluginExtensionLink,
AppPluginExtensionLinkConfig,
PluginExtensionLink,
PluginExtensionTypes,
} from '@grafana/data';
import type {
PluginExtensionRegistry,
PluginExtensionRegistryItem,
RegistryConfigureExtension,
} from '@grafana/runtime';
import { PluginPreloadResult } from '../pluginPreloader';
import { createErrorHandling } from './errorHandling';
import { createLinkValidator, isValidLinkPath } from './validateLink';
export function createPluginExtensionRegistry(preloadResults: PluginPreloadResult[]): PluginExtensionRegistry {
const registry: PluginExtensionRegistry = {};
for (const result of preloadResults) {
const pluginPlacementCount: Record<string, number> = {};
const { pluginId, linkExtensions, error } = result;
if (!Array.isArray(linkExtensions) || error) {
continue;
}
for (const extension of linkExtensions) {
const placement = extension.placement;
pluginPlacementCount[placement] = (pluginPlacementCount[placement] ?? 0) + 1;
const item = createRegistryLink(pluginId, extension);
// If there was an issue initialising the plugin, skip adding its extensions to the registry
// or if the plugin already have placed 2 items at the extension point.
if (!item || pluginPlacementCount[placement] > 2) {
continue;
}
if (!Array.isArray(registry[placement])) {
registry[placement] = [item];
continue;
}
registry[placement].push(item);
}
}
for (const item of Object.keys(registry)) {
Object.freeze(registry[item]);
}
return Object.freeze(registry);
}
function createRegistryLink(
pluginId: string,
config: AppPluginExtensionLinkConfig
): PluginExtensionRegistryItem<PluginExtensionLink> | undefined {
if (!isValidLinkPath(pluginId, config.path)) {
return undefined;
}
const id = `${pluginId}${config.placement}${config.title}`;
const extension = Object.freeze({
type: PluginExtensionTypes.link,
title: config.title,
description: config.description,
key: hashKey(id),
path: config.path,
});
return Object.freeze({
extension: extension,
configure: createLinkConfigure(pluginId, config, extension),
});
}
function hashKey(key: string): number {
return Array.from(key).reduce((s, c) => (Math.imul(31, s) + c.charCodeAt(0)) | 0, 0);
}
function createLinkConfigure(
pluginId: string,
config: AppPluginExtensionLinkConfig,
extension: PluginExtensionLink
): RegistryConfigureExtension<PluginExtensionLink> | undefined {
if (!config.configure) {
return undefined;
}
const options = {
pluginId: pluginId,
title: config.title,
logger: console.warn,
};
const mapper = mapToRegistryType(extension);
const validator = createLinkValidator(options);
const errorHandler = createErrorHandling<AppPluginExtensionLink>(options);
return mapper(validator(errorHandler(config.configure)));
}
function mapToRegistryType(
extension: PluginExtensionLink
): (configure: AppConfigureExtension<AppPluginExtensionLink>) => RegistryConfigureExtension<PluginExtensionLink> {
const configurable: AppPluginExtensionLink = {
title: extension.title,
description: extension.description,
path: extension.path,
};
return (configure) => {
return function mapper(context: object): PluginExtensionLink | undefined {
const configured = configure(configurable, context);
if (!configured) {
return undefined;
}
return {
...extension,
title: configured.title ?? extension.title,
description: configured.description ?? extension.description,
path: configured.path ?? extension.path,
};
};
};
}

View File

@ -0,0 +1,52 @@
import type { AppPluginExtensionLink } from '@grafana/data';
import { createLinkValidator } from './validateLink';
describe('extension link validator', () => {
const pluginId = 'grafana-basic-app';
const validator = createLinkValidator({
pluginId,
title: 'Link to something',
logger: jest.fn(),
});
const context = {};
const extension: AppPluginExtensionLink = {
title: 'Go to page one',
description: 'Will navigate the user to page one',
path: `/a/${pluginId}/one`,
};
it('should return link configuration if path is valid', () => {
const configureWithValidation = validator(() => {
return {
path: `/a/${pluginId}/other`,
};
});
const configured = configureWithValidation(extension, context);
expect(configured).toEqual({
path: `/a/${pluginId}/other`,
});
});
it('should return undefined if path is invalid', () => {
const configureWithValidation = validator(() => {
return {
path: `/other`,
};
});
const configured = configureWithValidation(extension, context);
expect(configured).toBeUndefined();
});
it('should return undefined if undefined is returned from inner configure', () => {
const configureWithValidation = validator(() => {
return undefined;
});
const configured = configureWithValidation(extension, context);
expect(configured).toBeUndefined();
});
});

View File

@ -0,0 +1,30 @@
import type { AppConfigureExtension, AppPluginExtensionLink } from '@grafana/data';
type Options = {
pluginId: string;
title: string;
logger: (msg: string, error?: unknown) => void;
};
export function createLinkValidator(options: Options) {
const { pluginId, title, logger } = options;
return (configure: AppConfigureExtension<AppPluginExtensionLink>): AppConfigureExtension<AppPluginExtensionLink> => {
return function validateLink(link, context) {
const configured = configure(link, context);
if (!isValidLinkPath(pluginId, configured?.path)) {
logger(
`[Plugins] Disabled extension '${title}' for '${pluginId}' beause configure didn't return a path with the correct prefix: '${`/a/${pluginId}/`}'`
);
return undefined;
}
return configured;
};
};
}
export function isValidLinkPath(pluginId: string, path?: string): boolean {
return path?.startsWith(`/a/${pluginId}/`) === true;
}

View File

@ -1,17 +1,27 @@
import { AppPluginConfig } from '@grafana/runtime';
import { AppPluginExtensionLinkConfig } from '@grafana/data';
import type { AppPluginConfig } from '@grafana/runtime';
import { importPluginModule } from './plugin_loader';
import * as pluginLoader from './plugin_loader';
export async function preloadPlugins(apps: Record<string, AppPluginConfig> = {}): Promise<void> {
export type PluginPreloadResult = {
pluginId: string;
linkExtensions: AppPluginExtensionLinkConfig[];
error?: unknown;
};
export async function preloadPlugins(apps: Record<string, AppPluginConfig> = {}): Promise<PluginPreloadResult[]> {
const pluginsToPreload = Object.values(apps).filter((app) => app.preload);
await Promise.all(pluginsToPreload.map(preloadPlugin));
return Promise.all(pluginsToPreload.map(preload));
}
async function preloadPlugin(plugin: AppPluginConfig): Promise<void> {
const { path, version } = plugin;
async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {
const { path, version, id: pluginId } = config;
try {
await importPluginModule(path, version);
} catch (error: unknown) {
console.error(`Failed to load plugin: ${path} (version: ${version})`, error);
const { plugin } = await pluginLoader.importPluginModule(path, version);
const { linkExtensions = [] } = plugin;
return { pluginId, linkExtensions };
} catch (error) {
console.error(`[Plugins] Failed to preload plugin: ${path} (version: ${version})`, error);
return { pluginId, linkExtensions: [], error };
}
}

View File

@ -2,7 +2,14 @@ import React, { useMemo, useState } from 'react';
import { useObservable } from 'react-use';
import AutoSizer from 'react-virtualized-auto-sizer';
import { ApplyFieldOverrideOptions, dateMath, FieldColorModeId, NavModelItem, PanelData } from '@grafana/data';
import {
ApplyFieldOverrideOptions,
dateMath,
FieldColorModeId,
isPluginExtensionLink,
NavModelItem,
PanelData,
} from '@grafana/data';
import { getPluginExtensions } from '@grafana/runtime';
import { DataTransformerConfig } from '@grafana/schema';
import { Button, HorizontalGroup, LinkButton, Table } from '@grafana/ui';
@ -149,15 +156,18 @@ export function getDefaultState(): State {
}
function LinkToBasicApp({ placement }: { placement: string }) {
const { extensions, error } = getPluginExtensions({ placement });
const { extensions } = getPluginExtensions({ placement });
if (error) {
if (extensions.length === 0) {
return null;
}
return (
<div>
{extensions.map((extension) => {
if (!isPluginExtensionLink(extension)) {
return null;
}
return (
<LinkButton href={extension.path} title={extension.description} key={extension.key}>
{extension.title}