mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
5bd2fac9c8
commit
8c8f584b41
@ -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"]
|
||||
|
@ -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": "^/.*"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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';
|
||||
|
34
packages/grafana-data/src/types/pluginExtensions.ts
Normal file
34
packages/grafana-data/src/types/pluginExtensions.ts
Normal 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;
|
||||
}
|
@ -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 {
|
||||
|
@ -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';
|
||||
|
@ -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>>>;
|
||||
}>;
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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.');
|
||||
}
|
||||
|
@ -399,7 +399,6 @@ func newAppDTO(plugin plugins.PluginDTO, settings pluginsettings.InfoDTO) *plugi
|
||||
}
|
||||
|
||||
if settings.Enabled {
|
||||
app.Extensions = plugin.Extensions
|
||||
app.Preload = plugin.Preload
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
@ -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 (
|
||||
|
@ -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",
|
||||
},
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"`
|
||||
|
@ -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"`
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
|
@ -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>,
|
||||
};
|
||||
}
|
||||
|
@ -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',
|
||||
})
|
||||
)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
79
public/app/features/plugins/extensions/errorHandling.test.ts
Normal file
79
public/app/features/plugins/extensions/errorHandling.test.ts
Normal 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();
|
||||
});
|
||||
});
|
43
public/app/features/plugins/extensions/errorHandling.ts
Normal file
43
public/app/features/plugins/extensions/errorHandling.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
3
public/app/features/plugins/extensions/placements.ts
Normal file
3
public/app/features/plugins/extensions/placements.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export enum GrafanaExtensions {
|
||||
DashboardPanelMenu = 'grafana/dashboard/panel/menu',
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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);
|
||||
}
|
326
public/app/features/plugins/extensions/registryFactory.test.ts
Normal file
326
public/app/features/plugins/extensions/registryFactory.test.ts
Normal 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);
|
||||
});
|
||||
});
|
132
public/app/features/plugins/extensions/registryFactory.ts
Normal file
132
public/app/features/plugins/extensions/registryFactory.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
52
public/app/features/plugins/extensions/validateLink.test.ts
Normal file
52
public/app/features/plugins/extensions/validateLink.test.ts
Normal 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();
|
||||
});
|
||||
});
|
30
public/app/features/plugins/extensions/validateLink.ts
Normal file
30
public/app/features/plugins/extensions/validateLink.ts
Normal 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;
|
||||
}
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
|
Loading…
Reference in New Issue
Block a user