mirror of
https://github.com/grafana/grafana.git
synced 2025-07-30 21:53:00 +08:00
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>
This commit is contained in:
@ -463,7 +463,7 @@
|
||||
},
|
||||
"enterpriseFeatures": {
|
||||
"type": "object",
|
||||
"description": "Grafana Enerprise specific features.",
|
||||
"description": "Grafana Enterprise specific features.",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"healthDiagnosticsErrors": {
|
||||
@ -472,6 +472,39 @@
|
||||
"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", "target", "path"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["link"]
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 3,
|
||||
"maxLength": 22
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"pattern": "^(plugins|grafana)/[a-z-/0-9]*$"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"maxLength": 200
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"pattern": "^/.*"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -76,16 +76,6 @@ export interface UnifiedAlertingConfig {
|
||||
minInterval: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the plugins that should be preloaded prior to start Grafana.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type PreloadPlugin = {
|
||||
path: string;
|
||||
version: string;
|
||||
};
|
||||
|
||||
/** Supported OAuth services
|
||||
*
|
||||
* @public
|
||||
@ -199,7 +189,6 @@ export interface GrafanaConfig {
|
||||
/** @deprecated Use `theme2` instead. */
|
||||
theme: GrafanaTheme;
|
||||
theme2: GrafanaTheme2;
|
||||
pluginsToPreload: PreloadPlugin[];
|
||||
featureToggles: FeatureToggles;
|
||||
licenseInfo: LicenseInfo;
|
||||
http2Enabled: boolean;
|
||||
|
@ -45,7 +45,6 @@ export type {
|
||||
GrafanaConfig,
|
||||
BuildInfo,
|
||||
LicenseInfo,
|
||||
PreloadPlugin,
|
||||
} from './config';
|
||||
export type { FeatureToggles } from './featureToggles.gen';
|
||||
export * from './alerts';
|
||||
|
@ -14,7 +14,6 @@ import {
|
||||
MapLayerOptions,
|
||||
OAuthSettings,
|
||||
PanelPluginMeta,
|
||||
PreloadPlugin,
|
||||
systemDateFormats,
|
||||
SystemDateFormatSettings,
|
||||
NewThemeOptions,
|
||||
@ -25,11 +24,32 @@ export interface AzureSettings {
|
||||
managedIdentityEnabled: boolean;
|
||||
}
|
||||
|
||||
export enum PluginExtensionTypes {
|
||||
link = 'link',
|
||||
}
|
||||
|
||||
export type PluginsExtensionLinkConfig = {
|
||||
target: string;
|
||||
type: PluginExtensionTypes.link;
|
||||
title: string;
|
||||
description: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type AppPluginConfig = {
|
||||
id: string;
|
||||
path: string;
|
||||
version: string;
|
||||
preload: boolean;
|
||||
extensions?: PluginsExtensionLinkConfig[];
|
||||
};
|
||||
|
||||
export class GrafanaBootConfig implements GrafanaConfig {
|
||||
isPublicDashboardView: boolean;
|
||||
snapshotEnabled = true;
|
||||
datasources: { [str: string]: DataSourceInstanceSettings } = {};
|
||||
panels: { [key: string]: PanelPluginMeta } = {};
|
||||
apps: Record<string, AppPluginConfig> = {};
|
||||
auth: AuthSettings = {};
|
||||
minRefreshInterval = '';
|
||||
appUrl = '';
|
||||
@ -77,7 +97,6 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
||||
/** @deprecated Use `theme2` instead. */
|
||||
theme: GrafanaTheme;
|
||||
theme2: GrafanaTheme2;
|
||||
pluginsToPreload: PreloadPlugin[] = [];
|
||||
featureToggles: FeatureToggles = {};
|
||||
licenseInfo: LicenseInfo = {} as LicenseInfo;
|
||||
rendererAvailable = false;
|
||||
|
@ -8,3 +8,10 @@ export * from './legacyAngularInjector';
|
||||
export * from './live';
|
||||
export * from './LocationService';
|
||||
export * from './appEvents';
|
||||
export { setPluginsExtensionRegistry } from './pluginExtensions/registry';
|
||||
export type { PluginsExtensionRegistry, PluginsExtensionLink, PluginsExtension } from './pluginExtensions/registry';
|
||||
export {
|
||||
type GetPluginExtensionsOptions,
|
||||
type PluginExtensionsResult,
|
||||
getPluginExtensions,
|
||||
} from './pluginExtensions/extensions';
|
||||
|
@ -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;
|
||||
}
|
@ -126,6 +126,7 @@ type FrontendSettingsDTO struct {
|
||||
Datasources map[string]plugins.DataSourceDTO `json:"datasources"`
|
||||
MinRefreshInterval string `json:"minRefreshInterval"`
|
||||
Panels map[string]plugins.PanelDTO `json:"panels"`
|
||||
Apps map[string]*plugins.AppDTO `json:"apps"`
|
||||
AppUrl string `json:"appUrl"`
|
||||
AppSubUrl string `json:"appSubUrl"`
|
||||
AllowOrgCreate bool `json:"allowOrgCreate"`
|
||||
@ -172,7 +173,6 @@ type FrontendSettingsDTO struct {
|
||||
AngularSupportEnabled bool `json:"angularSupportEnabled"`
|
||||
EditorsCanAdmin bool `json:"editorsCanAdmin"`
|
||||
DisableSanitizeHtml bool `json:"disableSanitizeHtml"`
|
||||
PluginsToPreload []*plugins.PreloadPlugin `json:"pluginsToPreload"`
|
||||
|
||||
Auth FrontendSettingsAuthDTO `json:"auth"`
|
||||
|
||||
|
@ -31,22 +31,20 @@ func (hs *HTTPServer) GetFrontendSettings(c *contextmodel.ReqContext) {
|
||||
|
||||
// getFrontendSettings returns a json object with all the settings needed for front end initialisation.
|
||||
func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.FrontendSettingsDTO, error) {
|
||||
enabledPlugins, err := hs.enabledPlugins(c.Req.Context(), c.OrgID)
|
||||
availablePlugins, err := hs.availablePlugins(c.Req.Context(), c.OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pluginsToPreload := make([]*plugins.PreloadPlugin, 0)
|
||||
for _, app := range enabledPlugins[plugins.App] {
|
||||
if app.Preload {
|
||||
pluginsToPreload = append(pluginsToPreload, &plugins.PreloadPlugin{
|
||||
Path: app.Module,
|
||||
Version: app.Info.Version,
|
||||
})
|
||||
}
|
||||
apps := make(map[string]*plugins.AppDTO, 0)
|
||||
for _, ap := range availablePlugins[plugins.App] {
|
||||
apps[ap.Plugin.ID] = newAppDTO(
|
||||
ap.Plugin,
|
||||
ap.Settings,
|
||||
)
|
||||
}
|
||||
|
||||
dataSources, err := hs.getFSDataSources(c, enabledPlugins)
|
||||
dataSources, err := hs.getFSDataSources(c, availablePlugins)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -59,7 +57,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
}
|
||||
|
||||
panels := make(map[string]plugins.PanelDTO)
|
||||
for _, panel := range enabledPlugins[plugins.Panel] {
|
||||
for _, ap := range availablePlugins[plugins.Panel] {
|
||||
panel := ap.Plugin
|
||||
if panel.State == plugins.AlphaRelease && !hs.Cfg.PluginsEnableAlpha {
|
||||
continue
|
||||
}
|
||||
@ -102,6 +101,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
Datasources: dataSources,
|
||||
MinRefreshInterval: setting.MinRefreshInterval,
|
||||
Panels: panels,
|
||||
Apps: apps,
|
||||
AppUrl: hs.Cfg.AppURL,
|
||||
AppSubUrl: hs.Cfg.AppSubURL,
|
||||
AllowOrgCreate: (setting.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin,
|
||||
@ -143,7 +143,6 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
|
||||
AngularSupportEnabled: hs.Cfg.AngularSupportEnabled,
|
||||
EditorsCanAdmin: hs.Cfg.EditorsCanAdmin,
|
||||
DisableSanitizeHtml: hs.Cfg.DisableSanitizeHtml,
|
||||
PluginsToPreload: pluginsToPreload,
|
||||
DateFormats: hs.Cfg.DateFormats,
|
||||
|
||||
Auth: dtos.FrontendSettingsAuthDTO{
|
||||
@ -259,7 +258,7 @@ func isSupportBundlesEnabled(hs *HTTPServer) bool {
|
||||
hs.Features.IsEnabled(featuremgmt.FlagSupportBundles)
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, enabledPlugins EnabledPlugins) (map[string]plugins.DataSourceDTO, error) {
|
||||
func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, availablePlugins AvailablePlugins) (map[string]plugins.DataSourceDTO, error) {
|
||||
orgDataSources := make([]*datasources.DataSource, 0)
|
||||
if c.OrgID != 0 {
|
||||
query := datasources.GetDataSourcesQuery{OrgID: c.OrgID, DataSourceLimit: hs.Cfg.DataSourceLimit}
|
||||
@ -300,11 +299,12 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, enabledPlugin
|
||||
ReadOnly: ds.ReadOnly,
|
||||
}
|
||||
|
||||
plugin, exists := enabledPlugins.Get(plugins.DataSource, ds.Type)
|
||||
ap, exists := availablePlugins.Get(plugins.DataSource, ds.Type)
|
||||
if !exists {
|
||||
c.Logger.Error("Could not find plugin definition for data source", "datasource_type", ds.Type)
|
||||
continue
|
||||
}
|
||||
plugin := ap.Plugin
|
||||
dsDTO.Preload = plugin.Preload
|
||||
dsDTO.Module = plugin.Module
|
||||
dsDTO.PluginMeta = &plugins.PluginMetaDTO{
|
||||
@ -397,6 +397,21 @@ func (hs *HTTPServer) getFSDataSources(c *contextmodel.ReqContext, enabledPlugin
|
||||
return dataSources, nil
|
||||
}
|
||||
|
||||
func newAppDTO(plugin plugins.PluginDTO, settings pluginsettings.InfoDTO) *plugins.AppDTO {
|
||||
app := &plugins.AppDTO{
|
||||
ID: plugin.ID,
|
||||
Version: plugin.Info.Version,
|
||||
Path: plugin.Module,
|
||||
Preload: plugin.Preload,
|
||||
}
|
||||
|
||||
if settings.Enabled {
|
||||
app.Extensions = plugin.Extensions
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
func getPanelSort(id string) int {
|
||||
sort := 100
|
||||
switch id {
|
||||
@ -438,52 +453,66 @@ func getPanelSort(id string) int {
|
||||
return sort
|
||||
}
|
||||
|
||||
// EnabledPlugins represents a mapping from plugin types (panel, data source, etc.) to plugin IDs to plugins
|
||||
type availablePluginDTO struct {
|
||||
Plugin plugins.PluginDTO
|
||||
Settings pluginsettings.InfoDTO
|
||||
}
|
||||
|
||||
// AvailablePlugins represents a mapping from plugin types (panel, data source, etc.) to plugin IDs to plugins
|
||||
// For example ["panel"] -> ["piechart"] -> {pie chart plugin DTO}
|
||||
type EnabledPlugins map[plugins.Type]map[string]plugins.PluginDTO
|
||||
type AvailablePlugins map[plugins.Type]map[string]*availablePluginDTO
|
||||
|
||||
func (ep EnabledPlugins) Get(pluginType plugins.Type, pluginID string) (plugins.PluginDTO, bool) {
|
||||
if _, exists := ep[pluginType][pluginID]; exists {
|
||||
return ep[pluginType][pluginID], true
|
||||
func (ap AvailablePlugins) Get(pluginType plugins.Type, pluginID string) (*availablePluginDTO, bool) {
|
||||
if _, exists := ap[pluginType][pluginID]; exists {
|
||||
return ap[pluginType][pluginID], true
|
||||
}
|
||||
|
||||
return plugins.PluginDTO{}, false
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) enabledPlugins(ctx context.Context, orgID int64) (EnabledPlugins, error) {
|
||||
ep := make(EnabledPlugins)
|
||||
func (hs *HTTPServer) availablePlugins(ctx context.Context, orgID int64) (AvailablePlugins, error) {
|
||||
ap := make(AvailablePlugins)
|
||||
|
||||
pluginSettingMap, err := hs.pluginSettings(ctx, orgID)
|
||||
if err != nil {
|
||||
return ep, err
|
||||
return ap, err
|
||||
}
|
||||
|
||||
apps := make(map[string]plugins.PluginDTO)
|
||||
apps := make(map[string]*availablePluginDTO)
|
||||
for _, app := range hs.pluginStore.Plugins(ctx, plugins.App) {
|
||||
if b, exists := pluginSettingMap[app.ID]; exists {
|
||||
app.Pinned = b.Pinned
|
||||
apps[app.ID] = app
|
||||
if s, exists := pluginSettingMap[app.ID]; exists {
|
||||
app.Pinned = s.Pinned
|
||||
apps[app.ID] = &availablePluginDTO{
|
||||
Plugin: app,
|
||||
Settings: *s,
|
||||
}
|
||||
}
|
||||
ep[plugins.App] = apps
|
||||
}
|
||||
ap[plugins.App] = apps
|
||||
|
||||
dataSources := make(map[string]plugins.PluginDTO)
|
||||
dataSources := make(map[string]*availablePluginDTO)
|
||||
for _, ds := range hs.pluginStore.Plugins(ctx, plugins.DataSource) {
|
||||
if _, exists := pluginSettingMap[ds.ID]; exists {
|
||||
dataSources[ds.ID] = ds
|
||||
if s, exists := pluginSettingMap[ds.ID]; exists {
|
||||
dataSources[ds.ID] = &availablePluginDTO{
|
||||
Plugin: ds,
|
||||
Settings: *s,
|
||||
}
|
||||
}
|
||||
ep[plugins.DataSource] = dataSources
|
||||
}
|
||||
ap[plugins.DataSource] = dataSources
|
||||
|
||||
panels := make(map[string]plugins.PluginDTO)
|
||||
panels := make(map[string]*availablePluginDTO)
|
||||
for _, p := range hs.pluginStore.Plugins(ctx, plugins.Panel) {
|
||||
if _, exists := pluginSettingMap[p.ID]; exists {
|
||||
panels[p.ID] = p
|
||||
if s, exists := pluginSettingMap[p.ID]; exists {
|
||||
panels[p.ID] = &availablePluginDTO{
|
||||
Plugin: p,
|
||||
Settings: *s,
|
||||
}
|
||||
}
|
||||
ep[plugins.Panel] = panels
|
||||
}
|
||||
ap[plugins.Panel] = panels
|
||||
|
||||
return ep, nil
|
||||
return ap, nil
|
||||
}
|
||||
|
||||
func (hs *HTTPServer) pluginSettings(ctx context.Context, orgID int64) (map[string]*pluginsettings.InfoDTO, error) {
|
||||
|
@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
@ -15,20 +16,19 @@ import (
|
||||
"github.com/grafana/grafana/pkg/login/social"
|
||||
"github.com/grafana/grafana/pkg/plugins"
|
||||
"github.com/grafana/grafana/pkg/plugins/config"
|
||||
"github.com/grafana/grafana/pkg/plugins/plugindef"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/licensing"
|
||||
pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service"
|
||||
pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings"
|
||||
"github.com/grafana/grafana/pkg/services/rendering"
|
||||
"github.com/grafana/grafana/pkg/services/secrets/fakes"
|
||||
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
|
||||
"github.com/grafana/grafana/pkg/services/updatechecker"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
)
|
||||
|
||||
func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt.FeatureManager) (*web.Mux, *HTTPServer) {
|
||||
func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt.FeatureManager, pstore plugins.Store, psettings pluginSettings.Service) (*web.Mux, *HTTPServer) {
|
||||
t.Helper()
|
||||
db.InitTestDB(t)
|
||||
cfg.IsFeatureToggleEnabled = features.IsEnabled
|
||||
@ -44,8 +44,15 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt.
|
||||
})
|
||||
}
|
||||
|
||||
sqlStore := db.InitTestDB(t)
|
||||
secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore())
|
||||
var pluginStore = pstore
|
||||
if pluginStore == nil {
|
||||
pluginStore = &plugins.FakePluginStore{}
|
||||
}
|
||||
|
||||
var pluginsSettings = psettings
|
||||
if pluginsSettings == nil {
|
||||
pluginsSettings = &pluginSettings.FakePluginSettings{}
|
||||
}
|
||||
|
||||
hs := &HTTPServer{
|
||||
Cfg: cfg,
|
||||
@ -55,12 +62,12 @@ func setupTestEnvironment(t *testing.T, cfg *setting.Cfg, features *featuremgmt.
|
||||
Cfg: cfg,
|
||||
RendererPluginManager: &fakeRendererManager{},
|
||||
},
|
||||
SQLStore: sqlStore,
|
||||
SQLStore: db.InitTestDB(t),
|
||||
SettingsProvider: setting.ProvideProvider(cfg),
|
||||
pluginStore: &plugins.FakePluginStore{},
|
||||
pluginStore: pluginStore,
|
||||
grafanaUpdateChecker: &updatechecker.GrafanaService{},
|
||||
AccessControl: accesscontrolmock.New().WithDisabled(),
|
||||
PluginSettings: pluginSettings.ProvideService(sqlStore, secretsService),
|
||||
PluginSettings: pluginsSettings,
|
||||
pluginsCDNService: pluginscdn.ProvideService(&config.Cfg{
|
||||
PluginsCDNURLTemplate: cfg.PluginsCDNURLTemplate,
|
||||
PluginSettings: cfg.PluginSettings,
|
||||
@ -91,7 +98,7 @@ func TestHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) {
|
||||
cfg.BuildVersion = "7.8.9"
|
||||
cfg.BuildCommit = "01234567"
|
||||
|
||||
m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures())
|
||||
m, hs := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
|
||||
|
||||
@ -182,7 +189,7 @@ func TestHTTPServer_GetFrontendSettings_pluginsCDNBaseURL(t *testing.T) {
|
||||
if test.mutateCfg != nil {
|
||||
test.mutateCfg(cfg)
|
||||
}
|
||||
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures())
|
||||
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), nil, nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -195,3 +202,154 @@ func TestHTTPServer_GetFrontendSettings_pluginsCDNBaseURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPServer_GetFrontendSettings_apps(t *testing.T) {
|
||||
type settings struct {
|
||||
Apps map[string]*plugins.AppDTO `json:"apps"`
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
desc string
|
||||
pluginStore func() plugins.Store
|
||||
pluginSettings func() pluginSettings.Service
|
||||
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{
|
||||
{
|
||||
Target: "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{
|
||||
{
|
||||
Target: "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{
|
||||
{
|
||||
Target: "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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.desc, func(t *testing.T) {
|
||||
cfg := setting.NewCfg()
|
||||
m, _ := setupTestEnvironment(t, cfg, featuremgmt.WithFeatures(), test.pluginStore(), test.pluginSettings())
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/frontend/settings", nil)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
m.ServeHTTP(recorder, req)
|
||||
var got settings
|
||||
err := json.Unmarshal(recorder.Body.Bytes(), &got)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.EqualValues(t, test.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newAppSettings(id string, enabled bool) map[string]*pluginSettings.DTO {
|
||||
return map[string]*pluginSettings.DTO{
|
||||
id: {
|
||||
ID: 0,
|
||||
OrgID: 1,
|
||||
PluginID: id,
|
||||
Enabled: enabled,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -334,6 +334,12 @@ func (l *Loader) readPluginJSON(pluginJSONPath string) (plugins.JSONData, error)
|
||||
}
|
||||
}
|
||||
|
||||
for i, extension := range plugin.Extensions {
|
||||
if !filepath.IsAbs(extension.Path) {
|
||||
plugin.Extensions[i].Path = path.Join("/", extension.Path)
|
||||
}
|
||||
}
|
||||
|
||||
return plugin, nil
|
||||
}
|
||||
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins/manager/loader/assetpath"
|
||||
"github.com/grafana/grafana/pkg/plugins/plugindef"
|
||||
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
@ -462,7 +463,72 @@ 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{
|
||||
{
|
||||
Target: "plugins/grafana-slo-app/slo-breach",
|
||||
Title: "Declare incident",
|
||||
Type: plugindef.ExtensionsLinkTypeLink,
|
||||
Description: "Declares a new incident",
|
||||
Path: "/incidents/declare",
|
||||
},
|
||||
{
|
||||
Target: "plugins/grafana-slo-app/slo-breach",
|
||||
Title: "Declare incident",
|
||||
Type: plugindef.ExtensionsLinkTypeLink,
|
||||
Description: "Declares a new incident (path without backslash)",
|
||||
Path: "/incidents/declare",
|
||||
},
|
||||
},
|
||||
Backend: false,
|
||||
},
|
||||
DefaultNavURL: "/plugins/test-app/page/root-page-react",
|
||||
PluginDir: filepath.Join(parentDir, "testdata/test-app-with-link-extensions"),
|
||||
Class: plugins.External,
|
||||
Signature: plugins.SignatureUnsigned,
|
||||
Module: "plugins/test-app/module",
|
||||
BaseURL: "public/plugins/test-app",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
reg := fakes.NewFakePluginRegistry()
|
||||
storage := fakes.NewFakePluginStorage()
|
||||
|
56
pkg/plugins/manager/testdata/test-app-with-link-extensions/plugin.json
vendored
Normal file
56
pkg/plugins/manager/testdata/test-app-with-link-extensions/plugin.json
vendored
Normal file
@ -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"
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/plugins/plugindef"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
)
|
||||
|
||||
@ -253,6 +254,14 @@ type PanelDTO struct {
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
type AppDTO struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Version string `json:"version"`
|
||||
Preload bool `json:"preload"`
|
||||
Extensions []*plugindef.ExtensionsLink `json:"extensions,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
signatureMissing ErrorCode = "signatureMissing"
|
||||
signatureModified ErrorCode = "signatureModified"
|
||||
@ -266,11 +275,6 @@ type Error struct {
|
||||
PluginID string `json:"pluginId,omitempty"`
|
||||
}
|
||||
|
||||
type PreloadPlugin struct {
|
||||
Path string `json:"path"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// Access-Control related definitions
|
||||
|
||||
// RoleRegistration stores a role and its assignments to basic roles
|
||||
|
@ -84,6 +84,10 @@ func TestParsePluginTestdata(t *testing.T) {
|
||||
rootid: "test-app",
|
||||
skip: "has a 'page'-type include which isn't a known part of spec",
|
||||
},
|
||||
"test-app-with-link-extensions": {
|
||||
rootid: "test-app",
|
||||
skip: "has a 'page'-type include which isn't a known part of spec",
|
||||
},
|
||||
"test-app-with-roles": {
|
||||
rootid: "test-app",
|
||||
},
|
||||
|
@ -1,8 +1,8 @@
|
||||
package plugindef
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/thema"
|
||||
)
|
||||
@ -122,6 +122,23 @@ seqs: [
|
||||
...
|
||||
}
|
||||
|
||||
#ExtensionsLink: {
|
||||
// Target where the link will be rendered
|
||||
target: =~"^(plugins|grafana)\/[a-z-/0-9]*$"
|
||||
// Type of extension
|
||||
type: "link"
|
||||
// Title that will be displayed for the rendered link
|
||||
title: string & strings.MinRunes(3) & strings.MaxRunes(22)
|
||||
// Description for the rendered link
|
||||
description: string & strings.MaxRunes(200)
|
||||
// Path relative to the extending plugin e.g. /incidents/declare
|
||||
path: =~"^\/.*"
|
||||
...
|
||||
}
|
||||
|
||||
// Extensions made by the current plugin.
|
||||
extensions?: [...#ExtensionsLink]
|
||||
|
||||
// For data source plugins, if the plugin supports logs.
|
||||
logs?: bool
|
||||
|
||||
@ -175,9 +192,9 @@ seqs: [
|
||||
// each of which has an action and an optional scope.
|
||||
// Example: the role 'Schedules Reader' bundles permissions to view all schedules of the plugin.
|
||||
#Role: {
|
||||
name: string,
|
||||
name: string
|
||||
name: =~"^([A-Z][0-9A-Za-z ]+)$"
|
||||
description: string,
|
||||
description: string
|
||||
permissions: [...#Permission]
|
||||
}
|
||||
|
||||
|
@ -29,6 +29,11 @@ const (
|
||||
DependencyTypePanel DependencyType = "panel"
|
||||
)
|
||||
|
||||
// Defines values for ExtensionsLinkType.
|
||||
const (
|
||||
ExtensionsLinkTypeLink ExtensionsLinkType = "link"
|
||||
)
|
||||
|
||||
// Defines values for IncludeRole.
|
||||
const (
|
||||
IncludeRoleAdmin IncludeRole = "Admin"
|
||||
@ -148,6 +153,27 @@ type Dependency struct {
|
||||
// DependencyType defines model for Dependency.Type.
|
||||
type DependencyType string
|
||||
|
||||
// ExtensionsLink defines model for ExtensionsLink.
|
||||
type ExtensionsLink struct {
|
||||
// Description for the rendered link
|
||||
Description string `json:"description"`
|
||||
|
||||
// Path relative to the extending plugin e.g. /incidents/declare
|
||||
Path string `json:"path"`
|
||||
|
||||
// Target where the link will be rendered
|
||||
Target string `json:"target"`
|
||||
|
||||
// Title that will be displayed for the rendered link
|
||||
Title string `json:"title"`
|
||||
|
||||
// Type of extension
|
||||
Type ExtensionsLinkType `json:"type"`
|
||||
}
|
||||
|
||||
// Type of extension
|
||||
type ExtensionsLinkType string
|
||||
|
||||
// Header describes an HTTP header that is forwarded with a proxied request for
|
||||
// a plugin route.
|
||||
type Header struct {
|
||||
@ -314,6 +340,9 @@ type PluginDef struct {
|
||||
// https://golang.org/doc/install/source#environment.
|
||||
Executable *string `json:"executable,omitempty"`
|
||||
|
||||
// Extensions made by the current plugin.
|
||||
Extensions *[]ExtensionsLink `json:"extensions,omitempty"`
|
||||
|
||||
// For data source plugins, include hidden queries in the data
|
||||
// request.
|
||||
HiddenQueries *bool `json:"hiddenQueries,omitempty"`
|
||||
|
@ -15,6 +15,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/pluginextensionv2"
|
||||
"github.com/grafana/grafana/pkg/plugins/backendplugin/secretsmanagerplugin"
|
||||
"github.com/grafana/grafana/pkg/plugins/plugindef"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
@ -146,6 +147,7 @@ type JSONData struct {
|
||||
|
||||
// App settings
|
||||
AutoEnabled bool `json:"autoEnabled"`
|
||||
Extensions []*plugindef.ExtensionsLink `json:"extensions"`
|
||||
|
||||
// Datasource settings
|
||||
Annotations bool `json:"annotations"`
|
||||
|
@ -33,6 +33,7 @@ import {
|
||||
setQueryRunnerFactory,
|
||||
setRunRequest,
|
||||
setPluginImportUtils,
|
||||
setPluginsExtensionRegistry,
|
||||
} from '@grafana/runtime';
|
||||
import { setPanelDataErrorView } from '@grafana/runtime/src/components/PanelDataErrorView';
|
||||
import { setPanelRenderer } from '@grafana/runtime/src/components/PanelRenderer';
|
||||
@ -69,6 +70,7 @@ import { getTimeSrv } from './features/dashboard/services/TimeSrv';
|
||||
import { PanelDataErrorView } from './features/panel/components/PanelDataErrorView';
|
||||
import { PanelRenderer } from './features/panel/components/PanelRenderer';
|
||||
import { DatasourceSrv } from './features/plugins/datasource_srv';
|
||||
import { createPluginExtensionsRegistry } from './features/plugins/extensions/registry';
|
||||
import { importPanelPlugin, syncGetPanelPlugin } from './features/plugins/importPanelPlugin';
|
||||
import { preloadPlugins } from './features/plugins/pluginPreloader';
|
||||
import { QueryRunner } from './features/query/state/QueryRunner';
|
||||
@ -168,6 +170,9 @@ export class GrafanaApp {
|
||||
setDataSourceSrv(dataSourceSrv);
|
||||
initWindowRuntime();
|
||||
|
||||
const pluginExtensionRegistry = createPluginExtensionsRegistry(config.apps);
|
||||
setPluginsExtensionRegistry(pluginExtensionRegistry);
|
||||
|
||||
// init modal manager
|
||||
const modalManager = new ModalManager();
|
||||
modalManager.init();
|
||||
@ -176,7 +181,7 @@ export class GrafanaApp {
|
||||
initI18nPromise,
|
||||
|
||||
// Preload selected app plugins
|
||||
await preloadPlugins(config.pluginsToPreload),
|
||||
await preloadPlugins(config.apps),
|
||||
]);
|
||||
|
||||
// initialize chrome service
|
||||
|
100
public/app/features/plugins/extensions/registry.test.ts
Normal file
100
public/app/features/plugins/extensions/registry.test.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
54
public/app/features/plugins/extensions/registry.ts
Normal file
54
public/app/features/plugins/extensions/registry.ts
Normal file
@ -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);
|
||||
}
|
@ -1,12 +1,13 @@
|
||||
import { PreloadPlugin } from '@grafana/data';
|
||||
import { AppPluginConfig } from '@grafana/runtime';
|
||||
|
||||
import { importPluginModule } from './plugin_loader';
|
||||
|
||||
export async function preloadPlugins(pluginsToPreload: PreloadPlugin[] = []): Promise<void> {
|
||||
export async function preloadPlugins(apps: Record<string, AppPluginConfig> = {}): Promise<void> {
|
||||
const pluginsToPreload = Object.values(apps).filter((app) => app.preload);
|
||||
await Promise.all(pluginsToPreload.map(preloadPlugin));
|
||||
}
|
||||
|
||||
async function preloadPlugin(plugin: PreloadPlugin): Promise<void> {
|
||||
async function preloadPlugin(plugin: AppPluginConfig): Promise<void> {
|
||||
const { path, version } = plugin;
|
||||
try {
|
||||
await importPluginModule(path, version);
|
||||
|
@ -3,8 +3,9 @@ import { useObservable } from 'react-use';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { ApplyFieldOverrideOptions, dateMath, FieldColorModeId, NavModelItem, PanelData } from '@grafana/data';
|
||||
import { getPluginExtensions } from '@grafana/runtime';
|
||||
import { DataTransformerConfig } from '@grafana/schema';
|
||||
import { Button, Table } from '@grafana/ui';
|
||||
import { Button, HorizontalGroup, LinkButton, Table } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { config } from 'app/core/config';
|
||||
import { useAppNotification } from 'app/core/copy/appNotification';
|
||||
@ -60,6 +61,9 @@ export const TestStuffPage = () => {
|
||||
return (
|
||||
<Page navModel={{ node: node, main: node }}>
|
||||
<Page.Contents>
|
||||
<HorizontalGroup>
|
||||
<LinkToBasicApp target="grafana/sandbox/testing" />
|
||||
</HorizontalGroup>
|
||||
{data && (
|
||||
<AutoSizer style={{ width: '100%', height: '600px' }}>
|
||||
{({ width }) => {
|
||||
@ -144,4 +148,24 @@ export function getDefaultState(): State {
|
||||
};
|
||||
}
|
||||
|
||||
function LinkToBasicApp({ target }: { target: string }) {
|
||||
const { extensions, error } = getPluginExtensions({ target });
|
||||
|
||||
if (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{extensions.map((extension) => {
|
||||
return (
|
||||
<LinkButton href={extension.href} title={extension.description} key={extension.key}>
|
||||
{extension.title}
|
||||
</LinkButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TestStuffPage;
|
||||
|
Reference in New Issue
Block a user