UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

336 lines (335 loc) 19 kB
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 })); }