Skip to content

Commit

Permalink
frontend: Unify general and cluster settings
Browse files Browse the repository at this point in the history
Accessing settings in Headlamp is confusing. This change adds the cluster
settings to the sidebar along with the general and plugin settings. When
we open a cluster, clicking the Settings (gear) icon at the top will now
take us to the cluster settings in the home context. If there are
multiple clusters set up, a cluster selector will appear at the top of
the cluster settings page.

Fixes: #1927 and #2037

Signed-off-by: Evangelos Skopelitis <[email protected]>
  • Loading branch information
skoeva committed Jul 29, 2024
1 parent 69e9227 commit c9ab9cb
Show file tree
Hide file tree
Showing 10 changed files with 249 additions and 132 deletions.
246 changes: 119 additions & 127 deletions frontend/src/components/App/Settings/SettingsCluster.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { Icon, InlineIcon } from '@iconify/react';
import { Box, Chip, IconButton, TextField } from '@mui/material';
import { Box, Chip, FormControl, IconButton, MenuItem, Select, TextField } from '@mui/material';
import { useTheme } from '@mui/material/styles';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { useHistory } from 'react-router-dom';
import helpers, { ClusterSettings } from '../../../helpers';
import { useCluster, useClustersConf } from '../../../lib/k8s';
import { useClustersConf } from '../../../lib/k8s';
import { deleteCluster, parseKubeConfig, renameCluster } from '../../../lib/k8s/apiProxy';
import { setConfig, setStatelessConfig } from '../../../redux/configSlice';
import { findKubeconfigByClusterName, updateStatelessClusterKubeconfig } from '../../../stateless/';
import { Link, NameValueTable, SectionBox } from '../../common';
import { NameValueTable, SectionBox } from '../../common';
import ConfirmButton from '../../common/ConfirmButton';

function isValidNamespaceFormat(namespace: string) {
Expand Down Expand Up @@ -38,45 +38,44 @@ function isValidClusterNameFormat(name: string) {
}

export default function SettingsCluster() {
const cluster = useCluster();
const clusterConf = useClustersConf();
const clusters = Object.keys(clusterConf || {});
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<ClusterSettings | null>(null);
const [newClusterName, setNewClusterName] = React.useState(cluster || '');
const [newClusterName, setNewClusterName] = React.useState('');
const [selectedCluster, setSelectedCluster] = React.useState('');
const theme = useTheme();

const history = useHistory();
const dispatch = useDispatch();

const clusterInfo = (clusterConf && clusterConf[cluster || '']) || null;
const clusterInfo = (clusterConf && clusterConf[selectedCluster || '']) || null;
const source = clusterInfo?.meta_data?.source || '';

const handleUpdateClusterName = (source: string) => {
try {
renameCluster(cluster || '', newClusterName, source)
renameCluster(selectedCluster || '', 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));
const kubeconfig = await findKubeconfigByClusterName(selectedCluster);
if (kubeconfig !== null) {
await updateStatelessClusterKubeconfig(kubeconfig, newClusterName, selectedCluster);
// Make another request for updated kubeconfig
const updatedKubeconfig = await findKubeconfigByClusterName(selectedCluster);
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();
Expand All @@ -90,7 +89,7 @@ export default function SettingsCluster() {
};

const removeCluster = () => {
deleteCluster(cluster || '')
deleteCluster(selectedCluster || '')
.then(config => {
dispatch(setConfig(config));
history.push('/');
Expand All @@ -104,40 +103,48 @@ export default function SettingsCluster() {

// check if cluster was loaded by user
const removableCluster = React.useMemo(() => {
if (!cluster) {
if (!selectedCluster) {
return false;
}

const clusterInfo = (clusterConf && clusterConf[cluster]) || null;
const clusterInfo = (clusterConf && clusterConf[selectedCluster]) || null;
return clusterInfo?.meta_data?.source === 'dynamic_cluster';
}, [cluster, clusterConf]);
}, [selectedCluster, clusterConf]);

React.useEffect(() => {
setClusterSettings(!!cluster ? helpers.loadClusterSettings(cluster || '') : null);
}, [cluster]);
setClusterSettings(
!!selectedCluster ? helpers.loadClusterSettings(selectedCluster || '') : null
);
}, [selectedCluster]);

React.useEffect(() => {
const clusterInfo = (clusterConf && clusterConf[cluster || '']) || null;
if (clusters.length > 0 && !selectedCluster) {
setSelectedCluster(clusters[0]);
}
}, [clusters, selectedCluster]);

React.useEffect(() => {
const clusterInfo = (clusterConf && clusterConf[selectedCluster || '']) || null;
const clusterConfNs = clusterInfo?.meta_data?.namespace;
if (!!clusterConfNs && clusterConfNs !== defaultNamespace) {
setDefaultNamespace(clusterConfNs);
}
}, [cluster, clusterConf]);
}, [selectedCluster, clusterConf]);

React.useEffect(() => {
if (clusterSettings?.defaultNamespace !== userDefaultNamespace) {
setUserDefaultNamespace(clusterSettings?.defaultNamespace || '');
}

if (clusterSettings?.currentName !== cluster) {
if (clusterSettings?.currentName !== selectedCluster) {
setNewClusterName(clusterSettings?.currentName || '');
}

// Avoid re-initializing settings as {} just because the cluster is not yet set.
if (clusterSettings !== null) {
helpers.storeClusterSettings(cluster || '', clusterSettings);
helpers.storeClusterSettings(selectedCluster || '', clusterSettings);
}
}, [cluster, clusterSettings]);
}, [selectedCluster, clusterSettings]);

React.useEffect(() => {
let timeoutHandle: NodeJS.Timeout | null = null;
Expand All @@ -163,10 +170,6 @@ export default function SettingsCluster() {
return clusterSettings?.defaultNamespace !== userDefaultNamespace;
}

if (!cluster) {
return null;
}

function storeNewAllowedNamespace(namespace: string) {
setNewAllowedNamespace('');
setClusterSettings((settings: ClusterSettings | null) => {
Expand Down Expand Up @@ -196,16 +199,11 @@ export default function SettingsCluster() {
}

function storeNewClusterName(name: string) {
let actualName = name;
if (name === cluster) {
actualName = '';
setNewClusterName(actualName);
}

setNewClusterName(name);
setClusterSettings((settings: ClusterSettings | null) => {
const newSettings = { ...(settings || {}) };
if (isValidClusterNameFormat(name)) {
newSettings.currentName = actualName;
newSettings.currentName = name;
}
return newSettings;
});
Expand All @@ -226,79 +224,83 @@ export default function SettingsCluster() {
<>
<SectionBox
title={
Object.keys(clusterConf || {}).length > 1
? t('translation|Cluster Settings ({{ clusterName }})', { clusterName: cluster || '' })
clusters.length < 2
? t('translation|Cluster Settings ({{ clusterName }})', {
clusterName: selectedCluster || '',
})
: t('translation|Cluster Settings')
}
backLink
headerProps={{
actions: [
<Link
routeName={'settings'}
align="right"
style={{ color: theme.palette.text.primary }}
>
{t('translation|General Settings')}
</Link>,
],
}}
>
{helpers.isElectron() && (
<NameValueTable
rows={[
{
name: t('translation|Name'),
value: (
<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 cluster. You can define custom modified name.'
)
: invalidClusterNameMessage
}
InputProps={{
endAdornment: (
<Box pt={2} textAlign="right">
<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}
>
{t('translation|Apply')}
</ConfirmButton>
</Box>
),
onKeyPress: event => {
if (event.key === 'Enter' && isValidCurrentName) {
handleUpdateClusterName(source);
}
},
autoComplete: 'off',
sx: { maxWidth: 250 },
}}
/>
),
},
]}
/>
{clusters.length > 1 && (
<FormControl variant="outlined" margin="normal" sx={{ minWidth: 250 }}>
<Select
value={selectedCluster}
onChange={event => setSelectedCluster(event.target.value)}
autoWidth
>
{clusters.map(cluster => (
<MenuItem key={cluster} value={cluster}>
{cluster}
</MenuItem>
))}
</Select>
</FormControl>
)}
<NameValueTable
rows={[
{
name: t('translation|Name'),
value: (
<TextField
onChange={event => {
let value = event.target.value;
value = value.replace(' ', '');
setNewClusterName(value);
}}
value={newClusterName}
placeholder={selectedCluster}
error={!isValidCurrentName}
helperText={
isValidCurrentName
? t(
'translation|The current name of cluster. You can define custom modified name.'
)
: invalidClusterNameMessage
}
InputProps={{
endAdornment: (
<Box textAlign="right">
<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: selectedCluster }
)}
disabled={!newClusterName || !isValidCurrentName}
>
{t('translation|Apply')}
</ConfirmButton>
</Box>
),
onKeyPress: event => {
if (event.key === 'Enter' && isValidCurrentName) {
handleUpdateClusterName(source);
}
},
autoComplete: 'off',
sx: { maxWidth: 250 },
}}
/>
),
},
]}
/>
<NameValueTable
rows={[
{
Expand Down Expand Up @@ -382,17 +384,7 @@ export default function SettingsCluster() {
sx: { maxWidth: 250 },
}}
/>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
'& > *': {
margin: theme.spacing(0.5),
},
marginTop: theme.spacing(1),
}}
aria-label={t('translation|Allowed namespaces')}
>
<Box aria-label={t('translation|Allowed namespaces')}>
{((clusterSettings || {}).allowedNamespaces || []).map(namespace => (
<Chip
key={namespace}
Expand Down Expand Up @@ -425,7 +417,7 @@ export default function SettingsCluster() {
confirmTitle={t('translation|Remove Cluster')}
confirmDescription={t(
'translation|Are you sure you want to remove the cluster "{{ clusterName }}"?',
{ clusterName: cluster }
{ clusterName: selectedCluster }
)}
>
{t('translation|Remove Cluster')}
Expand Down
Loading

0 comments on commit c9ab9cb

Please sign in to comment.