diff --git a/docs/sources/developers/plugins/plugin.schema.json b/docs/sources/developers/plugins/plugin.schema.json index 97c59c5495c..90958308b7c 100644 --- a/docs/sources/developers/plugins/plugin.schema.json +++ b/docs/sources/developers/plugins/plugin.schema.json @@ -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": "^/.*" + } + } + } } } } diff --git a/packages/grafana-data/src/types/config.ts b/packages/grafana-data/src/types/config.ts index 7df2e80381e..170b8d0ddb1 100644 --- a/packages/grafana-data/src/types/config.ts +++ b/packages/grafana-data/src/types/config.ts @@ -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; diff --git a/packages/grafana-data/src/types/index.ts b/packages/grafana-data/src/types/index.ts index 0c55fe6879e..e1eac8c160a 100644 --- a/packages/grafana-data/src/types/index.ts +++ b/packages/grafana-data/src/types/index.ts @@ -45,7 +45,6 @@ export type { GrafanaConfig, BuildInfo, LicenseInfo, - PreloadPlugin, } from './config'; export type { FeatureToggles } from './featureToggles.gen'; export * from './alerts'; diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index c53b35d0fbb..359f512b3cd 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -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 = {}; 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; diff --git a/packages/grafana-runtime/src/services/index.ts b/packages/grafana-runtime/src/services/index.ts index 04b7de736fd..03fd63db860 100644 --- a/packages/grafana-runtime/src/services/index.ts +++ b/packages/grafana-runtime/src/services/index.ts @@ -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'; diff --git a/packages/grafana-runtime/src/services/pluginExtensions/extensions.test.ts b/packages/grafana-runtime/src/services/pluginExtensions/extensions.test.ts new file mode 100644 index 00000000000..c3f83c5a667 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/extensions.test.ts @@ -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); + }); + }); +}); diff --git a/packages/grafana-runtime/src/services/pluginExtensions/extensions.ts b/packages/grafana-runtime/src/services/pluginExtensions/extensions.ts new file mode 100644 index 00000000000..55ea76b3ba3 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/extensions.ts @@ -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 }; +} diff --git a/packages/grafana-runtime/src/services/pluginExtensions/registry.ts b/packages/grafana-runtime/src/services/pluginExtensions/registry.ts new file mode 100644 index 00000000000..27a2a8d72f4 --- /dev/null +++ b/packages/grafana-runtime/src/services/pluginExtensions/registry.ts @@ -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; + +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; +} diff --git a/pkg/api/dtos/frontend_settings.go b/pkg/api/dtos/frontend_settings.go index edd62f527df..e54cc01accd 100644 --- a/pkg/api/dtos/frontend_settings.go +++ b/pkg/api/dtos/frontend_settings.go @@ -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"` @@ -158,21 +159,20 @@ type FrontendSettingsDTO struct { RudderstackSdkUrl string `json:"rudderstackSdkUrl"` RudderstackConfigUrl string `json:"rudderstackConfigUrl"` - FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"` - ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"` - ApplicationInsightsEndpointUrl string `json:"applicationInsightsEndpointUrl"` - DisableLoginForm bool `json:"disableLoginForm"` - DisableUserSignUp bool `json:"disableUserSignUp"` - LoginHint string `json:"loginHint"` - PasswordHint string `json:"passwordHint"` - ExternalUserMngInfo string `json:"externalUserMngInfo"` - ExternalUserMngLinkUrl string `json:"externalUserMngLinkUrl"` - ExternalUserMngLinkName string `json:"externalUserMngLinkName"` - ViewersCanEdit bool `json:"viewersCanEdit"` - AngularSupportEnabled bool `json:"angularSupportEnabled"` - EditorsCanAdmin bool `json:"editorsCanAdmin"` - DisableSanitizeHtml bool `json:"disableSanitizeHtml"` - PluginsToPreload []*plugins.PreloadPlugin `json:"pluginsToPreload"` + FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"` + ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"` + ApplicationInsightsEndpointUrl string `json:"applicationInsightsEndpointUrl"` + DisableLoginForm bool `json:"disableLoginForm"` + DisableUserSignUp bool `json:"disableUserSignUp"` + LoginHint string `json:"loginHint"` + PasswordHint string `json:"passwordHint"` + ExternalUserMngInfo string `json:"externalUserMngInfo"` + ExternalUserMngLinkUrl string `json:"externalUserMngLinkUrl"` + ExternalUserMngLinkName string `json:"externalUserMngLinkName"` + ViewersCanEdit bool `json:"viewersCanEdit"` + AngularSupportEnabled bool `json:"angularSupportEnabled"` + EditorsCanAdmin bool `json:"editorsCanAdmin"` + DisableSanitizeHtml bool `json:"disableSanitizeHtml"` Auth FrontendSettingsAuthDTO `json:"auth"` diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 621125c111e..f4650b60ab9 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -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 -// For example ["panel"] -> ["piechart"] -> {pie chart plugin DTO} -type EnabledPlugins map[plugins.Type]map[string]plugins.PluginDTO - -func (ep EnabledPlugins) Get(pluginType plugins.Type, pluginID string) (plugins.PluginDTO, bool) { - if _, exists := ep[pluginType][pluginID]; exists { - return ep[pluginType][pluginID], true - } - - return plugins.PluginDTO{}, false +type availablePluginDTO struct { + Plugin plugins.PluginDTO + Settings pluginsettings.InfoDTO } -func (hs *HTTPServer) enabledPlugins(ctx context.Context, orgID int64) (EnabledPlugins, error) { - ep := make(EnabledPlugins) +// 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 AvailablePlugins map[plugins.Type]map[string]*availablePluginDTO + +func (ap AvailablePlugins) Get(pluginType plugins.Type, pluginID string) (*availablePluginDTO, bool) { + if _, exists := ap[pluginType][pluginID]; exists { + return ap[pluginType][pluginID], true + } + + return nil, false +} + +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) { diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index 6b44f8cda17..dcc1fe51dec 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -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, + }, + }, + } +} diff --git a/pkg/plugins/manager/loader/loader.go b/pkg/plugins/manager/loader/loader.go index 1d7d99bcbea..12230eaf782 100644 --- a/pkg/plugins/manager/loader/loader.go +++ b/pkg/plugins/manager/loader/loader.go @@ -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 } diff --git a/pkg/plugins/manager/loader/loader_test.go b/pkg/plugins/manager/loader/loader_test.go index 190ae69a90e..d89bafd6782 100644 --- a/pkg/plugins/manager/loader/loader_test.go +++ b/pkg/plugins/manager/loader/loader_test.go @@ -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() diff --git a/pkg/plugins/manager/testdata/test-app-with-link-extensions/plugin.json b/pkg/plugins/manager/testdata/test-app-with-link-extensions/plugin.json new file mode 100644 index 00000000000..9c9f6b378ab --- /dev/null +++ b/pkg/plugins/manager/testdata/test-app-with-link-extensions/plugin.json @@ -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" + } +} \ No newline at end of file diff --git a/pkg/plugins/models.go b/pkg/plugins/models.go index 1d3af6e1b33..e9fd08f2b0a 100644 --- a/pkg/plugins/models.go +++ b/pkg/plugins/models.go @@ -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 diff --git a/pkg/plugins/pfs/pfs_test.go b/pkg/plugins/pfs/pfs_test.go index bef758a5924..d86c4e64764 100644 --- a/pkg/plugins/pfs/pfs_test.go +++ b/pkg/plugins/pfs/pfs_test.go @@ -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", }, diff --git a/pkg/plugins/plugindef/plugindef.cue b/pkg/plugins/plugindef/plugindef.cue index 75e325d0edf..56f4ba1996b 100644 --- a/pkg/plugins/plugindef/plugindef.cue +++ b/pkg/plugins/plugindef/plugindef.cue @@ -1,9 +1,9 @@ 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: =~"^([A-Z][0-9A-Za-z ]+)$" - description: string, + name: string + name: =~"^([A-Z][0-9A-Za-z ]+)$" + description: string permissions: [...#Permission] } diff --git a/pkg/plugins/plugindef/plugindef_types_gen.go b/pkg/plugins/plugindef/plugindef_types_gen.go index f2e8414a3c3..ebf145eb161 100644 --- a/pkg/plugins/plugindef/plugindef_types_gen.go +++ b/pkg/plugins/plugindef/plugindef_types_gen.go @@ -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"` diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index b9d1a210583..3cb95d2fc3a 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -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" ) @@ -145,7 +146,8 @@ type JSONData struct { SkipDataQuery bool `json:"skipDataQuery"` // App settings - AutoEnabled bool `json:"autoEnabled"` + AutoEnabled bool `json:"autoEnabled"` + Extensions []*plugindef.ExtensionsLink `json:"extensions"` // Datasource settings Annotations bool `json:"annotations"` diff --git a/public/app/app.ts b/public/app/app.ts index 560a1ae2165..8bbdd55eb1b 100644 --- a/public/app/app.ts +++ b/public/app/app.ts @@ -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 diff --git a/public/app/features/plugins/extensions/registry.test.ts b/public/app/features/plugins/extensions/registry.test.ts new file mode 100644 index 00000000000..9fcacc8bdd7 --- /dev/null +++ b/public/app/features/plugins/extensions/registry.test.ts @@ -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, + }; +} diff --git a/public/app/features/plugins/extensions/registry.ts b/public/app/features/plugins/extensions/registry.ts new file mode 100644 index 00000000000..ab2107c83bd --- /dev/null +++ b/public/app/features/plugins/extensions/registry.ts @@ -0,0 +1,54 @@ +import { + AppPluginConfig, + PluginExtensionTypes, + PluginsExtensionLinkConfig, + PluginsExtensionRegistry, + PluginsExtensionLink, +} from '@grafana/runtime'; + +export function createPluginExtensionsRegistry(apps: Record = {}): 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); +} diff --git a/public/app/features/plugins/pluginPreloader.ts b/public/app/features/plugins/pluginPreloader.ts index a8703471d89..4e030b1f143 100644 --- a/public/app/features/plugins/pluginPreloader.ts +++ b/public/app/features/plugins/pluginPreloader.ts @@ -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 { +export async function preloadPlugins(apps: Record = {}): Promise { + const pluginsToPreload = Object.values(apps).filter((app) => app.preload); await Promise.all(pluginsToPreload.map(preloadPlugin)); } -async function preloadPlugin(plugin: PreloadPlugin): Promise { +async function preloadPlugin(plugin: AppPluginConfig): Promise { const { path, version } = plugin; try { await importPluginModule(path, version); diff --git a/public/app/features/sandbox/TestStuffPage.tsx b/public/app/features/sandbox/TestStuffPage.tsx index a1bfb33837b..b9f20805b17 100644 --- a/public/app/features/sandbox/TestStuffPage.tsx +++ b/public/app/features/sandbox/TestStuffPage.tsx @@ -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 ( + + + {data && ( {({ width }) => { @@ -144,4 +148,24 @@ export function getDefaultState(): State { }; } +function LinkToBasicApp({ target }: { target: string }) { + const { extensions, error } = getPluginExtensions({ target }); + + if (error) { + return null; + } + + return ( +
+ {extensions.map((extension) => { + return ( + + {extension.title} + + ); + })} +
+ ); +} + export default TestStuffPage;