Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

frontend: Add feature to view deploy logs #2581

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading