Plugin Extensions: Streamline log messages (#95943)

* streamline log messages

* cleanup

* fix tests

* only log errors to the console

* more cleanup again

* cleanup

* Update public/app/features/plugins/extensions/errors.ts

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>

* pr feedback

* remove white space

* Update public/app/features/plugins/extensions/registry/ExposedComponentsRegistry.ts

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>

* revert touched file

* revert touched file

* fix tests

---------

Co-authored-by: Levente Balogh <balogh.levente.hu@gmail.com>
This commit is contained in:
Erik Sundell
2024-11-12 08:55:04 +01:00
committed by GitHub
co-authored by Levente Balogh
parent 477326b9c3
commit 4c0dde6f2f
21 changed files with 737 additions and 743 deletions
@@ -21,10 +21,10 @@ func TestLegacyStorageList(t *testing.T) {
folderService := &foldertest.FakeService{}
folderService.ExpectedFolders = []*folder.Folder{
&folder.Folder{UID: "parent", Title: "Folder Parent", ParentUID: ""},
&folder.Folder{UID: "child", Title: "Folder Child", ParentUID: "parent"},
&folder.Folder{UID: "anotherparent1", Title: "Folder Another Parent 1", ParentUID: ""},
&folder.Folder{UID: "anotherparent1", Title: "Folder Another Parent 2", ParentUID: ""},
{UID: "parent", Title: "Folder Parent", ParentUID: ""},
{UID: "child", Title: "Folder Child", ParentUID: "parent"},
{UID: "anotherparent1", Title: "Folder Another Parent 1", ParentUID: ""},
{UID: "anotherparent1", Title: "Folder Another Parent 2", ParentUID: ""},
}
usr := &user.SignedInUser{UserID: 1}
@@ -0,0 +1,42 @@
export const INVALID_EXTENSION_POINT_ID =
'Invalid usage of extension point. Reason: Extension point id should be prefixed with your plugin id, e.g "myorg-foo-app/toolbar/v1".';
export const EXTENSION_POINT_META_INFO_MISSING =
'Invalid usage of extension point. Reason: The extension point is not recorded in the "plugin.json" file. Extension points must be listed in the section "extensions.extensionPoints[]". Returning an empty array of extensions.';
export const TITLE_MISSING = 'Title is missing.';
export const DESCRIPTION_MISSING = 'Description is missing.';
export const INVALID_CONFIGURE_FUNCTION = 'The "configure" function is invalid. It should be a function.';
export const INVALID_PATH_OR_ON_CLICK = 'Either "path" or "onClick" is required.';
export const INVALID_PATH = 'The "path" is required and should start with "/a/<pluginId>".';
export const INVALID_EXPOSED_COMPONENT_ID =
"The component id does not match the id naming convention. Id should be prefixed with plugin id. e.g 'myorg-basic-app/my-component-id/v1'.";
export const EXPOSED_COMPONENT_ALREADY_EXISTS = 'An exposed component with the same id already exists.';
export const EXPOSED_COMPONENT_META_INFO_MISSING =
'The exposed component was not recorded in the plugin.json. Exposed component extensions must be listed in the section "extensions.exposedComponents[]". Currently, this is only required in development but will be enforced also in production builds in the future.';
export const EXPOSED_COMPONENT_DEPENDENCY_MISSING =
'Invalid usage of extension point. Reason: The exposed component is not recorded in the "plugin.json" file. Exposed components must be listed in the dependencies[] section.';
export const ADDED_COMPONENT_META_INFO_MISSING =
'The extension was not recorded in the plugin.json. Added component extensions must be listed in the section "extensions.addedComponents[]". Currently, this is only required in development but will be enforced also in production builds in the future.';
export const TITLE_NOT_MATCHING_META_INFO = 'The "title" doesn\'t match the title recorded in plugin.json.';
export const ADDED_LINK_META_INFO_MISSING =
'The extension was not recorded in the plugin.json. Added link extensions must be listed in the section "extensions.addedLinks[]". Currently, this is only required in development but will be enforced also in production builds in the future.';
export const DESCRIPTION_NOT_MATCHING_META_INFO =
'The "description" doesn\'t match the description recorded in plugin.json.';
export const TARGET_NOT_MATCHING_META_INFO =
'The "targets" for the registered extension does not match the targets recorded in plugin.json. Currently, this is only required in development but will be enforced also in production builds in the future.';
export const APP_NOT_FOUND = (pluginId: string) => `The app plugin with plugin id "${pluginId}" was not found.`;
@@ -3,6 +3,7 @@ import { nanoid } from 'nanoid';
import { Observable, ReplaySubject } from 'rxjs';
import { Labels, LogLevel } from '@grafana/data';
import { config } from '@grafana/runtime';
export type ExtensionsLogItem = {
level: LogLevel;
@@ -32,7 +33,7 @@ export class ExtensionsLog {
}
warning(message: string, labels?: Labels): void {
console.warn(message, { ...this.baseLabels, ...labels });
config.buildInfo.env === 'development' && console.warn(message, { ...this.baseLabels, ...labels });
this.log(LogLevel.warning, message, labels);
}
@@ -357,29 +357,6 @@ describe('AddedComponentsRegistry', () => {
});
});
it('should log a warning when added component id is not suffixed with component version', async () => {
const registry = new AddedComponentsRegistry();
const extensionPointId = 'grafana/test/home';
registry.register({
pluginId,
configs: [
{
title: 'Component 1 title',
description: 'Component 1 description',
targets: [extensionPointId],
component: () => React.createElement('div', null, 'Hello World1'),
},
],
});
expect(log.warning).toHaveBeenCalledWith(
`Added component "Component 1 title": it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.`
);
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
});
it('should not register component when title is missing', async () => {
const registry = new AddedComponentsRegistry();
const extensionPointId = 'grafana/alerting/home';
@@ -396,7 +373,7 @@ describe('AddedComponentsRegistry', () => {
],
});
expect(log.error).toHaveBeenCalledWith('Could not register added component. Reason: Title is missing.');
expect(log.error).toHaveBeenCalledWith('Could not register component extension. Reason: Title is missing.');
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
@@ -482,7 +459,7 @@ describe('AddedComponentsRegistry', () => {
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
expect(log.warning).toHaveBeenCalled();
expect(log.error).toHaveBeenCalled();
});
it('should register a component added by a core Grafana in dev-mode even if the meta-info is missing', async () => {
@@ -505,7 +482,7 @@ describe('AddedComponentsRegistry', () => {
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.warning).not.toHaveBeenCalled();
expect(log.error).not.toHaveBeenCalled();
});
it('should register a component added by a plugin in production mode even if the meta-info is missing', async () => {
@@ -531,7 +508,7 @@ describe('AddedComponentsRegistry', () => {
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.warning).not.toHaveBeenCalled();
expect(log.error).not.toHaveBeenCalled();
});
it('should register a component added by a plugin in dev-mode if the meta-info is present', async () => {
@@ -2,11 +2,14 @@ import { ReplaySubject } from 'rxjs';
import { PluginExtensionAddedComponentConfig } from '@grafana/data';
import { isAddedComponentMetaInfoMissing, isGrafanaDevMode, wrapWithPluginContext } from '../utils';
import { extensionPointEndsWithVersion, isGrafanaCoreExtensionPoint } from '../validators';
import * as errors from '../errors';
import { isGrafanaDevMode, wrapWithPluginContext } from '../utils';
import { isAddedComponentMetaInfoMissing } from '../validators';
import { PluginExtensionConfigs, Registry, RegistryType } from './Registry';
const logPrefix = 'Could not register component extension. Reason:';
export type AddedComponentRegistryItem<Props = {}> = {
pluginId: string;
title: string;
@@ -41,7 +44,7 @@ export class AddedComponentsRegistry extends Registry<
});
if (!config.title) {
configLog.error(`Could not register added component. Reason: Title is missing.`);
configLog.error(`${logPrefix} ${errors.TITLE_MISSING}`);
continue;
}
@@ -57,12 +60,6 @@ export class AddedComponentsRegistry extends Registry<
for (const extensionPointId of extensionPointIds) {
const pointIdLog = configLog.child({ extensionPointId });
if (!isGrafanaCoreExtensionPoint(extensionPointId) && !extensionPointEndsWithVersion(extensionPointId)) {
pointIdLog.warning(
`Added component "${config.title}": it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.`
);
}
const result = {
pluginId,
component: wrapWithPluginContext(pluginId, config.component, pointIdLog),
@@ -70,7 +67,7 @@ export class AddedComponentsRegistry extends Registry<
title: config.title,
};
pointIdLog.debug(`Added component from '${pluginId}' to '${extensionPointId}'`);
pointIdLog.debug('Added component extension successfully registered');
if (!(extensionPointId in registry)) {
registry[extensionPointId] = [result];
@@ -661,7 +661,7 @@ describe('AddedLinksRegistry', () => {
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
expect(log.warning).toHaveBeenCalled();
expect(log.error).toHaveBeenCalled();
});
it('should register a link added by core Grafana in dev-mode even if the meta-info is missing', async () => {
@@ -685,7 +685,7 @@ describe('AddedLinksRegistry', () => {
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.warning).not.toHaveBeenCalled();
expect(log.error).not.toHaveBeenCalled();
});
it('should register a link added by a plugin in production mode even if the meta-info is missing', async () => {
@@ -712,7 +712,7 @@ describe('AddedLinksRegistry', () => {
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.warning).not.toHaveBeenCalled();
expect(log.error).not.toHaveBeenCalled();
});
it('should register a link added by a plugin in dev-mode if the meta-info is present', async () => {
@@ -739,6 +739,6 @@ describe('AddedLinksRegistry', () => {
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.warning).not.toHaveBeenCalled();
expect(log.error).not.toHaveBeenCalled();
});
});
@@ -3,16 +3,14 @@ import { ReplaySubject } from 'rxjs';
import { IconName, PluginExtensionAddedLinkConfig } from '@grafana/data';
import { PluginAddedLinksConfigureFunc, PluginExtensionEventHelpers } from '@grafana/data/src/types/pluginExtensions';
import { isAddedLinkMetaInfoMissing, isGrafanaDevMode } from '../utils';
import {
extensionPointEndsWithVersion,
isConfigureFnValid,
isGrafanaCoreExtensionPoint,
isLinkPathValid,
} from '../validators';
import * as errors from '../errors';
import { isGrafanaDevMode } from '../utils';
import { isAddedLinkMetaInfoMissing, isConfigureFnValid, isLinkPathValid } from '../validators';
import { PluginExtensionConfigs, Registry, RegistryType } from './Registry';
const logPrefix = 'Could not register link extension. Reason:';
export type AddedLinkRegistryItem<Context extends object = object> = {
pluginId: string;
extensionPointId: string;
@@ -52,29 +50,26 @@ export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], Plugin
});
if (!title) {
configLog.error(`Could not register added link. Reason: Title is missing.`);
configLog.error(`${logPrefix} ${errors.TITLE_MISSING}`);
continue;
}
if (!isConfigureFnValid(configure)) {
configLog.error(`Could not register added link. Reason: configure is not a function.`);
configLog.error(`${logPrefix} ${errors.INVALID_CONFIGURE_FUNCTION}`);
continue;
}
if (!path && !onClick) {
configLog.error(`Could not register added. Reason: Either "path" or "onClick" is required.`);
configLog.error(`${logPrefix} ${errors.INVALID_PATH_OR_ON_CLICK}`);
continue;
}
if (path && !isLinkPathValid(pluginId, path)) {
configLog.error(
`Could not register added link. Reason: The "path" is required and should start with "/a/${pluginId}/".`
);
configLog.error(`${logPrefix} ${errors.INVALID_PATH}`);
continue;
}
if (pluginId !== 'grafana' && isGrafanaDevMode() && isAddedLinkMetaInfoMissing(pluginId, config, configLog)) {
configLog.warning(`Did not register links from plugin ${pluginId} due to missing meta information.`);
continue;
}
@@ -82,20 +77,13 @@ export class AddedLinksRegistry extends Registry<AddedLinkRegistryItem[], Plugin
for (const extensionPointId of extensionPointIds) {
const pointIdLog = configLog.child({ extensionPointId });
if (!isGrafanaCoreExtensionPoint(extensionPointId) && !extensionPointEndsWithVersion(extensionPointId)) {
pointIdLog.warning(
`Added link "${config.title}: it's recommended to suffix the extension point id ("${extensionPointId}") with a version, e.g 'myorg-basic-app/extension-point/v1'.`
);
}
const { targets, ...registryItem } = config;
if (!(extensionPointId in registry)) {
registry[extensionPointId] = [];
}
pointIdLog.debug(`Added link from '${pluginId}' to '${extensionPointId}'`);
pointIdLog.debug('Added link extension successfully registered');
registry[extensionPointId].push({ ...registryItem, pluginId, extensionPointId });
}
@@ -270,7 +270,7 @@ describe('ExposedComponentsRegistry', () => {
});
});
it('should log a warning if another component with the same id already exists in the registry', async () => {
it('should log an error if another component with the same id already exists in the registry', async () => {
const registry = new ExposedComponentsRegistry();
registry.register({
pluginId: 'grafana-basic-app1',
@@ -304,13 +304,13 @@ describe('ExposedComponentsRegistry', () => {
});
expect(log.error).toHaveBeenCalledWith(
"Could not register exposed component with 'grafana-basic-app1/hello-world/v1'. Reason: An exposed component with the same id already exists."
'Could not register exposed component. Reason: An exposed component with the same id already exists.'
);
const currentState2 = await registry.getState();
expect(Object.keys(currentState2)).toHaveLength(1);
});
it('should skip registering component and log a warning when id is not prefixed with plugin id', async () => {
it('should skip registering component and log an error when id is not prefixed with plugin id', async () => {
const registry = new ExposedComponentsRegistry();
registry.register({
pluginId: 'grafana-basic-app1',
@@ -325,33 +325,12 @@ describe('ExposedComponentsRegistry', () => {
});
expect(log.error).toHaveBeenCalledWith(
"Could not register exposed component with 'hello-world/v1'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id. e.g 'myorg-basic-app/my-component-id/v1'."
"Could not register exposed component. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id. e.g 'myorg-basic-app/my-component-id/v1'."
);
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
});
it('should log a error when exposed component id is not suffixed with component version', async () => {
const registry = new ExposedComponentsRegistry();
registry.register({
pluginId: 'grafana-basic-app1',
configs: [
{
id: 'grafana-basic-app1/hello-world',
title: 'not important',
description: 'not important',
component: () => React.createElement('div', null, 'Hello World1'),
},
],
});
expect(log.error).toHaveBeenCalledWith(
"Exposed component does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'."
);
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
});
it('should not register component when title is missing', async () => {
const registry = new ExposedComponentsRegistry();
@@ -367,9 +346,7 @@ describe('ExposedComponentsRegistry', () => {
],
});
expect(log.error).toHaveBeenCalledWith(
"Could not register exposed component with id 'grafana-basic-app/hello-world/v1'. Reason: Title is missing."
);
expect(log.error).toHaveBeenCalledWith('Could not register exposed component. Reason: Title is missing.');
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
@@ -455,7 +432,7 @@ describe('ExposedComponentsRegistry', () => {
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(0);
expect(log.warning).toHaveBeenCalled();
expect(log.error).toHaveBeenCalled();
});
it('should register an exposed component added by a core Grafana in dev-mode even if the meta-info is missing', async () => {
@@ -478,7 +455,7 @@ describe('ExposedComponentsRegistry', () => {
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.warning).not.toHaveBeenCalled();
expect(log.error).not.toHaveBeenCalled();
});
it('should register an exposed component added by a plugin in production mode even if the meta-info is missing', async () => {
@@ -504,7 +481,7 @@ describe('ExposedComponentsRegistry', () => {
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.warning).not.toHaveBeenCalled();
expect(log.error).not.toHaveBeenCalled();
});
it('should register an exposed component added by a plugin in dev-mode if the meta-info is present', async () => {
@@ -530,6 +507,6 @@ describe('ExposedComponentsRegistry', () => {
const currentState = await registry.getState();
expect(Object.keys(currentState)).toHaveLength(1);
expect(log.warning).not.toHaveBeenCalled();
expect(log.error).not.toHaveBeenCalled();
});
});
@@ -2,11 +2,14 @@ import { ReplaySubject } from 'rxjs';
import { PluginExtensionExposedComponentConfig } from '@grafana/data';
import { isExposedComponentMetaInfoMissing, isGrafanaDevMode } from '../utils';
import { extensionPointEndsWithVersion } from '../validators';
import * as errors from '../errors';
import { isGrafanaDevMode } from '../utils';
import { isExposedComponentMetaInfoMissing } from '../validators';
import { Registry, RegistryType, PluginExtensionConfigs } from './Registry';
const logPrefix = 'Could not register exposed component. Reason:';
export type ExposedComponentRegistryItem<Props = {}> = {
pluginId: string;
title: string;
@@ -45,27 +48,17 @@ export class ExposedComponentsRegistry extends Registry<
});
if (!id.startsWith(pluginId)) {
pointIdLog.error(
`Could not register exposed component with '${id}'. Reason: The component id does not match the id naming convention. Id should be prefixed with plugin id. e.g 'myorg-basic-app/my-component-id/v1'.`
);
pointIdLog.error(`${logPrefix} ${errors.INVALID_EXPOSED_COMPONENT_ID}`);
continue;
}
if (!extensionPointEndsWithVersion(id)) {
pointIdLog.error(
`Exposed component does not match the convention. It's recommended to suffix the id with the component version. e.g 'myorg-basic-app/my-component-id/v1'.`
);
}
if (registry[id]) {
pointIdLog.error(
`Could not register exposed component with '${id}'. Reason: An exposed component with the same id already exists.`
);
pointIdLog.error(`${logPrefix} ${errors.EXPOSED_COMPONENT_ALREADY_EXISTS}`);
continue;
}
if (!title) {
pointIdLog.error(`Could not register exposed component with id '${id}'. Reason: Title is missing.`);
pointIdLog.error(`${logPrefix} ${errors.TITLE_MISSING}`);
continue;
}
@@ -77,7 +70,7 @@ export class ExposedComponentsRegistry extends Registry<
continue;
}
pointIdLog.debug(`Exposed component from '${pluginId}' to '${id}'`);
pointIdLog.debug('Exposed component extension successfully registered');
registry[id] = { ...config, pluginId };
}
@@ -288,7 +288,7 @@ describe('usePluginComponent()', () => {
// Shouldn't return the component, as it's not present in the plugin.json dependencies
let { result } = renderHook(() => usePluginComponent(exposedComponentId), { wrapper });
expect(result.current.component).toBe(null);
expect(log.warning).toHaveBeenCalled();
expect(log.error).toHaveBeenCalled();
});
it('should return the exposed component if the meta-info is correct and in dev mode', () => {
@@ -5,8 +5,10 @@ import { usePluginContext } from '@grafana/data';
import { UsePluginComponentResult } from '@grafana/runtime';
import { useExposedComponentsRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
import { isExposedComponentDependencyMissing, isGrafanaDevMode, wrapWithPluginContext } from './utils';
import { isGrafanaDevMode, wrapWithPluginContext } from './utils';
import { isExposedComponentDependencyMissing } from './validators';
// Returns a component exposed by a plugin.
// (Exposed components can be defined in plugins by calling .exposeComponent() on the AppPlugin instance.)
@@ -33,10 +35,8 @@ export function usePluginComponent<Props extends object = {}>(id: string): UsePl
pluginId: registryItem.pluginId,
});
if (enableRestrictions && isExposedComponentDependencyMissing(id, pluginContext, componentLog)) {
componentLog.warning(
`usePluginComponent("${id}") - The exposed component ("${id}") is missing from the dependencies[] in the "plugin.json" file.`
);
if (enableRestrictions && isExposedComponentDependencyMissing(id, pluginContext)) {
componentLog.error(errors.EXPOSED_COMPONENT_DEPENDENCY_MISSING);
return {
isLoading: false,
component: null,
@@ -262,7 +262,7 @@ describe('usePluginComponents()', () => {
// (No restrictions due to isGrafanaDevMode() = false)
let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
expect(result.current.components.length).toBe(1);
expect(log.warning).not.toHaveBeenCalled();
expect(log.error).not.toHaveBeenCalled();
});
it('should not validate the extension point id in production mode', () => {
@@ -287,7 +287,7 @@ describe('usePluginComponents()', () => {
wrapper,
});
expect(result.current.components.length).toBe(0);
expect(log.warning).not.toHaveBeenCalled();
expect(log.error).not.toHaveBeenCalled();
});
it('should not validate the extension point meta-info if used in Grafana core (no plugin context)', () => {
@@ -316,7 +316,7 @@ describe('usePluginComponents()', () => {
wrapper,
});
expect(result.current.components.length).toBe(1);
expect(log.warning).not.toHaveBeenCalled();
expect(log.error).not.toHaveBeenCalled();
});
it('should not validate the extension point id if used in Grafana core (no plugin context)', () => {
@@ -332,7 +332,7 @@ describe('usePluginComponents()', () => {
wrapper,
});
expect(result.current.components.length).toBe(0);
expect(log.warning).not.toHaveBeenCalled();
expect(log.error).not.toHaveBeenCalled();
});
it('should validate if the extension point meta-info is correct if in dev-mode and used by a plugin', () => {
@@ -370,10 +370,10 @@ describe('usePluginComponents()', () => {
// Trying to render an extension point that is not defined in the plugin meta
let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
expect(result.current.components.length).toBe(0);
expect(log.warning).toHaveBeenCalled();
expect(log.error).toHaveBeenCalled();
});
it('should not log a warning if the extension point meta-info is correct if in dev-mode and used by a plugin', () => {
it('should not log an error if the extension point meta-info is correct if in dev-mode and used by a plugin', () => {
// Imitate running in dev mode
jest.mocked(isGrafanaDevMode).mockReturnValue(true);
@@ -414,6 +414,6 @@ describe('usePluginComponents()', () => {
// Trying to render an extension point that is not defined in the plugin meta
let { result } = renderHook(() => usePluginComponents({ extensionPointId }), { wrapper });
expect(result.current.components.length).toBe(0);
expect(log.warning).toHaveBeenCalled();
expect(log.error).toHaveBeenCalled();
});
});
@@ -8,9 +8,10 @@ import {
} from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions';
import { useAddedComponentsRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
import { isExtensionPointMetaInfoMissing, isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid } from './validators';
import { isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
// Returns an array of component extensions for the given extension point
export function usePluginComponents<Props extends object = {}>({
@@ -33,19 +34,11 @@ export function usePluginComponents<Props extends object = {}>({
});
if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) {
pointLog.warning(
`Extension point usePluginComponents("${extensionPointId}") - the id should be prefixed with your plugin id ("${pluginId}/").`
);
return {
isLoading: false,
components: [],
};
pointLog.error(errors.INVALID_EXTENSION_POINT_ID);
}
if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext, pointLog)) {
pointLog.warning(
`usePluginComponents("${extensionPointId}") - The extension point is missing from the "plugin.json" file.`
);
if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
return {
isLoading: false,
components: [],
@@ -4,11 +4,12 @@ import { useObservable } from 'react-use';
import { PluginExtension, usePluginContext } from '@grafana/data';
import { GetPluginExtensionsOptions, UsePluginExtensionsResult } from '@grafana/runtime';
import * as errors from './errors';
import { getPluginExtensions } from './getPluginExtensions';
import { log } from './logs/log';
import { PluginExtensionRegistries } from './registry/types';
import { isExtensionPointMetaInfoMissing, isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid } from './validators';
import { isGrafanaDevMode } from './utils';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
export function createUsePluginExtensions(registries: PluginExtensionRegistries) {
const observableAddedComponentsRegistry = registries.addedComponentsRegistry.asObservable();
@@ -34,19 +35,15 @@ export function createUsePluginExtensions(registries: PluginExtensionRegistries)
}
if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) {
pointLog.warning(
`Extension point usePluginExtensions("${extensionPointId}") - the id should be prefixed with your plugin id ("${pluginId}/").`
);
pointLog.error(errors.INVALID_EXTENSION_POINT_ID);
return {
isLoading: false,
extensions: [],
};
}
if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext, pointLog)) {
pointLog.warning(
`Invalid extension point. Reason: The extension point is not declared in the "plugin.json" file. ExtensionPointId: "${extensionPointId}"`
);
if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
return {
isLoading: false,
extensions: [],
@@ -307,7 +307,7 @@ describe('usePluginLinks()', () => {
// Trying to render an extension point that is not defined in the plugin meta
let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
expect(result.current.links.length).toBe(0);
expect(log.warning).toHaveBeenCalled();
expect(log.error).toHaveBeenCalled();
});
it('should not log a warning if the extension point meta-info is correct if in dev-mode and used by a plugin', () => {
@@ -345,6 +345,6 @@ describe('usePluginLinks()', () => {
// Trying to render an extension point that is not defined in the plugin meta
let { result } = renderHook(() => usePluginLinks({ extensionPointId }), { wrapper });
expect(result.current.links.length).toBe(0);
expect(log.warning).toHaveBeenCalled();
expect(log.error).toHaveBeenCalled();
});
});
@@ -9,6 +9,7 @@ import {
} from '@grafana/runtime/src/services/pluginExtensions/getPluginExtensions';
import { useAddedLinksRegistry } from './ExtensionRegistriesContext';
import * as errors from './errors';
import { log } from './logs/log';
import {
generateExtensionId,
@@ -16,10 +17,9 @@ import {
getLinkExtensionOverrides,
getLinkExtensionPathWithTracking,
getReadOnlyProxy,
isExtensionPointMetaInfoMissing,
isGrafanaDevMode,
} from './utils';
import { isExtensionPointIdValid } from './validators';
import { isExtensionPointIdValid, isExtensionPointMetaInfoMissing } from './validators';
// Returns an array of component extensions for the given extension point
export function usePluginLinks({
@@ -41,19 +41,15 @@ export function usePluginLinks({
});
if (enableRestrictions && !isExtensionPointIdValid({ extensionPointId, pluginId })) {
pointLog.warning(
`Extension point usePluginLinks("${extensionPointId}") - the id should be prefixed with your plugin id ("${pluginId}/").`
);
pointLog.error(errors.INVALID_EXTENSION_POINT_ID);
return {
isLoading: false,
links: [],
};
}
if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext, pointLog)) {
pointLog.warning(
`Invalid extension point. Reason: The extension point is not declared in the "plugin.json" file. ExtensionPointId: "${extensionPointId}"`
);
if (enableRestrictions && isExtensionPointMetaInfoMissing(extensionPointId, pluginContext)) {
pointLog.error(errors.EXTENSION_POINT_META_INFO_MISSING);
return {
isLoading: false,
links: [],
@@ -73,8 +69,16 @@ export function usePluginLinks({
for (const addedLink of registryState[extensionPointId] ?? []) {
const { pluginId } = addedLink;
const linkLog = pointLog.child({
path: addedLink.path ?? '',
title: addedLink.title,
description: addedLink.description ?? '',
onClick: typeof addedLink.onClick,
});
// Only limit if the `limitPerPlugin` is set
if (limitPerPlugin && extensionsByPlugin[pluginId] >= limitPerPlugin) {
linkLog.debug(`Skipping link extension from plugin "${pluginId}". Reason: Limit reached.`);
continue;
}
@@ -82,12 +86,6 @@ export function usePluginLinks({
extensionsByPlugin[pluginId] = 0;
}
const linkLog = pointLog.child({
path: addedLink.path ?? '',
title: addedLink.title,
description: addedLink.description ?? '',
onClick: typeof addedLink.onClick,
});
// Run the configure() function with the current context, and apply the ovverides
const overrides = getLinkExtensionOverrides(pluginId, addedLink, linkLog, frozenContext);
@@ -1,31 +1,17 @@
import { render, screen } from '@testing-library/react';
import { type Unsubscribable } from 'rxjs';
import {
dateTime,
PluginContextType,
PluginExtensionPoints,
PluginLoadingStrategy,
PluginType,
usePluginContext,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { dateTime, usePluginContext } from '@grafana/data';
import appEvents from 'app/core/app_events';
import { ShowModalReactEvent } from 'app/types/events';
import { log } from './logs/log';
import { createLogMock } from './logs/testUtils';
import {
deepFreeze,
handleErrorsInFn,
getReadOnlyProxy,
createOpenModalFunction,
wrapWithPluginContext,
isAddedLinkMetaInfoMissing,
isAddedComponentMetaInfoMissing,
isExposedComponentMetaInfoMissing,
isExposedComponentDependencyMissing,
isExtensionPointMetaInfoMissing,
} from './utils';
jest.mock('app/features/plugins/pluginSettings', () => ({
@@ -461,426 +447,4 @@ describe('Plugin Extensions / Utils', () => {
expect(screen.getByText('Version: 1.0.0')).toBeVisible();
});
});
describe('isAddedLinkMetaInfoMissing()', () => {
const originalApps = config.apps;
const pluginId = 'myorg-extensions-app';
const appPluginConfig = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
},
};
const extensionConfig = {
targets: [PluginExtensionPoints.DashboardPanelMenu],
title: 'Link title',
description: 'Link description',
};
beforeEach(() => {
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(false);
expect(log.warning).toHaveBeenCalledTimes(0);
});
it('should return TRUE and log a warning if the app config is not found', () => {
const log = createLogMock();
delete config.apps[pluginId];
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(true);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch("couldn't find app plugin");
});
it('should return TRUE and log a warning if the link has no meta-info in the plugin.json', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedLinks = [];
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(true);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('not registered in the plugin.json');
});
it('should return TRUE and log a warning if the "targets" do not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
const returnValue = isAddedLinkMetaInfoMissing(
pluginId,
{
...extensionConfig,
targets: [PluginExtensionPoints.DashboardPanelMenu, PluginExtensionPoints.ExploreToolbarAction],
},
log
);
expect(returnValue).toBe(true);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"targets" don\'t match');
});
});
describe('isAddedComponentMetaInfoMissing()', () => {
const originalApps = config.apps;
const pluginId = 'myorg-extensions-app';
const appPluginConfig = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
},
};
const extensionConfig = {
targets: [PluginExtensionPoints.DashboardPanelMenu],
title: 'Component title',
description: 'Component description',
component: () => <div>Component content</div>,
};
beforeEach(() => {
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(false);
expect(log.warning).toHaveBeenCalledTimes(0);
});
it('should return TRUE and log a warning if the app config is not found', () => {
const log = createLogMock();
delete config.apps[pluginId];
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(true);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch("couldn't find app plugin");
});
it('should return TRUE and log a warning if the Component has no meta-info in the plugin.json', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedComponents = [];
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(true);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('not registered in the plugin.json');
});
it('should return TRUE and log a warning if the "targets" do not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
const returnValue = isAddedComponentMetaInfoMissing(
pluginId,
{
...extensionConfig,
targets: [PluginExtensionPoints.ExploreToolbarAction],
},
log
);
expect(returnValue).toBe(true);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"targets" don\'t match');
});
});
describe('isExposedComponentMetaInfoMissing()', () => {
const originalApps = config.apps;
const pluginId = 'myorg-extensions-app';
const appPluginConfig = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
},
};
const exposedComponentConfig = {
id: `${pluginId}/component/v1`,
title: 'Exposed component',
description: 'Exposed component description',
component: () => <div>Component content</div>,
};
beforeEach(() => {
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
const log = createLogMock();
config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log);
expect(returnValue).toBe(false);
expect(log.warning).toHaveBeenCalledTimes(0);
});
it('should return TRUE and log a warning if the app config is not found', () => {
const log = createLogMock();
delete config.apps[pluginId];
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log);
expect(returnValue).toBe(true);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch("couldn't find app plugin");
});
it('should return TRUE and log a warning if the exposed component has no meta-info in the plugin.json', () => {
const log = createLogMock();
config.apps[pluginId].extensions.exposedComponents = [];
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log);
expect(returnValue).toBe(true);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('not registered in the plugin.json');
});
it('should return TRUE and log a warning if the title does not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
const returnValue = isExposedComponentMetaInfoMissing(
pluginId,
{
...exposedComponentConfig,
title: 'UPDATED',
},
log
);
expect(returnValue).toBe(true);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"title" doesn\'t match');
});
});
describe('isExposedComponentDependencyMissing()', () => {
let pluginContext: PluginContextType;
const pluginId = 'myorg-extensions-app';
const exposedComponentId = `${pluginId}/component/v1`;
beforeEach(() => {
pluginContext = {
meta: {
id: pluginId,
name: 'Extensions App',
type: PluginType.app,
module: '',
baseUrl: '',
info: {
author: {
name: 'MyOrg',
},
description: 'App for testing extensions',
links: [],
logos: {
large: '',
small: '',
},
screenshots: [],
updated: '2023-10-26T18:25:01Z',
version: '1.0.0',
},
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
},
};
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
const log = createLogMock();
pluginContext.meta.dependencies?.extensions.exposedComponents.push(exposedComponentId);
const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext, log);
expect(returnValue).toBe(false);
expect(log.warning).toHaveBeenCalledTimes(0);
});
it('should return TRUE and log a warning if the dependencies are missing', () => {
const log = createLogMock();
delete pluginContext.meta.dependencies;
const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext, log);
expect(returnValue).toBe(true);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch(`Using exposed component "${exposedComponentId}"`);
});
it('should return TRUE and log a warning if the exposed component id is not specified in the list of dependencies', () => {
const log = createLogMock();
const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext, log);
expect(returnValue).toBe(true);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch(`Using exposed component "${exposedComponentId}"`);
});
});
describe('isExtensionPointMetaInfoMissing()', () => {
let pluginContext: PluginContextType;
const pluginId = 'myorg-extensions-app';
const extensionPointId = `${pluginId}/extension-point/v1`;
const extensionPointConfig = {
id: extensionPointId,
title: 'Extension point title',
description: 'Extension point description',
};
beforeEach(() => {
pluginContext = {
meta: {
id: pluginId,
name: 'Extensions App',
type: PluginType.app,
module: '',
baseUrl: '',
info: {
author: {
name: 'MyOrg',
},
description: 'App for testing extensions',
links: [],
logos: {
large: '',
small: '',
},
screenshots: [],
updated: '2023-10-26T18:25:01Z',
version: '1.0.0',
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
},
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
},
};
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
const log = createLogMock();
pluginContext.meta.extensions?.extensionPoints.push(extensionPointConfig);
const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext, log);
expect(returnValue).toBe(false);
expect(log.warning).toHaveBeenCalledTimes(0);
});
it('should return TRUE and log a warning if the extension point id is not recorded in the plugin.json', () => {
const log = createLogMock();
const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext, log);
expect(returnValue).toBe(true);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch(`Extension point "${extensionPointId}"`);
});
});
});
@@ -16,9 +16,6 @@ import {
PanelMenuItem,
PluginExtensionAddedLinkConfig,
urlUtil,
PluginContextType,
PluginExtensionExposedComponentConfig,
PluginExtensionAddedComponentConfig,
} from '@grafana/data';
import { reportInteraction, config } from '@grafana/runtime';
import { Modal } from '@grafana/ui';
@@ -424,134 +421,3 @@ export function getLinkExtensionPathWithTracking(pluginId: string, path: string,
// Comes from the `app_mode` setting in the Grafana config (defaults to "development")
// Can be set with the `GF_DEFAULT_APP_MODE` environment variable
export const isGrafanaDevMode = () => config.buildInfo.env === 'development';
// Checks if the meta information is missing from the plugin's plugin.json file
export const isExtensionPointMetaInfoMissing = (
extensionPointId: string,
pluginContext: PluginContextType,
log: ExtensionsLog
) => {
const pluginId = pluginContext.meta?.id;
const extensionPoints = pluginContext.meta?.extensions?.extensionPoints;
if (!extensionPoints || !extensionPoints.some((ep) => ep.id === extensionPointId)) {
log.warning(
`Extension point "${extensionPointId}" - it's not recorded in the "plugin.json" for "${pluginId}". Please add it under "extensions.extensionPoints[]".`
);
return true;
}
return false;
};
// Checks if an exposed component that the plugin is depending on is missing from the `dependencies` in the plugin.json file
export const isExposedComponentDependencyMissing = (
id: string,
pluginContext: PluginContextType,
log: ExtensionsLog
) => {
const pluginId = pluginContext.meta?.id;
const exposedComponentsDependencies = pluginContext.meta?.dependencies?.extensions?.exposedComponents;
if (!exposedComponentsDependencies || !exposedComponentsDependencies.includes(id)) {
log.warning(
`Using exposed component "${id}" - it's not recorded in the "plugin.json" for "${pluginId}". Please add it under "dependencies.extensions.exposedComponents[]".`
);
return true;
}
return false;
};
export const isAddedLinkMetaInfoMissing = (
pluginId: string,
metaInfo: PluginExtensionAddedLinkConfig,
log: ExtensionsLog
) => {
const app = config.apps[pluginId];
const logPrefix = `Added-link "${metaInfo.title}" from "${pluginId}" -`;
const pluginJsonMetaInfo = app ? app.extensions.addedLinks.find(({ title }) => title === metaInfo.title) : null;
if (!app) {
log.warning(`${logPrefix} couldn't find app plugin "${pluginId}"`);
return true;
}
if (!pluginJsonMetaInfo) {
log.warning(`${logPrefix} not registered in the plugin.json under "extensions.addedLinks[]".`);
return true;
}
const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets];
if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) {
log.warning(`${logPrefix} the "targets" don't match with ones in the plugin.json under "extensions.addedLinks[]".`);
return true;
}
return false;
};
export const isAddedComponentMetaInfoMissing = (
pluginId: string,
metaInfo: PluginExtensionAddedComponentConfig,
log: ExtensionsLog
) => {
const app = config.apps[pluginId];
const logPrefix = `Added component "${metaInfo.title}" -`;
const pluginJsonMetaInfo = app ? app.extensions.addedComponents.find(({ title }) => title === metaInfo.title) : null;
if (!app) {
log.warning(`${logPrefix} couldn't find app plugin "${pluginId}"`);
return true;
}
if (!pluginJsonMetaInfo) {
log.warning(`${logPrefix} not registered in the plugin.json under "extensions.addedComponents[]".`);
return true;
}
const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets];
if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) {
log.warning(
`${logPrefix} the "targets" don't match with ones in the plugin.json under "extensions.addedComponents[]".`
);
return true;
}
return false;
};
export const isExposedComponentMetaInfoMissing = (
pluginId: string,
metaInfo: PluginExtensionExposedComponentConfig,
log: ExtensionsLog
) => {
const app = config.apps[pluginId];
const logPrefix = `Exposed component "${metaInfo.id}" -`;
const pluginJsonMetaInfo = app ? app.extensions.exposedComponents.find(({ id }) => id === metaInfo.id) : null;
if (!app) {
log.warning(`${logPrefix} couldn't find app plugin: "${pluginId}"`);
return true;
}
if (!pluginJsonMetaInfo) {
log.warning(`${logPrefix} not registered in the plugin.json under "extensions.exposedComponents[]".`);
return true;
}
if (pluginJsonMetaInfo.title !== metaInfo.title) {
log.warning(
`${logPrefix} the "title" doesn't match with one in the plugin.json under "extensions.exposedComponents[]".`
);
return true;
}
return false;
};
@@ -1,12 +1,26 @@
import { memo } from 'react';
import { PluginExtensionAddedLinkConfig, PluginExtensionLinkConfig, PluginExtensionPoints } from '@grafana/data';
import {
PluginContextType,
PluginExtensionAddedLinkConfig,
PluginExtensionLinkConfig,
PluginExtensionPoints,
PluginLoadingStrategy,
PluginType,
} from '@grafana/data';
import { config } from '@grafana/runtime';
import { createLogMock } from './logs/testUtils';
import {
assertConfigureIsValid,
assertLinkPathIsValid,
assertStringProps,
isAddedComponentMetaInfoMissing,
isAddedLinkMetaInfoMissing,
isExposedComponentDependencyMissing,
isExposedComponentMetaInfoMissing,
isExtensionPointIdValid,
isExtensionPointMetaInfoMissing,
isGrafanaCoreExtensionPoint,
isReactComponent,
} from './validators';
@@ -231,4 +245,472 @@ describe('Plugin Extension Validators', () => {
).toBe(false);
});
});
describe('isAddedLinkMetaInfoMissing()', () => {
const originalApps = config.apps;
const pluginId = 'myorg-extensions-app';
const appPluginConfig = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
},
};
const extensionConfig = {
targets: [PluginExtensionPoints.DashboardPanelMenu],
title: 'Link title',
description: 'Link description',
};
beforeEach(() => {
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(false);
expect(log.error).toHaveBeenCalledTimes(0);
});
it('should return TRUE and log an error if the app config is not found', () => {
const log = createLogMock();
delete config.apps[pluginId];
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch('The app plugin with plugin id');
});
it('should return TRUE and log an error if the link has no meta-info in the plugin.json', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedLinks = [];
const returnValue = isAddedLinkMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch(
'The extension was not recorded in the plugin.json. Added link extensions must be listed in the section "extensions.addedLinks[]"'
);
});
it('should return TRUE and log an error if the "targets" do not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
const returnValue = isAddedLinkMetaInfoMissing(
pluginId,
{
...extensionConfig,
targets: [PluginExtensionPoints.DashboardPanelMenu, PluginExtensionPoints.ExploreToolbarAction],
},
log
);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch(
'The "targets" for the registered extension does not match'
);
});
it('should return FALSE and log a warning if the "description" does not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedLinks.push(extensionConfig);
const returnValue = isAddedLinkMetaInfoMissing(
pluginId,
{
...extensionConfig,
description: 'Link description UPDATED',
},
log
);
expect(returnValue).toBe(false);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"description" doesn\'t match');
});
});
describe('isAddedComponentMetaInfoMissing()', () => {
const originalApps = config.apps;
const pluginId = 'myorg-extensions-app';
const appPluginConfig = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
},
};
const extensionConfig = {
targets: [PluginExtensionPoints.DashboardPanelMenu],
title: 'Component title',
description: 'Component description',
component: () => <div>Component content</div>,
};
beforeEach(() => {
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(false);
expect(log.error).toHaveBeenCalledTimes(0);
});
it('should return TRUE and log an error if the app config is not found', () => {
const log = createLogMock();
delete config.apps[pluginId];
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch('The app plugin with plugin id');
});
it('should return TRUE and log an error if the Component has no meta-info in the plugin.json', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedComponents = [];
const returnValue = isAddedComponentMetaInfoMissing(pluginId, extensionConfig, log);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch(
'The extension was not recorded in the plugin.json. Added component extensions must be listed in the section "extensions.addedComponents[]"'
);
});
it('should return TRUE and log an error if the "targets" do not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
const returnValue = isAddedComponentMetaInfoMissing(
pluginId,
{
...extensionConfig,
targets: [PluginExtensionPoints.ExploreToolbarAction],
},
log
);
expect(returnValue).toBe(true);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch(
'The "targets" for the registered extension does not match'
);
});
it('should return FALSE and log a warning if the "description" does not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.addedComponents.push(extensionConfig);
const returnValue = isAddedComponentMetaInfoMissing(
pluginId,
{
...extensionConfig,
description: 'UPDATED',
},
log
);
expect(returnValue).toBe(false);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"description" doesn\'t match');
});
});
describe('isExposedComponentMetaInfoMissing()', () => {
const originalApps = config.apps;
const pluginId = 'myorg-extensions-app';
const appPluginConfig = {
id: pluginId,
path: '',
version: '',
preload: false,
angular: {
detected: false,
hideDeprecation: false,
},
loadingStrategy: PluginLoadingStrategy.fetch,
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
},
};
const exposedComponentConfig = {
id: `${pluginId}/component/v1`,
title: 'Exposed component',
description: 'Exposed component description',
component: () => <div>Component content</div>,
};
beforeEach(() => {
config.apps = {
[pluginId]: appPluginConfig,
};
});
afterEach(() => {
config.apps = originalApps;
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
const log = createLogMock();
config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log);
expect(returnValue).toBe(false);
expect(log.warning).toHaveBeenCalledTimes(0);
});
it('should return TRUE and log an error if the app config is not found', () => {
const log = createLogMock();
delete config.apps[pluginId];
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch('The app plugin with plugin id');
});
it('should return TRUE and log an error if the exposed component has no meta-info in the plugin.json', () => {
const log = createLogMock();
config.apps[pluginId].extensions.exposedComponents = [];
const returnValue = isExposedComponentMetaInfoMissing(pluginId, exposedComponentConfig, log);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch(
'The exposed component was not recorded in the plugin.json. Exposed component extensions must be listed in the section "extensions.exposedComponents[]"'
);
});
it('should return TRUE and log an error if the title does not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
const returnValue = isExposedComponentMetaInfoMissing(
pluginId,
{
...exposedComponentConfig,
title: 'UPDATED',
},
log
);
expect(returnValue).toBe(true);
expect(log.error).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.error).mock.calls[0][0]).toMatch(
'The "title" doesn\'t match the title recorded in plugin.json.'
);
});
it('should return FALSE and log a warning if the "description" does not match', () => {
const log = createLogMock();
config.apps[pluginId].extensions.exposedComponents.push(exposedComponentConfig);
const returnValue = isExposedComponentMetaInfoMissing(
pluginId,
{
...exposedComponentConfig,
description: 'UPDATED',
},
log
);
expect(returnValue).toBe(false);
expect(log.warning).toHaveBeenCalledTimes(1);
expect(jest.mocked(log.warning).mock.calls[0][0]).toMatch('"description" doesn\'t match');
});
});
describe('isExposedComponentDependencyMissing()', () => {
let pluginContext: PluginContextType;
const pluginId = 'myorg-extensions-app';
const exposedComponentId = `${pluginId}/component/v1`;
beforeEach(() => {
pluginContext = {
meta: {
id: pluginId,
name: 'Extensions App',
type: PluginType.app,
module: '',
baseUrl: '',
info: {
author: {
name: 'MyOrg',
},
description: 'App for testing extensions',
links: [],
logos: {
large: '',
small: '',
},
screenshots: [],
updated: '2023-10-26T18:25:01Z',
version: '1.0.0',
},
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
},
};
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
pluginContext.meta.dependencies?.extensions.exposedComponents.push(exposedComponentId);
const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext);
expect(returnValue).toBe(false);
});
it('should return TRUE if the dependencies are missing', () => {
delete pluginContext.meta.dependencies;
const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext);
expect(returnValue).toBe(true);
});
it('should return TRUE if the exposed component id is not specified in the list of dependencies', () => {
const returnValue = isExposedComponentDependencyMissing(exposedComponentId, pluginContext);
expect(returnValue).toBe(true);
});
});
describe('isExtensionPointMetaInfoMissing()', () => {
let pluginContext: PluginContextType;
const pluginId = 'myorg-extensions-app';
const extensionPointId = `${pluginId}/extension-point/v1`;
const extensionPointConfig = {
id: extensionPointId,
title: 'Extension point title',
description: 'Extension point description',
};
beforeEach(() => {
pluginContext = {
meta: {
id: pluginId,
name: 'Extensions App',
type: PluginType.app,
module: '',
baseUrl: '',
info: {
author: {
name: 'MyOrg',
},
description: 'App for testing extensions',
links: [],
logos: {
large: '',
small: '',
},
screenshots: [],
updated: '2023-10-26T18:25:01Z',
version: '1.0.0',
},
extensions: {
addedLinks: [],
addedComponents: [],
exposedComponents: [],
extensionPoints: [],
},
dependencies: {
grafanaVersion: '8.0.0',
plugins: [],
extensions: {
exposedComponents: [],
},
},
},
};
});
it('should return FALSE if the meta-info in the plugin.json is correct', () => {
pluginContext.meta.extensions?.extensionPoints.push(extensionPointConfig);
const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext);
expect(returnValue).toBe(false);
});
it('should return TRUE if the extension point id is not recorded in the plugin.json', () => {
const returnValue = isExtensionPointMetaInfoMissing(extensionPointId, pluginContext);
expect(returnValue).toBe(true);
});
});
});
@@ -1,6 +1,16 @@
import type { PluginExtensionAddedLinkConfig, PluginExtension, PluginExtensionLink } from '@grafana/data';
import type {
PluginExtensionAddedLinkConfig,
PluginExtension,
PluginExtensionLink,
PluginContextType,
PluginExtensionAddedComponentConfig,
PluginExtensionExposedComponentConfig,
} from '@grafana/data';
import { PluginAddedLinksConfigureFunc, PluginExtensionPoints } from '@grafana/data/src/types/pluginExtensions';
import { isPluginExtensionLink } from '@grafana/runtime';
import { config, isPluginExtensionLink } from '@grafana/runtime';
import * as errors from './errors';
import { ExtensionsLog } from './logs/log';
export function assertPluginExtensionLink(
extension: PluginExtension | undefined,
@@ -103,3 +113,112 @@ export function isReactComponent(component: unknown): component is React.Compone
// (The main reason is that we don't want to start depending on React implementation details.)
return typeof component === 'function' || isReactMemoObject(component);
}
// Checks if the meta information is missing from the plugin's plugin.json file
export const isExtensionPointMetaInfoMissing = (extensionPointId: string, pluginContext: PluginContextType) => {
const extensionPoints = pluginContext.meta?.extensions?.extensionPoints;
return !extensionPoints || !extensionPoints.some((ep) => ep.id === extensionPointId);
};
// Checks if an exposed component that the plugin is depending on is missing from the `dependencies` in the plugin.json file
export const isExposedComponentDependencyMissing = (id: string, pluginContext: PluginContextType) => {
const exposedComponentsDependencies = pluginContext.meta?.dependencies?.extensions?.exposedComponents;
return !exposedComponentsDependencies || !exposedComponentsDependencies.includes(id);
};
export const isAddedLinkMetaInfoMissing = (
pluginId: string,
metaInfo: PluginExtensionAddedLinkConfig,
log: ExtensionsLog
) => {
const logPrefix = 'Could not register link extension. Reason:';
const app = config.apps[pluginId];
const pluginJsonMetaInfo = app ? app.extensions.addedLinks.find(({ title }) => title === metaInfo.title) : null;
if (!app) {
log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`);
return true;
}
if (!pluginJsonMetaInfo) {
log.error(`${logPrefix} ${errors.ADDED_LINK_META_INFO_MISSING}`);
return true;
}
const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets];
if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) {
log.error(`${logPrefix} ${errors.TARGET_NOT_MATCHING_META_INFO}`);
return true;
}
if (pluginJsonMetaInfo.description !== metaInfo.description) {
log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO);
}
return false;
};
export const isAddedComponentMetaInfoMissing = (
pluginId: string,
metaInfo: PluginExtensionAddedComponentConfig,
log: ExtensionsLog
) => {
const logPrefix = 'Could not register component extension. Reason:';
const app = config.apps[pluginId];
const pluginJsonMetaInfo = app ? app.extensions.addedComponents.find(({ title }) => title === metaInfo.title) : null;
if (!app) {
log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`);
return true;
}
if (!pluginJsonMetaInfo) {
log.error(`${logPrefix} ${errors.ADDED_COMPONENT_META_INFO_MISSING}`);
return true;
}
const targets = Array.isArray(metaInfo.targets) ? metaInfo.targets : [metaInfo.targets];
if (!targets.every((target) => pluginJsonMetaInfo.targets.includes(target))) {
log.error(`${logPrefix} ${errors.TARGET_NOT_MATCHING_META_INFO}`);
return true;
}
if (pluginJsonMetaInfo.description !== metaInfo.description) {
log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO);
}
return false;
};
export const isExposedComponentMetaInfoMissing = (
pluginId: string,
metaInfo: PluginExtensionExposedComponentConfig,
log: ExtensionsLog
) => {
const logPrefix = 'Could not register exposed component extension. Reason:';
const app = config.apps[pluginId];
const pluginJsonMetaInfo = app ? app.extensions.exposedComponents.find(({ id }) => id === metaInfo.id) : null;
if (!app) {
log.error(`${logPrefix} ${errors.APP_NOT_FOUND(pluginId)}`);
return true;
}
if (!pluginJsonMetaInfo) {
log.error(`${logPrefix} ${errors.EXPOSED_COMPONENT_META_INFO_MISSING}`);
return true;
}
if (pluginJsonMetaInfo.title !== metaInfo.title) {
log.error(`${logPrefix} ${errors.TITLE_NOT_MATCHING_META_INFO}`);
return true;
}
if (pluginJsonMetaInfo.description !== metaInfo.description) {
log.warning(errors.DESCRIPTION_NOT_MATCHING_META_INFO);
}
return false;
};
@@ -38,7 +38,7 @@ composableKinds: DataQuery: {
// Allows to group the results.
groupBy: [...string]
// Sets the maximum number of time series.
limit?: int64
limit?: int64
// Sets the maximum number of nodes in the flamegraph.
maxNodes?: int64
#PyroscopeQueryType: "metrics" | "profile" | *"both" @cuetsy(kind="type")