Skip to content

Commit 722f6bb

Browse files
committed
frontend: pod: add debugging ephemeral container support
1 parent b228929 commit 722f6bb

32 files changed

+1685
-202
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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+
/**
18+
* Storybook stories for PodDebugSettings component.
19+
* Demonstrates enabled and disabled configurations.
20+
*/
21+
22+
import { configureStore } from '@reduxjs/toolkit';
23+
import { Meta, StoryFn } from '@storybook/react';
24+
import React from 'react';
25+
import { Provider } from 'react-redux';
26+
import PodDebugSettings from './PodDebugSettings';
27+
28+
const mockClusterName = 'mock-cluster';
29+
30+
localStorage.setItem(
31+
`cluster_settings.${mockClusterName}`,
32+
JSON.stringify({
33+
podDebugTerminal: {
34+
isEnabled: true,
35+
debugImage: 'docker.io/library/busybox:latest',
36+
},
37+
})
38+
);
39+
40+
/** Creates mock Redux state for stories. */
41+
const getMockState = () => ({
42+
plugins: { loaded: true },
43+
theme: {
44+
name: 'light',
45+
logo: null,
46+
palette: {
47+
navbar: {
48+
background: '#fff',
49+
},
50+
},
51+
},
52+
});
53+
54+
export default {
55+
title: 'Settings/PodDebugSettings',
56+
component: PodDebugSettings,
57+
} as Meta<typeof PodDebugSettings>;
58+
59+
/** Story template with Redux Provider. */
60+
const Template: StoryFn<typeof PodDebugSettings> = args => {
61+
const store = configureStore({
62+
reducer: (state = getMockState()) => state,
63+
preloadedState: getMockState(),
64+
});
65+
66+
return (
67+
<Provider store={store}>
68+
<PodDebugSettings {...args} />
69+
</Provider>
70+
);
71+
};
72+
73+
/** Default story with debugging enabled and busybox image. */
74+
export const Default = Template.bind({});
75+
Default.args = {
76+
cluster: mockClusterName,
77+
};
78+
79+
/** Story with debugging disabled and alpine image. */
80+
export const Disabled = Template.bind({});
81+
Disabled.args = {
82+
cluster: mockClusterName,
83+
};
84+
Disabled.decorators = [
85+
Story => {
86+
localStorage.setItem(
87+
`cluster_settings.${mockClusterName}`,
88+
JSON.stringify({
89+
podDebugTerminal: {
90+
isEnabled: false,
91+
debugImage: 'docker.io/library/alpine:latest',
92+
},
93+
})
94+
);
95+
return <Story />;
96+
},
97+
];
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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 { Alert, Typography } from '@mui/material';
19+
import { useTheme } from '@mui/material/styles';
20+
import Switch from '@mui/material/Switch';
21+
import TextField from '@mui/material/TextField';
22+
import { useCallback, useEffect, useState } from 'react';
23+
import { useTranslation } from 'react-i18next';
24+
import {
25+
ClusterSettings,
26+
DEFAULT_POD_DEBUG_IMAGE,
27+
loadClusterSettings,
28+
storeClusterSettings,
29+
} from '../../../helpers/clusterSettings';
30+
import { NameValueTable } from '../../common/NameValueTable';
31+
import SectionBox from '../../common/SectionBox';
32+
33+
/**
34+
* Props for PodDebugSettings.
35+
*
36+
* @property {string} cluster - Cluster name for debug settings
37+
*/
38+
interface SettingsProps {
39+
cluster: string;
40+
}
41+
42+
/**
43+
* Settings component for pod debugging with ephemeral containers.
44+
*
45+
* Allows enabling/disabling debugging and configuring the default debug image per cluster.
46+
* Settings persist to localStorage and take effect immediately.
47+
*
48+
* @param props - Cluster name
49+
* @returns Settings section with debug controls
50+
*/
51+
export default function PodDebugSettings(props: SettingsProps) {
52+
const { cluster } = props;
53+
const { t } = useTranslation(['translation']);
54+
const theme = useTheme();
55+
const [clusterSettings, setClusterSettings] = useState<ClusterSettings | null>(null);
56+
const [userImage, setUserImage] = useState('');
57+
const [userIsEnabled, setUserIsEnabled] = useState<boolean | null>(null);
58+
59+
const podDebugLabelID = 'pod-debug-enabled-label';
60+
61+
useEffect(() => {
62+
setClusterSettings(!!cluster ? loadClusterSettings(cluster) : null);
63+
}, [cluster]);
64+
65+
useEffect(() => {
66+
if (clusterSettings?.podDebugTerminal?.debugImage !== userImage) {
67+
setUserImage(clusterSettings?.podDebugTerminal?.debugImage ?? '');
68+
}
69+
70+
setUserIsEnabled(clusterSettings?.podDebugTerminal?.isEnabled ?? true);
71+
72+
// Avoid re-initializing settings as {} just because the cluster is not yet set.
73+
if (clusterSettings !== null) {
74+
storeClusterSettings(cluster, clusterSettings);
75+
}
76+
}, [cluster, clusterSettings]);
77+
78+
function isEditingImage() {
79+
return clusterSettings?.podDebugTerminal?.debugImage !== userImage;
80+
}
81+
82+
const storeNewImage = useCallback(
83+
(image: string) => {
84+
let actualImage = image;
85+
if (image === DEFAULT_POD_DEBUG_IMAGE) {
86+
actualImage = '';
87+
setUserImage(actualImage);
88+
}
89+
90+
setClusterSettings((settings: ClusterSettings | null) => {
91+
const newSettings = { ...(settings || {}) };
92+
if (newSettings.podDebugTerminal === null || newSettings.podDebugTerminal === undefined) {
93+
newSettings.podDebugTerminal = {};
94+
}
95+
newSettings.podDebugTerminal.debugImage = actualImage;
96+
97+
return newSettings;
98+
});
99+
},
100+
[setClusterSettings, setUserImage]
101+
);
102+
103+
function storeNewEnabled(enabled: boolean) {
104+
setUserIsEnabled(enabled);
105+
106+
setClusterSettings((settings: ClusterSettings | null) => {
107+
const newSettings = { ...(settings || {}) };
108+
if (newSettings.podDebugTerminal === null || newSettings.podDebugTerminal === undefined) {
109+
newSettings.podDebugTerminal = {};
110+
}
111+
newSettings.podDebugTerminal.isEnabled = enabled;
112+
113+
return newSettings;
114+
});
115+
}
116+
117+
useEffect(() => {
118+
let timeoutHandle: NodeJS.Timeout | null = null;
119+
120+
if (isEditingImage()) {
121+
// We store the image after a timeout.
122+
timeoutHandle = setTimeout(() => {
123+
storeNewImage(userImage);
124+
}, 1000);
125+
}
126+
127+
return () => {
128+
if (timeoutHandle) {
129+
clearTimeout(timeoutHandle);
130+
}
131+
};
132+
}, [userImage, isEditingImage, storeNewImage]);
133+
134+
return (
135+
<SectionBox title={t('translation|Pod Debug Settings')} headerProps={{ headerStyle: 'label' }}>
136+
<NameValueTable
137+
rows={[
138+
{
139+
name: <Typography id={podDebugLabelID}>Enable Pod Debug</Typography>,
140+
value: (
141+
<Switch
142+
inputProps={{ 'aria-labelledby': podDebugLabelID }}
143+
checked={userIsEnabled ?? true}
144+
onChange={e => {
145+
const newEnabled = e.target.checked;
146+
storeNewEnabled(newEnabled);
147+
}}
148+
/>
149+
),
150+
},
151+
{
152+
name: 'Debug Image',
153+
value: (
154+
<TextField
155+
onChange={event => {
156+
let value = event.target.value;
157+
value = value.replace(' ', '');
158+
setUserImage(value);
159+
}}
160+
value={userImage}
161+
placeholder={DEFAULT_POD_DEBUG_IMAGE}
162+
helperText={t(
163+
'translation|The default image is used for creating ephemeral debug containers.'
164+
)}
165+
variant="outlined"
166+
size="small"
167+
InputProps={{
168+
endAdornment: isEditingImage() ? (
169+
<Icon
170+
width={24}
171+
color={theme.palette.text.secondary}
172+
icon="mdi:progress-check"
173+
/>
174+
) : (
175+
<Icon width={24} icon="mdi:check-bold" />
176+
),
177+
sx: { maxWidth: 300 },
178+
}}
179+
/>
180+
),
181+
},
182+
{
183+
name: t('translation|Important Note'),
184+
value: (
185+
<Alert severity="info" sx={{ maxWidth: 400 }}>
186+
{t(
187+
'translation|Ephemeral debug containers cannot be removed via Kubernetes API. They will remain in the pod specification even after the terminal closes. To remove them, the pod must be recreated.'
188+
)}
189+
</Alert>
190+
),
191+
},
192+
]}
193+
/>
194+
</SectionBox>
195+
);
196+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import SectionBox from '../../common/SectionBox';
4343
import { ClusterNameEditor } from './ClusterNameEditor';
4444
import ClusterSelector from './ClusterSelector';
4545
import NodeShellSettings from './NodeShellSettings';
46+
import PodDebugSettings from './PodDebugSettings';
4647
import { isValidNamespaceFormat } from './util';
4748

4849
export default function SettingsCluster() {
@@ -370,6 +371,7 @@ export default function SettingsCluster() {
370371
/>
371372
</SectionBox>
372373
<NodeShellSettings cluster={cluster} />
374+
<PodDebugSettings cluster={cluster} />
373375
{removableCluster && isElectron() && (
374376
<Box pt={2} textAlign="right">
375377
<ConfirmButton

0 commit comments

Comments
 (0)