@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
303 lines (302 loc) • 22.2 kB
JavaScript
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } 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 { Icon, InlineIcon } from '@iconify/react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useSnackbar } from 'notistack';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { getClusterAppearanceFromMeta, isValidCssColor } from '../../../helpers/clusterAppearance';
import { loadClusterSettings, storeClusterSettings, } from '../../../helpers/clusterSettings';
import { isElectron } from '../../../helpers/isElectron';
import { useCluster, useClustersConf } from '../../../lib/k8s';
import { deleteCluster } from '../../../lib/k8s/api/v1/clusterApi';
import { setConfig } from '../../../redux/configSlice';
import ConfirmButton from '../../common/ConfirmButton';
import Empty from '../../common/EmptyContent';
import Link from '../../common/Link';
import Loader from '../../common/Loader';
import NameValueTable from '../../common/NameValueTable';
import SectionBox from '../../common/SectionBox';
import { ClusterNameEditor } from './ClusterNameEditor';
import ClusterSelector from './ClusterSelector';
import ColorPicker from './ColorPicker';
import IconPicker from './IconPicker';
import NodeShellSettings from './NodeShellSettings';
import PodDebugSettings from './PodDebugSettings';
import { isValidNamespaceFormat } from './util';
export default function SettingsCluster() {
const clusterConf = useClustersConf();
const clusters = Object.values(clusterConf || {}).map(cluster => cluster.name);
const { t } = useTranslation(['translation']);
const [defaultNamespace, setDefaultNamespace] = React.useState('default');
const [userDefaultNamespace, setUserDefaultNamespace] = React.useState('');
const [newAllowedNamespace, setNewAllowedNamespace] = React.useState('');
const [clusterSettings, setClusterSettings] = React.useState(null);
const [cluster, setCluster] = React.useState(useCluster() || '');
const clusterFromURLRef = React.useRef('');
const [appearanceAccentColor, setAppearanceAccentColor] = React.useState('');
const [appearanceIcon, setAppearanceIcon] = React.useState('');
const [appearanceSaving, setAppearanceSaving] = React.useState(false);
const [appearanceError, setAppearanceError] = React.useState('');
// Dialog states for pickers
const [colorPickerOpen, setColorPickerOpen] = React.useState(false);
const [iconPickerOpen, setIconPickerOpen] = React.useState(false);
const theme = useTheme();
const history = useHistory();
const dispatch = useDispatch();
const location = useLocation();
const { enqueueSnackbar } = useSnackbar();
const removeCluster = () => {
deleteCluster(cluster || '')
.then(config => {
dispatch(setConfig(config));
history.push('/');
})
.catch((err) => {
enqueueSnackbar(t('translation|Failed to delete cluster: {{ error }}', { error: err.message }), {
variant: 'error',
preventDuplicate: true,
});
});
};
// check if cluster was loaded by user
const removableCluster = React.useMemo(() => {
if (!cluster) {
return false;
}
const clusterInfo = (clusterConf && clusterConf[cluster]) || null;
return clusterInfo?.meta_data?.source === 'dynamic_cluster';
}, [cluster, clusterConf]);
React.useEffect(() => {
setClusterSettings(!!cluster ? loadClusterSettings(cluster || '') : null);
}, [cluster]);
React.useEffect(() => {
// Load appearance from localStorage
const appearance = getClusterAppearanceFromMeta(cluster || '');
setAppearanceAccentColor(appearance.accentColor || '');
setAppearanceIcon(appearance.icon || '');
setAppearanceError('');
}, [cluster]);
React.useEffect(() => {
const clusterInfo = (clusterConf && clusterConf[cluster || '']) || null;
const clusterConfNs = clusterInfo?.meta_data?.namespace;
if (!!clusterConfNs && clusterConfNs !== defaultNamespace) {
setDefaultNamespace(clusterConfNs);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cluster, clusterConf]);
React.useEffect(() => {
if (clusterSettings?.defaultNamespace !== userDefaultNamespace) {
setUserDefaultNamespace(clusterSettings?.defaultNamespace || '');
}
// Avoid re-initializing settings as {} just because the cluster is not yet set.
if (clusterSettings !== null) {
storeClusterSettings(cluster || '', clusterSettings);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cluster, clusterSettings]);
React.useEffect(() => {
let timeoutHandle = null;
if (isEditingDefaultNamespace()) {
// We store the namespace after a timeout.
timeoutHandle = setTimeout(() => {
if (isValidNamespaceFormat(userDefaultNamespace)) {
storeNewDefaultNamespace(userDefaultNamespace);
}
}, 1000);
}
return () => {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
clusterFromURLRef.current = '';
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userDefaultNamespace]);
React.useEffect(() => {
const clusterFromUrl = new URLSearchParams(location.search).get('c');
clusterFromURLRef.current = clusterFromUrl || '';
if (clusterFromUrl && clusters.includes(clusterFromUrl)) {
setCluster(clusterFromUrl);
}
else if (clusters.length > 0 && !clusterFromUrl) {
history.replace(`/settings/cluster?c=${clusters[0]}`);
}
else {
setCluster('');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.search, clusters]);
function isEditingDefaultNamespace() {
return clusterSettings?.defaultNamespace !== userDefaultNamespace;
}
function storeNewAllowedNamespace(namespace) {
setNewAllowedNamespace('');
setClusterSettings((settings) => {
const newSettings = { ...(settings || {}) };
newSettings.allowedNamespaces = newSettings.allowedNamespaces || [];
newSettings.allowedNamespaces.push(namespace);
// Sort and avoid duplicates
newSettings.allowedNamespaces = [...new Set(newSettings.allowedNamespaces)].sort();
return newSettings;
});
}
function storeNewDefaultNamespace(namespace) {
let actualNamespace = namespace;
if (namespace === defaultNamespace) {
actualNamespace = '';
setUserDefaultNamespace(actualNamespace);
}
setClusterSettings((settings) => {
const newSettings = { ...(settings || {}) };
if (isValidNamespaceFormat(namespace)) {
newSettings.defaultNamespace = actualNamespace;
}
return newSettings;
});
}
const isValidDefaultNamespace = isValidNamespaceFormat(userDefaultNamespace);
const isValidNewAllowedNamespace = isValidNamespaceFormat(newAllowedNamespace);
const invalidNamespaceMessage = t("translation|Namespaces must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.");
// Wrapper to allow empty string (optional field)
const isValidAccentColor = (color) => !color || isValidCssColor(color);
// If we don't have yet a cluster name from the URL, we are still loading.
if (!clusterFromURLRef.current) {
return _jsx(Loader, { title: "Loading" });
}
if (clusters.length === 0) {
return (_jsxs(_Fragment, { children: [_jsx(SectionBox, { title: t('translation|Cluster Settings'), backLink: true }), _jsx(Empty, { color: theme.palette.mode === 'dark' ? 'error.light' : 'error.main', children: t('translation|There seem to be no clusters configured…') })] }));
}
if (!cluster) {
return (_jsx(_Fragment, { children: _jsxs(SectionBox, { title: t('translation|Cluster Settings'), backLink: true, children: [_jsx(Typography, { color: theme.palette.mode === 'dark' ? 'error.light' : 'error.main', component: "h3", variant: "h6", children: t('translation|Cluster {{ clusterName }} does not exist. Please select a valid cluster:', {
clusterName: clusterFromURLRef.current,
}) }), _jsx(ClusterSelector, { currentCluster: cluster, clusters: clusters })] }) }));
}
const defaultNamespaceLabelID = 'default-namespace-label';
const allowedNamespaceLabelID = 'allowed-namespace-label';
const appearanceLabelID = 'cluster-appearance-label';
const accentColorLabelID = 'accent-color-label';
const clusterIconLabelID = 'cluster-icon-label';
const colorButtonID = 'color-picker-button';
const iconButtonID = 'icon-picker-button';
return (_jsxs(_Fragment, { children: [_jsxs(SectionBox, { title: t('translation|Cluster Settings'), backLink: true, children: [_jsxs(Box, { display: "flex", justifyContent: "space-between", alignItems: "center", children: [_jsx(ClusterSelector, { clusters: clusters, currentCluster: cluster }), _jsx(Link, { routeName: "cluster", params: { cluster: cluster }, tooltip: t('translation|Go to cluster'), children: t('translation|Go to cluster') })] }), isElectron() && (_jsx(ClusterNameEditor, { cluster: cluster, clusterConf: clusterConf, clusterSettings: clusterSettings, setClusterSettings: setClusterSettings })), _jsx(NameValueTable, { rows: [
{
name: (_jsxs(Box, { children: [_jsx(Typography, { id: appearanceLabelID, children: t('translation|Appearance') }), _jsx(Typography, { variant: "body2", color: "textSecondary", children: t("translation|Stored in your browser's localStorage (per-browser setting).") })] })),
value: (_jsxs(Box, { display: "flex", flexDirection: "column", gap: 2, sx: { minWidth: 280 }, children: [_jsxs(Box, { children: [_jsx(Typography, { id: accentColorLabelID, variant: "subtitle2", component: "span", sx: { mb: 1 }, children: t('translation|Accent color') }), _jsxs(Box, { display: "flex", alignItems: "center", gap: 1, children: [appearanceAccentColor && (_jsx(Box, { sx: {
width: 32,
height: 32,
borderRadius: 1,
backgroundColor: appearanceAccentColor,
border: `1px solid ${theme.palette.divider}`,
} })), _jsx(Button, { id: colorButtonID, variant: "outlined", size: "small", onClick: () => setColorPickerOpen(true), startIcon: _jsx(Icon, { icon: "mdi:palette" }), "aria-labelledby": `${appearanceLabelID} ${colorButtonID}`, children: appearanceAccentColor
? t('translation|Change Color')
: t('translation|Choose Color') }), appearanceAccentColor && (_jsx(IconButton, { size: "small", onClick: () => setAppearanceAccentColor(''), "aria-label": t('translation|Clear accent color'), children: _jsx(Icon, { icon: "mdi:close" }) }))] })] }), _jsxs(Box, { children: [_jsx(Typography, { id: clusterIconLabelID, variant: "subtitle2", component: "span", sx: { mb: 1 }, children: t('translation|Cluster icon') }), _jsxs(Box, { display: "flex", alignItems: "center", gap: 1, children: [appearanceIcon && _jsx(Icon, { icon: appearanceIcon, width: 24 }), _jsx(Button, { id: iconButtonID, variant: "outlined", size: "small", onClick: () => setIconPickerOpen(true), startIcon: _jsx(Icon, { icon: "mdi:emoticon-outline" }), "aria-labelledby": `${appearanceLabelID} ${iconButtonID}`, children: appearanceIcon
? t('translation|Change Icon')
: t('translation|Choose Icon') }), appearanceIcon && (_jsx(IconButton, { size: "small", onClick: () => setAppearanceIcon(''), "aria-label": t('translation|Clear cluster icon'), children: _jsx(Icon, { icon: "mdi:close" }) }))] })] }), !!appearanceError && (_jsx(Typography, { color: theme.palette.mode === 'dark' ? 'error.light' : 'error.main', children: appearanceError })), _jsx(Box, { textAlign: "right", children: _jsx(ConfirmButton, { disabled: appearanceSaving || (!!appearanceAccentColor && !!appearanceError), onConfirm: () => {
if (!isValidAccentColor(appearanceAccentColor)) {
setAppearanceError(t('translation|Accent color format is invalid. Use hex (#ff0000), rgb(), rgba(), or a CSS color name.'));
return;
}
setAppearanceSaving(true);
setAppearanceError('');
try {
// Save appearance to localStorage via clusterSettings
setClusterSettings((settings) => {
const newSettings = { ...(settings || {}) };
newSettings.appearance = {
accentColor: appearanceAccentColor || undefined,
icon: appearanceIcon || undefined,
};
return newSettings;
});
// Force a re-render of components by dispatching a storage event
window.dispatchEvent(new Event('storage'));
}
catch (err) {
setAppearanceError(err.message);
}
finally {
setAppearanceSaving(false);
}
}, confirmTitle: t('translation|Apply appearance'), confirmDescription: t('translation|Apply appearance changes for "{{ clusterName }}"? This will be stored in your browser.', { clusterName: cluster }), children: appearanceSaving ? t('translation|Applying...') : t('translation|Apply') }) })] })),
},
] }), _jsx(NameValueTable, { rows: [
{
name: t('translation|Default namespace'),
nameID: defaultNamespaceLabelID,
value: (_jsx(TextField, { onChange: event => {
let value = event.target.value;
value = value.replace(' ', '');
setUserDefaultNamespace(value);
}, value: userDefaultNamespace, placeholder: defaultNamespace, error: !isValidDefaultNamespace, inputProps: {
'aria-labelledby': defaultNamespaceLabelID,
}, helperText: isValidDefaultNamespace
? t('translation|The default namespace for e.g. when applying resources (when not specified directly).')
: invalidNamespaceMessage, variant: "outlined", size: "small", InputProps: {
endAdornment: isEditingDefaultNamespace() ? (_jsx(Icon, { width: 24, color: theme.palette.text.secondary, icon: "mdi:progress-check" })) : (_jsx(Icon, { width: 24, icon: "mdi:check-bold" })),
sx: { maxWidth: 250 },
} })),
},
{
name: (_jsx(Typography, { id: allowedNamespaceLabelID, children: t('translation|Allowed namespaces') })),
value: (_jsxs(_Fragment, { children: [_jsx(TextField, { onChange: event => {
let value = event.target.value;
value = value.replace(' ', '');
setNewAllowedNamespace(value);
}, placeholder: "namespace", error: !isValidNewAllowedNamespace, value: newAllowedNamespace, helperText: isValidNewAllowedNamespace
? t('translation|The list of namespaces you are allowed to access in this cluster.')
: invalidNamespaceMessage, autoComplete: "off", inputProps: {
'aria-labelledby': allowedNamespaceLabelID,
form: {
autocomplete: 'off',
},
}, variant: "outlined", size: "small", InputProps: {
endAdornment: (_jsx(IconButton, { onClick: () => {
storeNewAllowedNamespace(newAllowedNamespace);
}, disabled: !newAllowedNamespace, size: "medium", "aria-label": t('translation|Add namespace'), children: _jsx(InlineIcon, { icon: "mdi:plus-circle" }) })),
onKeyPress: event => {
if (event.key === 'Enter') {
storeNewAllowedNamespace(newAllowedNamespace);
}
},
autoComplete: 'off',
sx: { maxWidth: 250 },
} }), _jsx(Box, { sx: {
display: 'flex',
flexWrap: 'wrap',
'& > *': {
margin: theme.spacing(0.5),
},
marginTop: theme.spacing(1),
}, children: ((clusterSettings || {}).allowedNamespaces || []).map(namespace => (_jsx(Chip, { label: namespace, size: "small", clickable: false, onDelete: () => {
setClusterSettings(settings => {
const newSettings = { ...settings };
newSettings.allowedNamespaces = newSettings.allowedNamespaces?.filter(ns => ns !== namespace);
return newSettings;
});
} }, namespace))) })] })),
},
] })] }), _jsx(NodeShellSettings, { cluster: cluster }), _jsx(PodDebugSettings, { cluster: cluster }), removableCluster && isElectron() && (_jsx(Box, { pt: 2, textAlign: "right", children: _jsx(ConfirmButton, { color: "secondary", onConfirm: () => removeCluster(), confirmTitle: t('translation|Remove Cluster'), confirmDescription: t('translation|Are you sure you want to remove the cluster "{{ clusterName }}"?', { clusterName: cluster }), children: t('translation|Remove Cluster') }) })), _jsx(ColorPicker, { open: colorPickerOpen, currentColor: appearanceAccentColor, onClose: () => setColorPickerOpen(false), onSelectColor: setAppearanceAccentColor, onError: setAppearanceError }), _jsx(IconPicker, { open: iconPickerOpen, currentIcon: appearanceIcon, onClose: () => setIconPickerOpen(false), onSelectIcon: setAppearanceIcon })] }));
}