Files
2025-05-29 16:11:59 +02:00

647 lines
20 KiB
TypeScript

import { css } from '@emotion/css';
import { cloneDeep, isArray, isObject } from 'lodash';
import * as React from 'react';
import { useAsync } from 'react-use';
import {
type PluginExtensionEventHelpers,
type PluginExtensionOpenModalOptions,
isDateTime,
dateTime,
PluginContextProvider,
PluginExtensionLink,
PanelMenuItem,
PluginExtensionAddedLinkConfig,
urlUtil,
PluginExtensionPoints,
ExtensionInfo,
} from '@grafana/data';
import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime';
import { Modal } from '@grafana/ui';
import appEvents from 'app/core/app_events';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import { OpenExtensionSidebarEvent, ShowModalReactEvent } from 'app/types/events';
import { ExtensionsLog, log } from './logs/log';
import { AddedLinkRegistryItem } from './registry/AddedLinksRegistry';
import { assertIsNotPromise, assertLinkPathIsValid, assertStringProps, isPromise } from './validators';
export function handleErrorsInFn(fn: Function, errorMessagePrefix = '') {
return (...args: unknown[]) => {
try {
return fn(...args);
} catch (e) {
if (e instanceof Error) {
console.warn(`${errorMessagePrefix}${e.message}`);
}
}
};
}
export function createOpenModalFunction(pluginId: string): PluginExtensionEventHelpers['openModal'] {
return async (options) => {
const { title, body, width, height } = options;
appEvents.publish(
new ShowModalReactEvent({
component: wrapWithPluginContext<ModalWrapperProps>(
pluginId,
getModalWrapper({ title, body, width, height }),
log
),
})
);
};
}
type ModalWrapperProps = {
onDismiss: () => void;
};
export const wrapWithPluginContext = <T,>(pluginId: string, Component: React.ComponentType<T>, log: ExtensionsLog) => {
const WrappedExtensionComponent = (props: T & React.JSX.IntrinsicAttributes) => {
const {
error,
loading,
value: pluginMeta,
} = useAsync(() => getPluginSettings(pluginId, { showErrorAlert: false }));
if (loading) {
return null;
}
if (error) {
log.error(`Could not fetch plugin meta information for "${pluginId}", aborting. (${error.message})`, {
stack: error.stack ?? '',
message: error.message,
});
return null;
}
if (!pluginMeta) {
log.error(`Fetched plugin meta information is empty for "${pluginId}", aborting.`);
return null;
}
return (
<PluginContextProvider meta={pluginMeta}>
<Component {...readOnlyCopy(props, log)} />
</PluginContextProvider>
);
};
return WrappedExtensionComponent;
};
// Wraps a component with a modal.
// This way we can make sure that the modal is closable, and we also make the usage simpler.
const getModalWrapper = ({
// The title of the modal (appears in the header)
title,
// A component that serves the body of the modal
body: Body,
width,
height,
}: PluginExtensionOpenModalOptions) => {
const className = css({ width, height });
const ModalWrapper = ({ onDismiss }: ModalWrapperProps) => {
return (
<Modal title={title} className={className} isOpen onDismiss={onDismiss} onClickBackdrop={onDismiss}>
<Body onDismiss={onDismiss} />
</Modal>
);
};
return ModalWrapper;
};
// Deep-clones and deep-freezes an object.
// (Returns with a new object, does not modify the original object)
//
// @param `object` The object to freeze
// @param `frozenProps` A set of objects that have already been frozen (used to prevent infinite recursion)
export function deepFreeze(value?: object | Record<string | symbol, unknown> | unknown[], frozenProps = new Map()) {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) {
return value;
}
// Deep cloning the object to prevent freezing the original object
const clonedValue = Array.isArray(value) ? [...value] : { ...value };
// Prevent infinite recursion by looking for cycles inside an object
if (frozenProps.has(value)) {
return frozenProps.get(value);
}
frozenProps.set(value, clonedValue);
const propNames = Reflect.ownKeys(clonedValue);
for (const name of propNames) {
const prop = Array.isArray(clonedValue) ? clonedValue[Number(name)] : clonedValue[name];
// If the property is an object:
// 1. clone it
// 2. freeze it
if (prop && (typeof prop === 'object' || typeof prop === 'function')) {
if (Array.isArray(clonedValue)) {
clonedValue[Number(name)] = deepFreeze(prop, frozenProps);
} else {
clonedValue[name] = deepFreeze(prop, frozenProps);
}
}
}
return Object.freeze(clonedValue);
}
export function generateExtensionId(pluginId: string, extensionPointId: string, title: string): string {
const str = `${pluginId}${extensionPointId}${title}`;
return Array.from(str)
.reduce((s, c) => (Math.imul(31, s) + c.charCodeAt(0)) | 0, 0)
.toString();
}
const _isReadOnlyProxy = Symbol('isReadOnlyProxy');
const _isMutationObserverProxy = Symbol('isMutationObserverProxy');
export class ReadOnlyProxyError extends Error {
constructor(message?: string) {
super(message ?? 'Mutating a read-only proxy object');
this.name = 'ReadOnlyProxyError';
}
}
/**
* Returns a proxy that wraps the given object in a way that makes it read only.
* If you try to modify the object a TypeError exception will be thrown.
*
* @param obj The object to make read only
* @returns A new read only object, does not modify the original object
*/
export function getReadOnlyProxy<T extends object>(obj: T): T {
if (!obj || typeof obj !== 'object' || isReadOnlyProxy(obj)) {
return obj;
}
const cache = new WeakMap();
return new Proxy(obj, {
defineProperty: () => false,
deleteProperty: () => false,
isExtensible: () => false,
set: () => false,
get(target, prop, receiver) {
if (prop === _isReadOnlyProxy) {
return true;
}
const value = Reflect.get(target, prop, receiver);
// This will create a clone of the date time object
// instead of creating a proxy because the underlying
// momentjs object needs to be able to mutate itself.
if (isDateTime(value)) {
return dateTime(value);
}
if (isObject(value) || isArray(value)) {
if (!cache.has(value)) {
cache.set(value, getReadOnlyProxy(value));
}
return cache.get(value);
}
return value;
},
});
}
/**
* Returns a proxy that logs any attempted mutation to the original object.
*
* @param obj The object to observe
* @returns A new proxy object that logs any attempted mutation to the original object
*/
export function getMutationObserverProxy<T extends object>(obj: T, _log: ExtensionsLog = log): T {
if (!obj || typeof obj !== 'object' || isMutationObserverProxy(obj)) {
return obj;
}
const cache = new WeakMap();
return new Proxy(obj, {
deleteProperty(target, prop) {
_log.warning(`Attempted to delete object property "${String(prop)}"`, {
stack: new Error().stack ?? '',
});
Reflect.deleteProperty(target, prop);
return true;
},
defineProperty(target, prop, descriptor) {
_log.warning(`Attempted to define object property "${String(prop)}"`, {
stack: new Error().stack ?? '',
});
Reflect.defineProperty(target, prop, descriptor);
return true;
},
set(target, prop, newValue) {
_log.warning(`Attempted to mutate object property "${String(prop)}"`, {
stack: new Error().stack ?? '',
});
Reflect.set(target, prop, newValue);
return true;
},
get(target, prop, receiver) {
if (prop === _isMutationObserverProxy) {
return true;
}
const value = Reflect.get(target, prop, receiver);
// Return read-only properties as-is to avoid proxy invariant violations
const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);
if (descriptor && !descriptor.configurable && !descriptor.writable) {
return value;
}
// This will create a clone of the date time object
// instead of creating a proxy because the underlying
// momentjs object needs to be able to mutate itself.
if (isDateTime(value)) {
return dateTime(value);
}
if (isObject(value) || isArray(value)) {
if (!cache.has(value)) {
cache.set(value, getMutationObserverProxy(value, _log));
}
return cache.get(value);
}
return value;
},
});
}
export function readOnlyCopy<T>(value: T, _log: ExtensionsLog = log): T {
// Primitive types are read-only by default
if (!value || typeof value !== 'object') {
return value;
}
if (config.featureToggles.extensionsReadOnlyProxy) {
return getReadOnlyProxy(value);
}
// In dev mode: we return a read-only proxy (throws errors for any mutation), but with a deep-cloned version of the original object (so no interference with other call-sites)
if (isGrafanaDevMode()) {
return getReadOnlyProxy(cloneDeep(value));
}
// Default: we return a proxy of a deep-cloned version of the original object, which logs warnings when mutation is attempted
return getMutationObserverProxy(cloneDeep(value), _log);
}
function isRecord(value: unknown): value is Record<string | number | symbol, unknown> {
return typeof value === 'object' && value !== null;
}
export function isReadOnlyProxy(value: unknown): boolean {
return isRecord(value) && value[_isReadOnlyProxy] === true;
}
export function isMutationObserverProxy(value: unknown): boolean {
return isRecord(value) && value[_isMutationObserverProxy] === true;
}
export function createAddedLinkConfig<T extends object>(
config: PluginExtensionAddedLinkConfig<T>
): PluginExtensionAddedLinkConfig {
const linkConfig: PluginExtensionAddedLinkConfig<T> = {
...config,
};
assertLinkConfig(linkConfig);
return linkConfig;
}
function assertLinkConfig<T extends object>(
config: PluginExtensionAddedLinkConfig<T>
): asserts config is PluginExtensionAddedLinkConfig {}
export function truncateTitle(title: string, length: number): string {
if (title.length < length) {
return title;
}
const part = title.slice(0, length - 3);
return `${part.trimEnd()}...`;
}
export function createExtensionSubMenu(extensions: PluginExtensionLink[]): PanelMenuItem[] {
const categorized: Record<string, PanelMenuItem[]> = {};
const uncategorized: PanelMenuItem[] = [];
for (const extension of extensions) {
const category = extension.category;
if (!category) {
uncategorized.push({
text: truncateTitle(extension.title, 25),
href: extension.path,
onClick: extension.onClick,
});
continue;
}
if (!Array.isArray(categorized[category])) {
categorized[category] = [];
}
categorized[category].push({
text: truncateTitle(extension.title, 25),
href: extension.path,
onClick: extension.onClick,
});
}
const subMenu = Object.keys(categorized).reduce((subMenu: PanelMenuItem[], category) => {
subMenu.push({
text: truncateTitle(category, 25),
type: 'group',
subMenu: categorized[category],
});
return subMenu;
}, []);
if (uncategorized.length > 0) {
if (subMenu.length > 0) {
subMenu.push({
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
text: 'divider',
type: 'divider',
});
}
Array.prototype.push.apply(subMenu, uncategorized);
}
return subMenu;
}
export function getLinkExtensionOverrides(
pluginId: string,
config: AddedLinkRegistryItem,
log: ExtensionsLog,
context?: object
) {
try {
const overrides = config.configure?.(context);
// Hiding the extension
if (overrides === undefined) {
return undefined;
}
let {
title = config.title,
description = config.description,
path = config.path,
icon = config.icon,
category = config.category,
...rest
} = overrides;
assertIsNotPromise(
overrides,
`The configure() function for "${config.title}" returned a promise, skipping updates.`
);
path && assertLinkPathIsValid(pluginId, path);
assertStringProps({ title, description }, ['title', 'description']);
if (Object.keys(rest).length > 0) {
log.warning(
`Extension "${config.title}", is trying to override restricted properties: ${Object.keys(rest).join(
', '
)} which will be ignored.`
);
}
return {
title,
description,
path,
icon,
category,
};
} catch (error) {
if (error instanceof Error) {
log.error(`Failed to configure link with title "${config.title}"`, {
stack: error.stack ?? '',
message: error.message,
});
}
// If there is an error, we hide the extension
// (This seems to be safest option in case the extension is doing something wrong.)
return undefined;
}
}
export function getLinkExtensionOnClick(
pluginId: string,
extensionPointId: string,
config: AddedLinkRegistryItem,
log: ExtensionsLog,
context?: object
): ((event?: React.MouseEvent) => void) | undefined {
const { onClick } = config;
if (!onClick) {
return;
}
return function onClickExtensionLink(event?: React.MouseEvent) {
try {
reportInteraction('ui_extension_link_clicked', {
pluginId: pluginId,
extensionPointId,
title: config.title,
category: config.category,
});
const helpers: PluginExtensionEventHelpers = {
context,
openModal: createOpenModalFunction(pluginId),
openSidebar: (componentTitle, context) => {
appEvents.publish(
new OpenExtensionSidebarEvent({
props: context,
pluginId,
componentTitle,
})
);
},
};
log.debug(`onClick '${config.title}' at '${extensionPointId}'`);
const result = onClick(event, helpers);
if (isPromise(result)) {
result.catch((error) => {
if (error instanceof Error) {
log.error(error.message, {
message: error.message,
stack: error.stack ?? '',
});
}
});
}
} catch (error) {
if (error instanceof Error) {
log.error(error.message, {
message: error.message,
stack: error.stack ?? '',
});
}
}
};
}
export function getLinkExtensionPathWithTracking(pluginId: string, path: string, extensionPointId: string): string {
return urlUtil.appendQueryToUrl(
path,
urlUtil.toUrlParams({
uel_pid: pluginId,
uel_epid: extensionPointId,
})
);
}
// 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';
export const getAppPluginConfigs = (pluginIds: string[] = []) =>
Object.values(config.apps).filter((app) => pluginIds.includes(app.id));
export const getAppPluginIdFromExposedComponentId = (exposedComponentId: string) => {
return exposedComponentId.split('/')[0];
};
// Returns a list of app plugin ids that are registering extensions to this extension point.
// (These plugins are necessary to be loaded to use the extension point.)
// (The function also returns the plugin ids that the plugins - that extend the extension point - depend on.)
export const getExtensionPointPluginDependencies = (extensionPointId: string): string[] => {
return Object.values(config.apps)
.filter(
(app) =>
app.extensions.addedLinks.some((link) => link.targets.includes(extensionPointId)) ||
app.extensions.addedComponents.some((component) => component.targets.includes(extensionPointId))
)
.map((app) => app.id)
.reduce((acc: string[], id: string) => {
return [...acc, id, ...getAppPluginDependencies(id)];
}, []);
};
export type ExtensionPointPluginMeta = Map<
string,
{
readonly addedComponents: ExtensionInfo[];
readonly addedLinks: ExtensionInfo[];
}
>;
/**
* Returns a map of plugin ids and their addedComponents and addedLinks to the extension point.
* @param extensionPointId - The id of the extension point.
* @returns A map of plugin ids and their addedComponents and addedLinks to the extension point.
*/
export const getExtensionPointPluginMeta = (extensionPointId: string): ExtensionPointPluginMeta => {
return new Map(
getExtensionPointPluginDependencies(extensionPointId)
.map((pluginId) => {
const app = config.apps[pluginId];
// if the plugin does not exist or does not expose any components or links to the extension point, return undefined
if (
!app ||
(!app.extensions.addedComponents.some((component) => component.targets.includes(extensionPointId)) &&
!app.extensions.addedLinks.some((link) => link.targets.includes(extensionPointId)))
) {
return undefined;
}
return [
pluginId,
{
addedComponents: app.extensions.addedComponents.filter((component) =>
component.targets.includes(extensionPointId)
),
addedLinks: app.extensions.addedLinks.filter((link) => link.targets.includes(extensionPointId)),
},
] as const;
})
.filter((c): c is NonNullable<typeof c> => c !== undefined)
);
};
// Returns a list of app plugin ids that are necessary to be loaded to use the exposed component.
// (It is first the plugin that exposes the component, and then the ones that it depends on.)
export const getExposedComponentPluginDependencies = (exposedComponentId: string) => {
const pluginId = getAppPluginIdFromExposedComponentId(exposedComponentId);
return [pluginId].reduce((acc: string[], pluginId: string) => {
return [...acc, pluginId, ...getAppPluginDependencies(pluginId)];
}, []);
};
// Returns a list of app plugin ids that are necessary to be loaded, based on the `dependencies.extensions`
// metadata field. (For example the plugins that expose components that the app depends on.)
// Heads up! This is a recursive function.
export const getAppPluginDependencies = (pluginId: string, visited: string[] = []): string[] => {
if (!config.apps[pluginId]) {
return [];
}
// Prevent infinite recursion (it would happen if there is a circular dependency between app plugins)
if (visited.includes(pluginId)) {
return [];
}
const pluginIdDependencies = config.apps[pluginId].dependencies.extensions.exposedComponents.map(
getAppPluginIdFromExposedComponentId
);
return (
pluginIdDependencies
.reduce((acc, _pluginId) => {
return [...acc, ...getAppPluginDependencies(_pluginId, [...visited, pluginId])];
}, pluginIdDependencies)
// We don't want the plugin to "depend on itself"
.filter((id) => id !== pluginId)
);
};
// Returns a list of app plugins that has to be loaded before core Grafana could finish the initialization.
export const getAppPluginsToAwait = () => {
const pluginIds = [
// The "cloud-home-app" is registering banners once it's loaded, and this can cause a rerender in the AppChrome if it's loaded after the Grafana app init.
'cloud-home-app',
];
return Object.values(config.apps).filter((app) => pluginIds.includes(app.id));
};
// Returns a list of app plugins that has to be preloaded in parallel with the core Grafana initialization.
export const getAppPluginsToPreload = () => {
// The DashboardPanelMenu extension point is using the `getPluginExtensions()` API in scenes at the moment, which means that it cannot yet benefit from dynamic plugin loading.
const dashboardPanelMenuPluginIds = getExtensionPointPluginDependencies(PluginExtensionPoints.DashboardPanelMenu);
const awaitedPluginIds = getAppPluginsToAwait().map((app) => app.id);
const isNotAwaited = (app: AppPluginConfig) => !awaitedPluginIds.includes(app.id);
return Object.values(config.apps).filter((app) => {
return isNotAwaited(app) && (app.preload || dashboardPanelMenuPluginIds.includes(app.id));
});
};