diff --git a/x-pack/legacy/plugins/infra/common/time/time_key.ts b/x-pack/legacy/plugins/infra/common/time/time_key.ts index dca64dacfcb21..117cd38314de0 100644 --- a/x-pack/legacy/plugins/infra/common/time/time_key.ts +++ b/x-pack/legacy/plugins/infra/common/time/time_key.ts @@ -11,6 +11,7 @@ export interface TimeKey { time: number; tiebreaker: number; gid?: string; + fromAutoReload?: boolean; } export interface UniqueTimeKey extends TimeKey { diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/jump_to_tail.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/jump_to_tail.tsx new file mode 100644 index 0000000000000..05f85ceed49c0 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/jump_to_tail.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable max-classes-per-file */ + +import { EuiButtonEmpty, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import * as React from 'react'; + +import euiStyled from '../../../../../../common/eui_styled_components'; + +interface LogTextStreamJumpToTailProps { + onClickJump?: () => void; + width: number; +} + +export class LogTextStreamJumpToTail extends React.PureComponent { + public render() { + const { onClickJump, width } = this.props; + return ( + + + + + + + + + + + ); + } +} + +const JumpToTailWrapper = euiStyled.div<{ width: number }>` + align-items: center; + display: flex; + min-height: ${props => props.theme.eui.euiSizeXXL}; + width: ${props => props.width}px; + position: fixed; + bottom: 0; + background-color: ${props => props.theme.eui.euiColorEmptyShade}; +`; + +const MessageWrapper = euiStyled.div` + padding: 8px 16px; +`; diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx index 40a5d8c30d4bb..4261c9daaeef0 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/scrollable_log_text_stream_view.tsx @@ -19,6 +19,7 @@ import { InfraLoadingPanel } from '../../loading'; import { getStreamItemBeforeTimeKey, getStreamItemId, parseStreamItemId, StreamItem } from './item'; import { LogColumnHeaders } from './column_headers'; import { LogTextStreamLoadingItemView } from './loading_item_view'; +import { LogTextStreamJumpToTail } from './jump_to_tail'; import { LogEntryRow } from './log_entry_row'; import { MeasurableItemView } from './measurable_item_view'; import { VerticalScrollPanel } from './vertical_scroll_panel'; @@ -52,11 +53,17 @@ interface ScrollableLogTextStreamViewProps { intl: InjectedIntl; highlightedItem: string | null; currentHighlightKey: UniqueTimeKey | null; + scrollLock: { + enable: () => void; + disable: () => void; + isEnabled: boolean; + }; } interface ScrollableLogTextStreamViewState { target: TimeKey | null; targetId: string | null; + items: StreamItem[]; } class ScrollableLogTextStreamViewClass extends React.PureComponent< @@ -70,30 +77,42 @@ class ScrollableLogTextStreamViewClass extends React.PureComponent< const hasNewTarget = nextProps.target && nextProps.target !== prevState.target; const hasItems = nextProps.items.length > 0; + // Prevent new entries from being appended and moving the stream forward when + // the user has scrolled up during live streaming + const nextItems = + hasItems && nextProps.scrollLock.isEnabled ? prevState.items : nextProps.items; + if (nextProps.isStreaming && hasItems) { return { target: nextProps.target, targetId: getStreamItemId(nextProps.items[nextProps.items.length - 1]), + items: nextItems, }; } else if (hasNewTarget && hasItems) { return { target: nextProps.target, targetId: getStreamItemId(getStreamItemBeforeTimeKey(nextProps.items, nextProps.target!)), + items: nextItems, }; } else if (!nextProps.target || !hasItems) { return { target: null, targetId: null, + items: [], }; } return null; } - public readonly state = { - target: null, - targetId: null, - }; + constructor(props: ScrollableLogTextStreamViewProps) { + super(props); + this.state = { + target: null, + targetId: null, + items: props.items, + }; + } public render() { const { @@ -106,14 +125,13 @@ class ScrollableLogTextStreamViewClass extends React.PureComponent< isLoadingMore, isReloading, isStreaming, - items, lastLoadedTime, scale, wrap, + scrollLock, } = this.props; - const { targetId } = this.state; + const { targetId, items } = this.state; const hasItems = items.length > 0; - return ( {isReloading && !hasItems ? ( @@ -163,6 +181,7 @@ class ScrollableLogTextStreamViewClass extends React.PureComponent< target={targetId} hideScrollbar={true} data-test-subj={'logStream'} + isLocked={scrollLock.isEnabled} > {registerChild => ( <> @@ -210,6 +229,12 @@ class ScrollableLogTextStreamViewClass extends React.PureComponent< lastStreamingUpdate={isStreaming ? lastLoadedTime : null} onLoadMore={this.handleLoadNewerItems} /> + {scrollLock.isEnabled && ( + + )} )} @@ -263,6 +288,9 @@ class ScrollableLogTextStreamViewClass extends React.PureComponent< pagesBelow: number; fromScroll: boolean; }) => { + if (fromScroll && this.props.isStreaming) { + this.props.scrollLock[pagesBelow === 0 ? 'disable' : 'enable'](); + } this.props.reportVisibleInterval({ endKey: parseStreamItemId(bottomChild), middleKey: parseStreamItemId(middleChild), @@ -273,6 +301,15 @@ class ScrollableLogTextStreamViewClass extends React.PureComponent< }); } ); + + private handleJumpToTail = () => { + const { items, scrollLock } = this.props; + scrollLock.disable(); + const lastItemTarget = getStreamItemId(items[items.length - 1]); + this.setState({ + targetId: lastItemTarget, + }); + }; } export const ScrollableLogTextStreamView = injectI18n(ScrollableLogTextStreamViewClass); diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/vertical_scroll_panel.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/vertical_scroll_panel.tsx index 8700239d84f62..8edec4d1777d0 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/vertical_scroll_panel.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_text_stream/vertical_scroll_panel.tsx @@ -29,6 +29,7 @@ interface VerticalScrollPanelProps { width: number; hideScrollbar?: boolean; 'data-test-subj'?: string; + isLocked: boolean; } interface VerticalScrollPanelSnapshot { @@ -217,7 +218,16 @@ export class VerticalScrollPanel extends React.PureComponent< prevState: {}, snapshot: VerticalScrollPanelSnapshot ) { - this.handleUpdatedChildren(snapshot.scrollTarget, snapshot.scrollOffset); + if ( + prevProps.height !== this.props.height || + prevProps.target !== this.props.target || + React.Children.count(prevProps.children) !== React.Children.count(this.props.children) + ) { + this.handleUpdatedChildren(snapshot.scrollTarget, snapshot.scrollOffset); + } + if (prevProps.isLocked && !this.props.isLocked && this.scrollRef.current) { + this.scrollRef.current.scrollTop = this.scrollRef.current.scrollHeight; + } } public componentWillUnmount() { diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_time_controls.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_time_controls.tsx index 4472ec40dcfd2..c5bb905782c20 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_time_controls.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_time_controls.tsx @@ -13,7 +13,7 @@ const noop = () => undefined; interface LogTimeControlsProps { currentTime: number | null; - startLiveStreaming: (interval: number) => any; + startLiveStreaming: () => any; stopLiveStreaming: () => any; isLiveStreaming: boolean; jumpToTime: (time: number) => any; @@ -25,7 +25,6 @@ class LogTimeControlsUI extends React.PureComponent { const { currentTime, isLiveStreaming, intl } = this.props; const currentMoment = currentTime ? moment(currentTime) : null; - if (isLiveStreaming) { return ( @@ -89,7 +88,7 @@ class LogTimeControlsUI extends React.PureComponent { }; private startLiveStreaming = () => { - this.props.startLiveStreaming(5000); + this.props.startLiveStreaming(); }; private stopLiveStreaming = () => { diff --git a/x-pack/legacy/plugins/infra/public/containers/logs/with_log_position.tsx b/x-pack/legacy/plugins/infra/public/containers/logs/with_log_position.tsx index d1f1b363d44bf..bbcec36e17f9d 100644 --- a/x-pack/legacy/plugins/infra/public/containers/logs/with_log_position.tsx +++ b/x-pack/legacy/plugins/infra/public/containers/logs/with_log_position.tsx @@ -18,6 +18,7 @@ export const withLogPosition = connect( (state: State) => ({ firstVisiblePosition: logPositionSelectors.selectFirstVisiblePosition(state), isAutoReloading: logPositionSelectors.selectIsAutoReloading(state), + isScrollLocked: logPositionSelectors.selectAutoReloadScrollLock(state), lastVisiblePosition: logPositionSelectors.selectFirstVisiblePosition(state), targetPosition: logPositionSelectors.selectTargetPosition(state), urlState: selectPositionUrlState(state), @@ -31,6 +32,8 @@ export const withLogPosition = connect( reportVisiblePositions: logPositionActions.reportVisiblePositions, startLiveStreaming: logPositionActions.startAutoReload, stopLiveStreaming: logPositionActions.stopAutoReload, + scrollLockLiveStreaming: logPositionActions.lockAutoReloadScroll, + scrollUnlockLiveStreaming: logPositionActions.unlockAutoReloadScroll, }) ); @@ -65,7 +68,7 @@ export const WithLogPositionUrlState = () => ( jumpToTargetPosition(newUrlState.position); } if (newUrlState && newUrlState.streamLive) { - startLiveStreaming(5000); + startLiveStreaming(); } else if ( newUrlState && typeof newUrlState.streamLive !== 'undefined' && @@ -81,7 +84,7 @@ export const WithLogPositionUrlState = () => ( jumpToTargetPositionTime(Date.now()); } if (initialUrlState && initialUrlState.streamLive) { - startLiveStreaming(5000); + startLiveStreaming(); } }} /> diff --git a/x-pack/legacy/plugins/infra/public/containers/logs/with_stream_items.ts b/x-pack/legacy/plugins/infra/public/containers/logs/with_stream_items.ts index 6a79d7b8e4ac9..12117d88f8283 100644 --- a/x-pack/legacy/plugins/infra/public/containers/logs/with_stream_items.ts +++ b/x-pack/legacy/plugins/infra/public/containers/logs/with_stream_items.ts @@ -21,6 +21,7 @@ export const withStreamItems = connect( isAutoReloading: logPositionSelectors.selectIsAutoReloading(state), isReloading: logEntriesSelectors.selectIsReloadingEntries(state), isLoadingMore: logEntriesSelectors.selectIsLoadingMoreEntries(state), + wasAutoReloadJustAborted: logPositionSelectors.selectAutoReloadJustAborted(state), hasMoreBeforeStart: logEntriesSelectors.selectHasMoreBeforeStart(state), hasMoreAfterEnd: logEntriesSelectors.selectHasMoreAfterEnd(state), lastLoadedTime: logEntriesSelectors.selectEntriesLastLoadedTime(state), @@ -54,12 +55,19 @@ export const WithStreamItems = withStreamItems( const { currentHighlightKey, logEntryHighlightsById } = useContext(LogHighlightsState.Context); const items = useMemo( () => - props.isReloading && !props.isAutoReloading + props.isReloading && !props.isAutoReloading && !props.wasAutoReloadJustAborted ? [] : props.entries.map(logEntry => createLogEntryStreamItem(logEntry, logEntryHighlightsById[logEntry.gid] || []) ), - [props.isReloading, props.isAutoReloading, props.entries, logEntryHighlightsById] + + [ + props.isReloading, + props.isAutoReloading, + props.wasAutoReloadJustAborted, + props.entries, + logEntryHighlightsById, + ] ); useEffect(() => { diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/stream/page_logs_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/stream/page_logs_content.tsx index caae36aac8a65..7e351bfe78952 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/stream/page_logs_content.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/stream/page_logs_content.tsx @@ -78,7 +78,15 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { - {({ isAutoReloading, jumpToTargetPosition, reportVisiblePositions, targetPosition }) => ( + {({ + isAutoReloading, + jumpToTargetPosition, + reportVisiblePositions, + targetPosition, + scrollLockLiveStreaming, + scrollUnlockLiveStreaming, + isScrollLocked, + }) => ( {({ currentHighlightKey, @@ -109,6 +117,11 @@ export const LogsPageLogsContent: React.FunctionComponent = () => { setFlyoutVisibility={setFlyoutVisibility} highlightedItem={surroundingLogsId ? surroundingLogsId : null} currentHighlightKey={currentHighlightKey} + scrollLock={{ + enable: scrollLockLiveStreaming, + disable: scrollUnlockLiveStreaming, + isEnabled: isScrollLocked, + }} /> )} diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/stream/page_toolbar.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/stream/page_toolbar.tsx index 8b7310e43dee5..a1adf1eefb20e 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/stream/page_toolbar.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/stream/page_toolbar.tsx @@ -132,8 +132,8 @@ export const LogsToolbar = injectI18n(({ intl }) => { currentTime={visibleMidpointTime} isLiveStreaming={isAutoReloading} jumpToTime={jumpToTargetPositionTime} - startLiveStreaming={interval => { - startLiveStreaming(interval); + startLiveStreaming={() => { + startLiveStreaming(); setSurroundingLogsId(null); }} stopLiveStreaming={stopLiveStreaming} diff --git a/x-pack/legacy/plugins/infra/public/store/local/log_position/actions.ts b/x-pack/legacy/plugins/infra/public/store/local/log_position/actions.ts index 86cd899b20b4d..ad83b6fda1b04 100644 --- a/x-pack/legacy/plugins/infra/public/store/local/log_position/actions.ts +++ b/x-pack/legacy/plugins/infra/public/store/local/log_position/actions.ts @@ -12,10 +12,11 @@ const actionCreator = actionCreatorFactory('x-pack/infra/local/log_position'); export const jumpToTargetPosition = actionCreator('JUMP_TO_TARGET_POSITION'); -export const jumpToTargetPositionTime = (time: number) => +export const jumpToTargetPositionTime = (time: number, fromAutoReload: boolean = false) => jumpToTargetPosition({ tiebreaker: 0, time, + fromAutoReload, }); export interface ReportVisiblePositionsPayload { @@ -31,6 +32,8 @@ export const reportVisiblePositions = actionCreator('START_AUTO_RELOAD'); - +export const startAutoReload = actionCreator('START_AUTO_RELOAD'); export const stopAutoReload = actionCreator('STOP_AUTO_RELOAD'); + +export const lockAutoReloadScroll = actionCreator('LOCK_AUTO_RELOAD_SCROLL'); +export const unlockAutoReloadScroll = actionCreator('UNLOCK_AUTO_RELOAD_SCROLL'); diff --git a/x-pack/legacy/plugins/infra/public/store/local/log_position/epic.ts b/x-pack/legacy/plugins/infra/public/store/local/log_position/epic.ts index 01b9b6eb0bb42..de9e806469b06 100644 --- a/x-pack/legacy/plugins/infra/public/store/local/log_position/epic.ts +++ b/x-pack/legacy/plugins/infra/public/store/local/log_position/epic.ts @@ -5,19 +5,49 @@ */ import { Action } from 'redux'; -import { Epic } from 'redux-observable'; +import { Epic, combineEpics } from 'redux-observable'; import { timer } from 'rxjs'; -import { exhaustMap, filter, map, takeUntil } from 'rxjs/operators'; +import { exhaustMap, filter, map, takeUntil, mapTo, withLatestFrom } from 'rxjs/operators'; -import { jumpToTargetPositionTime, startAutoReload, stopAutoReload } from './actions'; +import { + jumpToTargetPosition, + jumpToTargetPositionTime, + startAutoReload, + stopAutoReload, +} from './actions'; -export const createLogPositionEpic = (): Epic => action$ => +const LIVE_STREAM_INTERVAL = 5000; + +const createLiveStreamEpic = (): Epic< + Action, + Action, + State, + { selectIsAutoReloadingScrollLocked: (state: State) => boolean } +> => (action$, state$, { selectIsAutoReloadingScrollLocked }) => action$.pipe( filter(startAutoReload.match), - exhaustMap(({ payload }) => - timer(0, payload).pipe( - map(() => jumpToTargetPositionTime(Date.now())), + exhaustMap(() => + timer(0, LIVE_STREAM_INTERVAL).pipe( + withLatestFrom(state$), + filter(([, state]) => selectIsAutoReloadingScrollLocked(state) === false), + map(() => jumpToTargetPositionTime(Date.now(), true)), takeUntil(action$.pipe(filter(stopAutoReload.match))) ) ) ); + +const createLiveStreamScrollCancelEpic = (): Epic< + Action, + Action, + State, + { selectIsAutoReloadingLogEntries: (state: State) => boolean } +> => (action$, state$, { selectIsAutoReloadingLogEntries }) => + action$.pipe( + filter(action => jumpToTargetPosition.match(action) && !action.payload.fromAutoReload), + withLatestFrom(state$), + filter(([, state]) => selectIsAutoReloadingLogEntries(state)), + mapTo(stopAutoReload()) + ); + +export const createLogPositionEpic = () => + combineEpics(createLiveStreamEpic(), createLiveStreamScrollCancelEpic()); diff --git a/x-pack/legacy/plugins/infra/public/store/local/log_position/reducer.ts b/x-pack/legacy/plugins/infra/public/store/local/log_position/reducer.ts index 878ecae686e40..3b99e2d4f4379 100644 --- a/x-pack/legacy/plugins/infra/public/store/local/log_position/reducer.ts +++ b/x-pack/legacy/plugins/infra/public/store/local/log_position/reducer.ts @@ -13,15 +13,18 @@ import { reportVisiblePositions, startAutoReload, stopAutoReload, + lockAutoReloadScroll, + unlockAutoReloadScroll, } from './actions'; +import { loadEntriesActionCreators } from '../../remote/log_entries/operations/load'; + interface ManualTargetPositionUpdatePolicy { policy: 'manual'; } interface IntervalTargetPositionUpdatePolicy { policy: 'interval'; - interval: number; } type TargetPositionUpdatePolicy = @@ -37,6 +40,8 @@ export interface LogPositionState { endKey: TimeKey | null; }; controlsShouldDisplayTargetPosition: boolean; + autoReloadJustAborted: boolean; + autoReloadScrollLock: boolean; } export const initialLogPositionState: LogPositionState = { @@ -50,6 +55,8 @@ export const initialLogPositionState: LogPositionState = { startKey: null, }, controlsShouldDisplayTargetPosition: false, + autoReloadJustAborted: false, + autoReloadScrollLock: false, }; const targetPositionReducer = reducerWithInitialState(initialLogPositionState.targetPosition).case( @@ -60,9 +67,8 @@ const targetPositionReducer = reducerWithInitialState(initialLogPositionState.ta const targetPositionUpdatePolicyReducer = reducerWithInitialState( initialLogPositionState.updatePolicy ) - .case(startAutoReload, (state, interval) => ({ + .case(startAutoReload, () => ({ policy: 'interval', - interval, })) .case(stopAutoReload, () => ({ policy: 'manual', @@ -85,14 +91,37 @@ const controlsShouldDisplayTargetPositionReducer = reducerWithInitialState( initialLogPositionState.controlsShouldDisplayTargetPosition ) .case(jumpToTargetPosition, () => true) + .case(stopAutoReload, () => false) + .case(startAutoReload, () => true) .case(reportVisiblePositions, (state, { fromScroll }) => { if (fromScroll) return false; return state; }); +// If auto reload is aborted before a pending request finishes, this flag will +// prevent the UI from displaying the Loading Entries screen +const autoReloadJustAbortedReducer = reducerWithInitialState( + initialLogPositionState.autoReloadJustAborted +) + .case(stopAutoReload, () => true) + .case(startAutoReload, () => false) + .case(loadEntriesActionCreators.resolveDone, () => false) + .case(loadEntriesActionCreators.resolveFailed, () => false) + .case(loadEntriesActionCreators.resolve, () => false); + +const autoReloadScrollLockReducer = reducerWithInitialState( + initialLogPositionState.autoReloadScrollLock +) + .case(startAutoReload, () => false) + .case(stopAutoReload, () => false) + .case(lockAutoReloadScroll, () => true) + .case(unlockAutoReloadScroll, () => false); + export const logPositionReducer = combineReducers({ targetPosition: targetPositionReducer, updatePolicy: targetPositionUpdatePolicyReducer, visiblePositions: visiblePositionReducer, controlsShouldDisplayTargetPosition: controlsShouldDisplayTargetPositionReducer, + autoReloadJustAborted: autoReloadJustAbortedReducer, + autoReloadScrollLock: autoReloadScrollLockReducer, }); diff --git a/x-pack/legacy/plugins/infra/public/store/local/log_position/selectors.ts b/x-pack/legacy/plugins/infra/public/store/local/log_position/selectors.ts index 7104883a585c1..7a2fa86822c56 100644 --- a/x-pack/legacy/plugins/infra/public/store/local/log_position/selectors.ts +++ b/x-pack/legacy/plugins/infra/public/store/local/log_position/selectors.ts @@ -13,6 +13,10 @@ export const selectTargetPosition = (state: LogPositionState) => state.targetPos export const selectIsAutoReloading = (state: LogPositionState) => state.updatePolicy.policy === 'interval'; +export const selectAutoReloadScrollLock = (state: LogPositionState) => state.autoReloadScrollLock; + +export const selectAutoReloadJustAborted = (state: LogPositionState) => state.autoReloadJustAborted; + export const selectFirstVisiblePosition = (state: LogPositionState) => state.visiblePositions.startKey ? state.visiblePositions.startKey : null; diff --git a/x-pack/legacy/plugins/infra/public/store/store.ts b/x-pack/legacy/plugins/infra/public/store/store.ts index bdddcf7a4cc25..d699db6af042e 100644 --- a/x-pack/legacy/plugins/infra/public/store/store.ts +++ b/x-pack/legacy/plugins/infra/public/store/store.ts @@ -44,6 +44,7 @@ export function createStore({ apolloClient, observableApi }: StoreDependencies) selectHasMoreLogEntriesAfterEnd: logEntriesSelectors.selectHasMoreAfterEnd, selectHasMoreLogEntriesBeforeStart: logEntriesSelectors.selectHasMoreBeforeStart, selectIsAutoReloadingLogEntries: logPositionSelectors.selectIsAutoReloading, + selectIsAutoReloadingScrollLocked: logPositionSelectors.selectAutoReloadScrollLock, selectLogFilterQueryAsJson: logFilterSelectors.selectLogFilterQueryAsJson, selectLogTargetPosition: logPositionSelectors.selectTargetPosition, selectVisibleLogMidpointOrTarget: logPositionSelectors.selectVisibleMidpointOrTarget,