소스 검색

Explore: Allow pausing and resuming of live tailing (#18836)

Adding pause/resume buttons and pause on scroll to prevent new rows messing with the scroll position.
Andrej Ocenas 6 년 전
부모
커밋
e3181e66b4

+ 98 - 20
public/app/features/explore/LiveLogs.tsx

@@ -1,6 +1,6 @@
 import React, { PureComponent } from 'react';
 import { css, cx } from 'emotion';
-import { Themeable, withTheme, GrafanaTheme, selectThemeVariant, LinkButton, getLogRowStyles } from '@grafana/ui';
+import { Themeable, withTheme, GrafanaTheme, selectThemeVariant, getLogRowStyles } from '@grafana/ui';
 
 import { LogsModel, LogRowModel, TimeZone } from '@grafana/data';
 
@@ -32,34 +32,107 @@ const getStyles = (theme: GrafanaTheme) => ({
     display: flex;
     align-items: center;
   `,
+  button: css`
+    margin-right: ${theme.spacing.sm};
+  `,
 });
 
 export interface Props extends Themeable {
   logsResult?: LogsModel;
   timeZone: TimeZone;
   stopLive: () => void;
+  onPause: () => void;
+  onResume: () => void;
+  isPaused: boolean;
+}
+
+interface State {
+  logsResultToRender?: LogsModel;
 }
 
-class LiveLogs extends PureComponent<Props> {
+class LiveLogs extends PureComponent<Props, State> {
   private liveEndDiv: HTMLDivElement = null;
+  private scrollContainerRef = React.createRef<HTMLDivElement>();
+  private lastScrollPos: number | null = null;
+
+  constructor(props: Props) {
+    super(props);
+    this.state = {
+      logsResultToRender: props.logsResult,
+    };
+  }
 
   componentDidUpdate(prevProps: Props) {
-    if (this.liveEndDiv) {
-      this.liveEndDiv.scrollIntoView(false);
+    if (!prevProps.isPaused && this.props.isPaused) {
+      // So we paused the view and we changed the content size, but we want to keep the relative offset from the bottom.
+      if (this.lastScrollPos) {
+        // There is last scroll pos from when user scrolled up a bit so go to that position.
+        const { clientHeight, scrollHeight } = this.scrollContainerRef.current;
+        const scrollTop = scrollHeight - (this.lastScrollPos + clientHeight);
+        this.scrollContainerRef.current.scrollTo(0, scrollTop);
+        this.lastScrollPos = null;
+      } else {
+        // We do not have any position to jump to su the assumption is user just clicked pause. We can just scroll
+        // to the bottom.
+        if (this.liveEndDiv) {
+          this.liveEndDiv.scrollIntoView(false);
+        }
+      }
+    }
+  }
+
+  static getDerivedStateFromProps(nextProps: Props) {
+    if (!nextProps.isPaused) {
+      return {
+        // We update what we show only if not paused. We keep any background subscriptions running and keep updating
+        // our state, but we do not show the updates, this allows us start again showing correct result after resuming
+        // without creating a gap in the log results.
+        logsResultToRender: nextProps.logsResult,
+      };
+    } else {
+      return null;
     }
   }
 
+  /**
+   * Handle pausing when user scrolls up so that we stop resetting his position to the bottom when new row arrives.
+   * We do not need to throttle it here much, adding new rows should be throttled/buffered itself in the query epics
+   * and after you pause we remove the handler and add it after you manually resume, so this should not be fired often.
+   */
+  onScroll = (event: React.SyntheticEvent) => {
+    const { isPaused, onPause } = this.props;
+    const { scrollTop, clientHeight, scrollHeight } = event.currentTarget;
+    const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
+    if (distanceFromBottom >= 5 && !isPaused) {
+      onPause();
+      this.lastScrollPos = distanceFromBottom;
+    }
+  };
+
+  rowsToRender = () => {
+    const { isPaused } = this.props;
+    let rowsToRender: LogRowModel[] = this.state.logsResultToRender ? this.state.logsResultToRender.rows : [];
+    if (!isPaused) {
+      // A perf optimisation here. Show just 100 rows when streaming and full length when the streaming is paused.
+      rowsToRender = rowsToRender.slice(-100);
+    }
+    return rowsToRender;
+  };
+
   render() {
-    const { theme, timeZone } = this.props;
+    const { theme, timeZone, onPause, onResume, isPaused } = this.props;
     const styles = getStyles(theme);
-    const rowsToRender: LogRowModel[] = this.props.logsResult ? this.props.logsResult.rows : [];
     const showUtc = timeZone === 'utc';
     const { logsRow, logsRowLocalTime, logsRowMessage } = getLogRowStyles(theme);
 
     return (
       <>
-        <div className={cx(['logs-rows', styles.logsRowsLive])}>
-          {rowsToRender.map((row: any, index) => {
+        <div
+          onScroll={isPaused ? undefined : this.onScroll}
+          className={cx(['logs-rows', styles.logsRowsLive])}
+          ref={this.scrollContainerRef}
+        >
+          {this.rowsToRender().map((row: any, index) => {
             return (
               <div
                 className={row.fresh ? cx([logsRow, styles.logsRowFresh]) : cx([logsRow, styles.logsRowOld])}
@@ -82,24 +155,29 @@ class LiveLogs extends PureComponent<Props> {
           <div
             ref={element => {
               this.liveEndDiv = element;
-              if (this.liveEndDiv) {
+              // This is triggered on every update so on every new row. It keeps the view scrolled at the bottom by
+              // default.
+              if (this.liveEndDiv && !isPaused) {
                 this.liveEndDiv.scrollIntoView(false);
               }
             }}
           />
         </div>
         <div className={cx([styles.logsRowsIndicator])}>
-          <span>
-            Last line received: <ElapsedTime resetKey={this.props.logsResult} humanize={true} /> ago
-          </span>
-          <LinkButton
-            onClick={this.props.stopLive}
-            size="md"
-            variant="transparent"
-            style={{ color: theme.colors.orange }}
-          >
-            Stop Live
-          </LinkButton>
+          <button onClick={isPaused ? onResume : onPause} className={cx('btn btn-secondary', styles.button)}>
+            <i className={cx('fa', isPaused ? 'fa-play' : 'fa-pause')} />
+            &nbsp;
+            {isPaused ? 'Resume' : 'Pause'}
+          </button>
+          <button onClick={this.props.stopLive} className={cx('btn btn-inverse', styles.button)}>
+            <i className={'fa fa-stop'} />
+            &nbsp; Stop
+          </button>
+          {isPaused || (
+            <span>
+              Last line received: <ElapsedTime resetKey={this.props.logsResult} humanize={true} /> ago
+            </span>
+          )}
         </div>
       </>
     );

+ 28 - 2
public/app/features/explore/LogsContainer.tsx

@@ -18,7 +18,11 @@ import { ExploreId, ExploreItemState } from 'app/types/explore';
 import { StoreState } from 'app/types';
 
 import { changeDedupStrategy, updateTimeRange } from './state/actions';
-import { toggleLogLevelAction, changeRefreshIntervalAction } from 'app/features/explore/state/actionTypes';
+import {
+  toggleLogLevelAction,
+  changeRefreshIntervalAction,
+  setPausedStateAction,
+} from 'app/features/explore/state/actionTypes';
 import { deduplicatedLogsSelector, exploreItemUIStateSelector } from 'app/features/explore/state/selectors';
 import { getTimeZone } from '../profile/state/selectors';
 import { LiveLogsWithTheme } from './LiveLogs';
@@ -48,6 +52,8 @@ interface LogsContainerProps {
   updateTimeRange: typeof updateTimeRange;
   range: TimeRange;
   absoluteRange: AbsoluteTimeRange;
+  setPausedStateAction: typeof setPausedStateAction;
+  isPaused: boolean;
 }
 
 export class LogsContainer extends PureComponent<LogsContainerProps> {
@@ -62,6 +68,16 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
     this.props.stopLive({ exploreId, refreshInterval: offOption.value });
   };
 
+  onPause = () => {
+    const { exploreId } = this.props;
+    this.props.setPausedStateAction({ exploreId, isPaused: true });
+  };
+
+  onResume = () => {
+    const { exploreId } = this.props;
+    this.props.setPausedStateAction({ exploreId, isPaused: false });
+  };
+
   handleDedupStrategyChange = (dedupStrategy: LogsDedupStrategy) => {
     this.props.changeDedupStrategy(this.props.exploreId, dedupStrategy);
   };
@@ -104,7 +120,14 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
     if (isLive) {
       return (
         <Collapse label="Logs" loading={false} isOpen>
-          <LiveLogsWithTheme logsResult={logsResult} timeZone={timeZone} stopLive={this.onStopLive} />
+          <LiveLogsWithTheme
+            logsResult={logsResult}
+            timeZone={timeZone}
+            stopLive={this.onStopLive}
+            isPaused={this.props.isPaused}
+            onPause={this.onPause}
+            onResume={this.onResume}
+          />
         </Collapse>
       );
     }
@@ -146,6 +169,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }
     scanning,
     datasourceInstance,
     isLive,
+    isPaused,
     range,
     absoluteRange,
   } = item;
@@ -163,6 +187,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }
     dedupedResult,
     datasourceInstance,
     isLive,
+    isPaused,
     range,
     absoluteRange,
   };
@@ -173,6 +198,7 @@ const mapDispatchToProps = {
   toggleLogLevelAction,
   stopLive: changeRefreshIntervalAction,
   updateTimeRange,
+  setPausedStateAction,
 };
 
 export default hot(module)(

+ 7 - 0
public/app/features/explore/state/actionTypes.ts

@@ -197,6 +197,11 @@ export interface ChangeLoadingStatePayload {
   loadingState: LoadingState;
 }
 
+export interface SetPausedStatePayload {
+  exploreId: ExploreId;
+  isPaused: boolean;
+}
+
 /**
  * Adds a query row after the row with the given index.
  */
@@ -371,6 +376,8 @@ export const changeLoadingStateAction = actionCreatorFactory<ChangeLoadingStateP
   'changeLoadingStateAction'
 ).create();
 
+export const setPausedStateAction = actionCreatorFactory<SetPausedStatePayload>('explore/SET_PAUSED_STATE').create();
+
 export type HigherOrderAction =
   | ActionOf<SplitCloseActionPayload>
   | SplitOpenAction

+ 13 - 0
public/app/features/explore/state/reducers.ts

@@ -52,6 +52,7 @@ import {
   queryEndedAction,
   queryStreamUpdatedAction,
   QueryEndedPayload,
+  setPausedStateAction,
 } from './actionTypes';
 import { reducerFactory, ActionOf } from 'app/core/redux';
 import { updateLocation } from 'app/core/actions/location';
@@ -114,6 +115,7 @@ export const makeExploreItemState = (): ExploreItemState => ({
   supportedModes: [],
   mode: null,
   isLive: false,
+  isPaused: false,
   urlReplaced: false,
   queryState: new PanelQueryState(),
   queryResponse: createEmptyQueryResponse(),
@@ -209,6 +211,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
           state: live ? LoadingState.Streaming : LoadingState.NotStarted,
         },
         isLive: live,
+        isPaused: false,
         loading: live,
         logsResult,
       };
@@ -552,6 +555,16 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
       };
     },
   })
+  .addMapper({
+    filter: setPausedStateAction,
+    mapper: (state, action): ExploreItemState => {
+      const { isPaused } = action.payload;
+      return {
+        ...state,
+        isPaused: isPaused,
+      };
+    },
+  })
   .addMapper({
     //queryStreamUpdatedAction
     filter: queryEndedAction,

+ 8 - 0
public/app/types/explore.ts

@@ -251,7 +251,15 @@ export interface ExploreItemState {
   supportedModes: ExploreMode[];
   mode: ExploreMode;
 
+  /**
+   * If true, the view is in live tailing mode.
+   */
   isLive: boolean;
+
+  /**
+   * If true, the live tailing view is paused.
+   */
+  isPaused: boolean;
   urlReplaced: boolean;
 
   queryState: PanelQueryState;