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:
@@ -333,7 +333,8 @@ exports[`better eslint`] = {
|
|||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "5"]
|
||||||
],
|
],
|
||||||
"packages/grafana-data/src/types/app.ts:5381": [
|
"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": [
|
"packages/grafana-data/src/types/config.ts:5381": [
|
||||||
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
|
||||||
|
|||||||
@@ -472,39 +472,6 @@
|
|||||||
"default": false
|
"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 { KeyValue } from './data';
|
||||||
import { NavModel } from './navModel';
|
import { NavModel } from './navModel';
|
||||||
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
|
import { PluginMeta, GrafanaPlugin, PluginIncludeType } from './plugin';
|
||||||
|
import { extensionLinkConfigIsValid, PluginExtensionLink } from './pluginExtensions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
@@ -49,7 +50,25 @@ export interface AppPluginMeta<T extends KeyValue = KeyValue> extends PluginMeta
|
|||||||
// TODO anything specific to apps?
|
// 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>> {
|
export class AppPlugin<T extends KeyValue = KeyValue> extends GrafanaPlugin<AppPluginMeta<T>> {
|
||||||
|
private linkExtensions: AppPluginExtensionLinkConfig[] = [];
|
||||||
|
|
||||||
// Content under: /a/${plugin-id}/*
|
// Content under: /a/${plugin-id}/*
|
||||||
root?: ComponentType<AppRootProps<T>>;
|
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.
|
* This function may be called multiple times on the same instance.
|
||||||
* The first time, `this.meta` will be undefined
|
* The first time, `this.meta` will be undefined
|
||||||
*/
|
*/
|
||||||
init(meta: AppPluginMeta) {}
|
init(meta: AppPluginMeta<T>) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the component displayed under:
|
* 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 './slider';
|
||||||
export * from './accesscontrol';
|
export * from './accesscontrol';
|
||||||
export * from './icon';
|
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;
|
managedIdentityEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum PluginExtensionTypes {
|
|
||||||
link = 'link',
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PluginsExtensionLinkConfig = {
|
|
||||||
placement: string;
|
|
||||||
type: PluginExtensionTypes.link;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
path: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AppPluginConfig = {
|
export type AppPluginConfig = {
|
||||||
id: string;
|
id: string;
|
||||||
path: string;
|
path: string;
|
||||||
version: string;
|
version: string;
|
||||||
preload: boolean;
|
preload: boolean;
|
||||||
extensions?: PluginsExtensionLinkConfig[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class GrafanaBootConfig implements GrafanaConfig {
|
export class GrafanaBootConfig implements GrafanaConfig {
|
||||||
|
|||||||
@@ -8,10 +8,15 @@ export * from './legacyAngularInjector';
|
|||||||
export * from './live';
|
export * from './live';
|
||||||
export * from './LocationService';
|
export * from './LocationService';
|
||||||
export * from './appEvents';
|
export * from './appEvents';
|
||||||
export { setPluginsExtensionRegistry } from './pluginExtensions/registry';
|
|
||||||
export type { PluginsExtensionRegistry, PluginsExtensionLink, PluginsExtension } from './pluginExtensions/registry';
|
|
||||||
export {
|
export {
|
||||||
type GetPluginExtensionsOptions,
|
type PluginExtensionRegistry,
|
||||||
|
type PluginExtensionRegistryItem,
|
||||||
|
type RegistryConfigureExtension,
|
||||||
|
setPluginsExtensionRegistry,
|
||||||
|
} from './pluginExtensions/registry';
|
||||||
|
export {
|
||||||
|
type PluginExtensionsOptions,
|
||||||
type PluginExtensionsResult,
|
type PluginExtensionsResult,
|
||||||
getPluginExtensions,
|
getPluginExtensions,
|
||||||
} from './pluginExtensions/extensions';
|
} 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 { isPluginExtensionLink, PluginExtension, PluginExtensionLink, PluginExtensionTypes } from '@grafana/data';
|
||||||
import { setPluginsExtensionRegistry } from './registry';
|
|
||||||
|
import { getPluginExtensions } from './extensions';
|
||||||
|
import { PluginExtensionRegistryItem, setPluginsExtensionRegistry } from './registry';
|
||||||
|
|
||||||
describe('getPluginExtensions', () => {
|
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 pluginId = 'grafana-basic-app';
|
||||||
const linkId = 'declare-incident';
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
setPluginsExtensionRegistry({
|
setPluginsExtensionRegistry({
|
||||||
[`plugins/${pluginId}/${linkId}`]: [
|
[placement]: [
|
||||||
{
|
createRegistryLinkItem({
|
||||||
type: 'link',
|
|
||||||
title: 'Declare incident',
|
title: 'Declare incident',
|
||||||
description: 'Declaring an incident in the app',
|
description: 'Declaring an incident in the app',
|
||||||
path: `/a/${pluginId}/declare-incident`,
|
path: `/a/${pluginId}/declare-incident`,
|
||||||
key: 1,
|
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', () => {
|
it('should return extensions with correct path', () => {
|
||||||
const { extensions, error } = getPluginExtensions({
|
const { extensions } = getPluginExtensions({ placement });
|
||||||
placement: `plugins/${pluginId}/${linkId}`,
|
const [extension] = extensions;
|
||||||
|
|
||||||
|
assertLinkExtension(extension);
|
||||||
|
|
||||||
|
expect(extension.path).toBe(`/a/${pluginId}/declare-incident`);
|
||||||
|
expect(extensions.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(extensions[0].path).toBe(`/a/${pluginId}/declare-incident`);
|
it('should return extensions with correct description', () => {
|
||||||
expect(error).toBeUndefined();
|
const { extensions } = getPluginExtensions({ placement });
|
||||||
|
const [extension] = extensions;
|
||||||
|
|
||||||
|
assertLinkExtension(extension);
|
||||||
|
|
||||||
|
expect(extension.description).toBe('Declaring an incident in the app');
|
||||||
|
expect(extensions.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return a description for the requested link', () => {
|
it('should return extensions with correct title', () => {
|
||||||
const { extensions, error } = getPluginExtensions({
|
const { extensions } = getPluginExtensions({ placement });
|
||||||
placement: `plugins/${pluginId}/${linkId}`,
|
const [extension] = extensions;
|
||||||
|
|
||||||
|
assertLinkExtension(extension);
|
||||||
|
|
||||||
|
expect(extension.title).toBe('Declare incident');
|
||||||
|
expect(extensions.length).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(extensions[0].path).toBe(`/a/${pluginId}/declare-incident`);
|
it('should return an empty array when extensions cannot be found', () => {
|
||||||
expect(extensions[0].description).toBe('Declaring an incident in the app');
|
const { extensions } = getPluginExtensions({
|
||||||
expect(error).toBeUndefined();
|
placement: 'plugins/not-installed-app/news',
|
||||||
});
|
|
||||||
|
|
||||||
it('should return an empty array when no links can be found', () => {
|
|
||||||
const { extensions, error } = getPluginExtensions({
|
|
||||||
placement: `an-unknown-app/${linkId}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(extensions.length).toBe(0);
|
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;
|
placement: string;
|
||||||
|
context?: T;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PluginExtensionsResult = {
|
export type PluginExtensionsResult = {
|
||||||
extensions: PluginsExtension[];
|
extensions: PluginExtension[];
|
||||||
error?: Error;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class PluginExtensionsMissingError extends Error {
|
export function getPluginExtensions<T extends object = {}>(
|
||||||
readonly placement: string;
|
options: PluginExtensionsOptions<T>
|
||||||
|
): PluginExtensionsResult {
|
||||||
constructor(placement: string) {
|
const { placement, context } = options;
|
||||||
super(`Could not find extensions for '${placement}'`);
|
|
||||||
this.placement = placement;
|
|
||||||
this.name = PluginExtensionsMissingError.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPluginExtensions({ placement }: GetPluginExtensionsOptions): PluginExtensionsResult {
|
|
||||||
const registry = getPluginsExtensionRegistry();
|
const registry = getPluginsExtensionRegistry();
|
||||||
const extensions = registry[placement];
|
const items = registry[placement] ?? [];
|
||||||
|
|
||||||
if (!Array.isArray(extensions)) {
|
const extensions = items.reduce<PluginExtension[]>((result, item) => {
|
||||||
return {
|
if (!context || !item.configure) {
|
||||||
extensions: [],
|
result.push(item.extension);
|
||||||
error: new PluginExtensionsMissingError(placement),
|
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 = {
|
import { PluginExtension } from '@grafana/data';
|
||||||
type: 'link';
|
|
||||||
title: string;
|
export type RegistryConfigureExtension<T extends PluginExtension = PluginExtension, C extends object = object> = (
|
||||||
description: string;
|
context: C
|
||||||
path: string;
|
) => T | undefined;
|
||||||
key: number;
|
|
||||||
|
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: PluginExtensionRegistry): void {
|
||||||
|
if (registry && process.env.NODE_ENV !== 'test') {
|
||||||
export function setPluginsExtensionRegistry(instance: PluginsExtensionRegistry): void {
|
|
||||||
if (registry) {
|
|
||||||
throw new Error('setPluginsExtensionRegistry function should only be called once, when Grafana is starting.');
|
throw new Error('setPluginsExtensionRegistry function should only be called once, when Grafana is starting.');
|
||||||
}
|
}
|
||||||
registry = instance;
|
registry = instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPluginsExtensionRegistry(): PluginsExtensionRegistry {
|
export function getPluginsExtensionRegistry(): PluginExtensionRegistry {
|
||||||
if (!registry) {
|
if (!registry) {
|
||||||
throw new Error('getPluginsExtensionRegistry can only be used after the Grafana instance has started.');
|
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 {
|
if settings.Enabled {
|
||||||
app.Extensions = plugin.Extensions
|
|
||||||
app.Preload = plugin.Preload
|
app.Preload = plugin.Preload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
"github.com/grafana/grafana/pkg/login/social"
|
"github.com/grafana/grafana/pkg/login/social"
|
||||||
"github.com/grafana/grafana/pkg/plugins"
|
"github.com/grafana/grafana/pkg/plugins"
|
||||||
"github.com/grafana/grafana/pkg/plugins/config"
|
"github.com/grafana/grafana/pkg/plugins/config"
|
||||||
"github.com/grafana/grafana/pkg/plugins/plugindef"
|
|
||||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||||
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
@@ -214,102 +213,6 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
|
|||||||
pluginSettings func() pluginSettings.Service
|
pluginSettings func() pluginSettings.Service
|
||||||
expected settings
|
expected settings
|
||||||
}{
|
}{
|
||||||
{
|
|
||||||
desc: "app without extensions",
|
|
||||||
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{
|
|
||||||
{
|
|
||||||
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", 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: []*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",
|
desc: "disabled app with preload",
|
||||||
pluginStore: func() plugins.Store {
|
pluginStore: func() plugins.Store {
|
||||||
@@ -321,7 +224,6 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
|
|||||||
ID: "test-app",
|
ID: "test-app",
|
||||||
Info: plugins.Info{Version: "0.5.0"},
|
Info: plugins.Info{Version: "0.5.0"},
|
||||||
Type: plugins.App,
|
Type: plugins.App,
|
||||||
Extensions: []*plugindef.ExtensionsLink{},
|
|
||||||
Preload: true,
|
Preload: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -340,7 +242,6 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
|
|||||||
Preload: false,
|
Preload: false,
|
||||||
Path: "/test-app/module.js",
|
Path: "/test-app/module.js",
|
||||||
Version: "0.5.0",
|
Version: "0.5.0",
|
||||||
Extensions: nil,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -356,7 +257,6 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
|
|||||||
ID: "test-app",
|
ID: "test-app",
|
||||||
Info: plugins.Info{Version: "0.5.0"},
|
Info: plugins.Info{Version: "0.5.0"},
|
||||||
Type: plugins.App,
|
Type: plugins.App,
|
||||||
Extensions: []*plugindef.ExtensionsLink{},
|
|
||||||
Preload: true,
|
Preload: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -375,7 +275,6 @@ func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
|
|||||||
Preload: true,
|
Preload: true,
|
||||||
Path: "/test-app/module.js",
|
Path: "/test-app/module.js",
|
||||||
Version: "0.5.0",
|
Version: "0.5.0",
|
||||||
Extensions: nil,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -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
|
return plugin, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
|
"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/grafana/grafana/pkg/plugins/pluginscdn"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"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 {
|
for _, tt := range tests {
|
||||||
reg := fakes.NewFakePluginRegistry()
|
reg := fakes.NewFakePluginRegistry()
|
||||||
storage := fakes.NewFakePluginStorage()
|
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"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/grafana/grafana/pkg/plugins/plugindef"
|
|
||||||
"github.com/grafana/grafana/pkg/services/org"
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -259,7 +258,6 @@ type AppDTO struct {
|
|||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Preload bool `json:"preload"`
|
Preload bool `json:"preload"`
|
||||||
Extensions []*plugindef.ExtensionsLink `json:"extensions,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -84,10 +84,6 @@ func TestParsePluginTestdata(t *testing.T) {
|
|||||||
rootid: "test-app",
|
rootid: "test-app",
|
||||||
skip: "has a 'page'-type include which isn't a known part of spec",
|
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": {
|
"test-app-with-roles": {
|
||||||
rootid: "test-app",
|
rootid: "test-app",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
// For data source plugins, if the plugin supports logs.
|
||||||
logs?: bool
|
logs?: bool
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,6 @@ const (
|
|||||||
DependencyTypePanel DependencyType = "panel"
|
DependencyTypePanel DependencyType = "panel"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Defines values for ExtensionsLinkType.
|
|
||||||
const (
|
|
||||||
ExtensionsLinkTypeLink ExtensionsLinkType = "link"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Defines values for IncludeRole.
|
// Defines values for IncludeRole.
|
||||||
const (
|
const (
|
||||||
IncludeRoleAdmin IncludeRole = "Admin"
|
IncludeRoleAdmin IncludeRole = "Admin"
|
||||||
@@ -126,27 +121,6 @@ type Dependency struct {
|
|||||||
// DependencyType defines model for Dependency.Type.
|
// DependencyType defines model for Dependency.Type.
|
||||||
type DependencyType string
|
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
|
// Header describes an HTTP header that is forwarded with a proxied request for
|
||||||
// a plugin route.
|
// a plugin route.
|
||||||
type Header struct {
|
type Header struct {
|
||||||
@@ -313,9 +287,6 @@ type PluginDef struct {
|
|||||||
// https://golang.org/doc/install/source#environment.
|
// https://golang.org/doc/install/source#environment.
|
||||||
Executable *string `json:"executable,omitempty"`
|
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
|
// For data source plugins, include hidden queries in the data
|
||||||
// request.
|
// request.
|
||||||
HiddenQueries *bool `json:"hiddenQueries,omitempty"`
|
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/pluginextensionv2"
|
||||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
|
"github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
|
||||||
"github.com/grafana/grafana/pkg/plugins/log"
|
"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/services/org"
|
||||||
"github.com/grafana/grafana/pkg/util"
|
"github.com/grafana/grafana/pkg/util"
|
||||||
)
|
)
|
||||||
@@ -139,7 +138,6 @@ type JSONData struct {
|
|||||||
|
|
||||||
// App settings
|
// App settings
|
||||||
AutoEnabled bool `json:"autoEnabled"`
|
AutoEnabled bool `json:"autoEnabled"`
|
||||||
Extensions []*plugindef.ExtensionsLink `json:"extensions"`
|
|
||||||
|
|
||||||
// Datasource settings
|
// Datasource settings
|
||||||
Annotations bool `json:"annotations"`
|
Annotations bool `json:"annotations"`
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ import { getTimeSrv } from './features/dashboard/services/TimeSrv';
|
|||||||
import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView';
|
import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView';
|
||||||
import { PanelRenderer } from './features/panel/components/PanelRenderer';
|
import { PanelRenderer } from './features/panel/components/PanelRenderer';
|
||||||
import { DatasourceSrv } from './features/plugins/datasource_srv';
|
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 { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
|
||||||
import { preloadPlugins } from './features/plugins/pluginPreloader';
|
import { preloadPlugins } from './features/plugins/pluginPreloader';
|
||||||
import { QueryRunner } from './features/query/state/QueryRunner';
|
import { QueryRunner } from './features/query/state/QueryRunner';
|
||||||
@@ -174,15 +174,16 @@ export class GrafanaApp {
|
|||||||
setDataSourceSrv(dataSourceSrv);
|
setDataSourceSrv(dataSourceSrv);
|
||||||
initWindowRuntime();
|
initWindowRuntime();
|
||||||
|
|
||||||
const pluginExtensionRegistry = createPluginExtensionsRegistry(config.apps);
|
|
||||||
setPluginsExtensionRegistry(pluginExtensionRegistry);
|
|
||||||
|
|
||||||
// init modal manager
|
// init modal manager
|
||||||
const modalManager = new ModalManager();
|
const modalManager = new ModalManager();
|
||||||
modalManager.init();
|
modalManager.init();
|
||||||
|
|
||||||
// Preload selected app plugins
|
// 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
|
// initialize chrome service
|
||||||
const queryParams = locationService.getSearchObject();
|
const queryParams = locationService.getSearchObject();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export class PanelHeaderMenu extends PureComponent<Props> {
|
|||||||
iconClassName={menuItem.iconClassName}
|
iconClassName={menuItem.iconClassName}
|
||||||
onClick={menuItem.onClick}
|
onClick={menuItem.onClick}
|
||||||
shortcut={menuItem.shortcut}
|
shortcut={menuItem.shortcut}
|
||||||
|
href={menuItem.href}
|
||||||
>
|
>
|
||||||
{menuItem.subMenu && this.renderItems(menuItem.subMenu, true)}
|
{menuItem.subMenu && this.renderItems(menuItem.subMenu, true)}
|
||||||
</PanelHeaderMenuItem>
|
</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 { LoadingState } from '@grafana/schema';
|
||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
import * as actions from 'app/features/explore/state/main';
|
import * as actions from 'app/features/explore/state/main';
|
||||||
|
import { GrafanaExtensions } from 'app/features/plugins/extensions/placements';
|
||||||
import { setStore } from 'app/store/store';
|
import { setStore } from 'app/store/store';
|
||||||
|
|
||||||
import { PanelModel } from '../state';
|
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', () => {
|
it('should return the correct panel menu items', () => {
|
||||||
const panel = new PanelModel({});
|
const panel = new PanelModel({});
|
||||||
const dashboard = createDashboardModelFixture({});
|
const dashboard = createDashboardModelFixture({});
|
||||||
@@ -124,9 +135,264 @@ describe('getPanelMenu', () => {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('when panel is in view mode', () => {
|
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 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>(
|
||||||
|
{
|
||||||
|
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({});
|
||||||
|
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>(
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
() => 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', () => {
|
it('should return the correct panel menu items', () => {
|
||||||
const getExtendedMenu = () => [{ text: 'Toggle legend', shortcut: 'p l', click: jest.fn() }];
|
const getExtendedMenu = () => [{ text: 'Toggle legend', shortcut: 'p l', click: jest.fn() }];
|
||||||
const ctrl: any = { getExtendedMenu };
|
const ctrl: any = { getExtendedMenu };
|
||||||
@@ -192,9 +458,9 @@ describe('when panel is in view mode', () => {
|
|||||||
]
|
]
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('onNavigateToExplore', () => {
|
describe('onNavigateToExplore', () => {
|
||||||
const testSubUrl = '/testSubUrl';
|
const testSubUrl = '/testSubUrl';
|
||||||
const testUrl = '/testUrl';
|
const testUrl = '/testUrl';
|
||||||
const windowOpen = jest.fn();
|
const windowOpen = jest.fn();
|
||||||
@@ -238,4 +504,21 @@ describe('onNavigateToExplore', () => {
|
|||||||
|
|
||||||
expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`);
|
expect(windowOpen).toHaveBeenLastCalledWith(`${testSubUrl}${testUrl}`);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createRegistryItem<T extends PluginExtension>(
|
||||||
|
extension: T,
|
||||||
|
configure?: (context: PluginExtensionPanelContext) => T | undefined
|
||||||
|
): PluginExtensionRegistryItem<T> {
|
||||||
|
if (!configure) {
|
||||||
|
return {
|
||||||
|
extension,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
extension,
|
||||||
|
configure: configure as RegistryConfigureExtension<T>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { PanelMenuItem } from '@grafana/data';
|
import { isPluginExtensionLink, PanelMenuItem } from '@grafana/data';
|
||||||
import { AngularComponent, getDataSourceSrv, locationService, reportInteraction } from '@grafana/runtime';
|
import {
|
||||||
|
AngularComponent,
|
||||||
|
getDataSourceSrv,
|
||||||
|
getPluginExtensions,
|
||||||
|
locationService,
|
||||||
|
reportInteraction,
|
||||||
|
PluginExtensionPanelContext,
|
||||||
|
} from '@grafana/runtime';
|
||||||
import { LoadingState } from '@grafana/schema';
|
import { LoadingState } from '@grafana/schema';
|
||||||
import { PanelCtrl } from 'app/angular/panel/panel_ctrl';
|
import { PanelCtrl } from 'app/angular/panel/panel_ctrl';
|
||||||
import config from 'app/core/config';
|
import config from 'app/core/config';
|
||||||
@@ -19,6 +26,7 @@ import {
|
|||||||
} from 'app/features/dashboard/utils/panel';
|
} from 'app/features/dashboard/utils/panel';
|
||||||
import { InspectTab } from 'app/features/inspector/types';
|
import { InspectTab } from 'app/features/inspector/types';
|
||||||
import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard';
|
import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard';
|
||||||
|
import { GrafanaExtensions } from 'app/features/plugins/extensions/placements';
|
||||||
import { store } from 'app/store/store';
|
import { store } from 'app/store/store';
|
||||||
|
|
||||||
import { navigateToExplore } from '../../explore/state/main';
|
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;
|
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);
|
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> {
|
async function preload(config: AppPluginConfig): Promise<PluginPreloadResult> {
|
||||||
const { path, version } = plugin;
|
const { path, version, id: pluginId } = config;
|
||||||
try {
|
try {
|
||||||
await importPluginModule(path, version);
|
const { plugin } = await pluginLoader.importPluginModule(path, version);
|
||||||
} catch (error: unknown) {
|
const { linkExtensions = [] } = plugin;
|
||||||
console.error(`Failed to load plugin: ${path} (version: ${version})`, error);
|
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 { useObservable } from 'react-use';
|
||||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
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 { getPluginExtensions } from '@grafana/runtime';
|
||||||
import { DataTransformerConfig } from '@grafana/schema';
|
import { DataTransformerConfig } from '@grafana/schema';
|
||||||
import { Button, HorizontalGroup, LinkButton, Table } from '@grafana/ui';
|
import { Button, HorizontalGroup, LinkButton, Table } from '@grafana/ui';
|
||||||
@@ -149,15 +156,18 @@ export function getDefaultState(): State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LinkToBasicApp({ placement }: { placement: string }) {
|
function LinkToBasicApp({ placement }: { placement: string }) {
|
||||||
const { extensions, error } = getPluginExtensions({ placement });
|
const { extensions } = getPluginExtensions({ placement });
|
||||||
|
|
||||||
if (error) {
|
if (extensions.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{extensions.map((extension) => {
|
{extensions.map((extension) => {
|
||||||
|
if (!isPluginExtensionLink(extension)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<LinkButton href={extension.path} title={extension.description} key={extension.key}>
|
<LinkButton href={extension.path} title={extension.description} key={extension.key}>
|
||||||
{extension.title}
|
{extension.title}
|
||||||
|
|||||||
Reference in New Issue
Block a user