mirror of https://github.com/grafana/grafana
Plugins: Support for link extensions (#61663)
* added extensions to plugin.json and exposing it via frontend settings. * added extensions to the plugin.json schema. * changing the extensions in frontend settings to a map instead of an array. * wip * feat(pluginregistry): begin wiring up registry * feat(pluginextensions): prevent duplicate links and clean up * added test case for link extensions. * added tests and implemented the getPluginLink function. * wip * feat(pluginextensions): expose plugin extension registry * fix(pluginextensions): appease the typescript gods post rename * renamed file and will throw error if trying to call setExtensionsRegistry if trying to call it twice. * added reafactorings. * fixed failing test. * minor refactorings to make sure we only include extensions if the app is enabled. * fixed some nits. * Update public/app/features/plugins/extensions/registry.test.ts Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com> * Update packages/grafana-runtime/src/services/pluginExtensions/registry.ts Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com> * Update packages/grafana-runtime/src/services/pluginExtensions/registry.ts Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com> * Update public/app/features/plugins/extensions/registry.test.ts Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com> * Moved types for extensions from data to runtime. * added a small example on how you could consume link extensions. * renamed after feedback from levi. * updated the plugindef.cue. * using the generated plugin def. * added tests for apps and extensions. * fixed linting issues. * wip * wip * wip * wip * test(extensions): fix up failing tests * feat(extensions): freeze registry extension arrays, include type in registry items * added restrictions in the pugindef cue schema. * wip * added required fields. * added key to uniquely identify each item. * test(pluginextensions): align tests with implementation * chore(schema): refresh reference.md --------- Co-authored-by: Jack Westbrook <jack.westbrook@gmail.com> Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>pull/63071/head
parent
8a94688114
commit
1cfd3f81fb
@ -0,0 +1,51 @@ |
|||||||
|
import { getPluginExtensions, PluginExtensionsMissingError } from './extensions'; |
||||||
|
import { setPluginsExtensionRegistry } from './registry'; |
||||||
|
|
||||||
|
describe('getPluginExtensions', () => { |
||||||
|
describe('when getting a registered extension link', () => { |
||||||
|
const pluginId = 'grafana-basic-app'; |
||||||
|
const linkId = 'declare-incident'; |
||||||
|
|
||||||
|
beforeAll(() => { |
||||||
|
setPluginsExtensionRegistry({ |
||||||
|
[`plugins/${pluginId}/${linkId}`]: [ |
||||||
|
{ |
||||||
|
type: 'link', |
||||||
|
title: 'Declare incident', |
||||||
|
description: 'Declaring an incident in the app', |
||||||
|
href: `/a/${pluginId}/declare-incident`, |
||||||
|
key: 1, |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should return a collection of extensions to the plugin', () => { |
||||||
|
const { extensions, error } = getPluginExtensions({ |
||||||
|
target: `plugins/${pluginId}/${linkId}`, |
||||||
|
}); |
||||||
|
|
||||||
|
expect(extensions[0].href).toBe(`/a/${pluginId}/declare-incident`); |
||||||
|
expect(error).toBeUndefined(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should return a description for the requested link', () => { |
||||||
|
const { extensions, error } = getPluginExtensions({ |
||||||
|
target: `plugins/${pluginId}/${linkId}`, |
||||||
|
}); |
||||||
|
|
||||||
|
expect(extensions[0].href).toBe(`/a/${pluginId}/declare-incident`); |
||||||
|
expect(extensions[0].description).toBe('Declaring an incident in the app'); |
||||||
|
expect(error).toBeUndefined(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should return an empty array when no links can be found', () => { |
||||||
|
const { extensions, error } = getPluginExtensions({ |
||||||
|
target: `an-unknown-app/${linkId}`, |
||||||
|
}); |
||||||
|
|
||||||
|
expect(extensions.length).toBe(0); |
||||||
|
expect(error).toBeInstanceOf(PluginExtensionsMissingError); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
||||||
@ -0,0 +1,34 @@ |
|||||||
|
import { getPluginsExtensionRegistry, PluginsExtension } from './registry'; |
||||||
|
|
||||||
|
export type GetPluginExtensionsOptions = { |
||||||
|
target: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export type PluginExtensionsResult = { |
||||||
|
extensions: PluginsExtension[]; |
||||||
|
error?: Error; |
||||||
|
}; |
||||||
|
|
||||||
|
export class PluginExtensionsMissingError extends Error { |
||||||
|
readonly target: string; |
||||||
|
|
||||||
|
constructor(target: string) { |
||||||
|
super(`Could not find extensions for '${target}'`); |
||||||
|
this.target = target; |
||||||
|
this.name = PluginExtensionsMissingError.name; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function getPluginExtensions({ target }: GetPluginExtensionsOptions): PluginExtensionsResult { |
||||||
|
const registry = getPluginsExtensionRegistry(); |
||||||
|
const extensions = registry[target]; |
||||||
|
|
||||||
|
if (!Array.isArray(extensions)) { |
||||||
|
return { |
||||||
|
extensions: [], |
||||||
|
error: new PluginExtensionsMissingError(target), |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
return { extensions }; |
||||||
|
} |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
export type PluginsExtensionLink = { |
||||||
|
type: 'link'; |
||||||
|
title: string; |
||||||
|
description: string; |
||||||
|
href: string; |
||||||
|
key: number; |
||||||
|
}; |
||||||
|
|
||||||
|
export type PluginsExtension = PluginsExtensionLink; |
||||||
|
|
||||||
|
export type PluginsExtensionRegistry = Record<string, PluginsExtension[]>; |
||||||
|
|
||||||
|
let registry: PluginsExtensionRegistry | undefined; |
||||||
|
|
||||||
|
export function setPluginsExtensionRegistry(instance: PluginsExtensionRegistry): void { |
||||||
|
if (registry) { |
||||||
|
throw new Error('setPluginsExtensionRegistry function should only be called once, when Grafana is starting.'); |
||||||
|
} |
||||||
|
registry = instance; |
||||||
|
} |
||||||
|
|
||||||
|
export function getPluginsExtensionRegistry(): PluginsExtensionRegistry { |
||||||
|
if (!registry) { |
||||||
|
throw new Error('getPluginsExtensionRegistry can only be used after the Grafana instance has started.'); |
||||||
|
} |
||||||
|
return registry; |
||||||
|
} |
||||||
@ -0,0 +1,56 @@ |
|||||||
|
{ |
||||||
|
"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": [ |
||||||
|
{ |
||||||
|
"target": "plugins/grafana-slo-app/slo-breach", |
||||||
|
"type": "link", |
||||||
|
"title": "Declare incident", |
||||||
|
"description": "Declares a new incident", |
||||||
|
"path": "/incidents/declare" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"target": "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" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,100 @@ |
|||||||
|
import { AppPluginConfig, PluginExtensionTypes, PluginsExtensionLinkConfig } from '@grafana/runtime'; |
||||||
|
|
||||||
|
import { createPluginExtensionsRegistry } from './registry'; |
||||||
|
|
||||||
|
describe('Plugin registry', () => { |
||||||
|
describe('createPluginExtensionsRegistry function', () => { |
||||||
|
const registry = createPluginExtensionsRegistry({ |
||||||
|
'belugacdn-app': createConfig([ |
||||||
|
{ |
||||||
|
target: 'plugins/belugacdn-app/menu', |
||||||
|
title: 'The title', |
||||||
|
type: PluginExtensionTypes.link, |
||||||
|
description: 'Incidents are occurring!', |
||||||
|
path: '/incidents/declare', |
||||||
|
}, |
||||||
|
]), |
||||||
|
'strava-app': createConfig([ |
||||||
|
{ |
||||||
|
target: 'plugins/strava-app/menu', |
||||||
|
title: 'The title', |
||||||
|
type: PluginExtensionTypes.link, |
||||||
|
description: 'Incidents are occurring!', |
||||||
|
path: '/incidents/declare', |
||||||
|
}, |
||||||
|
]), |
||||||
|
'duplicate-links-app': createConfig([ |
||||||
|
{ |
||||||
|
target: 'plugins/duplicate-links-app/menu', |
||||||
|
title: 'The title', |
||||||
|
type: PluginExtensionTypes.link, |
||||||
|
description: 'Incidents are occurring!', |
||||||
|
path: '/incidents/declare', |
||||||
|
}, |
||||||
|
{ |
||||||
|
target: '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!', |
||||||
|
href: '/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!', |
||||||
|
href: '/a/belugacdn-app/incidents/declare', |
||||||
|
key: 539074708, |
||||||
|
}); |
||||||
|
|
||||||
|
expect(pluginBLink).toEqual({ |
||||||
|
title: 'The title', |
||||||
|
type: 'link', |
||||||
|
description: 'Incidents are occurring!', |
||||||
|
href: '/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, |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,54 @@ |
|||||||
|
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 target = extension.target; |
||||||
|
const item = createRegistryItem(pluginId, extension); |
||||||
|
|
||||||
|
if (!Array.isArray(registry[target])) { |
||||||
|
registry[target] = [item]; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
registry[target].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 href = `/a/${pluginId}${extension.path}`; |
||||||
|
|
||||||
|
return Object.freeze({ |
||||||
|
type: PluginExtensionTypes.link, |
||||||
|
title: extension.title, |
||||||
|
description: extension.description, |
||||||
|
href: href, |
||||||
|
key: hashKey(`${extension.title}${href}`), |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function hashKey(key: string): number { |
||||||
|
return Array.from(key).reduce((s, c) => (Math.imul(31, s) + c.charCodeAt(0)) | 0, 0); |
||||||
|
} |
||||||
Loading…
Reference in new issue