UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

326 lines (325 loc) 19.8 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 { Icon, InlineIcon } from '@iconify/react'; import Box from '@mui/material/Box'; import Chip from '@mui/material/Chip'; import FormControl from '@mui/material/FormControl'; import IconButton from '@mui/material/IconButton'; import InputLabel from '@mui/material/InputLabel'; import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; import { useTheme } from '@mui/material/styles'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useHistory, useLocation } from 'react-router-dom'; import { loadClusterSettings, storeClusterSettings, } from '../../../helpers/clusterSettings'; import { isElectron } from '../../../helpers/isElectron'; import { useCluster, useClustersConf } from '../../../lib/k8s'; import { deleteCluster, parseKubeConfig, renameCluster } from '../../../lib/k8s/apiProxy'; import { setConfig, setStatelessConfig } from '../../../redux/configSlice'; import { findKubeconfigByClusterName, updateStatelessClusterKubeconfig } from '../../../stateless/'; 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 NodeShellSettings from './NodeShellSettings'; import { isValidNamespaceFormat } from './util'; function isValidClusterNameFormat(name) { // We allow empty isValidClusterNameFormat just because that's the default value in our case. if (!name) { return true; } // Validates that the namespace is a valid DNS-1123 label and returns a boolean. // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names const regex = new RegExp('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$'); return regex.test(name); } function ClusterSelector(props) { const { currentCluster = '', clusters } = props; const history = useHistory(); const { t } = useTranslation('glossary'); return (_jsxs(FormControl, { variant: "outlined", margin: "normal", size: "small", sx: { minWidth: 250 }, children: [_jsx(InputLabel, { id: "settings--cluster-selector", children: t('glossary|Cluster') }), _jsx(Select, { labelId: "settings--cluster-selector", value: currentCluster, onChange: event => { history.replace(`/settings/cluster?c=${event.target.value}`); }, label: t('glossary|Cluster'), autoWidth: true, "aria-label": t('glossary|Cluster selector'), children: clusters.map(clusterName => (_jsx(MenuItem, { value: clusterName, children: clusterName }, clusterName))) })] })); } 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 [newClusterName, setNewClusterName] = React.useState(cluster || ''); const theme = useTheme(); const history = useHistory(); const dispatch = useDispatch(); const location = useLocation(); const clusterInfo = (clusterConf && clusterConf[cluster || '']) || null; const source = clusterInfo?.meta_data?.source || ''; const handleUpdateClusterName = (source) => { try { renameCluster(cluster || '', newClusterName, source) .then(async (config) => { if (cluster) { const kubeconfig = await findKubeconfigByClusterName(cluster); if (kubeconfig !== null) { await updateStatelessClusterKubeconfig(kubeconfig, newClusterName, cluster); // Make another request for updated kubeconfig const updatedKubeconfig = await findKubeconfigByClusterName(cluster); if (updatedKubeconfig !== null) { parseKubeConfig({ kubeconfig: updatedKubeconfig }) .then((config) => { storeNewClusterName(newClusterName); dispatch(setStatelessConfig(config)); }) .catch((err) => { console.error('Error updating cluster name:', err.message); }); } } else { dispatch(setConfig(config)); } } history.push('/'); window.location.reload(); }) .catch((err) => { console.error('Error updating cluster name:', err.message); }); } catch (error) { console.error('Error updating cluster name:', error); } }; const removeCluster = () => { deleteCluster(cluster || '') .then(config => { dispatch(setConfig(config)); history.push('/'); }) .catch((err) => { if (err.message === 'Not Found') { // TODO: create notification with error message } }); }; // 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(() => { const clusterInfo = (clusterConf && clusterConf[cluster || '']) || null; const clusterConfNs = clusterInfo?.meta_data?.namespace; if (!!clusterConfNs && clusterConfNs !== defaultNamespace) { setDefaultNamespace(clusterConfNs); } }, [cluster, clusterConf]); React.useEffect(() => { if (clusterSettings?.defaultNamespace !== userDefaultNamespace) { setUserDefaultNamespace(clusterSettings?.defaultNamespace || ''); } if (clusterSettings?.currentName !== cluster) { setNewClusterName(clusterSettings?.currentName || ''); } // Avoid re-initializing settings as {} just because the cluster is not yet set. if (clusterSettings !== null) { storeClusterSettings(cluster || '', clusterSettings); } }, [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 = ''; } }; }, [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(''); } }, [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; }); } function storeNewClusterName(name) { let actualName = name; if (name === cluster) { actualName = ''; setNewClusterName(actualName); } setClusterSettings((settings) => { const newSettings = { ...(settings || {}) }; if (isValidClusterNameFormat(name)) { newSettings.currentName = actualName; } return newSettings; }); } const isValidDefaultNamespace = isValidNamespaceFormat(userDefaultNamespace); const isValidCurrentName = isValidClusterNameFormat(newClusterName); 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."); const invalidClusterNameMessage = t("translation|Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character."); // 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, { clusters: clusters })] }) })); } 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(NameValueTable, { rows: [ { name: t('translation|Name'), value: (_jsx(TextField, { onChange: event => { let value = event.target.value; value = value.replace(' ', ''); setNewClusterName(value); }, value: newClusterName, placeholder: cluster, error: !isValidCurrentName, helperText: isValidCurrentName ? t('translation|The current name of the cluster. You can define a custom name.') : invalidClusterNameMessage, InputProps: { endAdornment: (_jsx(Box, { pt: 2, textAlign: "right", children: _jsx(ConfirmButton, { onConfirm: () => { if (isValidCurrentName) { handleUpdateClusterName(source); } }, confirmTitle: t('translation|Change name'), confirmDescription: t('translation|Are you sure you want to change the name for "{{ clusterName }}"?', { clusterName: cluster }), disabled: !newClusterName || !isValidCurrentName, children: t('translation|Apply') }) })), onKeyPress: event => { if (event.key === 'Enter' && isValidCurrentName) { handleUpdateClusterName(source); } }, autoComplete: 'off', sx: { maxWidth: 250 }, } })), }, ] })), _jsx(NameValueTable, { rows: [ { name: t('translation|Default namespace'), value: (_jsx(TextField, { onChange: event => { let value = event.target.value; value = value.replace(' ', ''); setUserDefaultNamespace(value); }, value: userDefaultNamespace, placeholder: defaultNamespace, error: !isValidDefaultNamespace, 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: 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: { 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), }, "aria-label": t('translation|Allowed namespaces'), 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 }), 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') }) }))] })); }