@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
336 lines (335 loc) • 19 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
/*
* 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 Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import Link from '@mui/material/Link';
import { useTheme } from '@mui/material/styles';
import Switch from '@mui/material/Switch';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { isElectron } from '../../../helpers/isElectron';
import { useFilterFunc } from '../../../lib/util';
import { reloadPage, setPluginSettings } from '../../../plugin/pluginsSlice';
import { useTypedSelector } from '../../../redux/hooks';
import { Link as HeadlampLink } from '../../common/';
import ActionButton from '../../common/ActionButton';
import SectionBox from '../../common/SectionBox';
import SectionFilterHeader from '../../common/SectionFilterHeader';
import Table from '../../common/Table';
import { usePluginDelete } from './usePluginDelete';
const EnableSwitch = (props) => {
const theme = useTheme();
return (_jsx(Switch, { focusVisibleClassName: ".Mui-focusVisible", disableRipple: true, sx: {
width: 42,
height: 26,
padding: 0,
'& .MuiSwitch-switchBase': {
padding: 0,
margin: '2px',
transitionDuration: '300ms',
'&.Mui-checked': {
transform: 'translateX(16px)',
color: '#fff',
'& + .MuiSwitch-track': {
backgroundColor: theme.palette.mode === 'dark' ? '#2ECA45' : '#0078d4',
opacity: 1,
border: 0,
},
'&.Mui-disabled + .MuiSwitch-track': {
opacity: 0.5,
},
},
'&.Mui-focusVisible .MuiSwitch-thumb': {
color: '#33cf4d',
border: '6px solid #fff',
},
'&.Mui-disabled .MuiSwitch-thumb': {
color: theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[600],
},
'&.Mui-disabled + .MuiSwitch-track': {
opacity: theme.palette.mode === 'light' ? 0.7 : 0.3,
},
},
'& .MuiSwitch-thumb': {
boxSizing: 'border-box',
width: 22,
height: 22,
},
'& .MuiSwitch-track': {
borderRadius: 26 / 2,
backgroundColor: theme.palette.mode === 'light' ? '#E9E9EA' : '#39393D',
opacity: 1,
transition: theme.transitions.create(['background-color'], {
duration: 500,
}),
},
}, ...props }));
};
/** PluginSettingsPure is the main component to where we render the plugin data. */
export function PluginSettingsPure(props) {
const { t } = useTranslation(['translation']);
/** Plugin arr to be rendered to the page from prop data */
const pluginArr = props.plugins ? props.plugins : [];
/** enableSave state enables the save button when changes are made to the plugin list */
const [enableSave, setEnableSave] = useState(false);
/**
* pluginChanges state is the array of plugin data and any current changes made by the user to a plugin's "Enable" field via toggler.
* The name and origin fields are split for consistency.
* Plugins that are not loaded (isLoaded === false) are initialized with isEnabled = false.
*/
const [pluginChanges, setPluginChanges] = useState(() => pluginArr.map((plugin) => {
const [author, name] = plugin.name.includes('@')
? plugin.name.split(/\/(.+)/)
: [null, plugin.name];
return {
...plugin,
displayName: name ?? plugin.name,
origin: plugin.origin ?? author?.substring(1) ?? t('translation|Unknown'),
// If the plugin is not loaded, ensure it's disabled
isEnabled: plugin.isLoaded === false ? false : plugin.isEnabled,
};
}));
/**
* useEffect to control the rendering of the save button.
* By default, the enableSave is set to false.
* If props.plugins matches pluginChanges enableSave is set to false, disabling the save button.
*/
useEffect(() => {
/** This matcher function compares the fields of name, type and isEnabled of each object in props.plugins to each object in pluginChanges */
function matcher(objA, objB) {
return (objA.name === objB.name && objA.type === objB.type && objA.isEnabled === objB.isEnabled);
}
/**
* arrayComp returns true if each object in both arrays are identical by name, type and isEnabled.
* If both arrays are identical in this scope, then no changes need to be saved.
* If they do not match, there are changes in the pluginChanges array that can be saved and thus enableSave should be enabled.
*/
const arrayComp = props.plugins.length === pluginChanges.length &&
props.plugins.every((val, key) => matcher(val, pluginChanges[key]));
/** For storybook usage, determines if the save button should be enabled by default */
if (props.saveAlwaysEnable) {
setEnableSave(true);
}
else {
if (arrayComp) {
setEnableSave(false);
}
if (!arrayComp) {
setEnableSave(true);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginChanges]);
/**
* onSaveButton function to be called once the user clicks the Save button.
* This function then takes the current state of the pluginChanges array and inputs it to the onSave prop function.
*/
function onSaveButtonHandler() {
props.onSave(pluginChanges);
}
/**
* Confirm with the user, optimistically remove the plugin row, and call onDelete.
* Restores the row if onDelete rejects so the list stays in sync with the backend.
*/
async function handleDeleteClick(plugin) {
if (!window.confirm(t('translation|Are you sure you want to delete this plugin?')))
return;
// Optimistic update: remove the row immediately so the user gets feedback.
// Match by both name and type so we don't remove other versions of the same plugin.
const previous = pluginChanges;
setPluginChanges((current) => current.filter((p) => !(p.name === plugin.name && p.type === plugin.type)));
if (!props.onDelete)
return;
try {
await props.onDelete(plugin);
}
catch {
// Restore the row if deletion fails.
setPluginChanges(previous);
}
}
/**
* On change function handler to control the enableSave state and update the pluginChanges state.
* This function is called on every plugin toggle action and recreates the state for pluginChanges.
* Once the user clicks a toggle, the Save button is also rendered via setEnableSave.
* Now handles plugins by both name and type to support multiple versions of the same plugin.
* When enabling a plugin, it automatically disables other versions of the same plugin.
*/
function switchChangeHandler(plug) {
const plugName = plug.name;
const plugType = plug.type;
const newEnabledState = !plug.isEnabled;
setPluginChanges((currentInfo) => currentInfo.map((p) => {
// Match by both name and type to handle multiple versions
if (p.name === plugName && p.type === plugType) {
return { ...p, isEnabled: newEnabledState };
}
// If we're enabling this plugin, disable other versions with the same name
if (newEnabledState && p.name === plugName && p.type !== plugType) {
return { ...p, isEnabled: false };
}
return p;
}));
}
return (_jsxs(_Fragment, { children: [_jsx(SectionBox, { title: _jsx(SectionFilterHeader, { title: t('translation|Plugins'), noNamespaceFilter: true }), children: _jsx(Table, { columns: [
{
header: t('translation|Name'),
accessorKey: 'name',
muiTableBodyCellProps: {
sx: {
flexDirection: 'column',
alignItems: 'flex-start',
width: 'unset',
},
},
Cell: ({ row: { original: plugin } }) => {
// Check if there are clashing plugins (same name, different type)
const hasClashingPlugins = pluginChanges.some((p) => p.name === plugin.name && p.type !== plugin.type);
return (_jsxs(_Fragment, { children: [_jsxs(Typography, { variant: "subtitle1", component: "div", children: [_jsx(HeadlampLink, { routeName: 'pluginDetails', params: { name: plugin.name, type: plugin.type || 'shipped' }, align: "right", children: plugin.displayName }), hasClashingPlugins && (_jsx(Chip, { label: t('translation|Multiple versions'), size: "small", color: "warning", variant: "outlined", sx: { ml: 1, fontSize: '0.7rem', height: '18px' } }))] }), _jsx(Typography, { variant: "caption", children: plugin.version })] }));
},
},
{
header: t('translation|Description'),
accessorKey: 'description',
},
{
header: t('translation|Type'),
accessorFn: (plugin) => plugin.type || 'unknown',
Cell: ({ row: { original: plugin } }) => {
const typeLabels = {
development: {
label: t('translation|Development'),
color: 'primary',
},
user: {
label: t('translation|User-installed'),
color: 'info',
},
shipped: {
label: t('translation|Shipped'),
color: 'default',
},
};
const typeInfo = typeLabels[plugin.type || 'shipped'];
return _jsx(Chip, { label: typeInfo.label, size: "small", color: typeInfo.color });
},
},
{
header: t('translation|Origin'),
Cell: ({ row: { original: plugin } }) => {
const url = plugin?.homepage || plugin?.repository?.url;
return plugin?.origin ? (url ? (_jsx(Link, { href: url, children: plugin.origin })) : (plugin?.origin)) : (t('translation|Unknown'));
},
},
{
header: t('translation|Status'),
Cell: ({ row: { original: plugin } }) => {
if (plugin.isCompatible === false) {
return (_jsx(Tooltip, { title: t('translation|This plugin is not compatible with this version of Headlamp'), children: _jsx(Chip, { label: t('translation|Incompatible'), size: "small", color: "error" }) }));
}
// Show if this plugin is overridden by a higher priority version
if (plugin.isLoaded === false && plugin.overriddenBy) {
const overrideLabels = {
development: t('translation|Development'),
user: t('translation|User-installed'),
shipped: t('translation|Shipped'),
};
return (_jsx(Tooltip, { title: t('translation|Overridden by {{type}} version', {
type: overrideLabels[plugin.overriddenBy],
}), children: _jsx(Chip, { label: t('translation|Not Loaded'), size: "small", color: "warning", variant: "outlined" }) }));
}
// Show if disabled
if (plugin.isEnabled === false) {
return (_jsx(Chip, { label: t('translation|Disabled'), size: "small", color: "default", variant: "outlined" }));
}
// Show if loaded and enabled
return _jsx(Chip, { label: t('translation|Loaded'), size: "small", color: "success" });
},
},
{
header: t('translation|Enable'),
accessorFn: (plugin) => plugin.isEnabled,
Cell: ({ row: { original: plugin } }) => {
if (!plugin.isCompatible || !isElectron()) {
return null;
}
// Find the current state of this plugin in pluginChanges
const currentPlugin = pluginChanges.find((p) => p.name === plugin.name && p.type === plugin.type);
// Plugin should be checked if it's enabled in the current state
const isChecked = currentPlugin?.isEnabled !== false;
return (_jsx(EnableSwitch, { "aria-label": `Toggle ${plugin.name}`, checked: isChecked, onChange: () => switchChangeHandler(plugin), color: "primary", name: plugin.name }));
},
},
{
header: t('translation|Delete'),
accessorKey: 'delete',
enableSorting: false,
Cell: ({ row: { original: plugin } }) => plugin.type === 'shipped' ? null : (_jsx(ActionButton, { description: t('translation|Delete Plugin'), icon: "mdi:delete", onClick: () => handleDeleteClick(plugin) })),
},
]
// remove the enable and delete columns if we're not in app mode
.filter(el => !(el.header === t('translation|Enable') && !isElectron()))
.filter(el => !(el.header === t('translation|Delete') && !isElectron())), data: pluginChanges, getRowId: (row) => `${row.name}-${row.type}`, filterFunction: useFilterFunc(['.name']), muiTableBodyRowProps: ({ row }) => {
const plugin = row.original;
// Check if there are clashing plugins (same name, different type)
const hasClashingPlugins = pluginChanges.some((p) => p.name === plugin.name && p.type !== plugin.type);
// Generate a consistent color based on plugin name
if (hasClashingPlugins) {
const hash = plugin.name.split('').reduce((acc, char) => {
return char.charCodeAt(0) + ((acc << 5) - acc);
}, 0);
const hue = Math.abs(hash) % 360;
return {
sx: {
backgroundColor: theme => theme.palette.mode === 'dark'
? `hsla(${hue}, 30%, 20%, 0.3)`
: `hsla(${hue}, 50%, 85%, 0.4)`,
'&:hover': {
backgroundColor: theme => theme.palette.mode === 'dark'
? `hsla(${hue}, 30%, 25%, 0.4) !important`
: `hsla(${hue}, 50%, 80%, 0.5) !important`,
},
},
};
}
return {};
} }) }), enableSave && isElectron() && (_jsx(Box, { sx: {
position: 'sticky',
bottom: 0,
display: 'flex',
justifyContent: 'flex-end',
padding: '12px 16px',
backgroundColor: theme => theme.palette.background.paper,
borderTop: theme => `1px solid ${theme.palette.divider}`,
zIndex: 10,
margin: '-16px -16px 0 -16px',
}, children: _jsx(Button, { variant: "contained", color: "primary", onClick: () => onSaveButtonHandler(), children: t('translation|Save & Apply') }) }))] }));
}
/** Container function for the PluginSettingsPure, onSave prop returns plugins */
export default function PluginSettings() {
const dispatch = useDispatch();
const pluginSettings = useTypedSelector(state => state.plugins.pluginSettings);
const handleDelete = usePluginDelete();
return (_jsx(PluginSettingsPure, { plugins: pluginSettings, onSave: plugins => {
dispatch(setPluginSettings(plugins));
dispatch(reloadPage());
}, onDelete: handleDelete }));
}