Skip to content

Commit

Permalink
frontend: Add feature to view deploy logs
Browse files Browse the repository at this point in the history
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 <[email protected]>
  • Loading branch information
knrt10 committed Nov 18, 2024
1 parent d24fc9f commit d9de25c
Show file tree
Hide file tree
Showing 9 changed files with 360 additions and 16 deletions.
325 changes: 325 additions & 0 deletions frontend/src/components/common/Resource/LogsButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,325 @@
import {
Box,
FormControl,
InputLabel,
MenuItem,
Select,
styled,
Switch,
Tab,
Tabs,
} from '@mui/material';
import FormControlLabel from '@mui/material/FormControlLabel';
import { Terminal as XTerminal } from '@xterm/xterm';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { request } from '../../../lib/k8s/apiProxy';
import { KubeContainerStatus } from '../../../lib/k8s/cluster';
import Deployment from '../../../lib/k8s/deployment';
import { KubeObject } from '../../../lib/k8s/KubeObject';
import Pod from '../../../lib/k8s/pod';
import ActionButton from '../ActionButton';
import { LogViewer } from '../LogViewer';
import { LightTooltip } from '../Tooltip';

// Component props interface
interface LogsButtonProps {
item: KubeObject | null;
}

// Styled component for consistent padding in form controls
const PaddedFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
margin: 0,
paddingTop: theme.spacing(2),
paddingRight: theme.spacing(2),
}));

export function LogsButton({ item }: LogsButtonProps) {
const [showLogs, setShowLogs] = useState(false);
const [pods, setPods] = useState<Pod[]>([]);
const [selectedPod, setSelectedPod] = useState(0);
const [selectedContainer, setSelectedContainer] = useState('');

const [logs, setLogs] = useState<{ logs: string[]; lastLineShown: number }>({
logs: [],
lastLineShown: -1,
});

const [showTimestamps, setShowTimestamps] = useState<boolean>(true);
const [follow, setFollow] = useState<boolean>(true);
const [lines, setLines] = useState<number>(100);
const [showPrevious, setShowPrevious] = React.useState<boolean>(false);
const [showReconnectButton, setShowReconnectButton] = useState(false);

const xtermRef = React.useRef<XTerminal | null>(null);
const { t } = useTranslation(['glossary', 'translation']);

const clearLogs = React.useCallback(() => {
if (xtermRef.current) {
xtermRef.current.clear();
}
setLogs({ logs: [], lastLineShown: -1 });
}, []);

// Fetch related pods.
async function getRelatedPods(): Promise<Pod[]> {
if (item instanceof Deployment) {
const labelSelector = item.getMatchLabelsList().join(',');
const response = await request(
`/api/v1/namespaces/${item.metadata.namespace}/pods?labelSelector=${labelSelector}`,
{ method: 'GET' }
);
return response.items.map((podData: any) => new Pod(podData));
}
return [];
}

// Event handlers for log viewing options
function handleLinesChange(event: any) {
setLines(event.target.value);
}

function handleTimestampsChange() {
setShowTimestamps(prev => !prev);
}

function handleFollowChange() {
setFollow(prev => !prev);
}

function handlePreviousChange() {
setShowPrevious(previous => !previous);
}

// Handler for initial logs button click
async function handleClick() {
if (item instanceof Deployment) {
const fetchedPods = await getRelatedPods();
if (fetchedPods.length > 0) {
setPods(fetchedPods);
setSelectedPod(0);
setSelectedContainer(fetchedPods[0].spec.containers[0].name);
setShowLogs(true);
}
}
}

// Handler for closing the logs viewer
function handleClose() {
setShowLogs(false);
setPods([]);
setSelectedPod(0);
setSelectedContainer('');
setLogs({ logs: [], lastLineShown: -1 });
}

// Get containers for the selected pod
const containers = React.useMemo(() => {
if (!pods[selectedPod]) return [];
return pods[selectedPod].spec.containers.map(container => container.name);
}, [pods, selectedPod]);

// Check if a container has been restarted
function hasContainerRestarted(podName: string, containerName: string) {
const pod = pods.find(p => p.getName() === podName);
const cont = pod?.status?.containerStatuses?.find(
(c: KubeContainerStatus) => c.name === containerName
);
if (!cont) {
return false;
}

return cont.restartCount > 0;
}

// Handler for reconnecting to logs stream
function handleReconnect() {
if (pods[selectedPod] && selectedContainer) {
setShowReconnectButton(false);
setLogs({ logs: [], lastLineShown: -1 });
}
}

// Effect for fetching and updating logs
React.useEffect(() => {
let cleanup: (() => void) | null = null;

if (showLogs && pods[selectedPod] && selectedContainer) {
const pod = pods[selectedPod];

clearLogs();

// Handle paused logs state
if (!follow && logs.logs.length > 0) {
xtermRef.current?.write(
'\n\n' +
t('translation|Logs are paused. Click the follow button to resume following them.') +
'\r\n'
);
return;
}

// Start log streaming
cleanup = pod.getLogs(
selectedContainer,
(newLogs: string[]) => {
setLogs(current => {
const terminalRef = xtermRef.current;
if (!terminalRef) return current;

// Handle complete log refresh
if (current.lastLineShown >= newLogs.length) {
terminalRef.clear();
terminalRef.write(newLogs.join('').replaceAll('\n', '\r\n'));
} else {
// Handle incremental log updates
const newLines = newLogs.slice(current.lastLineShown + 1);
if (newLines.length > 0) {
terminalRef.write(newLines.join('').replaceAll('\n', '\r\n'));
}
}

return {
logs: newLogs,
lastLineShown: newLogs.length - 1,
};
});
},
{
tailLines: lines,
showPrevious,
showTimestamps,
follow,
onReconnectStop: () => {
setShowReconnectButton(true);
},
}
);
}

return () => cleanup?.();
}, [selectedPod, selectedContainer, showLogs, lines, showTimestamps, follow, clearLogs, t]);

const topActions = [
<Box
key="container-controls"
sx={{ display: 'flex', gap: 2, alignItems: 'center', width: '100%' }}
>
{/* Pod selection tabs */}
<Tabs
value={selectedPod}
onChange={(_, value) => {
setSelectedPod(value);
const newPod = pods[value];
if (newPod && newPod.spec.containers.length > 0) {
setSelectedContainer(newPod.spec.containers[0].name);
}
clearLogs();
}}
variant="scrollable"
scrollButtons="auto"
>
{pods.map(pod => (
<Tab key={pod.metadata.uid} label={pod.metadata.name} />
))}
</Tabs>

{/* Container selection dropdown */}
<FormControl sx={{ minWidth: 200 }}>
<InputLabel>Container</InputLabel>
<Select
value={selectedContainer}
onChange={e => {
setSelectedContainer(e.target.value);
clearLogs();
}}
label="Container"
>
{containers.map(container => (
<MenuItem key={container} value={container}>
{container}
</MenuItem>
))}
</Select>
</FormControl>

{/* Lines selector */}
<FormControl sx={{ minWidth: 120 }}>
<InputLabel>Lines</InputLabel>
<Select value={lines} onChange={handleLinesChange}>
{[100, 1000, 2500].map(i => (
<MenuItem key={i} value={i}>
{i}
</MenuItem>
))}
<MenuItem value={-1}>All</MenuItem>
</Select>
</FormControl>

{/* Show previous logs switch */}
<LightTooltip
title={
hasContainerRestarted(pods[selectedPod]?.getName(), selectedContainer)
? t('translation|Show logs for previous instances of this container.')
: t(
'translation|You can only select this option for containers that have been restarted.'
)
}
>
<PaddedFormControlLabel
label={t('translation|Show previous')}
disabled={!hasContainerRestarted(pods[selectedPod]?.getName(), selectedContainer)}
control={
<Switch
checked={showPrevious}
onChange={handlePreviousChange}
name="checkPrevious"
color="primary"
size="small"
/>
}
/>
</LightTooltip>

{/* Timestamps switch */}
<FormControlLabel
control={<Switch checked={showTimestamps} onChange={handleTimestampsChange} size="small" />}
label="Timestamps"
/>

{/* Follow logs switch */}
<FormControlLabel
control={<Switch checked={follow} onChange={handleFollowChange} size="small" />}
label="Follow"
/>
</Box>,
];

return (
<>
{/* Show logs button for deployments */}
{item instanceof Deployment && (
<ActionButton
icon="mdi:file-document-box-outline"
onClick={handleClick}
description={t('Show Logs')}
/>
)}

{/* Logs viewer dialog */}
{pods[selectedPod] && showLogs && (
<LogViewer
title={item?.getName() || ''}
downloadName={`${item?.getName()}_${pods[selectedPod].getName()}`}
open={showLogs}
onClose={handleClose}
logs={logs.logs}
topActions={topActions}
xtermRef={xtermRef}
handleReconnect={handleReconnect}
showReconnectButton={showReconnectButton}
/>
)}
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import ErrorBoundary from '../../ErrorBoundary';
import SectionHeader, { HeaderStyle } from '../../SectionHeader';
import DeleteButton from '../DeleteButton';
import EditButton from '../EditButton';
import { LogsButton } from '../LogsButton';
import { RestartButton } from '../RestartButton';
import ScaleButton from '../ScaleButton';

Expand Down Expand Up @@ -44,6 +45,9 @@ export function MainInfoHeader<T extends KubeObject>(props: MainInfoHeaderProps<
case DefaultHeaderAction.RESTART:
Action = RestartButton;
break;
case DefaultHeaderAction.DEPLOYMENT_LOGS:
Action = LogsButton;
break;
case DefaultHeaderAction.SCALE:
Action = ScaleButton;
break;
Expand Down Expand Up @@ -79,6 +83,9 @@ export function MainInfoHeader<T extends KubeObject>(props: MainInfoHeaderProps<
{
id: DefaultHeaderAction.RESTART,
},
{
id: DefaultHeaderAction.DEPLOYMENT_LOGS,
},
{
id: DefaultHeaderAction.SCALE,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@
<div
class="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root"
/>
<div
class="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root"
/>
<div
class="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
<div
class="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root"
/>
<div
class="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root"
/>
<div
class="MuiGrid-root MuiGrid-item css-13i4rnv-MuiGrid-root"
>
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/common/Resource/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@ const checkExports = [
'SimpleEditor',
'ViewButton',
'AuthVisible',
'LogsButton',
];

function getFilesToVerify() {
const filesToVerify: string[] = [];
fs.readdirSync(__dirname).forEach(file => {
const fileNoSuffix = file.replace(/\.[^/.]+$/, '');
if (!avoidCheck.find(suffix => fileNoSuffix.endsWith(suffix))) {
if (!avoidCheck.find(suffix => fileNoSuffix.endsWith(suffix)) && fileNoSuffix) {
filesToVerify.push(fileNoSuffix);
}
});
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/common/Resource/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export { default as ResourceTableColumnChooser } from './ResourceTableColumnChoo
export { addResourceTableColumnsProcessor } from './resourceTableSlice';
export * from './RestartButton';
export * from './ScaleButton';
export * from './LogsButton';
export { default as ScaleButton } from './ScaleButton';
export * from './SimpleEditor';
export { default as SimpleEditor } from './SimpleEditor';
Expand Down
Loading

0 comments on commit d9de25c

Please sign in to comment.