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  };