Skip to content

frontend: PluginSettings: Refactor local storage and plugin data #3123

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { isElectron } from '../../../helpers/isElectron';
import { usePlugins } from '../../../lib/k8s/api/v2/plugins';
import { useFilterFunc } from '../../../lib/util';
import { PluginInfo, reloadPage, setPluginSettings } from '../../../plugin/pluginsSlice';
import { useTypedSelector } from '../../../redux/reducers/reducers';
Expand Down Expand Up @@ -306,9 +307,15 @@ export default function PluginSettings() {

const pluginSettings = useTypedSelector(state => state.plugins.pluginSettings);

const { data: plugins } = usePlugins(pluginSettings);

if (!plugins?.length) {
return null;
}

return (
<PluginSettingsPure
plugins={pluginSettings}
plugins={plugins}
onSave={plugins => {
dispatch(setPluginSettings(plugins));
dispatch(reloadPage());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { useParams } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import { isElectron } from '../../../helpers/isElectron';
import { getCluster } from '../../../lib/cluster';
import { usePlugins } from '../../../lib/k8s/api/v2/plugins';
import { deletePlugin } from '../../../lib/k8s/apiProxy';
import { ConfigStore } from '../../../plugin/configStore';
import { PluginInfo, reloadPage } from '../../../plugin/pluginsSlice';
Expand Down Expand Up @@ -82,13 +83,14 @@ const PluginSettingsDetailsInitializer = (props: { plugin: PluginInfo }) => {
};

export default function PluginSettingsDetails() {
const pluginSettings = useTypedSelector(state => state.plugins.pluginSettings);
const { name } = useParams<{ name: string }>();
const pluginSettings = useTypedSelector(state => state.plugins.pluginSettings);
const { data: plugins } = usePlugins(pluginSettings);

const plugin = useMemo(() => {
const decodedName = decodeURIComponent(name);
return pluginSettings.find(plugin => plugin.name === decodedName);
}, [pluginSettings, name]);
return plugins?.find((plugin: PluginInfo) => plugin.name === decodedName);
}, [plugins, name]);

if (!plugin) {
return <NotFoundComponent />;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/k8s/api/v2/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const BASE_HTTP_URL = getAppUrl();
*
* @returns fetch Response
*/
async function backendFetch(url: string | URL, init: RequestInit) {
export async function backendFetch(url: string | URL, init?: RequestInit) {
const response = await fetch(makeUrl([BASE_HTTP_URL, url]), init);

// The backend signals through this header that it wants a reload.
Expand Down
115 changes: 115 additions & 0 deletions frontend/src/lib/k8s/api/v2/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* 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.
*/

import '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import semver from 'semver';
import { getAppUrl } from '../../../../helpers/getAppUrl';
import { PluginInfo, PluginSettings } from '../../../../plugin/pluginsSlice';
import { backendFetch } from './fetch';

/*
* this function is used to check the compatibility of the plugin version with the headlamp version
*
* @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.
*/
export function checkCompatibleVersion(packageInfo: PluginInfo, checkAllVersions: boolean = false) {
/*
* this is the compatible version of the plugin with the headlamp version
*/
const compatibleVersion = !checkAllVersions ? '>=0.8.0-alpha.3' : '';

// Can set this to a semver version range like '>=0.8.0-alpha.3'.
// '' means all versions.
const isCompatible = semver.satisfies(
semver.coerce(packageInfo.devDependencies?.['@kinvolk/headlamp-plugin']) || '',
compatibleVersion
);

return isCompatible;
}

export async function getPluginPaths() {
const pluginPaths = (await fetch(`${getAppUrl()}plugins`).then(resp => resp.json())) as string[];

return pluginPaths;
}

/*
* this function is used for the plugin settings and is extended from the original `fetchAndExecutePlugins` function in the Plugins.tsx file
* - the function is used to fetch the plugins from the backend and return the plugins with their settings
* - it will also do a compatibility check for the plugins and return the plugins with their compatibility status,
* - the compatibility check is needed to render the plugin switches
*/
export async function getPlugins(pluginSettings: PluginSettings[]) {
const pluginPaths = await getPluginPaths();

const packageInfosPromise = await Promise.all<PluginInfo>(
pluginPaths.map(path =>
backendFetch(`${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 packageInfos = await packageInfosPromise;

const pluginsWithIsEnabled = packageInfos.map(plugin => {
const matchedSetting = pluginSettings.find(p => plugin.name === p.name);
if (matchedSetting) {
const isCompatible = checkCompatibleVersion(plugin);

return {
...plugin,
settingsComponent: matchedSetting.settingsComponent,
displaySettingsComponentWithSaveButton:
matchedSetting.displaySettingsComponentWithSaveButton,
isEnabled: matchedSetting.isEnabled,
isCompatible: isCompatible,
};
}
return plugin;
});

return pluginsWithIsEnabled;
}

export function usePlugins(pluginSettings: { name: string; isEnabled: boolean }[]) {
// takes two params, the key and the function that will be called to get the data
return useQuery({ queryKey: ['plugins'], queryFn: () => getPlugins(pluginSettings) });
}
30 changes: 5 additions & 25 deletions frontend/src/plugin/filterSources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,7 @@ describe('filterSources', () => {
const settingsPackages = undefined;
const appMode = false;

const { sourcesToExecute } = filterSources(
sources,
packageInfos,
appMode,
'>=0.8.0-alpha.3',
settingsPackages
);
const { sourcesToExecute } = filterSources(sources, packageInfos, appMode, settingsPackages);
expect(sourcesToExecute.length).toBe(0);
});

Expand All @@ -55,7 +49,6 @@ describe('filterSources', () => {
sources,
packageInfos,
appMode,
'>=0.8.0-alpha.3',
settingsPackages
);
expect(Object.keys(incompatiblePlugins).length).toBe(0);
Expand Down Expand Up @@ -84,13 +77,7 @@ describe('filterSources', () => {
},
];
const appMode = true;
const { sourcesToExecute } = filterSources(
sources,
packageInfos,
appMode,
'>=0.8.0-alpha.3',
settingsPackages
);
const { sourcesToExecute } = filterSources(sources, packageInfos, appMode, settingsPackages);

expect(sourcesToExecute.length).toBe(0);
});
Expand Down Expand Up @@ -138,13 +125,7 @@ describe('filterSources', () => {
},
];
const appMode = true;
const { sourcesToExecute } = filterSources(
sources,
packageInfos,
appMode,
'>=0.8.0-alpha.3',
settingsPackages
);
const { sourcesToExecute } = filterSources(sources, packageInfos, appMode, settingsPackages);

expect(sourcesToExecute.length).toBe(1);
expect(sourcesToExecute[0]).toBe('source1');
Expand Down Expand Up @@ -197,7 +178,6 @@ describe('filterSources', () => {
sources,
packageInfos,
appMode,
'>=0.8.0-alpha.3',
settingsPackages
);

Expand All @@ -210,8 +190,8 @@ describe('filterSources', () => {
sources,
packageInfos,
appMode,
'', // empty string disables compatibility check
settingsPackages
settingsPackages,
true
);

expect(disabledCompatCheck.sourcesToExecute.length).toBe(2);
Expand Down
62 changes: 16 additions & 46 deletions frontend/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,12 @@ 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 { checkCompatibleVersion, getPluginPaths, getPlugins } from '../lib/k8s/api/v2/plugins';
import * as ApiProxy from '../lib/k8s/apiProxy';
import * as Crd from '../lib/k8s/crd';
import * as Notification from '../lib/notification';
Expand Down Expand Up @@ -143,10 +143,6 @@ export async function initializePlugins() {
* @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
Expand All @@ -156,8 +152,8 @@ export function filterSources(
sources: string[],
packageInfos: PluginInfo[],
appMode: boolean,
compatibleVersion: string,
settingsPackages?: PluginInfo[]
settingsPackages?: PluginInfo[],
disableCompatibilityCheck?: boolean
) {
const incompatiblePlugins: Record<string, PluginInfo> = {};

Expand Down Expand Up @@ -185,11 +181,11 @@ export function filterSources(
return enabledInSettings;
});

const checkAllVersion = disableCompatibilityCheck;

const compatible = enabledSourcesAndPackageInfos.filter(({ packageInfo }) => {
const isCompatible = semver.satisfies(
semver.coerce(packageInfo.devDependencies?.['@kinvolk/headlamp-plugin']) || '',
compatibleVersion
);
const isCompatible = checkCompatibleVersion(packageInfo, checkAllVersion);

if (!isCompatible) {
incompatiblePlugins[packageInfo.name] = packageInfo;
return false;
Expand Down Expand Up @@ -217,7 +213,7 @@ export function filterSources(
*/
export function updateSettingsPackages(
backendPlugins: PluginInfo[],
settingsPlugins: PluginInfo[]
settingsPlugins: { name: string; version?: string; isEnabled: boolean }[]
): PluginInfo[] {
if (backendPlugins.length === 0) return [];

Expand All @@ -227,7 +223,11 @@ export function updateSettingsPackages(
settingsPlugins.map(p => p.name + p.version).join('');

if (!pluginsChanged) {
return settingsPlugins;
const updatedPlugins = backendPlugins.filter(plugin =>
settingsPlugins.some(setting => setting.name === plugin.name)
);

return updatedPlugins;
}

return backendPlugins.map(plugin => {
Expand Down Expand Up @@ -259,42 +259,17 @@ export function updateSettingsPackages(
*
*/
export async function fetchAndExecutePlugins(
settingsPackages: PluginInfo[],
settingsPackages: { name: string; isEnabled: boolean }[],
onSettingsChange: (plugins: PluginInfo[]) => void,
onIncompatible: (plugins: Record<string, PluginInfo>) => void
) {
const pluginPaths = (await fetch(`${getAppUrl()}plugins`).then(resp => resp.json())) as string[];
const pluginPaths = await getPluginPaths();

const sourcesPromise = Promise.all(
pluginPaths.map(path => fetch(`${getAppUrl()}${path}/main.js`).then(resp => resp.text()))
);

const packageInfosPromise = await Promise.all<PluginInfo>(
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 packageInfosPromise = await getPlugins(settingsPackages);

const sources = await sourcesPromise;
const packageInfos = await packageInfosPromise;
Expand All @@ -305,15 +280,10 @@ export async function fetchAndExecutePlugins(
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
);

Expand Down
Loading
Loading