LiveLogs.tsx 7.5 KB

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