Skip to content

Commit 7bf418e

Browse files
authored
Merge pull request kubernetes-sigs#1603 from farodin91/add-node-shell
frontend: add node shell
2 parents 0bb3bbb + ce1992f commit 7bf418e

30 files changed

+895
-50
lines changed

app/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/*
2+
* Copyright 2025 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Icon } from '@iconify/react';
18+
import { useTheme } from '@mui/material/styles';
19+
import Switch from '@mui/material/Switch';
20+
import TextField from '@mui/material/TextField';
21+
import { useEffect, useState } from 'react';
22+
import { useTranslation } from 'react-i18next';
23+
import {
24+
ClusterSettings,
25+
DEFAULT_NODE_SHELL_LINUX_IMAGE,
26+
DEFAULT_NODE_SHELL_NAMESPACE,
27+
loadClusterSettings,
28+
storeClusterSettings,
29+
} from '../../../helpers/clusterSettings';
30+
import { NameValueTable, SectionBox } from '../../common';
31+
import { isValidNamespaceFormat } from './util';
32+
33+
/**
34+
* Props for the Settings component.
35+
* @interface SettingsProps
36+
* @property {Object.<string, {isEnabled?: boolean, namespace?: string, image?: string}>} data - Configuration data for each cluster
37+
* @property {Function} onDataChange - Callback function when data changes
38+
*/
39+
interface SettingsProps {
40+
cluster: string;
41+
}
42+
43+
export default function NodeShellSettings(props: SettingsProps) {
44+
const { cluster } = props;
45+
const { t } = useTranslation(['translation']);
46+
const theme = useTheme();
47+
const [clusterSettings, setClusterSettings] = useState<ClusterSettings | null>(null);
48+
const [userNamespace, setUserNamespace] = useState('');
49+
const [userImage, setUserImage] = useState('');
50+
const [userIsEnabled, setUserIsEnabled] = useState<boolean | null>(null);
51+
52+
useEffect(() => {
53+
setClusterSettings(!!cluster ? loadClusterSettings(cluster || '') : null);
54+
}, [cluster]);
55+
56+
useEffect(() => {
57+
if (clusterSettings?.nodeShellTerminal?.namespace !== userNamespace) {
58+
setUserNamespace(clusterSettings?.nodeShellTerminal?.namespace ?? '');
59+
}
60+
61+
if (clusterSettings?.nodeShellTerminal?.linuxImage !== userImage) {
62+
setUserImage(clusterSettings?.nodeShellTerminal?.linuxImage ?? '');
63+
}
64+
65+
setUserIsEnabled(clusterSettings?.nodeShellTerminal?.isEnabled ?? true);
66+
67+
// Avoid re-initializing settings as {} just because the cluster is not yet set.
68+
if (clusterSettings !== null) {
69+
storeClusterSettings(cluster || '', clusterSettings);
70+
}
71+
}, [cluster, clusterSettings]);
72+
73+
//const selectedClusterData = data?.[selectedCluster] || {};
74+
//const isEnabled = selectedClusterData.isEnabled ?? true;
75+
const isValidNamespace = isValidNamespaceFormat(userNamespace);
76+
const invalidNamespaceMessage = t(
77+
"translation|Namespaces must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character."
78+
);
79+
80+
function isEditingNamespace() {
81+
return clusterSettings?.nodeShellTerminal?.namespace !== userNamespace;
82+
}
83+
84+
function isEditingImage() {
85+
return clusterSettings?.nodeShellTerminal?.linuxImage !== userImage;
86+
}
87+
88+
function storeNewNamespace(namespace: string) {
89+
let actualNamespace = namespace;
90+
if (namespace === DEFAULT_NODE_SHELL_NAMESPACE) {
91+
actualNamespace = '';
92+
setUserNamespace(actualNamespace);
93+
}
94+
95+
setClusterSettings((settings: ClusterSettings | null) => {
96+
const newSettings = { ...(settings || {}) };
97+
if (isValidNamespaceFormat(namespace)) {
98+
if (newSettings.nodeShellTerminal === null || newSettings.nodeShellTerminal === undefined) {
99+
newSettings.nodeShellTerminal = {};
100+
}
101+
newSettings.nodeShellTerminal.namespace = actualNamespace;
102+
}
103+
return newSettings;
104+
});
105+
}
106+
107+
function storeNewImage(image: string) {
108+
let actualImage = image;
109+
if (image === DEFAULT_NODE_SHELL_LINUX_IMAGE) {
110+
actualImage = '';
111+
setUserImage(actualImage);
112+
}
113+
114+
setClusterSettings((settings: ClusterSettings | null) => {
115+
const newSettings = { ...(settings || {}) };
116+
if (newSettings.nodeShellTerminal === null || newSettings.nodeShellTerminal === undefined) {
117+
newSettings.nodeShellTerminal = {};
118+
}
119+
newSettings.nodeShellTerminal.linuxImage = actualImage;
120+
121+
return newSettings;
122+
});
123+
}
124+
125+
function storeNewEnabled(enabled: boolean) {
126+
setUserIsEnabled(enabled);
127+
128+
setClusterSettings((settings: ClusterSettings | null) => {
129+
const newSettings = { ...(settings || {}) };
130+
if (newSettings.nodeShellTerminal === null || newSettings.nodeShellTerminal === undefined) {
131+
newSettings.nodeShellTerminal = {};
132+
}
133+
newSettings.nodeShellTerminal.isEnabled = enabled;
134+
135+
return newSettings;
136+
});
137+
}
138+
139+
useEffect(() => {
140+
let timeoutHandle: NodeJS.Timeout | null = null;
141+
142+
if (isEditingNamespace()) {
143+
// We store the namespace after a timeout.
144+
timeoutHandle = setTimeout(() => {
145+
if (isValidNamespaceFormat(userNamespace)) {
146+
storeNewNamespace(userNamespace);
147+
}
148+
}, 1000);
149+
}
150+
151+
return () => {
152+
if (timeoutHandle) {
153+
clearTimeout(timeoutHandle);
154+
}
155+
};
156+
}, [userNamespace]);
157+
158+
useEffect(() => {
159+
let timeoutHandle: NodeJS.Timeout | null = null;
160+
161+
if (isEditingImage()) {
162+
// We store the namespace after a timeout.
163+
timeoutHandle = setTimeout(() => {
164+
storeNewImage(userImage);
165+
}, 1000);
166+
}
167+
168+
return () => {
169+
if (timeoutHandle) {
170+
clearTimeout(timeoutHandle);
171+
}
172+
};
173+
}, [userImage]);
174+
175+
return (
176+
<SectionBox title={t('translation|Node Shell Settings')} headerProps={{ headerStyle: 'label' }}>
177+
<NameValueTable
178+
rows={[
179+
{
180+
name: 'Enable Node Shell',
181+
value: (
182+
<Switch
183+
checked={userIsEnabled ?? true}
184+
onChange={e => {
185+
const newEnabled = e.target.checked;
186+
storeNewEnabled(newEnabled);
187+
}}
188+
/>
189+
),
190+
},
191+
{
192+
name: 'Linux Image',
193+
value: (
194+
<TextField
195+
onChange={event => {
196+
let value = event.target.value;
197+
value = value.replace(' ', '');
198+
setUserImage(value);
199+
}}
200+
value={userImage}
201+
placeholder={DEFAULT_NODE_SHELL_LINUX_IMAGE}
202+
helperText={t(
203+
'translation|The default image is used for dropping a shell into a node (when not specified directly).'
204+
)}
205+
variant="outlined"
206+
size="small"
207+
InputProps={{
208+
endAdornment: isEditingImage() ? (
209+
<Icon
210+
width={24}
211+
color={theme.palette.text.secondary}
212+
icon="mdi:progress-check"
213+
/>
214+
) : (
215+
<Icon width={24} icon="mdi:check-bold" />
216+
),
217+
sx: { maxWidth: 300 },
218+
}}
219+
/>
220+
),
221+
},
222+
{
223+
name: 'Namespace',
224+
value: (
225+
<TextField
226+
onChange={event => {
227+
let value = event.target.value;
228+
value = value.replace(' ', '');
229+
setUserNamespace(value);
230+
}}
231+
value={userNamespace}
232+
placeholder={DEFAULT_NODE_SHELL_NAMESPACE}
233+
error={!isValidNamespace}
234+
helperText={
235+
isValidNamespace
236+
? t('translation|The default namespace is kube-system.')
237+
: invalidNamespaceMessage
238+
}
239+
variant="outlined"
240+
size="small"
241+
InputProps={{
242+
endAdornment: isEditingNamespace() ? (
243+
<Icon
244+
width={24}
245+
color={theme.palette.text.secondary}
246+
icon="mdi:progress-check"
247+
/>
248+
) : (
249+
<Icon width={24} icon="mdi:check-bold" />
250+
),
251+
sx: { maxWidth: 250 },
252+
}}
253+
/>
254+
),
255+
},
256+
]}
257+
/>
258+
</SectionBox>
259+
);
260+
}

frontend/src/components/App/Settings/SettingsCluster.tsx

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,8 @@ import { findKubeconfigByClusterName, updateStatelessClusterKubeconfig } from '.
4444
import { Link, Loader, NameValueTable, SectionBox } from '../../common';
4545
import ConfirmButton from '../../common/ConfirmButton';
4646
import Empty from '../../common/EmptyContent';
47-
48-
function isValidNamespaceFormat(namespace: string) {
49-
// We allow empty strings just because that's the default value in our case.
50-
if (!namespace) {
51-
return true;
52-
}
53-
54-
// Validates that the namespace is a valid DNS-1123 label and returns a boolean.
55-
// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names
56-
const regex = new RegExp('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$');
57-
return regex.test(namespace);
58-
}
47+
import NodeShellSettings from './NodeShellSettings';
48+
import { isValidNamespaceFormat } from './util';
5949

6050
function isValidClusterNameFormat(name: string) {
6151
// We allow empty isValidClusterNameFormat just because that's the default value in our case.
@@ -339,12 +329,7 @@ export default function SettingsCluster() {
339329

340330
return (
341331
<>
342-
<SectionBox
343-
title={t('translation|Cluster Settings ({{ clusterName }})', {
344-
clusterName: cluster,
345-
})}
346-
backLink
347-
>
332+
<SectionBox title={t('translation|Cluster Settings')} backLink>
348333
<Box display="flex" justifyContent="space-between" alignItems="center">
349334
<ClusterSelector clusters={clusters} currentCluster={cluster} />
350335
<Link
@@ -534,6 +519,7 @@ export default function SettingsCluster() {
534519
]}
535520
/>
536521
</SectionBox>
522+
<NodeShellSettings cluster={cluster} />
537523
{removableCluster && isElectron() && (
538524
<Box pt={2} textAlign="right">
539525
<ConfirmButton
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2025 The Kubernetes Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export function isValidNamespaceFormat(namespace: string) {
18+
// We allow empty strings just because that's the default value in our case.
19+
if (!namespace) {
20+
return true;
21+
}
22+
23+
// Validates that the namespace is a valid DNS-1123 label and returns a boolean.
24+
// https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names
25+
const regex = new RegExp('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$');
26+
return regex.test(namespace);
27+
}

frontend/src/components/node/Details.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { ConditionsSection, DetailsGrid, OwnedPodsSection } from '../common/Reso
3838
import AuthVisible from '../common/Resource/AuthVisible';
3939
import { SectionBox } from '../common/SectionBox';
4040
import { NameValueTable } from '../common/SimpleTable';
41+
import { NodeShellAction } from './NodeShellAction';
4142
import { NodeTaintsLabel } from './utils';
4243

4344
function NodeConditionsLabel(props: { node: Node }) {
@@ -243,6 +244,10 @@ export default function NodeDetails(props: { name?: string; cluster?: string })
243244
</AuthVisible>
244245
),
245246
},
247+
{
248+
id: DefaultHeaderAction.NODE_SHELL,
249+
action: <NodeShellAction item={item} />,
250+
},
246251
];
247252
}}
248253
extraInfo={item =>

0 commit comments

Comments
 (0)