@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
309 lines (308 loc) • 12.1 kB
JavaScript
/*
* Copyright 2025 The Kubernetes Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* The index.ts should have the functions that Headlamp itself needs for
* loading the plugins.
* The lib.ts file should carry the bits to be used by plugins whereas
*/
import * as Iconify from '@iconify/react';
import * as ReactMonacoEditor from '@monaco-editor/react';
import * as MuiLab from '@mui/lab';
import * as MuiMaterial from '@mui/material';
import * as MuiMaterialStyles from '@mui/material/styles';
import { styled } from '@mui/system';
import * as Lodash from 'lodash';
import * as MonacoEditor from 'monaco-editor';
import * as Notistack from 'notistack';
import * as React from 'react';
import * as ReactJSX from 'react/jsx-runtime';
import * as ReactDOM from 'react-dom';
import * as ReactRedux from 'react-redux';
import * as ReactRouter from 'react-router-dom';
import * as Recharts from 'recharts';
import semver from 'semver';
import { themeSlice } from '../components/App/themeSlice';
import * as CommonComponents from '../components/common';
import { getAppUrl } from '../helpers/getAppUrl';
import { isElectron } from '../helpers/isElectron';
import * as K8s from '../lib/k8s';
import * as ApiProxy from '../lib/k8s/apiProxy';
import * as Crd from '../lib/k8s/crd';
import * as Notification from '../lib/notification';
import * as Router from '../lib/router';
import * as Utils from '../lib/util';
import { eventAction, HeadlampEventType } from '../redux/headlampEventSlice';
import store from '../redux/stores/store';
import { ConfigStore } from './configStore';
import { Headlamp, Plugin } from './lib';
import Registry, * as registryToExport from './registry';
window.pluginLib = {
ApiProxy,
ReactMonacoEditor: {
...ReactMonacoEditor,
// required for compatibility with plugins built with webpack
__esModule: true,
},
MonacoEditor,
K8s,
ConfigStore,
Crd: {
...Crd,
// required for compatibility with plugins built with webpack
__esModule: true,
},
CommonComponents,
MuiMaterial: {
...MuiMaterial,
styles: MuiMaterialStyles,
},
/**
* @mui/styles is not compatible with React.StrictMode or React 18, and it will not be updated.
* Workaround is using styled function from @mui/system
*/
MuiStyles: {
makeStyles: styled,
},
MuiLab,
React,
ReactJSX,
ReactDOM,
Recharts,
ReactRouter,
ReactRedux,
Router,
Utils,
Iconify,
Lodash,
Notistack,
Notification,
Headlamp,
Plugin,
...registryToExport,
};
// backwards compat.
window.pluginLib.MuiCore = window.pluginLib.MuiMaterial;
// @todo: should window.plugins be private?
// @todo: Should all the plugin objects be in a single window.Headlamp object?
window.plugins = {};
/**
* Load plugins in the frontend/src/plugin/plugins/ folder.
*
* Plugins can be developed inside the headlamp repo.
* Move them out of the repo to an external location when they are ready.
*
* @see Plugin
*/
function loadDevPlugins() {
import.meta.glob(['./plugins/*.index.{js,ts,tsx}'], { eager: true });
}
/**
* Load external, then local plugins. Then initialize() them in order with a Registry.
*/
export async function initializePlugins() {
await loadDevPlugins();
// Initialize every plugin in the order they were loaded.
return new Promise(resolve => {
for (const pluginName of Object.keys(window.plugins)) {
const plugin = window.plugins[pluginName];
try {
// @todo: what should happen if this fails?
plugin.initialize(new Registry());
}
catch (e) {
console.error(`Plugin initialize() error in ${pluginName}:`, e);
}
}
resolve(undefined);
});
}
/**
* This can be used to filter out which of the plugins we should execute.
*
* @param sources array of source to execute. Has the same order as packageInfos.
* @param packageInfos array of package.json contents
* @param appMode if we are in app mode
* @param compatibleVersion headlamp-plugin version this build is compatible with.
* If the plugin engine version is not compatible, the plugin will not be loaded.
* Can be set to a semver range, e.g. '>= 0.6.0' or '0.6.0 - 0.7.0'.
* If set to an empty string, all plugin versions will be loaded.
* @param settingsPackages the packages from settings
*
* @returns the sources to execute and incompatible PackageInfos
* with this structure { sourcesToExecute, incompatiblePackageInfos }
*/
export function filterSources(sources, packageInfos, appMode, compatibleVersion, settingsPackages) {
const incompatiblePlugins = {};
// combine the parallel arrays
const sourcesAndPackageInfos = sources.map((source, i) => {
return { source, packageInfo: packageInfos[i] };
});
const enabledSourcesAndPackageInfos = sourcesAndPackageInfos.filter(({ packageInfo }) => {
// When not in appMode we don't have settings to enable plugins.
if (!appMode) {
return true;
}
// No plugins should be enabled if settings are not set.
if (!settingsPackages) {
return false;
}
// settingsPackages might have a different order or length than packageInfos
// If it's not in the settings don't enable the plugin.
const enabledInSettings = settingsPackages[settingsPackages.findIndex(x => x.name === packageInfo.name)]?.isEnabled ===
true;
return enabledInSettings;
});
const compatible = enabledSourcesAndPackageInfos.filter(({ packageInfo }) => {
const isCompatible = semver.satisfies(semver.coerce(packageInfo.devDependencies?.['@kinvolk/headlamp-plugin']) || '', compatibleVersion);
if (!isCompatible) {
incompatiblePlugins[packageInfo.name] = packageInfo;
return false;
}
return true;
});
return {
sourcesToExecute: compatible.map(({ source }) => source),
incompatiblePlugins,
};
}
/**
* Gives back updates settings from the backend.
*
* If there are new plugins, it includes the new ones with isEnabled=true.
*
* If plugins are not there anymore in the backend list,
* then it removes them from the settings list of plugins.
*
* @param backendPlugins the list of plugins info from the backend.
* @param settingsPlugins the list of plugins the settings already knows about.
* @returns plugin info for the settings.
*/
export function updateSettingsPackages(backendPlugins, settingsPlugins) {
if (backendPlugins.length === 0)
return [];
const pluginsChanged = backendPlugins.length !== settingsPlugins.length ||
backendPlugins.map(p => p.name + p.version).join('') !==
settingsPlugins.map(p => p.name + p.version).join('');
if (!pluginsChanged) {
return settingsPlugins;
}
return backendPlugins.map(plugin => {
const index = settingsPlugins.findIndex(x => x.name === plugin.name);
if (index === -1) {
// It's a new one settings doesn't know about so we do not enable it by default
return {
...plugin,
isEnabled: true,
};
}
return {
...settingsPlugins[index],
...plugin,
};
});
}
/**
* Get the list of plugins,
* download all the plugin source,
* download all the plugin package.json files,
* execute the plugins,
* .initialize() plugins that register (not all do).
*
* @param settingsPackages The packages settings knows about.
* @param onSettingsChange Called when the plugins are different to what is in settings.
* @param onIncompatible Called when there are incompatible plugins.
*
*/
export async function fetchAndExecutePlugins(settingsPackages, onSettingsChange, onIncompatible) {
const pluginPaths = (await fetch(`${getAppUrl()}plugins`).then(resp => resp.json()));
const sourcesPromise = Promise.all(pluginPaths.map(path => fetch(`${getAppUrl()}${path}/main.js`).then(resp => resp.text())));
const packageInfosPromise = await Promise.all(pluginPaths.map(path => fetch(`${getAppUrl()}${path}/package.json`).then(resp => {
if (!resp.ok) {
if (resp.status !== 404) {
return Promise.reject(resp);
}
{
console.warn('Missing package.json. ' +
`Please upgrade the plugin ${path}` +
' by running "headlamp-plugin extract" again.' +
' Please use headlamp-plugin >= 0.8.0');
return {
name: path.split('/').slice(-1)[0],
version: '0.0.0',
author: 'unknown',
description: '',
};
}
}
return resp.json();
})));
const sources = await sourcesPromise;
const packageInfos = await packageInfosPromise;
const updatedSettingsPackages = updateSettingsPackages(packageInfos, settingsPackages);
const settingsChanged = packageInfos.length !== settingsPackages.length;
if (settingsChanged) {
onSettingsChange(updatedSettingsPackages);
}
// Can set this to a semver version range like '>=0.8.0-alpha.3'.
// '' means all versions.
const compatibleHeadlampPluginVersion = '>=0.8.0-alpha.3';
const { sourcesToExecute, incompatiblePlugins } = filterSources(sources, packageInfos, isElectron(), compatibleHeadlampPluginVersion, updatedSettingsPackages);
if (Object.keys(incompatiblePlugins).length > 0) {
onIncompatible(incompatiblePlugins);
}
const packagesIncompatibleSet = updatedSettingsPackages.map((plugin) => {
return {
...plugin,
isCompatible: !incompatiblePlugins[plugin.name],
};
});
onSettingsChange(packagesIncompatibleSet);
sourcesToExecute.forEach((source, index) => {
// Execute plugins inside a context (not in global/window)
(function (str) {
try {
const pluginName = packageInfos[index].name.split('/').slice(-1)[0];
// Giving an evaled code a filename will make it easier to use source maps
const sourceMapPath = `\n//# sourceURL=//${pluginName}/dist/main.js`;
const result = eval(str + sourceMapPath);
return result;
}
catch (e) {
// We just continue if there is an error.
console.error(`Plugin execution error in ${pluginPaths[index]}:`, e);
store.dispatch(eventAction({
type: HeadlampEventType.PLUGIN_LOADING_ERROR,
data: {
pluginInfo: { name: packageInfos[index].name, version: packageInfos[index].version },
error: e,
},
}));
}
}).call({}, source);
});
await initializePlugins();
const pluginsLoaded = updatedSettingsPackages.map(plugin => ({
name: plugin.name,
version: plugin.version,
isEnabled: plugin.isEnabled,
}));
store.dispatch(eventAction({
type: HeadlampEventType.PLUGINS_LOADED,
data: { plugins: pluginsLoaded },
}));
// Refresh theme name if the theme that was used from a plugin was deleted
store.dispatch(themeSlice.actions.ensureValidThemeName());
}