-
Notifications
You must be signed in to change notification settings - Fork 185
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]>
- Loading branch information
Showing
9 changed files
with
360 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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} | ||
/> | ||
)} | ||
</> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.