chore: preload plugins from new api

This commit is contained in:
Hugo Häggmark
2025-12-03 11:32:11 +01:00
parent 1fe9a38a2a
commit c154654162
8 changed files with 129 additions and 33 deletions
+1
View File
@@ -84,6 +84,7 @@
"xss": "^1.0.14"
},
"devDependencies": {
"@grafana/plugin-types": "^0.0.48",
"@grafana/scenes": "6.38.0",
"@rollup/plugin-node-resolve": "16.0.1",
"@testing-library/react": "16.3.0",
+1
View File
@@ -921,3 +921,4 @@ export {
} from './rbac/rbac';
export { type UserStorage } from './types/userStorage';
export { type PluginMetasResponse, type Spec } from './types/plugin';
+55
View File
@@ -1,5 +1,7 @@
import { ComponentType } from 'react';
import { PluginSchema } from '@grafana/plugin-types/plugin-schema';
import { KeyValue } from './data';
import { IconName } from './icon';
@@ -266,3 +268,56 @@ export class GrafanaPlugin<T extends PluginMeta = PluginMeta> {
this.meta = {} as T;
}
}
export interface PluginMetasResponse {
kind: string;
apiVersion: APIVersion;
metadata: StatusClass;
items: Item[];
}
export enum APIVersion {
PluginsGrafanaAppV0Alpha1 = 'plugins.grafana.app/v0alpha1',
}
export interface Item {
kind: Kind;
apiVersion: APIVersion;
metadata: PurpleMetadata;
spec: Spec;
status: StatusClass;
}
export enum Kind {
PluginMeta = 'PluginMeta',
}
export interface PurpleMetadata {
name: string;
namespace: Namespace;
}
export enum Namespace {
Default = 'default',
}
export interface Spec {
pluginJson: PluginSchema;
module: Module;
baseURL: string;
signature: Signature;
angular: Angular;
translations?: PluginSchema['languages'];
}
export interface Angular {
detected: boolean;
}
export interface Module {
path: string;
loadingStrategy: PluginLoadingStrategy;
}
export interface Signature {
status: PluginSignatureStatus;
}
+3
View File
@@ -26,6 +26,8 @@ import {
UnifiedAlertingConfig,
GrafanaConfig,
CurrentUserDTO,
Spec,
PluginType,
} from '@grafana/data';
/**
@@ -266,6 +268,7 @@ export class GrafanaBootConfig {
listScopesEndpoint = '';
openFeatureContext: Record<string, unknown> = {};
plugins: Record<PluginType, Spec[]> = { app: [], datasource: [], panel: [], renderer: [] };
constructor(
options: BootData['settings'] & {
+17
View File
@@ -11,12 +11,14 @@ import { createRoot } from 'react-dom/client';
import {
locationUtil,
monacoLanguageRegistry,
PluginMetasResponse,
setLocale,
setTimeZoneResolver,
setWeekStart,
standardEditorsRegistry,
standardFieldConfigEditorRegistry,
standardTransformersRegistry,
Spec,
} from '@grafana/data';
import { DEFAULT_LANGUAGE } from '@grafana/i18n';
import { initializeI18n, loadNamespacedResources } from '@grafana/i18n/internal';
@@ -257,6 +259,21 @@ export class GrafanaApp {
const skipAppPluginsPreload =
config.featureToggles.rendererDisableAppPluginsPreload && contextSrv.user.authenticatedBy === 'render';
if (contextSrv.user.orgRole !== '' && !skipAppPluginsPreload) {
const response = await backendSrv.get<PluginMetasResponse>(
`/apis/plugins.grafana.app/v0alpha1/namespaces/${config.namespace}/pluginmetas`
);
const plugins: Record<string, Spec[]> = {};
response.items.reduce((acc, curr) => {
if (!acc[curr.spec.pluginJson.type]) {
acc[curr.spec.pluginJson.type] = [];
}
acc[curr.spec.pluginJson.type].push(curr.spec);
return acc;
}, plugins);
updateConfig({ plugins });
const appPluginsToAwait = getAppPluginsToAwait();
const appPluginsToPreload = getAppPluginsToPreload();
@@ -15,10 +15,12 @@ import {
urlUtil,
PluginExtensionPoints,
ExtensionInfo,
Spec,
} from '@grafana/data';
import { reportInteraction, config, AppPluginConfig } from '@grafana/runtime';
import { reportInteraction, config } from '@grafana/runtime';
import { Modal } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { getConfig } from 'app/core/config';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
import {
CloseExtensionSidebarEvent,
@@ -605,7 +607,7 @@ export function getLinkExtensionPathWithTracking(pluginId: string, path: string,
export const isGrafanaDevMode = () => config.buildInfo.env === 'development';
export const getAppPluginConfigs = (pluginIds: string[] = []) =>
Object.values(config.apps).filter((app) => pluginIds.includes(app.id));
getConfig().plugins.app.filter((app) => pluginIds.includes(app.pluginJson.id));
export const getAppPluginIdFromExposedComponentId = (exposedComponentId: string) => {
return exposedComponentId.split('/')[0];
@@ -615,13 +617,13 @@ export const getAppPluginIdFromExposedComponentId = (exposedComponentId: string)
// (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(
return getConfig()
.plugins.app.filter(
(app) =>
app.extensions.addedLinks.some((link) => link.targets.includes(extensionPointId)) ||
app.extensions.addedComponents.some((component) => component.targets.includes(extensionPointId))
app.pluginJson.extensions?.addedLinks?.some((link) => link.targets.includes(extensionPointId)) ||
app.pluginJson.extensions?.addedComponents?.some((component) => component.targets.includes(extensionPointId))
)
.map((app) => app.id)
.map((app) => app.pluginJson.id)
.reduce((acc: string[], id: string) => {
return [...acc, id, ...getAppPluginDependencies(id)];
}, []);
@@ -644,22 +646,26 @@ export const getExtensionPointPluginMeta = (extensionPointId: string): Extension
return new Map(
getExtensionPointPluginDependencies(extensionPointId)
.map((pluginId) => {
const app = config.apps[pluginId];
const app = getConfig().plugins.app.find((a) => a.pluginJson.id === 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)))
(!app.pluginJson.extensions?.addedComponents?.some((component) =>
component.targets.includes(extensionPointId)
) &&
!app.pluginJson.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)),
addedComponents:
app.pluginJson.extensions?.addedComponents?.filter((component) =>
component.targets.includes(extensionPointId)
) || [],
addedLinks:
app.pluginJson.extensions?.addedLinks?.filter((link) => link.targets.includes(extensionPointId)) || [],
},
] as const;
})
@@ -681,7 +687,7 @@ export const getExposedComponentPluginDependencies = (exposedComponentId: string
// 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]) {
if (!getConfig().plugins.app.find((a) => a.pluginJson.id === pluginId)) {
return [];
}
@@ -690,17 +696,17 @@ export const getAppPluginDependencies = (pluginId: string, visited: string[] = [
return [];
}
const pluginIdDependencies = config.apps[pluginId].dependencies.extensions.exposedComponents.map(
getAppPluginIdFromExposedComponentId
);
const spec = getConfig().plugins.app.find((a) => a.pluginJson.id === pluginId);
const pluginIdDependencies =
spec?.pluginJson.dependencies?.extensions?.exposedComponents?.map(getAppPluginIdFromExposedComponentId) || [];
return (
pluginIdDependencies
.reduce((acc, _pluginId) => {
?.reduce((acc, _pluginId) => {
return [...acc, ...getAppPluginDependencies(_pluginId, [...visited, pluginId])];
}, pluginIdDependencies)
// We don't want the plugin to "depend on itself"
.filter((id) => id !== pluginId)
.filter((id) => id !== pluginId) || []
);
};
@@ -711,17 +717,19 @@ export const getAppPluginsToAwait = () => {
'cloud-home-app',
];
return Object.values(config.apps).filter((app) => pluginIds.includes(app.id));
const app = getConfig().plugins?.app;
console.log({ app });
return getConfig().plugins?.app.filter((app) => pluginIds.includes(app.pluginJson.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);
const awaitedPluginIds = getAppPluginsToAwait().map((app) => app.pluginJson.id);
const isNotAwaited = (app: Spec) => !awaitedPluginIds.includes(app.pluginJson.id);
return Object.values(config.apps).filter((app) => {
return isNotAwaited(app) && (app.preload || dashboardPanelMenuPluginIds.includes(app.id));
return getConfig().plugins?.app.filter((app) => {
return isNotAwaited(app) && (app.pluginJson.preload || dashboardPanelMenuPluginIds.includes(app.pluginJson.id));
});
};
+11 -8
View File
@@ -1,8 +1,8 @@
import type {
AppPluginConfig,
PluginExtensionAddedLinkConfig,
PluginExtensionExposedComponentConfig,
PluginExtensionAddedComponentConfig,
Spec,
} from '@grafana/data';
import { contextSrv } from 'app/core/services/context_srv';
import { getPluginSettings } from 'app/features/plugins/pluginSettings';
@@ -23,29 +23,32 @@ export const clearPreloadedPluginsCache = () => {
preloadPromises.clear();
};
export async function preloadPlugins(apps: AppPluginConfig[] = []) {
export async function preloadPlugins(apps: Spec[] = []) {
// Create preload promises for each app, reusing existing promises if already loading
const promises = apps.map((app) => {
if (!preloadPromises.has(app.id)) {
preloadPromises.set(app.id, preload(app));
if (!preloadPromises.has(app.pluginJson.id)) {
preloadPromises.set(app.pluginJson.id, preload(app));
}
return preloadPromises.get(app.id)!;
return preloadPromises.get(app.pluginJson.id)!;
});
await Promise.all(promises);
}
async function preload(config: AppPluginConfig): Promise<void> {
async function preload(config: Spec): Promise<void> {
const showErrorAlert = contextSrv.user.orgRole !== '';
try {
const meta = await getPluginSettings(config.id, { showErrorAlert });
const meta = await getPluginSettings(config.pluginJson.id, { showErrorAlert });
await pluginImporter.importApp(meta);
} catch (error) {
if (!showErrorAlert) {
return;
}
console.error(`[Plugins] Failed to preload plugin: ${config.path} (version: ${config.version})`, error);
console.error(
`[Plugins] Failed to preload plugin: ${config.module.path} (version: ${config.pluginJson.info.version})`,
error
);
}
}
+8
View File
@@ -3106,6 +3106,7 @@ __metadata:
dependencies:
"@braintree/sanitize-url": "npm:7.0.1"
"@grafana/i18n": "npm:12.4.0-pre"
"@grafana/plugin-types": "npm:^0.0.48"
"@grafana/scenes": "npm:6.38.0"
"@grafana/schema": "npm:12.4.0-pre"
"@leeoniya/ufuzzy": "npm:1.0.19"
@@ -3458,6 +3459,13 @@ __metadata:
languageName: node
linkType: hard
"@grafana/plugin-types@npm:^0.0.48":
version: 0.0.48
resolution: "@grafana/plugin-types@npm:0.0.48"
checksum: 10/28514a152a00fffb13c2fd7ad8fe0fbef5b2ef869db89f2ad6c74a7f09b0d47b5d5e46f4edc6ce1a7d4741f4f30dcebde5e688f27c2ca3ee5be4f41ece2f93a4
languageName: node
linkType: hard
"@grafana/plugin-ui@npm:^0.11.0, @grafana/plugin-ui@npm:^0.11.1":
version: 0.11.1
resolution: "@grafana/plugin-ui@npm:0.11.1"