LiveLogs.tsx 7.1 KB

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