LiveLogs.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. import React, { PureComponent } from 'react';
  2. import { css, cx } from 'emotion';
  3. import { Themeable, withTheme, GrafanaTheme, selectThemeVariant, getLogRowStyles } from '@grafana/ui';
  4. import { LogsModel, LogRowModel, TimeZone } from '@grafana/data';
  5. import ElapsedTime from './ElapsedTime';
  6. const getStyles = (theme: GrafanaTheme) => ({
  7. logsRowsLive: css`
  8. label: logs-rows-live;
  9. display: flex;
  10. flex-flow: column nowrap;
  11. height: 65vh;
  12. overflow-y: auto;
  13. :first-child {
  14. margin-top: auto !important;
  15. }
  16. `,
  17. logsRowFresh: css`
  18. label: logs-row-fresh;
  19. color: ${theme.colors.text};
  20. background-color: ${selectThemeVariant({ light: theme.colors.gray6, dark: theme.colors.gray1 }, theme.type)};
  21. `,
  22. logsRowOld: css`
  23. label: logs-row-old;
  24. opacity: 0.8;
  25. `,
  26. logsRowsIndicator: css`
  27. font-size: ${theme.typography.size.md};
  28. padding: ${theme.spacing.sm} 0;
  29. display: flex;
  30. align-items: center;
  31. `,
  32. button: css`
  33. margin-right: ${theme.spacing.sm};
  34. `,
  35. });
  36. export interface Props extends Themeable {
  37. logsResult?: LogsModel;
  38. timeZone: TimeZone;
  39. stopLive: () => void;
  40. onPause: () => void;
  41. onResume: () => void;
  42. isPaused: boolean;
  43. }
  44. interface State {
  45. logsResultToRender?: LogsModel;
  46. }
  47. class LiveLogs extends PureComponent<Props, State> {
  48. private liveEndDiv: HTMLDivElement = null;
  49. private scrollContainerRef = React.createRef<HTMLDivElement>();
  50. private lastScrollPos: number | null = null;
  51. constructor(props: Props) {
  52. super(props);
  53. this.state = {
  54. logsResultToRender: props.logsResult,
  55. };
  56. }
  57. componentDidUpdate(prevProps: Props) {
  58. if (!prevProps.isPaused && this.props.isPaused) {
  59. // So we paused the view and we changed the content size, but we want to keep the relative offset from the bottom.
  60. if (this.lastScrollPos) {
  61. // There is last scroll pos from when user scrolled up a bit so go to that position.
  62. const { clientHeight, scrollHeight } = this.scrollContainerRef.current;
  63. const scrollTop = scrollHeight - (this.lastScrollPos + clientHeight);
  64. this.scrollContainerRef.current.scrollTo(0, scrollTop);
  65. this.lastScrollPos = null;
  66. } else {
  67. // We do not have any position to jump to su the assumption is user just clicked pause. We can just scroll
  68. // to the bottom.
  69. if (this.liveEndDiv) {
  70. this.liveEndDiv.scrollIntoView(false);
  71. }
  72. }
  73. }
  74. }
  75. static getDerivedStateFromProps(nextProps: Props) {
  76. if (!nextProps.isPaused) {
  77. return {
  78. // We update what we show only if not paused. We keep any background subscriptions running and keep updating
  79. // our state, but we do not show the updates, this allows us start again showing correct result after resuming
  80. // without creating a gap in the log results.
  81. logsResultToRender: nextProps.logsResult,
  82. };
  83. } else {
  84. return null;
  85. }
  86. }
  87. /**
  88. * Handle pausing when user scrolls up so that we stop resetting his position to the bottom when new row arrives.
  89. * We do not need to throttle it here much, adding new rows should be throttled/buffered itself in the query epics
  90. * and after you pause we remove the handler and add it after you manually resume, so this should not be fired often.
  91. */
  92. onScroll = (event: React.SyntheticEvent) => {
  93. const { isPaused, onPause } = this.props;
  94. const { scrollTop, clientHeight, scrollHeight } = event.currentTarget;
  95. const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
  96. if (distanceFromBottom >= 5 && !isPaused) {
  97. onPause();
  98. this.lastScrollPos = distanceFromBottom;
  99. }
  100. };
  101. rowsToRender = () => {
  102. const { isPaused } = this.props;
  103. let rowsToRender: LogRowModel[] = this.state.logsResultToRender ? this.state.logsResultToRender.rows : [];
  104. if (!isPaused) {
  105. // A perf optimisation here. Show just 100 rows when streaming and full length when the streaming is paused.
  106. rowsToRender = rowsToRender.slice(-100);
  107. }
  108. return rowsToRender;
  109. };
  110. render() {
  111. const { theme, timeZone, onPause, onResume, isPaused } = this.props;
  112. const styles = getStyles(theme);
  113. const showUtc = timeZone === 'utc';
  114. const { logsRow, logsRowLocalTime, logsRowMessage } = getLogRowStyles(theme);
  115. return (
  116. <>
  117. <div
  118. onScroll={isPaused ? undefined : this.onScroll}
  119. className={cx(['logs-rows', styles.logsRowsLive])}
  120. ref={this.scrollContainerRef}
  121. >
  122. {this.rowsToRender().map((row: any, index) => {
  123. return (
  124. <div
  125. className={row.fresh ? cx([logsRow, styles.logsRowFresh]) : cx([logsRow, styles.logsRowOld])}
  126. key={`${row.timeEpochMs}-${index}`}
  127. >
  128. {showUtc && (
  129. <div className={cx([logsRowLocalTime])} title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
  130. {row.timeUtc}
  131. </div>
  132. )}
  133. {!showUtc && (
  134. <div className={cx([logsRowLocalTime])} title={`${row.timeUtc} (${row.timeFromNow})`}>
  135. {row.timeLocal}
  136. </div>
  137. )}
  138. <div className={cx([logsRowMessage])}>{row.entry}</div>
  139. </div>
  140. );
  141. })}
  142. <div
  143. ref={element => {
  144. this.liveEndDiv = element;
  145. // This is triggered on every update so on every new row. It keeps the view scrolled at the bottom by
  146. // default.
  147. if (this.liveEndDiv && !isPaused) {
  148. this.liveEndDiv.scrollIntoView(false);
  149. }
  150. }}
  151. />
  152. </div>
  153. <div className={cx([styles.logsRowsIndicator])}>
  154. <button onClick={isPaused ? onResume : onPause} className={cx('btn btn-secondary', styles.button)}>
  155. <i className={cx('fa', isPaused ? 'fa-play' : 'fa-pause')} />
  156. &nbsp;
  157. {isPaused ? 'Resume' : 'Pause'}
  158. </button>
  159. <button onClick={this.props.stopLive} className={cx('btn btn-inverse', styles.button)}>
  160. <i className={'fa fa-stop'} />
  161. &nbsp; Stop
  162. </button>
  163. {isPaused || (
  164. <span>
  165. Last line received: <ElapsedTime resetKey={this.props.logsResult} humanize={true} /> ago
  166. </span>
  167. )}
  168. </div>
  169. </>
  170. );
  171. }
  172. }
  173. export const LiveLogsWithTheme = withTheme(LiveLogs);