diff --git a/frontend/src/components/App/Settings/AllowedNamespaces.tsx b/frontend/src/components/App/Settings/AllowedNamespaces.tsx deleted file mode 100644 index 119c0e7132f..00000000000 --- a/frontend/src/components/App/Settings/AllowedNamespaces.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import { InlineIcon } from '@iconify/react'; -import { Box, Chip, IconButton, TextField, useTheme } from '@mui/material'; -import { Dispatch, SetStateAction, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ClusterSettings } from '../../../helpers'; -import { isValidNamespaceFormat } from './DefaultNamespace'; - -interface AllowedNamespacesProps { - clusterSettings: ClusterSettings | null; - setClusterSettings: Dispatch>; -} - -export default function AllowedNamespaces(props: AllowedNamespacesProps) { - const { clusterSettings, setClusterSettings } = props; - const { t } = useTranslation(['translation']); - const [newAllowedNamespace, setNewAllowedNamespace] = useState(''); - const theme = useTheme(); - - function storeNewAllowedNamespace(namespace: string) { - setNewAllowedNamespace(''); - setClusterSettings((settings: ClusterSettings | null) => { - const newSettings = { ...(settings || {}) }; - newSettings.allowedNamespaces = newSettings.allowedNamespaces || []; - newSettings.allowedNamespaces.push(namespace); - // Sort and avoid duplicates - newSettings.allowedNamespaces = [...new Set(newSettings.allowedNamespaces)].sort(); - return newSettings; - }); - } - 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." - ); - return ( - <> - { - 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', - }, - }} - InputProps={{ - endAdornment: ( - { - storeNewAllowedNamespace(newAllowedNamespace); - }} - disabled={!newAllowedNamespace} - size="medium" - aria-label={t('translation|Add namespace')} - > - - - ), - onKeyPress: event => { - if (event.key === 'Enter') { - storeNewAllowedNamespace(newAllowedNamespace); - } - }, - autoComplete: 'off', - sx: { maxWidth: 250 }, - }} - /> - *': { - margin: theme.spacing(0.5), - }, - marginTop: theme.spacing(1), - }} - aria-label={t('translation|Allowed namespaces')} - > - {((clusterSettings || {}).allowedNamespaces || []).map(namespace => ( - { - setClusterSettings(settings => { - const newSettings = { ...settings }; - newSettings.allowedNamespaces = newSettings.allowedNamespaces?.filter( - ns => ns !== namespace - ); - return newSettings; - }); - }} - /> - ))} - - - ); -} diff --git a/frontend/src/components/App/Settings/CustomClusterName.tsx b/frontend/src/components/App/Settings/CustomClusterName.tsx deleted file mode 100644 index bc35bc195d8..00000000000 --- a/frontend/src/components/App/Settings/CustomClusterName.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { Box, TextField } from '@mui/material'; -import { Dispatch, SetStateAction, useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useDispatch } from 'react-redux'; -import { useHistory } from 'react-router'; -import helpers, { ClusterSettings } from '../../../helpers'; -import { parseKubeConfig, renameCluster } from '../../../lib/k8s/apiProxy'; -import { setConfig, setStatelessConfig } from '../../../redux/configSlice'; -import { findKubeconfigByClusterName, updateStatelessClusterKubeconfig } from '../../../stateless/'; -import { ConfirmButton } from '../../common'; - -function isValidClusterNameFormat(name: string) { - // 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); -} - -interface CustomClusterNameProps { - currentCluster?: string; - clusterSettings: ClusterSettings | null; - source: string; - setClusterSettings: Dispatch>; -} - -export default function CustomClusterName(props: CustomClusterNameProps) { - const { currentCluster = '', clusterSettings, source, setClusterSettings } = props; - const { t } = useTranslation(['translation']); - const [newClusterName, setNewClusterName] = useState(currentCluster || ''); - const isValidCurrentName = isValidClusterNameFormat(newClusterName); - const history = useHistory(); - const dispatch = useDispatch(); - - useEffect(() => { - if (clusterSettings?.currentName !== currentCluster) { - setNewClusterName(clusterSettings?.currentName || ''); - } - - // Avoid re-initializing settings as {} just because the cluster is not yet set. - if (clusterSettings !== null) { - helpers.storeClusterSettings(currentCluster || '', clusterSettings); - } - }, [currentCluster, clusterSettings]); - - const handleUpdateClusterName = (source: string) => { - try { - renameCluster(currentCluster || '', newClusterName, source) - .then(async config => { - if (currentCluster) { - const kubeconfig = await findKubeconfigByClusterName(currentCluster); - if (kubeconfig !== null) { - await updateStatelessClusterKubeconfig(kubeconfig, newClusterName, currentCluster); - // Make another request for updated kubeconfig - const updatedKubeconfig = await findKubeconfigByClusterName(currentCluster); - if (updatedKubeconfig !== null) { - parseKubeConfig({ kubeconfig: updatedKubeconfig }) - .then((config: any) => { - storeNewClusterName(newClusterName); - dispatch(setStatelessConfig(config)); - }) - .catch((err: Error) => { - console.error('Error updating cluster name:', err.message); - }); - } - } else { - dispatch(setConfig(config)); - } - } - history.push('/'); - window.location.reload(); - }) - .catch((err: Error) => { - console.error('Error updating cluster name:', err.message); - }); - } catch (error) { - console.error('Error updating cluster name:', error); - } - }; - - function storeNewClusterName(name: string) { - let actualName = name; - if (name === currentCluster) { - actualName = ''; - setNewClusterName(actualName); - } - - setClusterSettings((settings: ClusterSettings | null) => { - const newSettings = { ...(settings || {}) }; - if (isValidClusterNameFormat(name)) { - newSettings.currentName = actualName; - } - return newSettings; - }); - } - - const invalidClusterNameMessage = t( - "translation|Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character." - ); - return ( - { - let value = event.target.value; - value = value.replace(' ', ''); - setNewClusterName(value); - }} - value={newClusterName} - placeholder={currentCluster} - error={!isValidCurrentName} - helperText={ - isValidCurrentName - ? t('translation|The current name of cluster. You can define custom modified name.') - : invalidClusterNameMessage - } - InputProps={{ - endAdornment: ( - - { - if (isValidCurrentName) { - handleUpdateClusterName(source); - } - }} - confirmTitle={t('translation|Change name')} - confirmDescription={t( - 'translation|Are you sure you want to change the name for "{{ clusterName }}"?', - { clusterName: currentCluster } - )} - disabled={!newClusterName || !isValidCurrentName} - > - {t('translation|Apply')} - - - ), - onKeyPress: event => { - if (event.key === 'Enter' && isValidCurrentName) { - handleUpdateClusterName(source); - } - }, - autoComplete: 'off', - sx: { maxWidth: 250 }, - }} - /> - ); -} diff --git a/frontend/src/components/App/Settings/DefaultNamespace.tsx b/frontend/src/components/App/Settings/DefaultNamespace.tsx deleted file mode 100644 index 2c4568de09e..00000000000 --- a/frontend/src/components/App/Settings/DefaultNamespace.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { Icon } from '@iconify/react'; -import { TextField, useTheme } from '@mui/material'; -import { Dispatch, MutableRefObject, SetStateAction, useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import helpers, { ClusterSettings } from '../../../helpers'; -import { ConfigState } from '../../../redux/configSlice'; - -export function isValidNamespaceFormat(namespace: string) { - // We allow empty strings just because that's the default value in our case. - if (!namespace) { - 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(namespace); -} - -interface DefaultNamespaceProps { - currentCluster?: string; - clusterSettings: ClusterSettings | null; - clusterConf: ConfigState['allClusters']; - setClusterSettings: Dispatch>; - clusterFromURLRef: MutableRefObject; -} - -export default function DefaultNamespace(props: DefaultNamespaceProps) { - const { - currentCluster = '', - clusterSettings, - clusterConf, - setClusterSettings, - clusterFromURLRef, - } = props; - const { t } = useTranslation(['translation']); - const [defaultNamespace, setDefaultNamespace] = useState('default'); - const [userDefaultNamespace, setUserDefaultNamespace] = useState(''); - const theme = useTheme(); - - useEffect(() => { - const clusterInfo = (clusterConf && clusterConf[currentCluster || '']) || null; - const clusterConfNs = clusterInfo?.meta_data?.namespace; - if (!!clusterConfNs && clusterConfNs !== defaultNamespace) { - setDefaultNamespace(clusterConfNs); - } - }, [currentCluster, clusterConf]); - - useEffect(() => { - if (clusterSettings?.defaultNamespace !== userDefaultNamespace) { - setUserDefaultNamespace(clusterSettings?.defaultNamespace || ''); - } - - // Avoid re-initializing settings as {} just because the cluster is not yet set. - if (clusterSettings !== null) { - helpers.storeClusterSettings(currentCluster || '', clusterSettings); - } - }, [currentCluster, clusterSettings]); - - useEffect(() => { - let timeoutHandle: NodeJS.Timeout | null = 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]); - - function isEditingDefaultNamespace() { - return clusterSettings?.defaultNamespace !== userDefaultNamespace; - } - - function storeNewDefaultNamespace(namespace: string) { - let actualNamespace = namespace; - if (namespace === defaultNamespace) { - actualNamespace = ''; - setUserDefaultNamespace(actualNamespace); - } - - setClusterSettings((settings: ClusterSettings | null) => { - const newSettings = { ...(settings || {}) }; - if (isValidNamespaceFormat(namespace)) { - newSettings.defaultNamespace = actualNamespace; - } - return newSettings; - }); - } - - const isValidDefaultNamespace = isValidNamespaceFormat(userDefaultNamespace); - const invalidNamespaceMessage = t( - "translation|Namespaces must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character." - ); - return ( - { - 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 - } - InputProps={{ - endAdornment: isEditingDefaultNamespace() ? ( - - ) : ( - - ), - sx: { maxWidth: 250 }, - }} - /> - ); -} diff --git a/frontend/src/components/App/Settings/SettingsCluster.tsx b/frontend/src/components/App/Settings/SettingsCluster.tsx index dcb0acca367..67eaafa6296 100644 --- a/frontend/src/components/App/Settings/SettingsCluster.tsx +++ b/frontend/src/components/App/Settings/SettingsCluster.tsx @@ -1,4 +1,14 @@ -import { Box, FormControl, MenuItem, Select, Typography } from '@mui/material'; +import { Icon, InlineIcon } from '@iconify/react'; +import { + Box, + Chip, + FormControl, + IconButton, + MenuItem, + Select, + TextField, + Typography, +} from '@mui/material'; import { useTheme } from '@mui/material/styles'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -6,14 +16,36 @@ import { useDispatch } from 'react-redux'; import { useHistory, useLocation } from 'react-router-dom'; import helpers, { ClusterSettings } from '../../../helpers'; import { useCluster, useClustersConf } from '../../../lib/k8s'; -import { deleteCluster } from '../../../lib/k8s/apiProxy'; -import { setConfig } from '../../../redux/configSlice'; -import { Link, Loader, NameValueTable, NameValueTableRow, SectionBox } from '../../common'; +import { deleteCluster, parseKubeConfig, renameCluster } from '../../../lib/k8s/apiProxy'; +import { setConfig, setStatelessConfig } from '../../../redux/configSlice'; +import { findKubeconfigByClusterName, updateStatelessClusterKubeconfig } from '../../../stateless/'; +import { Link, Loader, NameValueTable, SectionBox } from '../../common'; import ConfirmButton from '../../common/ConfirmButton'; import Empty from '../../common/EmptyContent'; -import AllowedNamespaces from './AllowedNamespaces'; -import CustomClusterName from './CustomClusterName'; -import DefaultNamespace from './DefaultNamespace'; + +function isValidNamespaceFormat(namespace: string) { + // We allow empty strings just because that's the default value in our case. + if (!namespace) { + 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(namespace); +} + +function isValidClusterNameFormat(name: string) { + // 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); +} interface ClusterSelectorProps { currentCluster?: string; @@ -51,9 +83,13 @@ 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(); @@ -63,6 +99,41 @@ export default function SettingsCluster() { const clusterInfo = (clusterConf && clusterConf[cluster || '']) || null; const source = clusterInfo?.meta_data?.source || ''; + const handleUpdateClusterName = (source: string) => { + 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: any) => { + storeNewClusterName(newClusterName); + dispatch(setStatelessConfig(config)); + }) + .catch((err: Error) => { + console.error('Error updating cluster name:', err.message); + }); + } + } else { + dispatch(setConfig(config)); + } + } + history.push('/'); + window.location.reload(); + }) + .catch((err: Error) => { + console.error('Error updating cluster name:', err.message); + }); + } catch (error) { + console.error('Error updating cluster name:', error); + } + }; + const removeCluster = () => { deleteCluster(cluster || '') .then(config => { @@ -90,6 +161,49 @@ export default function SettingsCluster() { setClusterSettings(!!cluster ? helpers.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) { + helpers.storeClusterSettings(cluster || '', clusterSettings); + } + }, [cluster, clusterSettings]); + + React.useEffect(() => { + let timeoutHandle: NodeJS.Timeout | null = 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 || ''; @@ -103,6 +217,65 @@ export default function SettingsCluster() { } }, [location.search, clusters]); + function isEditingDefaultNamespace() { + return clusterSettings?.defaultNamespace !== userDefaultNamespace; + } + + function storeNewAllowedNamespace(namespace: string) { + setNewAllowedNamespace(''); + setClusterSettings((settings: ClusterSettings | null) => { + 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: string) { + let actualNamespace = namespace; + if (namespace === defaultNamespace) { + actualNamespace = ''; + setUserDefaultNamespace(actualNamespace); + } + + setClusterSettings((settings: ClusterSettings | null) => { + const newSettings = { ...(settings || {}) }; + if (isValidNamespaceFormat(namespace)) { + newSettings.defaultNamespace = actualNamespace; + } + return newSettings; + }); + } + + function storeNewClusterName(name: string) { + let actualName = name; + if (name === cluster) { + actualName = ''; + setNewClusterName(actualName); + } + + setClusterSettings((settings: ClusterSettings | null) => { + 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 ; @@ -140,46 +313,6 @@ export default function SettingsCluster() { ); } - let prefixRows: NameValueTableRow[] = []; - if (helpers.isElectron()) { - prefixRows = [ - { - name: t('translation|Name'), - value: ( - - ), - }, - ]; - } - - const rows = [ - { - name: t('translation|Default namespace'), - value: ( - - ), - }, - { - name: t('translation|Allowed namespaces'), - value: ( - - ), - }, - ]; return ( <> @@ -199,7 +332,180 @@ export default function SettingsCluster() { {t('translation|Go to cluster')} - + {helpers.isElectron() && ( + { + let value = event.target.value; + value = value.replace(' ', ''); + setNewClusterName(value); + }} + value={newClusterName} + placeholder={cluster} + error={!isValidCurrentName} + helperText={ + isValidCurrentName + ? t( + 'translation|The current name of cluster. You can define custom modified name.' + ) + : invalidClusterNameMessage + } + InputProps={{ + endAdornment: ( + + { + 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} + > + {t('translation|Apply')} + + + ), + onKeyPress: event => { + if (event.key === 'Enter' && isValidCurrentName) { + handleUpdateClusterName(source); + } + }, + autoComplete: 'off', + sx: { maxWidth: 250 }, + }} + /> + ), + }, + ]} + /> + )} + { + 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 + } + InputProps={{ + endAdornment: isEditingDefaultNamespace() ? ( + + ) : ( + + ), + sx: { maxWidth: 250 }, + }} + /> + ), + }, + { + name: t('translation|Allowed namespaces'), + value: ( + <> + { + 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', + }, + }} + InputProps={{ + endAdornment: ( + { + storeNewAllowedNamespace(newAllowedNamespace); + }} + disabled={!newAllowedNamespace} + size="medium" + aria-label={t('translation|Add namespace')} + > + + + ), + onKeyPress: event => { + if (event.key === 'Enter') { + storeNewAllowedNamespace(newAllowedNamespace); + } + }, + autoComplete: 'off', + sx: { maxWidth: 250 }, + }} + /> + *': { + margin: theme.spacing(0.5), + }, + marginTop: theme.spacing(1), + }} + aria-label={t('translation|Allowed namespaces')} + > + {((clusterSettings || {}).allowedNamespaces || []).map(namespace => ( + { + setClusterSettings(settings => { + const newSettings = { ...settings }; + newSettings.allowedNamespaces = newSettings.allowedNamespaces?.filter( + ns => ns !== namespace + ); + return newSettings; + }); + }} + /> + ))} + + + ), + }, + ]} + /> {removableCluster && helpers.isElectron() && (