Skip to content

Commit d542f04

Browse files
author
Kautilya Tripathi
committed
frontend: Add feature to view deploy logs
This adds feature to view all the pods of pods in a deployment rather than going to a specific pod. Fixes: #2552 Signed-off-by: Kautilya Tripathi <ktripathi@microsoft.com>
1 parent 920445e commit d542f04

File tree

51 files changed

+486
-16
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+486
-16
lines changed
Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
import {
2+
Box,
3+
FormControl,
4+
InputLabel,
5+
MenuItem,
6+
Select,
7+
styled,
8+
Switch,
9+
Tab,
10+
Tabs,
11+
} from '@mui/material';
12+
import FormControlLabel from '@mui/material/FormControlLabel';
13+
import { Terminal as XTerminal } from '@xterm/xterm';
14+
import React, { useState } from 'react';
15+
import { useTranslation } from 'react-i18next';
16+
import { request } from '../../../lib/k8s/apiProxy';
17+
import { KubeContainerStatus } from '../../../lib/k8s/cluster';
18+
import Deployment from '../../../lib/k8s/deployment';
19+
import { KubeObject } from '../../../lib/k8s/KubeObject';
20+
import Pod from '../../../lib/k8s/pod';
21+
import ActionButton from '../ActionButton';
22+
import { LogViewer } from '../LogViewer';
23+
import { LightTooltip } from '../Tooltip';
24+
25+
// Component props interface
26+
interface LogsButtonProps {
27+
item: KubeObject | null;
28+
}
29+
30+
// Styled component for consistent padding in form controls
31+
const PaddedFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
32+
margin: 0,
33+
paddingTop: theme.spacing(2),
34+
paddingRight: theme.spacing(2),
35+
}));
36+
37+
export function LogsButton({ item }: LogsButtonProps) {
38+
const [showLogs, setShowLogs] = useState(false);
39+
const [pods, setPods] = useState<Pod[]>([]);
40+
const [selectedPod, setSelectedPod] = useState(0);
41+
const [selectedContainer, setSelectedContainer] = useState('');
42+
43+
const [logs, setLogs] = useState<{ logs: string[]; lastLineShown: number }>({
44+
logs: [],
45+
lastLineShown: -1,
46+
});
47+
48+
const [showTimestamps, setShowTimestamps] = useState<boolean>(true);
49+
const [follow, setFollow] = useState<boolean>(true);
50+
const [lines, setLines] = useState<number>(100);
51+
const [showPrevious, setShowPrevious] = React.useState<boolean>(false);
52+
const [showReconnectButton, setShowReconnectButton] = useState(false);
53+
54+
const xtermRef = React.useRef<XTerminal | null>(null);
55+
const { t } = useTranslation(['glossary', 'translation']);
56+
57+
const clearLogs = React.useCallback(() => {
58+
if (xtermRef.current) {
59+
xtermRef.current.clear();
60+
}
61+
setLogs({ logs: [], lastLineShown: -1 });
62+
}, []);
63+
64+
// Fetch related pods.
65+
async function getRelatedPods(): Promise<Pod[]> {
66+
if (item instanceof Deployment) {
67+
const labelSelector = item.getMatchLabelsList().join(',');
68+
const response = await request(
69+
`/api/v1/namespaces/${item.metadata.namespace}/pods?labelSelector=${labelSelector}`,
70+
{ method: 'GET' }
71+
);
72+
return response.items.map((podData: any) => new Pod(podData));
73+
}
74+
return [];
75+
}
76+
77+
// Event handlers for log viewing options
78+
function handleLinesChange(event: any) {
79+
setLines(event.target.value);
80+
}
81+
82+
function handleTimestampsChange() {
83+
setShowTimestamps(prev => !prev);
84+
}
85+
86+
function handleFollowChange() {
87+
setFollow(prev => !prev);
88+
}
89+
90+
function handlePreviousChange() {
91+
setShowPrevious(previous => !previous);
92+
}
93+
94+
// Handler for initial logs button click
95+
async function handleClick() {
96+
if (item instanceof Deployment) {
97+
const fetchedPods = await getRelatedPods();
98+
if (fetchedPods.length > 0) {
99+
setPods(fetchedPods);
100+
setSelectedPod(0);
101+
setSelectedContainer(fetchedPods[0].spec.containers[0].name);
102+
setShowLogs(true);
103+
}
104+
}
105+
}
106+
107+
// Handler for closing the logs viewer
108+
function handleClose() {
109+
setShowLogs(false);
110+
setPods([]);
111+
setSelectedPod(0);
112+
setSelectedContainer('');
113+
setLogs({ logs: [], lastLineShown: -1 });
114+
}
115+
116+
// Get containers for the selected pod
117+
const containers = React.useMemo(() => {
118+
if (!pods[selectedPod]) return [];
119+
return pods[selectedPod].spec.containers.map(container => container.name);
120+
}, [pods, selectedPod]);
121+
122+
// Check if a container has been restarted
123+
function hasContainerRestarted(podName: string, containerName: string) {
124+
const pod = pods.find(p => p.getName() === podName);
125+
const cont = pod?.status?.containerStatuses?.find(
126+
(c: KubeContainerStatus) => c.name === containerName
127+
);
128+
if (!cont) {
129+
return false;
130+
}
131+
132+
return cont.restartCount > 0;
133+
}
134+
135+
// Handler for reconnecting to logs stream
136+
function handleReconnect() {
137+
if (pods[selectedPod] && selectedContainer) {
138+
setShowReconnectButton(false);
139+
setLogs({ logs: [], lastLineShown: -1 });
140+
}
141+
}
142+
143+
// Effect for fetching and updating logs
144+
React.useEffect(() => {
145+
let cleanup: (() => void) | null = null;
146+
147+
if (showLogs && pods[selectedPod] && selectedContainer) {
148+
const pod = pods[selectedPod];
149+
150+
clearLogs();
151+
152+
// Handle paused logs state
153+
if (!follow && logs.logs.length > 0) {
154+
xtermRef.current?.write(
155+
'\n\n' +
156+
t('translation|Logs are paused. Click the follow button to resume following them.') +
157+
'\r\n'
158+
);
159+
return;
160+
}
161+
162+
// Start log streaming
163+
cleanup = pod.getLogs(
164+
selectedContainer,
165+
(newLogs: string[]) => {
166+
setLogs(current => {
167+
const terminalRef = xtermRef.current;
168+
if (!terminalRef) return current;
169+
170+
// Handle complete log refresh
171+
if (current.lastLineShown >= newLogs.length) {
172+
terminalRef.clear();
173+
terminalRef.write(newLogs.join('').replaceAll('\n', '\r\n'));
174+
} else {
175+
// Handle incremental log updates
176+
const newLines = newLogs.slice(current.lastLineShown + 1);
177+
if (newLines.length > 0) {
178+
terminalRef.write(newLines.join('').replaceAll('\n', '\r\n'));
179+
}
180+
}
181+
182+
return {
183+
logs: newLogs,
184+
lastLineShown: newLogs.length - 1,
185+
};
186+
});
187+
},
188+
{
189+
tailLines: lines,
190+
showPrevious,
191+
showTimestamps,
192+
follow,
193+
onReconnectStop: () => {
194+
setShowReconnectButton(true);
195+
},
196+
}
197+
);
198+
}
199+
200+
return () => cleanup?.();
201+
}, [selectedPod, selectedContainer, showLogs, lines, showTimestamps, follow, clearLogs, t]);
202+
203+
const topActions = [
204+
<Box
205+
key="container-controls"
206+
sx={{ display: 'flex', gap: 2, alignItems: 'center', width: '100%' }}
207+
>
208+
{/* Pod selection tabs */}
209+
<Tabs
210+
value={selectedPod}
211+
onChange={(_, value) => {
212+
setSelectedPod(value);
213+
const newPod = pods[value];
214+
if (newPod && newPod.spec.containers.length > 0) {
215+
setSelectedContainer(newPod.spec.containers[0].name);
216+
}
217+
clearLogs();
218+
}}
219+
variant="scrollable"
220+
scrollButtons="auto"
221+
>
222+
{pods.map(pod => (
223+
<Tab key={pod.metadata.uid} label={pod.metadata.name} />
224+
))}
225+
</Tabs>
226+
227+
{/* Container selection dropdown */}
228+
<FormControl sx={{ minWidth: 200 }}>
229+
<InputLabel>Container</InputLabel>
230+
<Select
231+
value={selectedContainer}
232+
onChange={e => {
233+
setSelectedContainer(e.target.value);
234+
clearLogs();
235+
}}
236+
label="Container"
237+
>
238+
{containers.map(container => (
239+
<MenuItem key={container} value={container}>
240+
{container}
241+
</MenuItem>
242+
))}
243+
</Select>
244+
</FormControl>
245+
246+
{/* Lines selector */}
247+
<FormControl sx={{ minWidth: 120 }}>
248+
<InputLabel>Lines</InputLabel>
249+
<Select value={lines} onChange={handleLinesChange}>
250+
{[100, 1000, 2500].map(i => (
251+
<MenuItem key={i} value={i}>
252+
{i}
253+
</MenuItem>
254+
))}
255+
<MenuItem value={-1}>All</MenuItem>
256+
</Select>
257+
</FormControl>
258+
259+
{/* Show previous logs switch */}
260+
<LightTooltip
261+
title={
262+
hasContainerRestarted(pods[selectedPod]?.getName(), selectedContainer)
263+
? t('translation|Show logs for previous instances of this container.')
264+
: t(
265+
'translation|You can only select this option for containers that have been restarted.'
266+
)
267+
}
268+
>
269+
<PaddedFormControlLabel
270+
label={t('translation|Show previous')}
271+
disabled={!hasContainerRestarted(pods[selectedPod]?.getName(), selectedContainer)}
272+
control={
273+
<Switch
274+
checked={showPrevious}
275+
onChange={handlePreviousChange}
276+
name="checkPrevious"
277+
color="primary"
278+
size="small"
279+
/>
280+
}
281+
/>
282+
</LightTooltip>
283+
284+
{/* Timestamps switch */}
285+
<FormControlLabel
286+
control={<Switch checked={showTimestamps} onChange={handleTimestampsChange} size="small" />}
287+
label="Timestamps"
288+
/>
289+
290+
{/* Follow logs switch */}
291+
<FormControlLabel
292+
control={<Switch checked={follow} onChange={handleFollowChange} size="small" />}
293+
label="Follow"
294+
/>
295+
</Box>,
296+
];
297+
298+
return (
299+
<>
300+
{/* Show logs button for deployments */}
301+
{item instanceof Deployment && (
302+
<ActionButton
303+
icon="mdi:file-document-box-outline"
304+
onClick={handleClick}
305+
description={t('Show Logs')}
306+
/>
307+
)}
308+
309+
{/* Logs viewer dialog */}
310+
{pods[selectedPod] && showLogs && (
311+
<LogViewer
312+
title={item?.getName() || ''}
313+
downloadName={`${item?.getName()}_${pods[selectedPod].getName()}`}
314+
open={showLogs}
315+
onClose={handleClose}
316+
logs={logs.logs}
317+
topActions={topActions}
318+
xtermRef={xtermRef}
319+
handleReconnect={handleReconnect}
320+
showReconnectButton={showReconnectButton}
321+
/>
322+
)}
323+
</>
324+
);
325+
}

frontend/src/components/common/Resource/MainInfoSection/MainInfoSectionHeader.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import ErrorBoundary from '../../ErrorBoundary';
1212
import SectionHeader, { HeaderStyle } from '../../SectionHeader';
1313
import DeleteButton from '../DeleteButton';
1414
import EditButton from '../EditButton';
15+
import { LogsButton } from '../LogsButton';
1516
import { RestartButton } from '../RestartButton';
1617
import ScaleButton from '../ScaleButton';
1718

@@ -44,6 +45,9 @@ export function MainInfoHeader<T extends KubeObject>(props: MainInfoHeaderProps<
4445
case DefaultHeaderAction.RESTART:
4546
Action = RestartButton;
4647
break;
48+
case DefaultHeaderAction.DEPLOYMENT_LOGS:
49+
Action = LogsButton;
50+
break;
4751
case DefaultHeaderAction.SCALE:
4852
Action = ScaleButton;
4953
break;
@@ -79,6 +83,9 @@ export function MainInfoHeader<T extends KubeObject>(props: MainInfoHeaderProps<
7983
{
8084
id: DefaultHeaderAction.RESTART,
8185
},
86+
{
87+
id: DefaultHeaderAction.DEPLOYMENT_LOGS,
88+
},
8289
{
8390
id: DefaultHeaderAction.SCALE,
8491
},

frontend/src/components/common/Resource/MainInfoSection/__snapshots__/MainInfoSection.Normal.stories.storyshot

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@
5252
<div
5353
class="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root"
5454
/>
55+
<div
56+
class="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root"
57+
/>
5558
<div
5659
class="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root"
5760
>

frontend/src/components/common/Resource/MainInfoSection/__snapshots__/MainInfoSection.NullBacklink.stories.storyshot

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
<div
3535
class="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root"
3636
/>
37+
<div
38+
class="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root"
39+
/>
3740
<div
3841
class="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root"
3942
>

frontend/src/components/common/Resource/index.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,14 @@ const checkExports = [
3333
'SimpleEditor',
3434
'ViewButton',
3535
'AuthVisible',
36+
'LogsButton',
3637
];
3738

3839
function getFilesToVerify() {
3940
const filesToVerify: string[] = [];
4041
fs.readdirSync(__dirname).forEach(file => {
4142
const fileNoSuffix = file.replace(/\.[^/.]+$/, '');
42-
if (!avoidCheck.find(suffix => fileNoSuffix.endsWith(suffix))) {
43+
if (!avoidCheck.find(suffix => fileNoSuffix.endsWith(suffix)) && fileNoSuffix) {
4344
filesToVerify.push(fileNoSuffix);
4445
}
4546
});

frontend/src/components/common/Resource/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export { default as ResourceTableColumnChooser } from './ResourceTableColumnChoo
2727
export { addResourceTableColumnsProcessor } from './resourceTableSlice';
2828
export * from './RestartButton';
2929
export * from './ScaleButton';
30+
export * from './LogsButton';
3031
export { default as ScaleButton } from './ScaleButton';
3132
export * from './SimpleEditor';
3233
export { default as SimpleEditor } from './SimpleEditor';

0 commit comments

Comments
 (0)