diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index d829ebffc3..255b3cd2d6 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -77,28 +77,10 @@ export function sortOrderChanged(sortedBy, sortedDesc) { } function resetNodesDeltaBuffer() { - clearTimeout(nodesDeltaBufferUpdateTimer); + clearInterval(nodesDeltaBufferUpdateTimer); return { type: ActionTypes.CLEAR_NODES_DELTA_BUFFER }; } -function bufferDeltaUpdate(delta) { - return (dispatch, getState) => { - if (delta.add === null && delta.update === null && delta.remove === null) { - log('Discarding empty nodes delta'); - return; - } - - if (getState().get('nodesDeltaBuffer').size >= NODES_DELTA_BUFFER_SIZE_LIMIT) { - dispatch({ type: ActionTypes.CONSOLIDATE_NODES_DELTA_BUFFER }); - } - - dispatch({ - type: ActionTypes.ADD_TO_NODES_DELTA_BUFFER, - delta, - }); - log('Buffering node delta, new size', getState().get('nodesDeltaBuffer').size); - }; -} // // Networks @@ -443,23 +425,17 @@ export function clickTopology(topologyId) { }; } -export function startMovingInTime() { +export function startWebsocketTransition() { return { - type: ActionTypes.START_MOVING_IN_TIME, + type: ActionTypes.START_WEBSOCKET_TRANSITION, }; } -export function websocketQueryTimestamp(timestampSinceNow) { - // If the timestamp stands for a time less than one second ago, - // assume we are actually interested in the current time. - if (timestampSinceNow < 1000) { - timestampSinceNow = null; - } - +export function websocketQueryInPast(millisecondsInPast) { return (dispatch, getState) => { dispatch({ - type: ActionTypes.WEBSOCKET_QUERY_TIMESTAMP, - timestampSinceNow, + type: ActionTypes.WEBSOCKET_QUERY_MILLISECONDS_IN_PAST, + millisecondsInPast, }); updateWebsocketChannel(getState(), dispatch); dispatch(resetNodesDeltaBuffer()); @@ -621,12 +597,15 @@ export function receiveNodesDelta(delta) { setTimeout(() => dispatch({ type: ActionTypes.SET_RECEIVED_NODES_DELTA }), 0); const state = getState(); - const movingInTime = state.get('websocketMovingInTime'); + const movingInTime = state.get('websocketTransitioning'); const hasChanges = delta.add || delta.update || delta.remove; if (hasChanges || movingInTime) { if (isPausedSelector(state)) { - dispatch(bufferDeltaUpdate(delta)); + if (state.get('nodesDeltaBuffer').size >= NODES_DELTA_BUFFER_SIZE_LIMIT) { + dispatch({ type: ActionTypes.CONSOLIDATE_NODES_DELTA_BUFFER }); + } + dispatch({ type: ActionTypes.BUFFER_NODES_DELTA, delta }); } else { dispatch({ type: ActionTypes.RECEIVE_NODES_DELTA, @@ -637,24 +616,16 @@ export function receiveNodesDelta(delta) { }; } -function maybeUpdateFromNodesDeltaBuffer() { - return (dispatch, getState) => { - if (isPausedSelector(getState())) { - dispatch(resetNodesDeltaBuffer()); - } else { - if (!getState().get('nodesDeltaBuffer').isEmpty()) { - const delta = getState().get('nodesDeltaBuffer').first(); - dispatch({ type: ActionTypes.POP_NODES_DELTA_BUFFER }); - dispatch(receiveNodesDelta(delta)); - } - if (!getState().get('nodesDeltaBuffer').isEmpty()) { - nodesDeltaBufferUpdateTimer = setTimeout( - () => dispatch(maybeUpdateFromNodesDeltaBuffer()), - NODES_DELTA_BUFFER_FEED_INTERVAL, - ); - } - } - }; +function updateFromNodesDeltaBuffer(dispatch, state) { + const isPaused = isPausedSelector(state); + const isBufferEmpty = state.get('nodesDeltaBuffer').isEmpty(); + + if (isPaused || isBufferEmpty) { + dispatch(resetNodesDeltaBuffer()); + } else { + dispatch(receiveNodesDelta(state.get('nodesDeltaBuffer').first())); + dispatch({ type: ActionTypes.POP_NODES_DELTA_BUFFER }); + } } export function clickResumeUpdate() { @@ -662,7 +633,11 @@ export function clickResumeUpdate() { dispatch({ type: ActionTypes.CLICK_RESUME_UPDATE }); - dispatch(maybeUpdateFromNodesDeltaBuffer(getState)); + // Periodically merge buffered nodes deltas until the buffer is emptied. + nodesDeltaBufferUpdateTimer = setInterval( + () => updateFromNodesDeltaBuffer(dispatch, getState()), + NODES_DELTA_BUFFER_FEED_INTERVAL, + ); }; } diff --git a/client/app/scripts/components/nodes.js b/client/app/scripts/components/nodes.js index a8fb1dd751..fea480ff94 100644 --- a/client/app/scripts/components/nodes.js +++ b/client/app/scripts/components/nodes.js @@ -34,11 +34,7 @@ const NODES_DISPLAY_EMPTY_CAUSES = [ ]; const renderCauses = causes => ( - + ); class Nodes extends React.Component { @@ -56,9 +52,9 @@ class Nodes extends React.Component { render() { const { topologiesLoaded, nodesLoaded, topologies, currentTopology, isGraphViewMode, - isTableViewMode, isResourceViewMode, movingInTime } = this.props; + isTableViewMode, isResourceViewMode, websocketTransitioning } = this.props; - const className = classNames('nodes-wrapper', { blurred: movingInTime }); + const className = classNames('nodes-wrapper', { blurred: websocketTransitioning }); // TODO: Rename view mode components. return ( @@ -89,9 +85,9 @@ function mapStateToProps(state) { topologyNodeCountZero: isTopologyNodeCountZero(state), nodesDisplayEmpty: isNodesDisplayEmpty(state), topologyEmpty: isTopologyEmpty(state), - movingInTime: state.get('websocketMovingInTime'), + websocketTransitioning: state.get('websocketTransitioning'), currentTopology: state.get('currentTopology'), - nodesLoaded: state.get('nodesLoaded') || state.get('websocketMovingInTime'), + nodesLoaded: state.get('nodesLoaded'), topologies: state.get('topologies'), topologiesLoaded: state.get('topologiesLoaded'), }; diff --git a/client/app/scripts/components/timeline-control.js b/client/app/scripts/components/timeline-control.js index 813c33559f..8206114cbb 100644 --- a/client/app/scripts/components/timeline-control.js +++ b/client/app/scripts/components/timeline-control.js @@ -8,9 +8,9 @@ import { debounce } from 'lodash'; import PauseButton from './pause-button'; import TopologyTimestampButton from './topology-timestamp-button'; import { - websocketQueryTimestamp, + websocketQueryInPast, + startWebsocketTransition, clickResumeUpdate, - startMovingInTime, } from '../actions/app-actions'; import { TIMELINE_DEBOUNCE_INTERVAL } from '../constants/timer'; @@ -72,37 +72,28 @@ class TimelineControl extends React.Component { super(props, context); this.state = { - showTimelinePanel: false, + showSliderPanel: false, millisecondsInPast: 0, rangeOptionSelected: sliderRanges.last1Hour, }; - this.jumpToNow = this.jumpToNow.bind(this); - this.toggleTimelinePanel = this.toggleTimelinePanel.bind(this); - this.handleSliderChange = this.handleSliderChange.bind(this); this.renderRangeOption = this.renderRangeOption.bind(this); + this.handleTimestampClick = this.handleTimestampClick.bind(this); + this.handleJumpToNowClick = this.handleJumpToNowClick.bind(this); + this.handleSliderChange = this.handleSliderChange.bind(this); this.debouncedUpdateTimestamp = debounce( this.updateTimestamp.bind(this), TIMELINE_DEBOUNCE_INTERVAL); } componentWillUnmount() { - this.updateTimestamp(null); - } - - updateTimestamp(timestampSinceNow) { - this.props.websocketQueryTimestamp(timestampSinceNow); - this.props.clickResumeUpdate(); - } - - toggleTimelinePanel() { - this.setState({ showTimelinePanel: !this.state.showTimelinePanel }); + this.updateTimestamp(); } handleSliderChange(sliderValue) { const millisecondsInPast = this.getRangeMilliseconds() - sliderValue; - this.props.startMovingInTime(); - this.debouncedUpdateTimestamp(millisecondsInPast); this.setState({ millisecondsInPast }); + this.debouncedUpdateTimestamp(millisecondsInPast); + this.props.startWebsocketTransition(); } handleRangeOptionClick(rangeOption) { @@ -110,24 +101,33 @@ class TimelineControl extends React.Component { const rangeMilliseconds = this.getRangeMilliseconds(rangeOption); if (this.state.millisecondsInPast > rangeMilliseconds) { - this.updateTimestamp(rangeMilliseconds); this.setState({ millisecondsInPast: rangeMilliseconds }); + this.updateTimestamp(rangeMilliseconds); + this.props.startWebsocketTransition(); } } - getRangeMilliseconds(rangeOption) { - rangeOption = rangeOption || this.state.rangeOptionSelected; - return moment().diff(rangeOption.getStart()); - } - - jumpToNow() { + handleJumpToNowClick() { this.setState({ - showTimelinePanel: false, + showSliderPanel: false, millisecondsInPast: 0, rangeOptionSelected: sliderRanges.last1Hour, }); - this.props.startMovingInTime(); - this.updateTimestamp(null); + this.updateTimestamp(); + this.props.startWebsocketTransition(); + } + + handleTimestampClick() { + this.setState({ showSliderPanel: !this.state.showSliderPanel }); + } + + updateTimestamp(millisecondsInPast = 0) { + this.props.websocketQueryInPast(millisecondsInPast); + this.props.clickResumeUpdate(); + } + + getRangeMilliseconds(rangeOption = this.state.rangeOptionSelected) { + return moment().diff(rangeOption.getStart()); } renderRangeOption(rangeOption) { @@ -144,7 +144,7 @@ class TimelineControl extends React.Component { renderJumpToNowButton() { return ( - + ); @@ -164,13 +164,13 @@ class TimelineControl extends React.Component { } render() { - const { movingInTime } = this.props; - const { showTimelinePanel, millisecondsInPast } = this.state; + const { websocketTransitioning } = this.props; + const { showSliderPanel, millisecondsInPast } = this.state; const isCurrent = (millisecondsInPast === 0); return (
- {showTimelinePanel &&
+ {showSliderPanel &&
Explore
@@ -196,13 +196,13 @@ class TimelineControl extends React.Component { {this.renderTimelineSlider()}
}
- {movingInTime &&
+ {websocketTransitioning &&
} {!isCurrent && this.renderJumpToNowButton()} @@ -214,15 +214,15 @@ class TimelineControl extends React.Component { function mapStateToProps(state) { return { - movingInTime: state.get('websocketMovingInTime'), + websocketTransitioning: state.get('websocketTransitioning'), }; } export default connect( mapStateToProps, { - websocketQueryTimestamp, + websocketQueryInPast, + startWebsocketTransition, clickResumeUpdate, - startMovingInTime, } )(TimelineControl); diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index 817a0559f1..5edd000ac0 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -3,9 +3,12 @@ import { zipObject } from 'lodash'; const ACTION_TYPES = [ 'ADD_QUERY_FILTER', 'BLUR_SEARCH', + 'BUFFER_NODES_DELTA', 'CACHE_ZOOM_STATE', + 'CHANGE_INSTANCE', 'CHANGE_TOPOLOGY_OPTION', 'CLEAR_CONTROL_ERROR', + 'CLEAR_NODES_DELTA_BUFFER', 'CLICK_BACKGROUND', 'CLICK_CLOSE_DETAILS', 'CLICK_CLOSE_TERMINAL', @@ -18,60 +21,57 @@ const ACTION_TYPES = [ 'CLICK_TERMINAL', 'CLICK_TOPOLOGY', 'CLOSE_WEBSOCKET', - 'START_MOVING_IN_TIME', - 'WEBSOCKET_QUERY_TIMESTAMP', - 'CLEAR_NODES_DELTA_BUFFER', 'CONSOLIDATE_NODES_DELTA_BUFFER', - 'ADD_TO_NODES_DELTA_BUFFER', - 'POP_NODES_DELTA_BUFFER', 'DEBUG_TOOLBAR_INTERFERING', 'DESELECT_NODE', - 'DO_CONTROL', 'DO_CONTROL_ERROR', 'DO_CONTROL_SUCCESS', + 'DO_CONTROL', 'DO_SEARCH', 'ENTER_EDGE', 'ENTER_NODE', 'FOCUS_SEARCH', 'HIDE_HELP', 'HOVER_METRIC', - 'UNHOVER_METRIC', 'LEAVE_EDGE', 'LEAVE_NODE', + 'OPEN_WEBSOCKET', 'PIN_METRIC', + 'PIN_NETWORK', 'PIN_SEARCH', - 'UNPIN_METRIC', - 'UNPIN_SEARCH', - 'OPEN_WEBSOCKET', + 'POP_NODES_DELTA_BUFFER', + 'RECEIVE_API_DETAILS', 'RECEIVE_CONTROL_NODE_REMOVED', - 'RECEIVE_CONTROL_PIPE', 'RECEIVE_CONTROL_PIPE_STATUS', + 'RECEIVE_CONTROL_PIPE', + 'RECEIVE_ERROR', 'RECEIVE_NODE_DETAILS', - 'RECEIVE_NODES', 'RECEIVE_NODES_DELTA', 'RECEIVE_NODES_FOR_TOPOLOGY', + 'RECEIVE_NODES', 'RECEIVE_NOT_FOUND', + 'RECEIVE_SERVICE_IMAGES', 'RECEIVE_TOPOLOGIES', - 'RECEIVE_API_DETAILS', - 'RECEIVE_ERROR', + 'REQUEST_SERVICE_IMAGES', 'RESET_LOCAL_VIEW_STATE', 'ROUTE_TOPOLOGY', - 'SHOW_HELP', - 'SET_VIEWPORT_DIMENSIONS', - 'SET_EXPORTING_GRAPH', 'SELECT_NETWORK', - 'TOGGLE_TROUBLESHOOTING_MENU', - 'PIN_NETWORK', - 'UNPIN_NETWORK', - 'SHOW_NETWORKS', + 'SET_EXPORTING_GRAPH', 'SET_RECEIVED_NODES_DELTA', - 'SORT_ORDER_CHANGED', 'SET_VIEW_MODE', - 'CHANGE_INSTANCE', - 'TOGGLE_CONTRAST_MODE', + 'SET_VIEWPORT_DIMENSIONS', + 'SHOW_HELP', + 'SHOW_NETWORKS', 'SHUTDOWN', - 'REQUEST_SERVICE_IMAGES', - 'RECEIVE_SERVICE_IMAGES' + 'SORT_ORDER_CHANGED', + 'START_WEBSOCKET_TRANSITION', + 'TOGGLE_CONTRAST_MODE', + 'TOGGLE_TROUBLESHOOTING_MENU', + 'UNHOVER_METRIC', + 'UNPIN_METRIC', + 'UNPIN_NETWORK', + 'UNPIN_SEARCH', + 'WEBSOCKET_QUERY_MILLISECONDS_IN_PAST', ]; export default zipObject(ACTION_TYPES, ACTION_TYPES); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index d2152c343c..4369f742ac 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -23,7 +23,6 @@ import { } from '../selectors/topology'; import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming'; import { consolidateNodesDeltas } from '../utils/nodes-delta-utils'; -import { getWebsocketQueryTimestamp } from '../utils/web-api-utils'; import { applyPinnedSearches } from '../utils/search-utils'; import { findTopologyById, @@ -93,8 +92,8 @@ export const initialState = makeMap({ versionUpdate: null, viewport: makeMap(), websocketClosed: false, - websocketMovingInTime: false, - websocketQueryTimestampSinceNow: null, + websocketTransitioning: false, + websocketQueryMillisecondsInPast: 0, zoomCache: makeMap(), serviceImages: makeMap() }); @@ -297,9 +296,8 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.CLICK_PAUSE_UPDATE: { - const pausedAt = state.get('websocketQueryTimestampSinceNow') ? - moment(getWebsocketQueryTimestamp(state)) : moment().utc(); - return state.set('updatePausedAt', pausedAt); + const millisecondsInPast = state.get('websocketQueryMillisecondsInPast'); + return state.set('updatePausedAt', moment().utc().subtract(millisecondsInPast)); } case ActionTypes.CLICK_RELATIVE: { @@ -354,12 +352,20 @@ export function rootReducer(state = initialState, action) { return state; } - case ActionTypes.START_MOVING_IN_TIME: { - return state.set('websocketMovingInTime', true); + // + // websockets + // + + case ActionTypes.OPEN_WEBSOCKET: { + return state.set('websocketClosed', false); } - case ActionTypes.WEBSOCKET_QUERY_TIMESTAMP: { - return state.set('websocketQueryTimestampSinceNow', action.timestampSinceNow); + case ActionTypes.START_WEBSOCKET_TRANSITION: { + return state.set('websocketTransitioning', true); + } + + case ActionTypes.WEBSOCKET_QUERY_MILLISECONDS_IN_PAST: { + return state.set('websocketQueryMillisecondsInPast', action.millisecondsInPast); } case ActionTypes.CLOSE_WEBSOCKET: { @@ -385,7 +391,7 @@ export function rootReducer(state = initialState, action) { return state.update('nodesDeltaBuffer', buffer => buffer.shift()); } - case ActionTypes.ADD_TO_NODES_DELTA_BUFFER: { + case ActionTypes.BUFFER_NODES_DELTA: { return state.update('nodesDeltaBuffer', buffer => buffer.push(action.delta)); } @@ -522,10 +528,6 @@ export function rootReducer(state = initialState, action) { return state; } - case ActionTypes.OPEN_WEBSOCKET: { - return state.set('websocketClosed', false); - } - case ActionTypes.DO_CONTROL_ERROR: { return state.setIn(['controlStatus', action.nodeId], makeMap({ pending: false, @@ -614,8 +616,8 @@ export function rootReducer(state = initialState, action) { state = state.set('errorUrl', null); - if (state.get('websocketMovingInTime')) { - state = state.set('websocketMovingInTime', false); + if (state.get('websocketTransitioning')) { + state = state.set('websocketTransitioning', false); state = clearNodes(state); } diff --git a/client/app/scripts/utils/nodes-delta-utils.js b/client/app/scripts/utils/nodes-delta-utils.js index 173a54504f..0c1f01983c 100644 --- a/client/app/scripts/utils/nodes-delta-utils.js +++ b/client/app/scripts/utils/nodes-delta-utils.js @@ -4,7 +4,7 @@ import { union, size, map, find, reject, each } from 'lodash'; const log = debug('scope:nodes-delta-utils'); -// TODO: Would be nice to have a unit test for this function. +// TODO: It would be nice to have a unit test for this function. export function consolidateNodesDeltas(first, second) { let toAdd = union(first.add, second.add); let toUpdate = union(first.update, second.update); diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 7eae675963..25d3c0ea03 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -237,9 +237,12 @@ export function getTopologies(options, dispatch, initialPoll) { }); } -export function getWebsocketQueryTimestamp(state) { - const sinceNow = state.get('websocketQueryTimestampSinceNow'); - return sinceNow ? moment().utc().subtract(sinceNow).toISOString() : null; +function getWebsocketQueryTimestamp(state) { + const millisecondsInPast = state.get('websocketQueryMillisecondsInPast'); + // The timestamp query parameter will be used only if it's in the past. + if (millisecondsInPast === 0) return null; + + return moment().utc().subtract(millisecondsInPast).toISOString(); } export function updateWebsocketChannel(state, dispatch) { diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 26c5a542fa..f458095af1 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -82,7 +82,7 @@ } &.active { - @extend .blinkable; + & > * { @extend .blinkable; } border: 1px solid $text-tertiary-color; } } @@ -236,12 +236,12 @@ .button { margin-left: 0.5em; } .topology-timestamp-button:not(.current) { - @extend .blinkable; + & > * { @extend .blinkable; } font-weight: bold; } } - .timeline-panel { + .slider-panel { width: 355px; .caption, .slider-tip {