Commit e3181e66 by Andrej Ocenas Committed by GitHub

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.
parent dd206806
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { css, cx } from 'emotion'; 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'; import { LogsModel, LogRowModel, TimeZone } from '@grafana/data';
...@@ -32,34 +32,107 @@ const getStyles = (theme: GrafanaTheme) => ({ ...@@ -32,34 +32,107 @@ const getStyles = (theme: GrafanaTheme) => ({
display: flex; display: flex;
align-items: center; align-items: center;
`, `,
button: css`
margin-right: ${theme.spacing.sm};
`,
}); });
export interface Props extends Themeable { export interface Props extends Themeable {
logsResult?: LogsModel; logsResult?: LogsModel;
timeZone: TimeZone; timeZone: TimeZone;
stopLive: () => void; 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 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) { componentDidUpdate(prevProps: Props) {
if (this.liveEndDiv) { if (!prevProps.isPaused && this.props.isPaused) {
this.liveEndDiv.scrollIntoView(false); // 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() { render() {
const { theme, timeZone } = this.props; const { theme, timeZone, onPause, onResume, isPaused } = this.props;
const styles = getStyles(theme); const styles = getStyles(theme);
const rowsToRender: LogRowModel[] = this.props.logsResult ? this.props.logsResult.rows : [];
const showUtc = timeZone === 'utc'; const showUtc = timeZone === 'utc';
const { logsRow, logsRowLocalTime, logsRowMessage } = getLogRowStyles(theme); const { logsRow, logsRowLocalTime, logsRowMessage } = getLogRowStyles(theme);
return ( return (
<> <>
<div className={cx(['logs-rows', styles.logsRowsLive])}> <div
{rowsToRender.map((row: any, index) => { onScroll={isPaused ? undefined : this.onScroll}
className={cx(['logs-rows', styles.logsRowsLive])}
ref={this.scrollContainerRef}
>
{this.rowsToRender().map((row: any, index) => {
return ( return (
<div <div
className={row.fresh ? cx([logsRow, styles.logsRowFresh]) : cx([logsRow, styles.logsRowOld])} className={row.fresh ? cx([logsRow, styles.logsRowFresh]) : cx([logsRow, styles.logsRowOld])}
...@@ -82,24 +155,29 @@ class LiveLogs extends PureComponent<Props> { ...@@ -82,24 +155,29 @@ class LiveLogs extends PureComponent<Props> {
<div <div
ref={element => { ref={element => {
this.liveEndDiv = 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); this.liveEndDiv.scrollIntoView(false);
} }
}} }}
/> />
</div> </div>
<div className={cx([styles.logsRowsIndicator])}> <div className={cx([styles.logsRowsIndicator])}>
<span> <button onClick={isPaused ? onResume : onPause} className={cx('btn btn-secondary', styles.button)}>
Last line received: <ElapsedTime resetKey={this.props.logsResult} humanize={true} /> ago <i className={cx('fa', isPaused ? 'fa-play' : 'fa-pause')} />
</span> &nbsp;
<LinkButton {isPaused ? 'Resume' : 'Pause'}
onClick={this.props.stopLive} </button>
size="md" <button onClick={this.props.stopLive} className={cx('btn btn-inverse', styles.button)}>
variant="transparent" <i className={'fa fa-stop'} />
style={{ color: theme.colors.orange }} &nbsp; Stop
> </button>
Stop Live {isPaused || (
</LinkButton> <span>
Last line received: <ElapsedTime resetKey={this.props.logsResult} humanize={true} /> ago
</span>
)}
</div> </div>
</> </>
); );
......
...@@ -18,7 +18,11 @@ import { ExploreId, ExploreItemState } from 'app/types/explore'; ...@@ -18,7 +18,11 @@ import { ExploreId, ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { changeDedupStrategy, updateTimeRange } from './state/actions'; 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 { deduplicatedLogsSelector, exploreItemUIStateSelector } from 'app/features/explore/state/selectors';
import { getTimeZone } from '../profile/state/selectors'; import { getTimeZone } from '../profile/state/selectors';
import { LiveLogsWithTheme } from './LiveLogs'; import { LiveLogsWithTheme } from './LiveLogs';
...@@ -48,6 +52,8 @@ interface LogsContainerProps { ...@@ -48,6 +52,8 @@ interface LogsContainerProps {
updateTimeRange: typeof updateTimeRange; updateTimeRange: typeof updateTimeRange;
range: TimeRange; range: TimeRange;
absoluteRange: AbsoluteTimeRange; absoluteRange: AbsoluteTimeRange;
setPausedStateAction: typeof setPausedStateAction;
isPaused: boolean;
} }
export class LogsContainer extends PureComponent<LogsContainerProps> { export class LogsContainer extends PureComponent<LogsContainerProps> {
...@@ -62,6 +68,16 @@ export class LogsContainer extends PureComponent<LogsContainerProps> { ...@@ -62,6 +68,16 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
this.props.stopLive({ exploreId, refreshInterval: offOption.value }); 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) => { handleDedupStrategyChange = (dedupStrategy: LogsDedupStrategy) => {
this.props.changeDedupStrategy(this.props.exploreId, dedupStrategy); this.props.changeDedupStrategy(this.props.exploreId, dedupStrategy);
}; };
...@@ -104,7 +120,14 @@ export class LogsContainer extends PureComponent<LogsContainerProps> { ...@@ -104,7 +120,14 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
if (isLive) { if (isLive) {
return ( return (
<Collapse label="Logs" loading={false} isOpen> <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> </Collapse>
); );
} }
...@@ -146,6 +169,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string } ...@@ -146,6 +169,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }
scanning, scanning,
datasourceInstance, datasourceInstance,
isLive, isLive,
isPaused,
range, range,
absoluteRange, absoluteRange,
} = item; } = item;
...@@ -163,6 +187,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string } ...@@ -163,6 +187,7 @@ function mapStateToProps(state: StoreState, { exploreId }: { exploreId: string }
dedupedResult, dedupedResult,
datasourceInstance, datasourceInstance,
isLive, isLive,
isPaused,
range, range,
absoluteRange, absoluteRange,
}; };
...@@ -173,6 +198,7 @@ const mapDispatchToProps = { ...@@ -173,6 +198,7 @@ const mapDispatchToProps = {
toggleLogLevelAction, toggleLogLevelAction,
stopLive: changeRefreshIntervalAction, stopLive: changeRefreshIntervalAction,
updateTimeRange, updateTimeRange,
setPausedStateAction,
}; };
export default hot(module)( export default hot(module)(
......
...@@ -197,6 +197,11 @@ export interface ChangeLoadingStatePayload { ...@@ -197,6 +197,11 @@ export interface ChangeLoadingStatePayload {
loadingState: LoadingState; loadingState: LoadingState;
} }
export interface SetPausedStatePayload {
exploreId: ExploreId;
isPaused: boolean;
}
/** /**
* Adds a query row after the row with the given index. * Adds a query row after the row with the given index.
*/ */
...@@ -371,6 +376,8 @@ export const changeLoadingStateAction = actionCreatorFactory<ChangeLoadingStateP ...@@ -371,6 +376,8 @@ export const changeLoadingStateAction = actionCreatorFactory<ChangeLoadingStateP
'changeLoadingStateAction' 'changeLoadingStateAction'
).create(); ).create();
export const setPausedStateAction = actionCreatorFactory<SetPausedStatePayload>('explore/SET_PAUSED_STATE').create();
export type HigherOrderAction = export type HigherOrderAction =
| ActionOf<SplitCloseActionPayload> | ActionOf<SplitCloseActionPayload>
| SplitOpenAction | SplitOpenAction
......
...@@ -52,6 +52,7 @@ import { ...@@ -52,6 +52,7 @@ import {
queryEndedAction, queryEndedAction,
queryStreamUpdatedAction, queryStreamUpdatedAction,
QueryEndedPayload, QueryEndedPayload,
setPausedStateAction,
} from './actionTypes'; } from './actionTypes';
import { reducerFactory, ActionOf } from 'app/core/redux'; import { reducerFactory, ActionOf } from 'app/core/redux';
import { updateLocation } from 'app/core/actions/location'; import { updateLocation } from 'app/core/actions/location';
...@@ -114,6 +115,7 @@ export const makeExploreItemState = (): ExploreItemState => ({ ...@@ -114,6 +115,7 @@ export const makeExploreItemState = (): ExploreItemState => ({
supportedModes: [], supportedModes: [],
mode: null, mode: null,
isLive: false, isLive: false,
isPaused: false,
urlReplaced: false, urlReplaced: false,
queryState: new PanelQueryState(), queryState: new PanelQueryState(),
queryResponse: createEmptyQueryResponse(), queryResponse: createEmptyQueryResponse(),
...@@ -209,6 +211,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta ...@@ -209,6 +211,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
state: live ? LoadingState.Streaming : LoadingState.NotStarted, state: live ? LoadingState.Streaming : LoadingState.NotStarted,
}, },
isLive: live, isLive: live,
isPaused: false,
loading: live, loading: live,
logsResult, logsResult,
}; };
...@@ -553,6 +556,16 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta ...@@ -553,6 +556,16 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
}, },
}) })
.addMapper({ .addMapper({
filter: setPausedStateAction,
mapper: (state, action): ExploreItemState => {
const { isPaused } = action.payload;
return {
...state,
isPaused: isPaused,
};
},
})
.addMapper({
//queryStreamUpdatedAction //queryStreamUpdatedAction
filter: queryEndedAction, filter: queryEndedAction,
mapper: (state, action): ExploreItemState => { mapper: (state, action): ExploreItemState => {
......
...@@ -251,7 +251,15 @@ export interface ExploreItemState { ...@@ -251,7 +251,15 @@ export interface ExploreItemState {
supportedModes: ExploreMode[]; supportedModes: ExploreMode[];
mode: ExploreMode; mode: ExploreMode;
/**
* If true, the view is in live tailing mode.
*/
isLive: boolean; isLive: boolean;
/**
* If true, the live tailing view is paused.
*/
isPaused: boolean;
urlReplaced: boolean; urlReplaced: boolean;
queryState: PanelQueryState; queryState: PanelQueryState;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment