github.com/argoproj/argo-cd/v3@v3.2.1/ui/src/app/applications/components/pod-logs-viewer/pod-logs-viewer.tsx (about) 1 import {DataLoader} from 'argo-ui'; 2 import * as classNames from 'classnames'; 3 import * as React from 'react'; 4 import {useEffect, useState, useRef} from 'react'; 5 import {bufferTime, catchError, delay, retryWhen} from 'rxjs/operators'; 6 7 import {LogEntry} from '../../../shared/models'; 8 import {services, ViewPreferences} from '../../../shared/services'; 9 10 import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer'; 11 12 import './pod-logs-viewer.scss'; 13 import {CopyLogsButton} from './copy-logs-button'; 14 import {DownloadLogsButton} from './download-logs-button'; 15 import {ContainerSelector} from './container-selector'; 16 import {FollowToggleButton} from './follow-toggle-button'; 17 import {ShowPreviousLogsToggleButton} from './show-previous-logs-toggle-button'; 18 import {PodHighlightButton} from './pod-logs-highlight-button'; 19 import {TimestampsToggleButton} from './timestamps-toggle-button'; 20 import {DarkModeToggleButton} from './dark-mode-toggle-button'; 21 import {FullscreenButton} from './fullscreen-button'; 22 import {Spacer} from '../../../shared/components/spacer'; 23 import {LogMessageFilter} from './log-message-filter'; 24 import {SinceSecondsSelector} from './since-seconds-selector'; 25 import {TailSelector} from './tail-selector'; 26 import {PodNamesToggleButton} from './pod-names-toggle-button'; 27 import {AutoScrollButton} from './auto-scroll-button'; 28 import {WrapLinesButton} from './wrap-lines-button'; 29 import {MatchCaseToggleButton} from './match-case-toggle-button'; 30 import Ansi from 'ansi-to-react'; 31 import {EMPTY} from 'rxjs'; 32 33 export interface PodLogsProps { 34 namespace: string; 35 applicationNamespace: string; 36 applicationName: string; 37 podName?: string; 38 containerName: string; 39 group?: string; 40 kind?: string; 41 name?: string; 42 timestamp?: string; 43 containerGroups?: any[]; 44 onClickContainer?: (group: any, i: number, tab: string) => void; 45 fullscreen?: boolean; 46 } 47 48 export interface PodLogsQueryProps { 49 viewPodNames?: boolean; 50 viewTimestamps?: boolean; 51 follow?: boolean; 52 showPreviousLogs?: boolean; 53 filterText?: string; 54 tail?: number; 55 matchCase?: boolean; 56 sinceSeconds?: number; 57 } 58 59 // ansi colors, see https://en.wikipedia.org/wiki/ANSI_escape_code#Colors 60 const blue = '\u001b[34m'; 61 const magenta = '\u001b[35m'; 62 const colors = [blue, magenta]; 63 const reset = '\u001b[0m'; 64 const whiteOnYellow = '\u001b[1m\u001b[43;1m\u001b[37m'; 65 66 // Default colors using argo-ui theme variables 67 const POD_COLORS_LIGHT = ['var(--pod-background-light)']; 68 const POD_COLORS_DARK = ['var(--pod-background-dark)']; 69 70 const getPodColors = (isDark: boolean) => { 71 const envColors = (window as any).env?.POD_COLORS?.[isDark ? 'dark' : 'light']; 72 return envColors || (isDark ? POD_COLORS_DARK : POD_COLORS_LIGHT); 73 }; 74 75 function getPodBackgroundColor(podName: string, darkMode: boolean) { 76 const colors = getPodColors(darkMode); 77 return colors[0]; 78 } 79 80 // ansi color for pod name 81 function podColor(podName: string, isDarkMode: boolean, isSelected: boolean) { 82 if (!isSelected) { 83 return ''; 84 } 85 return isDarkMode ? colors[1] : colors[0]; 86 } 87 88 // https://2ality.com/2012/09/empty-regexp.html 89 const matchNothing = /.^/; 90 91 export const PodsLogsViewer = (props: PodLogsProps) => { 92 const {containerName, onClickContainer, timestamp, containerGroups, applicationName, applicationNamespace, namespace, podName, group, kind, name} = props; 93 const queryParams = new URLSearchParams(location.search); 94 const [selectedPod, setSelectedPod] = useState<string | null>(null); 95 const [viewPodNames, setViewPodNames] = useState(queryParams.get('viewPodNames') === 'true'); 96 const [follow, setFollow] = useState(queryParams.get('follow') !== 'false'); 97 const [viewTimestamps, setViewTimestamps] = useState(queryParams.get('viewTimestamps') === 'true'); 98 const [previous, setPreviousLogs] = useState(queryParams.get('showPreviousLogs') === 'true'); 99 const [tail, setTail] = useState<number>(parseInt(queryParams.get('tail'), 10) || 1000); 100 const [matchCase, setMatchCase] = useState(queryParams.get('matchCase') === 'true'); 101 const [sinceSeconds, setSinceSeconds] = useState(parseInt(queryParams.get('sinceSeconds'), 10) || 0); 102 const [filter, setFilter] = useState(queryParams.get('filterText') || ''); 103 const [highlight, setHighlight] = useState<RegExp>(matchNothing); 104 const [scrollToBottom, setScrollToBottom] = useState(true); 105 const [logs, setLogs] = useState<LogEntry[]>([]); 106 const logsContainerRef = useRef(null); 107 const uniquePods = Array.from(new Set(logs.map(log => log.podName))); 108 const [errorMessage, setErrorMessage] = useState<string | null>(null); 109 110 const setWithQueryParams = <T extends (val: any) => void>(key: string, cb: T) => { 111 return (val => { 112 cb(val); 113 queryParams.set(key, val.toString()); 114 history.replaceState(null, '', `${location.pathname}?${queryParams}`); 115 }) as T; 116 }; 117 118 const setViewPodNamesWithQueryParams = setWithQueryParams('viewPodNames', setViewPodNames); 119 const setViewTimestampsWithQueryParams = setWithQueryParams('viewTimestamps', setViewTimestamps); 120 const setFollowWithQueryParams = setWithQueryParams('follow', setFollow); 121 const setPreviousLogsWithQueryParams = setWithQueryParams('showPreviousLogs', setPreviousLogs); 122 const setTailWithQueryParams = setWithQueryParams('tail', setTail); 123 const setFilterWithQueryParams = setWithQueryParams('filterText', setFilter); 124 const setMatchCaseWithQueryParams = setWithQueryParams('matchCase', setMatchCase); 125 126 const onToggleViewPodNames = (val: boolean) => { 127 setViewPodNamesWithQueryParams(val); 128 if (val) { 129 setViewTimestampsWithQueryParams(false); 130 } 131 }; 132 133 useEffect(() => { 134 // https://stackoverflow.com/questions/3561493/is-there-a-regexp-escape-function-in-javascript 135 // matchNothing this is chosen instead of empty regexp, because that would match everything and break colored logs 136 // eslint-disable-next-line no-useless-escape 137 setHighlight(filter === '' ? matchNothing : new RegExp(filter.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g' + (matchCase ? '' : 'i'))); 138 }, [filter, matchCase]); 139 140 if (!containerName || containerName === '') { 141 return <div>Pod does not have container with name {containerName}</div>; 142 } 143 144 useEffect(() => setScrollToBottom(true), [follow]); 145 146 useEffect(() => { 147 if (scrollToBottom) { 148 const element = logsContainerRef.current; 149 if (element) { 150 element.scrollTop = element.scrollHeight; 151 } 152 } 153 }, [logs, scrollToBottom]); 154 155 useEffect(() => { 156 setLogs([]); 157 const logsSource = services.applications 158 .getContainerLogs({ 159 applicationName, 160 appNamespace: applicationNamespace, 161 namespace, 162 podName, 163 resource: {group, kind, name}, 164 containerName, 165 tail, 166 follow, 167 sinceSeconds, 168 filter, 169 previous, 170 matchCase 171 }) 172 .pipe( 173 bufferTime(100), 174 catchError((error: any) => { 175 const errorBody = JSON.parse(error.body); 176 if (errorBody.error && errorBody.error.message) { 177 if (errorBody.error.message.includes('max pods to view logs are reached')) { 178 setErrorMessage('Max pods to view logs are reached. Please provide more granular query.'); 179 return EMPTY; // Non-retryable condition, stop the stream and display the error message. 180 } 181 } 182 }), 183 retryWhen(errors => errors.pipe(delay(500))) 184 ) 185 .subscribe(log => { 186 if (log.length) { 187 setLogs(previousLogs => previousLogs.concat(log)); 188 } 189 }); 190 191 return () => logsSource.unsubscribe(); 192 }, [applicationName, applicationNamespace, namespace, podName, group, kind, name, containerName, tail, follow, sinceSeconds, filter, previous, matchCase]); 193 194 const handleScroll = (event: React.WheelEvent<HTMLDivElement>) => { 195 if (event.deltaY < 0) setScrollToBottom(false); 196 }; 197 198 const renderLog = (log: LogEntry, lineNum: number, darkMode: boolean) => { 199 const podNameContent = viewPodNames 200 ? (lineNum === 0 || logs[lineNum - 1].podName !== log.podName 201 ? `${podColor(log.podName, darkMode, selectedPod === log.podName)}${log.podName}${reset}` 202 : ' '.repeat(log.podName.length)) + ' ' 203 : ''; 204 205 // show the timestamp if requested, pad with spaces to align 206 const timestampContent = viewTimestamps ? (lineNum === 0 || logs[lineNum - 1].timeStamp !== log.timeStamp ? log.timeStampStr : '').padEnd(30) + ' ' : ''; 207 208 // show the log content without colors, only highlight search terms 209 const logContent = log.content?.replace(highlight, (substring: string) => whiteOnYellow + substring + reset); 210 211 return {podNameContent, timestampContent, logContent}; 212 }; 213 214 const logsContent = (width: number, height: number, isWrapped: boolean, prefs: ViewPreferences) => ( 215 <div 216 ref={logsContainerRef} 217 onScroll={handleScroll} 218 style={{ 219 width, 220 height, 221 overflow: 'scroll', 222 minWidth: isWrapped ? 'fit-content' : '100%' 223 }}> 224 <div 225 style={{ 226 width: '100%', 227 minWidth: isWrapped ? 'fit-content' : '100%' 228 }}> 229 {logs.map((log, lineNum) => { 230 const {podNameContent, timestampContent, logContent} = renderLog(log, lineNum, prefs.appDetails.darkMode); 231 return ( 232 <div 233 key={lineNum} 234 style={{ 235 whiteSpace: isWrapped ? 'normal' : 'pre', 236 lineHeight: '1.5rem', 237 backgroundColor: selectedPod === log.podName ? getPodBackgroundColor(log.podName, prefs.appDetails.darkMode) : 'transparent', 238 padding: '1px 8px', 239 width: '100%', 240 marginLeft: '-8px', 241 marginRight: '-8px' 242 }} 243 className='noscroll'> 244 {viewPodNames && (lineNum === 0 || logs[lineNum - 1].podName !== log.podName) && ( 245 <span onClick={() => setSelectedPod(selectedPod === log.podName ? null : log.podName)} style={{cursor: 'pointer'}} className='pod-name-link'> 246 <Ansi>{podNameContent}</Ansi> 247 </span> 248 )} 249 {viewPodNames && !(lineNum === 0 || logs[lineNum - 1].podName !== log.podName) && ( 250 <span> 251 <Ansi>{podNameContent}</Ansi> 252 </span> 253 )} 254 <Ansi>{timestampContent + logContent}</Ansi> 255 </div> 256 ); 257 })} 258 </div> 259 </div> 260 ); 261 262 const preferenceLoader = React.useCallback(() => services.viewPreferences.getPreferences(), []); 263 return ( 264 <DataLoader load={preferenceLoader}> 265 {(prefs: ViewPreferences) => { 266 return ( 267 <React.Fragment> 268 <div className='pod-logs-viewer__settings'> 269 <span> 270 <FollowToggleButton follow={follow} setFollow={setFollowWithQueryParams} /> 271 {follow && <AutoScrollButton scrollToBottom={scrollToBottom} setScrollToBottom={setScrollToBottom} />} 272 <ShowPreviousLogsToggleButton setPreviousLogs={setPreviousLogsWithQueryParams} showPreviousLogs={previous} /> 273 <Spacer /> 274 <PodHighlightButton selectedPod={selectedPod} setSelectedPod={setSelectedPod} pods={uniquePods} darkMode={prefs.appDetails.darkMode} /> 275 <Spacer /> 276 <ContainerSelector containerGroups={containerGroups} containerName={containerName} onClickContainer={onClickContainer} /> 277 <Spacer /> 278 {!follow && ( 279 <> 280 <SinceSecondsSelector sinceSeconds={sinceSeconds} setSinceSeconds={n => setSinceSeconds(n)} /> 281 <TailSelector tail={tail} setTail={setTailWithQueryParams} /> 282 </> 283 )} 284 <LogMessageFilter filterText={filter} setFilterText={setFilterWithQueryParams} /> 285 </span> 286 <Spacer /> 287 <span> 288 <MatchCaseToggleButton matchCase={matchCase} setMatchCase={setMatchCaseWithQueryParams} /> 289 <WrapLinesButton prefs={prefs} /> 290 <PodNamesToggleButton viewPodNames={viewPodNames} setViewPodNames={onToggleViewPodNames} /> 291 <TimestampsToggleButton setViewTimestamps={setViewTimestampsWithQueryParams} viewTimestamps={viewTimestamps} timestamp={timestamp} /> 292 <DarkModeToggleButton prefs={prefs} /> 293 </span> 294 <Spacer /> 295 <span> 296 <CopyLogsButton logs={logs} /> 297 <DownloadLogsButton {...props} /> 298 <FullscreenButton 299 {...props} 300 viewPodNames={viewPodNames} 301 viewTimestamps={viewTimestamps} 302 follow={follow} 303 showPreviousLogs={previous} 304 filterText={filter} 305 matchCase={matchCase} 306 tail={tail} 307 sinceSeconds={sinceSeconds} 308 /> 309 </span> 310 </div> 311 <div className={classNames('pod-logs-viewer', {'pod-logs-viewer--inverted': prefs.appDetails.darkMode})} onWheel={handleScroll}> 312 {errorMessage ? ( 313 <div>{errorMessage}</div> 314 ) : ( 315 <AutoSizer>{({width, height}: {width: number; height: number}) => logsContent(width, height, prefs.appDetails.wrapLines, prefs)}</AutoSizer> 316 )} 317 </div> 318 </React.Fragment> 319 ); 320 }} 321 </DataLoader> 322 ); 323 };