mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
class LiveLogs extends PureComponent<Props> {
|
||||
interface State {
|
||||
logsResultToRender?: LogsModel;
|
||||
}
|
||||
|
||||
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')} />
|
||||
|
||||
{isPaused ? 'Resume' : 'Pause'}
|
||||
</button>
|
||||
<button onClick={this.props.stopLive} className={cx('btn btn-inverse', styles.button)}>
|
||||
<i className={'fa fa-stop'} />
|
||||
Stop
|
||||
</button>
|
||||
{isPaused || (
|
||||
<span>
|
||||
Last line received: <ElapsedTime resetKey={this.props.logsResult} humanize={true} /> ago
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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)(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user