diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index a06479960e..854a1c1d45 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -8,12 +8,12 @@ import { doControlRequest, getAllNodes, getResourceViewNodesSnapshot, - updateWebsocketChannel, getNodeDetails, getTopologies, deletePipe, stopPolling, teardownWebsockets, + getNodes, } from '../utils/web-api-utils'; import { storageSet } from '../utils/storage-utils'; import { loadTheme } from '../utils/contrast-utils'; @@ -29,8 +29,6 @@ import { resourceViewAvailableSelector, } from '../selectors/topology'; -import { NODES_DELTA_BUFFER_SIZE_LIMIT } from '../constants/limits'; -import { NODES_DELTA_BUFFER_FEED_INTERVAL } from '../constants/timer'; import { GRAPH_VIEW_MODE, TABLE_VIEW_MODE, @@ -40,8 +38,6 @@ import { const log = debug('scope:app-actions'); -// TODO: This shouldn't be exposed here as a global variable. -let nodesDeltaBufferUpdateTimer = null; export function showHelp() { return { type: ActionTypes.SHOW_HELP }; @@ -75,11 +71,6 @@ export function sortOrderChanged(sortedBy, sortedDesc) { }; } -function resetNodesDeltaBuffer() { - clearInterval(nodesDeltaBufferUpdateTimer); - return { type: ActionTypes.CLEAR_NODES_DELTA_BUFFER }; -} - // // Networks @@ -216,11 +207,8 @@ export function changeTopologyOption(option, value, topologyId, addOrRemove) { }); updateRoute(getState); // update all request workers with new options - dispatch(resetNodesDeltaBuffer()); - const state = getState(); - getTopologies(state, dispatch); - updateWebsocketChannel(state, dispatch); - getNodeDetails(state, dispatch); + getTopologies(getState, dispatch); + getNodes(getState, dispatch); }; } @@ -344,13 +332,13 @@ export function clickNode(nodeId, label, origin) { nodeId }); updateRoute(getState); - getNodeDetails(getState(), dispatch); + getNodeDetails(getState, dispatch); }; } -export function clickPauseUpdate() { +export function pauseTimeAtNow() { return { - type: ActionTypes.CLICK_PAUSE_UPDATE + type: ActionTypes.PAUSE_TIME_AT_NOW }; } @@ -364,7 +352,7 @@ export function clickRelative(nodeId, topologyId, label, origin) { topologyId }); updateRoute(getState); - getNodeDetails(getState(), dispatch); + getNodeDetails(getState, dispatch); }; } @@ -375,12 +363,10 @@ function updateTopology(dispatch, getState) { getResourceViewNodesSnapshot(state, dispatch); } updateRoute(getState); - // update all request workers with new options - dispatch(resetNodesDeltaBuffer()); // NOTE: This is currently not needed for our static resource // view, but we'll need it here later and it's simpler to just // keep it than to redo the nodes delta updating logic. - updateWebsocketChannel(state, dispatch); + getNodes(getState, dispatch); } export function clickShowTopologyForNode(topologyId, nodeId) { @@ -404,28 +390,6 @@ export function clickTopology(topologyId) { }; } -export function timeTravelStartTransition() { - return { - type: ActionTypes.TIME_TRAVEL_START_TRANSITION, - }; -} - -export function timeTravelJumpToPast(millisecondsInPast) { - return (dispatch, getServiceState) => { - dispatch({ - type: ActionTypes.TIME_TRAVEL_MILLISECONDS_IN_PAST, - millisecondsInPast, - }); - const scopeState = getServiceState().scope; - updateWebsocketChannel(scopeState, dispatch); - dispatch(resetNodesDeltaBuffer()); - getTopologies(getServiceState().scope, dispatch); - if (isResourceViewModeSelector(scopeState)) { - getResourceViewNodesSnapshot(scopeState, dispatch); - } - }; -} - export function cacheZoomState(zoomState) { return { type: ActionTypes.CACHE_ZOOM_STATE, @@ -573,29 +537,22 @@ export function receiveNodeDetails(details) { export function receiveNodesDelta(delta) { return (dispatch, getState) => { - // - // allow css-animation to run smoothly by scheduling it to run on the - // next tick after any potentially expensive canvas re-draws have been - // completed. - // - setTimeout(() => dispatch({ type: ActionTypes.SET_RECEIVED_NODES_DELTA }), 0); - - // TODO: This way of getting the Scope state is a bit hacky, so try to replace - // it with something better. The problem is that all the actions that are called - // from the components wrapped in have a global Service state - // returned by getState(). Since method is called from both contexts, getState() - // will sometimes return Scope state subtree and sometimes the whole Service state. - const state = getState().scope || getState(); - const movingInTime = state.get('timeTravelTransitioning'); - const hasChanges = delta.add || delta.update || delta.remove; - - if (hasChanges || movingInTime) { - if (isPausedSelector(state)) { - 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 { + if (!isPausedSelector(getState())) { + // Allow css-animation to run smoothly by scheduling it to run on the + // next tick after any potentially expensive canvas re-draws have been + // completed. + setTimeout(() => dispatch({ type: ActionTypes.SET_RECEIVED_NODES_DELTA }), 0); + + // When moving in time, we will consider the transition complete + // only when the first batch of nodes delta has been received. We + // do that because we want to keep the previous state blurred instead + // of transitioning over an empty state like when switching topologies. + if (getState().get('timeTravelTransitioning')) { + dispatch({ type: ActionTypes.FINISH_TIME_TRAVEL_TRANSITION }); + } + + const hasChanges = delta.add || delta.update || delta.remove; + if (hasChanges) { dispatch({ type: ActionTypes.RECEIVE_NODES_DELTA, delta @@ -605,30 +562,58 @@ export function receiveNodesDelta(delta) { }; } -function updateFromNodesDeltaBuffer(dispatch, state) { - const isPaused = isPausedSelector(state); - const isBufferEmpty = state.get('nodesDeltaBuffer').isEmpty(); +export function resumeTime() { + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.RESUME_TIME + }); + // After unpausing, all of the following calls will re-activate polling. + getTopologies(getState, dispatch); + getNodes(getState, dispatch, true); + if (isResourceViewModeSelector(getState())) { + getResourceViewNodesSnapshot(getState(), dispatch); + } + }; +} + +export function startTimeTravel() { + return (dispatch, getState) => { + dispatch({ + type: ActionTypes.START_TIME_TRAVEL + }); + if (!getState().get('nodesLoaded')) { + getNodes(getState, dispatch); + if (isResourceViewModeSelector(getState())) { + getResourceViewNodesSnapshot(getState(), dispatch); + } + } + }; +} + +export function receiveNodes(nodes) { + return { + type: ActionTypes.RECEIVE_NODES, + nodes, + }; +} - if (isPaused || isBufferEmpty) { - dispatch(resetNodesDeltaBuffer()); - } else { - dispatch(receiveNodesDelta(state.get('nodesDeltaBuffer').first())); - dispatch({ type: ActionTypes.POP_NODES_DELTA_BUFFER }); - } +export function timeTravelStartTransition() { + return { + type: ActionTypes.TIME_TRAVEL_START_TRANSITION, + }; } -export function clickResumeUpdate() { +export function jumpToTime(timestamp) { return (dispatch, getState) => { dispatch({ - type: ActionTypes.CLICK_RESUME_UPDATE + type: ActionTypes.JUMP_TO_TIME, + timestamp, }); - // TODO: Find a better way to do this (see the comment above). - const state = getState().scope || getState(); - // Periodically merge buffered nodes deltas until the buffer is emptied. - nodesDeltaBufferUpdateTimer = setInterval( - () => updateFromNodesDeltaBuffer(dispatch, state), - NODES_DELTA_BUFFER_FEED_INTERVAL, - ); + getTopologies(getState, dispatch); + getNodes(getState, dispatch); + if (isResourceViewModeSelector(getState())) { + getResourceViewNodesSnapshot(getState(), dispatch); + } }; } @@ -641,18 +626,15 @@ export function receiveNodesForTopology(nodes, topologyId) { } export function receiveTopologies(topologies) { - return (dispatch, getGlobalState) => { - // NOTE: Fortunately, this will go when Time Travel is out of . - const getState = () => getGlobalState().scope || getGlobalState(); + return (dispatch, getState) => { const firstLoad = !getState().get('topologiesLoaded'); dispatch({ type: ActionTypes.RECEIVE_TOPOLOGIES, topologies }); - const state = getState(); - updateWebsocketChannel(state, dispatch); - getNodeDetails(state, dispatch); + getNodes(getState, dispatch); // Populate search matches on first load + const state = getState(); if (firstLoad && state.get('searchQuery')) { dispatch(focusSearch()); } @@ -754,7 +736,7 @@ export function setContrastMode(enabled) { export function getTopologiesWithInitialPoll() { return (dispatch, getState) => { - getTopologies(getState(), dispatch, true); + getTopologies(getState, dispatch, true); }; } @@ -765,13 +747,12 @@ export function route(urlState) { type: ActionTypes.ROUTE_TOPOLOGY }); // update all request workers with new options - const state = getState(); - getTopologies(state, dispatch); - updateWebsocketChannel(state, dispatch); - getNodeDetails(state, dispatch); + getTopologies(getState, dispatch); + getNodes(getState, dispatch); // If we are landing on the resource view page, we need to fetch not only all the // nodes for the current topology, but also the nodes of all the topologies that make // the layers in the resource view. + const state = getState(); if (isResourceViewModeSelector(state)) { getResourceViewNodesSnapshot(state, dispatch); } diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 1b01062cc2..5adc44bc4b 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -1,5 +1,6 @@ import debug from 'debug'; import React from 'react'; +import classNames from 'classnames'; import { connect } from 'react-redux'; import { debounce } from 'lodash'; @@ -12,7 +13,6 @@ import Search from './search'; import Status from './status'; import Topologies from './topologies'; import TopologyOptions from './topology-options'; -import CloudFeature from './cloud-feature'; import Overlay from './overlay'; import { getApiDetails } from '../utils/web-api-utils'; import { @@ -33,6 +33,7 @@ import { import Details from './details'; import Nodes from './nodes'; import TimeTravel from './time-travel'; +import TimeControl from './time-control'; import ViewModeSelector from './view-mode-selector'; import NetworkSelector from './networks-selector'; import DebugToolbar, { showingDebugToolbar, toggleDebugToolbar } from './debug-toolbar'; @@ -166,12 +167,15 @@ class App extends React.Component { } render() { - const { isTableViewMode, isGraphViewMode, isResourceViewMode, showingDetails, showingHelp, - showingNetworkSelector, showingTroubleshootingMenu, timeTravelTransitioning } = this.props; + const { isTableViewMode, isGraphViewMode, isResourceViewMode, showingDetails, + showingHelp, showingNetworkSelector, showingTroubleshootingMenu, + timeTravelTransitioning, showingTimeTravel } = this.props; + + const className = classNames('scope-app', { 'time-travel-open': showingTimeTravel }); const isIframe = window !== window.top; return ( -
+
{showingDebugToolbar() && } {showingHelp && } @@ -181,22 +185,22 @@ class App extends React.Component { {showingDetails &&
}
-
- {!isIframe && - - } + +
+
+ {!isIframe && + + } +
+ + + +
- - -
- - {!isResourceViewMode && } - - {showingNetworkSelector && isGraphViewMode && } {!isResourceViewMode && } @@ -224,6 +228,7 @@ function mapStateToProps(state) { searchQuery: state.get('searchQuery'), showingDetails: state.get('nodeDetails').size > 0, showingHelp: state.get('showingHelp'), + showingTimeTravel: state.get('showingTimeTravel'), showingTroubleshootingMenu: state.get('showingTroubleshootingMenu'), showingNetworkSelector: availableNetworksSelector(state).count() > 0, showingTerminal: state.get('controlPipes').size > 0, diff --git a/client/app/scripts/components/footer.js b/client/app/scripts/components/footer.js index d23f5685c6..410edc45f8 100644 --- a/client/app/scripts/components/footer.js +++ b/client/app/scripts/components/footer.js @@ -2,7 +2,6 @@ import React from 'react'; import { connect } from 'react-redux'; import Plugins from './plugins'; -import PauseButton from './pause-button'; import { trackMixpanelEvent } from '../utils/tracking-utils'; import { clickDownloadGraph, @@ -66,7 +65,6 @@ class Footer extends React.Component {
- - {label !== '' && {label}} - - - ); - } -} - -function mapStateToProps(state) { - return { - hasUpdates: !state.get('nodesDeltaBuffer').isEmpty(), - updateCount: state.get('nodesDeltaBuffer').size, - updatePausedAt: state.get('updatePausedAt'), - topologyViewMode: state.get('topologyViewMode'), - currentTopology: state.get('currentTopology'), - isPaused: isPausedSelector(state), - }; -} - -export default connect( - mapStateToProps, - { - clickPauseUpdate, - clickResumeUpdate, - } -)(PauseButton); diff --git a/client/app/scripts/components/status.js b/client/app/scripts/components/status.js index 4332b7f775..43c02b8da8 100644 --- a/client/app/scripts/components/status.js +++ b/client/app/scripts/components/status.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; -import { isNowSelector } from '../selectors/time-travel'; +import { isPausedSelector } from '../selectors/time-travel'; class Status extends React.Component { @@ -47,7 +47,7 @@ function mapStateToProps(state) { return { errorUrl: state.get('errorUrl'), filteredNodeCount: state.get('nodes').filter(node => node.get('filtered')).size, - showingCurrentState: isNowSelector(state), + showingCurrentState: !isPausedSelector(state), topologiesLoaded: state.get('topologiesLoaded'), topology: state.get('currentTopology'), websocketClosed: state.get('websocketClosed'), diff --git a/client/app/scripts/components/time-control.js b/client/app/scripts/components/time-control.js new file mode 100644 index 0000000000..2cfe27dd7b --- /dev/null +++ b/client/app/scripts/components/time-control.js @@ -0,0 +1,128 @@ +import React from 'react'; +import moment from 'moment'; +import classNames from 'classnames'; +import { connect } from 'react-redux'; + +import CloudFeature from './cloud-feature'; +import TimeTravelButton from './time-travel-button'; +import { trackMixpanelEvent } from '../utils/tracking-utils'; +import { pauseTimeAtNow, resumeTime, startTimeTravel } from '../actions/app-actions'; + + +const className = isSelected => ( + classNames('time-control-action', { 'time-control-action-selected': isSelected }) +); + +class TimeControl extends React.Component { + constructor(props, context) { + super(props, context); + + this.handleNowClick = this.handleNowClick.bind(this); + this.handlePauseClick = this.handlePauseClick.bind(this); + this.handleTravelClick = this.handleTravelClick.bind(this); + this.getTrackingMetadata = this.getTrackingMetadata.bind(this); + } + + getTrackingMetadata(data = {}) { + const { currentTopology } = this.props; + return { + layout: this.props.topologyViewMode, + topologyId: currentTopology && currentTopology.get('id'), + parentTopologyId: currentTopology && currentTopology.get('parentId'), + ...data + }; + } + + handleNowClick() { + trackMixpanelEvent('scope.time.resume.click', this.getTrackingMetadata()); + this.props.resumeTime(); + } + + handlePauseClick() { + trackMixpanelEvent('scope.time.pause.click', this.getTrackingMetadata()); + this.props.pauseTimeAtNow(); + } + + handleTravelClick() { + if (!this.props.showingTimeTravel) { + trackMixpanelEvent('scope.time.travel.click', this.getTrackingMetadata({ open: true })); + this.props.startTimeTravel(); + } else { + trackMixpanelEvent('scope.time.travel.click', this.getTrackingMetadata({ open: false })); + this.props.resumeTime(); + } + } + + render() { + const { showingTimeTravel, pausedAt, timeTravelTransitioning, topologiesLoaded } = this.props; + + const isPausedNow = pausedAt && !showingTimeTravel; + const isTimeTravelling = showingTimeTravel; + const isRunningNow = !pausedAt; + + if (!topologiesLoaded) return null; + + return ( +
+
+
+ {timeTravelTransitioning && } +
+
+ + {isRunningNow && } + Live + + + {isPausedNow && } + {isPausedNow ? 'Paused' : 'Pause'} + + + + +
+
+ {(isPausedNow || isTimeTravelling) && + Showing state from {moment(pausedAt).fromNow()} + } + {isRunningNow && timeTravelTransitioning && + Resuming the live state + } +
+ ); + } +} + +function mapStateToProps(state) { + return { + topologyViewMode: state.get('topologyViewMode'), + topologiesLoaded: state.get('topologiesLoaded'), + currentTopology: state.get('currentTopology'), + showingTimeTravel: state.get('showingTimeTravel'), + timeTravelTransitioning: state.get('timeTravelTransitioning'), + pausedAt: state.get('pausedAt'), + }; +} + +export default connect( + mapStateToProps, + { + resumeTime, + pauseTimeAtNow, + startTimeTravel, + } +)(TimeControl); diff --git a/client/app/scripts/components/time-travel-button.js b/client/app/scripts/components/time-travel-button.js new file mode 100644 index 0000000000..3bb1764819 --- /dev/null +++ b/client/app/scripts/components/time-travel-button.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { connect } from 'react-redux'; + + +// TODO: Move this back into TimeTravelControls once we move away from CloudFeature. +class TimeTravelButton extends React.Component { + render() { + const { className, onClick, isTimeTravelling, hasTimeTravel } = this.props; + + if (!hasTimeTravel) return null; + + return ( + + {isTimeTravelling && } + Time Travel + + ); + } +} + +function mapStateToProps({ root }, { params }) { + const cloudInstance = root.instances[params.orgId] || {}; + const featureFlags = cloudInstance.featureFlags || []; + return { + hasTimeTravel: featureFlags.includes('time-travel'), + }; +} + +export default connect(mapStateToProps)(TimeTravelButton); diff --git a/client/app/scripts/components/time-travel-timestamp.js b/client/app/scripts/components/time-travel-timestamp.js deleted file mode 100644 index 5b44a6e4e8..0000000000 --- a/client/app/scripts/components/time-travel-timestamp.js +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react'; -import moment from 'moment'; -import classNames from 'classnames'; -import { connect } from 'react-redux'; - -import { isPausedSelector } from '../selectors/time-travel'; -import { TIMELINE_TICK_INTERVAL } from '../constants/timer'; - - -class TimeTravelTimestamp extends React.Component { - componentDidMount() { - this.timer = setInterval(() => { - if (!this.props.isPaused) { - this.forceUpdate(); - } - }, TIMELINE_TICK_INTERVAL); - } - - componentWillUnmount() { - clearInterval(this.timer); - } - - renderTimestamp() { - const { isPaused, updatePausedAt, millisecondsInPast } = this.props; - const timestamp = isPaused ? updatePausedAt : moment().utc().subtract(millisecondsInPast); - - return ( - - ); - } - - render() { - const { selected, onClick, millisecondsInPast } = this.props; - const isCurrent = (millisecondsInPast === 0); - - const className = classNames('button time-travel-timestamp', { - selected, current: isCurrent - }); - - return ( - - - {isCurrent ? 'now' : this.renderTimestamp()} - - - - ); - } -} - -function mapStateToProps({ scope }) { - return { - isPaused: isPausedSelector(scope), - updatePausedAt: scope.get('updatePausedAt'), - }; -} - -export default connect(mapStateToProps)(TimeTravelTimestamp); diff --git a/client/app/scripts/components/time-travel.js b/client/app/scripts/components/time-travel.js index 282e7dc634..979913d799 100644 --- a/client/app/scripts/components/time-travel.js +++ b/client/app/scripts/components/time-travel.js @@ -1,89 +1,52 @@ import React from 'react'; -import moment from 'moment'; import Slider from 'rc-slider'; +import moment from 'moment'; import classNames from 'classnames'; import { connect } from 'react-redux'; -import { debounce } from 'lodash'; +import { debounce, map } from 'lodash'; -import TimeTravelTimestamp from './time-travel-timestamp'; import { trackMixpanelEvent } from '../utils/tracking-utils'; import { - timeTravelJumpToPast, + jumpToTime, + resumeTime, timeTravelStartTransition, - clickResumeUpdate, } from '../actions/app-actions'; import { - TIMELINE_SLIDER_UPDATE_INTERVAL, + TIMELINE_TICK_INTERVAL, TIMELINE_DEBOUNCE_INTERVAL, } from '../constants/timer'; -const sliderRanges = { - last15Minutes: { - label: 'Last 15 minutes', - getStart: () => moment().utc().subtract(15, 'minutes'), - }, - last1Hour: { - label: 'Last 1 hour', - getStart: () => moment().utc().subtract(1, 'hour'), - }, - last6Hours: { - label: 'Last 6 hours', - getStart: () => moment().utc().subtract(6, 'hours'), - }, - last24Hours: { - label: 'Last 24 hours', - getStart: () => moment().utc().subtract(24, 'hours'), - }, - last7Days: { - label: 'Last 7 days', - getStart: () => moment().utc().subtract(7, 'days'), - }, - last30Days: { - label: 'Last 30 days', - getStart: () => moment().utc().subtract(30, 'days'), - }, - last90Days: { - label: 'Last 90 days', - getStart: () => moment().utc().subtract(90, 'days'), - }, - last1Year: { - label: 'Last 1 year', - getStart: () => moment().subtract(1, 'year'), - }, - todaySoFar: { - label: 'Today so far', - getStart: () => moment().utc().startOf('day'), - }, - thisWeekSoFar: { - label: 'This week so far', - getStart: () => moment().utc().startOf('week'), - }, - thisMonthSoFar: { - label: 'This month so far', - getStart: () => moment().utc().startOf('month'), - }, - thisYearSoFar: { - label: 'This year so far', - getStart: () => moment().utc().startOf('year'), - }, +const getTimestampStates = (timestamp) => { + timestamp = timestamp || moment(); + return { + sliderValue: moment(timestamp).valueOf(), + inputValue: moment(timestamp).utc().format(), + }; }; +const ONE_HOUR_MS = moment.duration(1, 'hour'); +const FIVE_MINUTES_MS = moment.duration(5, 'minutes'); + class TimeTravel extends React.Component { constructor(props, context) { super(props, context); this.state = { - showSliderPanel: false, - millisecondsInPast: 0, - rangeOptionSelected: sliderRanges.last1Hour, + // TODO: Showing a three months of history is quite arbitrary; + // we should instead get some meaningful 'beginning of time' from + // the backend and make the slider show whole active history. + sliderMinValue: moment().subtract(6, 'months').valueOf(), + ...getTimestampStates(props.pausedAt), }; - this.renderRangeOption = this.renderRangeOption.bind(this); - this.handleTimestampClick = this.handleTimestampClick.bind(this); - this.handleJumpToNowClick = this.handleJumpToNowClick.bind(this); + this.handleInputChange = this.handleInputChange.bind(this); this.handleSliderChange = this.handleSliderChange.bind(this); + this.handleJumpClick = this.handleJumpClick.bind(this); + this.renderMarks = this.renderMarks.bind(this); + this.renderMark = this.renderMark.bind(this); + this.travelTo = this.travelTo.bind(this); this.debouncedUpdateTimestamp = debounce( this.updateTimestamp.bind(this), TIMELINE_DEBOUNCE_INTERVAL); @@ -93,84 +56,60 @@ class TimeTravel extends React.Component { componentDidMount() { // Force periodic re-renders to update the slider position as time goes by. - this.timer = setInterval(() => { this.forceUpdate(); }, TIMELINE_SLIDER_UPDATE_INTERVAL); + this.timer = setInterval(() => { this.forceUpdate(); }, TIMELINE_TICK_INTERVAL); + } + + componentWillReceiveProps(props) { + this.setState(getTimestampStates(props.pausedAt)); } componentWillUnmount() { clearInterval(this.timer); - this.updateTimestamp(); + this.props.resumeTime(); } - handleSliderChange(sliderValue) { - let millisecondsInPast = this.getRangeMilliseconds() - sliderValue; - - // If the slider value is less than 1s away from the right-end (current time), - // assume we meant the current time - this is important for the '... so far' - // ranges where the range of values changes over time. - if (millisecondsInPast < 1000) { - millisecondsInPast = 0; - } - - this.setState({ millisecondsInPast }); - this.props.timeTravelStartTransition(); - this.debouncedUpdateTimestamp(millisecondsInPast); - + handleSliderChange(timestamp) { + this.travelTo(timestamp, true); this.debouncedTrackSliderChange(); } - handleRangeOptionClick(rangeOption) { - this.setState({ rangeOptionSelected: rangeOption }); - - const rangeMilliseconds = this.getRangeMilliseconds(rangeOption); - if (this.state.millisecondsInPast > rangeMilliseconds) { - this.setState({ millisecondsInPast: rangeMilliseconds }); - this.updateTimestamp(rangeMilliseconds); - this.props.timeTravelStartTransition(); - } - - trackMixpanelEvent('scope.time.range.select', { - layout: this.props.topologyViewMode, - topologyId: this.props.currentTopology.get('id'), - parentTopologyId: this.props.currentTopology.get('parentId'), - label: rangeOption.label, - }); - } + handleInputChange(ev) { + let timestamp = moment(ev.target.value); + this.setState({ inputValue: ev.target.value }); - handleJumpToNowClick() { - this.setState({ - showSliderPanel: false, - millisecondsInPast: 0, - rangeOptionSelected: sliderRanges.last1Hour, - }); - this.updateTimestamp(); - this.props.timeTravelStartTransition(); + if (timestamp.isValid()) { + timestamp = Math.max(timestamp, this.state.sliderMinValue); + timestamp = Math.min(timestamp, moment().valueOf()); + this.travelTo(timestamp, true); - trackMixpanelEvent('scope.time.now.click', { - layout: this.props.topologyViewMode, - topologyId: this.props.currentTopology.get('id'), - parentTopologyId: this.props.currentTopology.get('parentId'), - }); + trackMixpanelEvent('scope.time.timestamp.edit', { + layout: this.props.topologyViewMode, + topologyId: this.props.currentTopology.get('id'), + parentTopologyId: this.props.currentTopology.get('parentId'), + }); + } } - handleTimestampClick() { - const showSliderPanel = !this.state.showSliderPanel; - this.setState({ showSliderPanel }); - - trackMixpanelEvent('scope.time.timestamp.click', { - layout: this.props.topologyViewMode, - topologyId: this.props.currentTopology.get('id'), - parentTopologyId: this.props.currentTopology.get('parentId'), - showSliderPanel, - }); + handleJumpClick(millisecondsDelta) { + let timestamp = this.state.sliderValue + millisecondsDelta; + timestamp = Math.max(timestamp, this.state.sliderMinValue); + timestamp = Math.min(timestamp, moment().valueOf()); + this.travelTo(timestamp, true); } - updateTimestamp(millisecondsInPast = 0) { - this.props.timeTravelJumpToPast(millisecondsInPast); - this.props.clickResumeUpdate(); + updateTimestamp(timestamp) { + this.props.jumpToTime(moment(timestamp)); } - getRangeMilliseconds(rangeOption = this.state.rangeOptionSelected) { - return moment().diff(rangeOption.getStart()); + travelTo(timestamp, debounced = false) { + this.props.timeTravelStartTransition(); + this.setState(getTimestampStates(timestamp)); + if (debounced) { + this.debouncedUpdateTimestamp(timestamp); + } else { + this.debouncedUpdateTimestamp.cancel(); + this.updateTimestamp(timestamp); + } } trackSliderChange() { @@ -181,106 +120,105 @@ class TimeTravel extends React.Component { }); } - renderRangeOption(rangeOption) { - const handleClick = () => { this.handleRangeOptionClick(rangeOption); }; - const selected = (this.state.rangeOptionSelected.label === rangeOption.label); - const className = classNames('option', { selected }); + renderMark({ timestampValue, label }) { + const sliderMaxValue = moment().valueOf(); + const pos = (sliderMaxValue - timestampValue) / (sliderMaxValue - this.state.sliderMinValue); - return ( - - {rangeOption.label} - - ); - } + // Ignore the month marks that are very close to 'Now' + if (label !== 'Now' && pos < 0.05) return null; - renderJumpToNowButton() { + const style = { marginLeft: `calc(${(1 - pos) * 100}% - 32px)`, width: '64px' }; return ( - - - + ); } - renderTimeSlider() { - const { millisecondsInPast } = this.state; - const rangeMilliseconds = this.getRangeMilliseconds(); + renderMarks() { + const { sliderMinValue } = this.state; + const sliderMaxValue = moment().valueOf(); + const ticks = [{ timestampValue: sliderMaxValue, label: 'Now' }]; + let monthsBack = 0; + let timestamp; + + do { + timestamp = moment().utc().subtract(monthsBack, 'months').startOf('month'); + if (timestamp.valueOf() >= sliderMinValue) { + // Months are broken by the year tag, e.g. November, December, 2016, February, etc... + let label = timestamp.format('MMMM'); + if (label === 'January') { + label = timestamp.format('YYYY'); + } + ticks.push({ timestampValue: timestamp.valueOf(), label }); + } + monthsBack += 1; + } while (timestamp.valueOf() >= sliderMinValue); return ( - +
+ {map(ticks, tick => this.renderMark(tick))} +
); } render() { - const { timeTravelTransitioning, hasTimeTravel } = this.props; - const { showSliderPanel, millisecondsInPast, rangeOptionSelected } = this.state; - const lowerCaseLabel = rangeOptionSelected.label.toLowerCase(); - const isCurrent = (millisecondsInPast === 0); + const { sliderValue, sliderMinValue, inputValue } = this.state; + const sliderMaxValue = moment().valueOf(); - // Don't render the time travel control if it's not explicitly enabled for this instance. - if (!hasTimeTravel) return null; + const className = classNames('time-travel', { visible: this.props.showingTimeTravel }); return ( -
- {showSliderPanel &&
-
-
- {this.renderRangeOption(sliderRanges.last15Minutes)} - {this.renderRangeOption(sliderRanges.last1Hour)} - {this.renderRangeOption(sliderRanges.last6Hours)} - {this.renderRangeOption(sliderRanges.last24Hours)} -
-
- {this.renderRangeOption(sliderRanges.last7Days)} - {this.renderRangeOption(sliderRanges.last30Days)} - {this.renderRangeOption(sliderRanges.last90Days)} - {this.renderRangeOption(sliderRanges.last1Year)} -
-
- {this.renderRangeOption(sliderRanges.todaySoFar)} - {this.renderRangeOption(sliderRanges.thisWeekSoFar)} - {this.renderRangeOption(sliderRanges.thisMonthSoFar)} - {this.renderRangeOption(sliderRanges.thisYearSoFar)} -
-
- Move the slider to explore {lowerCaseLabel} - {this.renderTimeSlider()} -
} - ); } } -function mapStateToProps({ scope, root }, { params }) { - const cloudInstance = root.instances[params.orgId] || {}; - const featureFlags = cloudInstance.featureFlags || []; +function mapStateToProps(state) { return { - hasTimeTravel: featureFlags.includes('time-travel'), - timeTravelTransitioning: scope.get('timeTravelTransitioning'), - topologyViewMode: scope.get('topologyViewMode'), - currentTopology: scope.get('currentTopology'), + showingTimeTravel: state.get('showingTimeTravel'), + topologyViewMode: state.get('topologyViewMode'), + currentTopology: state.get('currentTopology'), + pausedAt: state.get('pausedAt'), }; } export default connect( mapStateToProps, { - timeTravelJumpToPast, + jumpToTime, + resumeTime, timeTravelStartTransition, - clickResumeUpdate, } )(TimeTravel); diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index fc48dc6494..5238ad391f 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -3,25 +3,20 @@ 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', 'CLICK_FORCE_RELAYOUT', 'CLICK_NODE', - 'CLICK_PAUSE_UPDATE', 'CLICK_RELATIVE', - 'CLICK_RESUME_UPDATE', 'CLICK_SHOW_TOPOLOGY_FOR_NODE', 'CLICK_TERMINAL', 'CLICK_TOPOLOGY', 'CLOSE_WEBSOCKET', - 'CONSOLIDATE_NODES_DELTA_BUFFER', 'DEBUG_TOOLBAR_INTERFERING', 'DESELECT_NODE', 'DO_CONTROL_ERROR', @@ -30,16 +25,18 @@ const ACTION_TYPES = [ 'DO_SEARCH', 'ENTER_EDGE', 'ENTER_NODE', + 'FINISH_TIME_TRAVEL_TRANSITION', 'FOCUS_SEARCH', 'HIDE_HELP', 'HOVER_METRIC', + 'JUMP_TO_TIME', 'LEAVE_EDGE', 'LEAVE_NODE', 'OPEN_WEBSOCKET', + 'PAUSE_TIME_AT_NOW', 'PIN_METRIC', 'PIN_NETWORK', 'PIN_SEARCH', - 'POP_NODES_DELTA_BUFFER', 'RECEIVE_API_DETAILS', 'RECEIVE_CONTROL_NODE_REMOVED', 'RECEIVE_CONTROL_PIPE_STATUS', @@ -54,6 +51,7 @@ const ACTION_TYPES = [ 'RECEIVE_TOPOLOGIES', 'REQUEST_SERVICE_IMAGES', 'RESET_LOCAL_VIEW_STATE', + 'RESUME_TIME', 'ROUTE_TOPOLOGY', 'SELECT_NETWORK', 'SET_EXPORTING_GRAPH', @@ -64,7 +62,7 @@ const ACTION_TYPES = [ 'SHOW_NETWORKS', 'SHUTDOWN', 'SORT_ORDER_CHANGED', - 'TIME_TRAVEL_MILLISECONDS_IN_PAST', + 'START_TIME_TRAVEL', 'TIME_TRAVEL_START_TRANSITION', 'TOGGLE_CONTRAST_MODE', 'TOGGLE_TROUBLESHOOTING_MENU', diff --git a/client/app/scripts/constants/limits.js b/client/app/scripts/constants/limits.js index 56e80aa409..afb2c79382 100644 --- a/client/app/scripts/constants/limits.js +++ b/client/app/scripts/constants/limits.js @@ -1,3 +1,2 @@ export const NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT = 5; -export const NODES_DELTA_BUFFER_SIZE_LIMIT = 100; diff --git a/client/app/scripts/constants/styles.js b/client/app/scripts/constants/styles.js index d04c23102e..a72ccd065a 100644 --- a/client/app/scripts/constants/styles.js +++ b/client/app/scripts/constants/styles.js @@ -39,7 +39,7 @@ export const EDGE_WAYPOINTS_CAP = 10; export const CANVAS_MARGINS = { [GRAPH_VIEW_MODE]: { top: 160, left: 40, right: 40, bottom: 150 }, - [TABLE_VIEW_MODE]: { top: 160, left: 40, right: 40, bottom: 30 }, + [TABLE_VIEW_MODE]: { top: 220, left: 40, right: 40, bottom: 30 }, [RESOURCE_VIEW_MODE]: { top: 140, left: 210, right: 40, bottom: 150 }, }; diff --git a/client/app/scripts/constants/timer.js b/client/app/scripts/constants/timer.js index fdb9d2e069..65107e23cf 100644 --- a/client/app/scripts/constants/timer.js +++ b/client/app/scripts/constants/timer.js @@ -1,7 +1,6 @@ /* Intervals in ms */ export const API_REFRESH_INTERVAL = 30000; export const TOPOLOGY_REFRESH_INTERVAL = 5000; -export const NODES_DELTA_BUFFER_FEED_INTERVAL = 1000; export const TOPOLOGY_LOADER_DELAY = 100; @@ -10,5 +9,4 @@ export const VIEWPORT_RESIZE_DEBOUNCE_INTERVAL = 200; export const ZOOM_CACHE_DEBOUNCE_INTERVAL = 500; export const TIMELINE_DEBOUNCE_INTERVAL = 500; -export const TIMELINE_TICK_INTERVAL = 100; -export const TIMELINE_SLIDER_UPDATE_INTERVAL = 10000; +export const TIMELINE_TICK_INTERVAL = 500; diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index f1f2a164ce..81e6b60075 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -20,7 +20,6 @@ import { isResourceViewModeSelector, } from '../selectors/topology'; import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming'; -import { consolidateNodesDeltas } from '../utils/nodes-delta-utils'; import { applyPinnedSearches } from '../utils/search-utils'; import { findTopologyById, @@ -58,12 +57,12 @@ export const initialState = makeMap({ mouseOverNodeId: null, nodeDetails: makeOrderedMap(), // nodeId -> details nodes: makeOrderedMap(), // nodeId -> node - nodesDeltaBuffer: makeList(), nodesLoaded: false, // nodes cache, infrequently updated, used for search & resource view nodesByTopology: makeMap(), // topologyId -> nodes // class of metric, e.g. 'cpu', rather than 'host_cpu' or 'process_cpu'. // allows us to keep the same metric "type" selected when the topology changes. + pausedAt: null, pinnedMetricType: null, pinnedNetwork: null, plugins: makeList(), @@ -74,16 +73,15 @@ export const initialState = makeMap({ selectedNetwork: null, selectedNodeId: null, showingHelp: false, + showingTimeTravel: false, showingTroubleshootingMenu: false, showingNetworks: false, timeTravelTransitioning: false, - timeTravelMillisecondsInPast: 0, topologies: makeList(), topologiesLoaded: false, topologyOptions: makeOrderedMap(), // topologyId -> options topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl topologyViewMode: GRAPH_VIEW_MODE, - updatePausedAt: null, version: '...', versionUpdate: null, viewport: makeMap(), @@ -174,16 +172,39 @@ function closeAllNodeDetails(state) { return state; } -function resumeUpdate(state) { - return state.set('updatePausedAt', null); -} - function clearNodes(state) { return state .update('nodes', nodes => nodes.clear()) .set('nodesLoaded', false); } +// TODO: These state changes should probably be calculated from selectors. +function updateStateFromNodes(state) { + // Apply pinned searches, filters nodes that dont match. + state = applyPinnedSearches(state); + + // In case node or edge disappears before mouseleave event. + const nodesIds = state.get('nodes').keySeq(); + if (!nodesIds.contains(state.get('mouseOverNodeId'))) { + state = state.set('mouseOverNodeId', null); + } + if (!nodesIds.some(nodeId => includes(state.get('mouseOverEdgeId'), nodeId))) { + state = state.set('mouseOverEdgeId', null); + } + + // Update the nodes cache only if we're not in the resource view mode, as we + // intentionally want to keep it static before we figure how to keep it up-to-date. + if (!isResourceViewModeSelector(state)) { + const nodesForCurrentTopologyKey = ['nodesByTopology', state.get('currentTopologyId')]; + state = state.setIn(nodesForCurrentTopologyKey, state.get('nodes')); + } + + // Clear the error. + state = state.set('errorUrl', null); + + return state; +} + export function rootReducer(state = initialState, action) { if (!action.type) { error('Payload missing a type!', action); @@ -195,7 +216,6 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.CHANGE_TOPOLOGY_OPTION: { - state = resumeUpdate(state); // set option on parent topology const topology = findTopologyById(state.get('topologies'), action.topologyId); if (topology) { @@ -289,11 +309,6 @@ export function rootReducer(state = initialState, action) { return state; } - case ActionTypes.CLICK_PAUSE_UPDATE: { - const millisecondsInPast = state.get('timeTravelMillisecondsInPast'); - return state.set('updatePausedAt', moment().utc().subtract(millisecondsInPast)); - } - case ActionTypes.CLICK_RELATIVE: { if (state.hasIn(['nodeDetails', action.nodeId])) { // bring to front @@ -313,13 +328,7 @@ export function rootReducer(state = initialState, action) { return state; } - case ActionTypes.CLICK_RESUME_UPDATE: { - return resumeUpdate(state); - } - case ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE: { - state = resumeUpdate(state); - state = state.update('nodeDetails', nodeDetails => nodeDetails.filter((v, k) => k === action.nodeId)); state = state.update('controlPipes', controlPipes => controlPipes.clear()); @@ -334,7 +343,6 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.CLICK_TOPOLOGY: { - state = resumeUpdate(state); state = closeAllNodeDetails(state); const currentTopologyId = state.get('currentTopologyId'); @@ -347,15 +355,37 @@ export function rootReducer(state = initialState, action) { } // - // time travel + // time control // + case ActionTypes.RESUME_TIME: { + state = state.set('timeTravelTransitioning', true); + state = state.set('showingTimeTravel', false); + return state.set('pausedAt', null); + } + + case ActionTypes.PAUSE_TIME_AT_NOW: { + state = state.set('showingTimeTravel', false); + return state.set('pausedAt', moment().utc()); + } + + case ActionTypes.START_TIME_TRAVEL: { + state = state.set('timeTravelTransitioning', false); + state = state.set('showingTimeTravel', true); + return state.set('pausedAt', moment().utc()); + } + + case ActionTypes.JUMP_TO_TIME: { + return state.set('pausedAt', action.timestamp); + } + case ActionTypes.TIME_TRAVEL_START_TRANSITION: { return state.set('timeTravelTransitioning', true); } - case ActionTypes.TIME_TRAVEL_MILLISECONDS_IN_PAST: { - return state.set('timeTravelMillisecondsInPast', action.millisecondsInPast); + case ActionTypes.FINISH_TIME_TRAVEL_TRANSITION: { + state = state.set('timeTravelTransitioning', false); + return clearNodes(state); } // @@ -370,29 +400,6 @@ export function rootReducer(state = initialState, action) { return state.set('websocketClosed', true); } - // - // nodes delta buffer - // - - case ActionTypes.CLEAR_NODES_DELTA_BUFFER: { - return state.update('nodesDeltaBuffer', buffer => buffer.clear()); - } - - case ActionTypes.CONSOLIDATE_NODES_DELTA_BUFFER: { - const firstDelta = state.getIn(['nodesDeltaBuffer', 0]); - const secondDelta = state.getIn(['nodesDeltaBuffer', 1]); - const deltaUnion = consolidateNodesDeltas(firstDelta, secondDelta); - return state.update('nodesDeltaBuffer', buffer => buffer.shift().set(0, deltaUnion)); - } - - case ActionTypes.POP_NODES_DELTA_BUFFER: { - return state.update('nodesDeltaBuffer', buffer => buffer.shift()); - } - - case ActionTypes.BUFFER_NODES_DELTA: { - return state.update('nodesDeltaBuffer', buffer => buffer.push(action.delta)); - } - // // networks // @@ -534,6 +541,11 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.RECEIVE_NODE_DETAILS: { + // Freeze node details data updates after the first load when paused. + if (state.getIn(['nodeDetails', action.details.id, 'details']) && state.get('pausedAt')) { + return state; + } + state = state.set('errorUrl', null); // disregard if node is not selected anymore @@ -562,31 +574,18 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.RECEIVE_NODES_DELTA: { + // Ignore periodic nodes updates after the first load when paused. + if (state.get('nodesLoaded') && state.get('pausedAt')) { + return state; + } + log('RECEIVE_NODES_DELTA', 'remove', size(action.delta.remove), 'update', size(action.delta.update), 'add', size(action.delta.add)); - state = state.set('errorUrl', null); - - // When moving in time, we will consider the transition complete - // only when the first batch of nodes delta has been received. We - // do that because we want to keep the previous state blurred instead - // of transitioning over an empty state like when switching topologies. - if (state.get('timeTravelTransitioning')) { - state = state.set('timeTravelTransitioning', false); - state = clearNodes(state); - } - - // nodes that no longer exist + // remove nodes that no longer exist each(action.delta.remove, (nodeId) => { - // in case node disappears before mouseleave event - if (state.get('mouseOverNodeId') === nodeId) { - state = state.set('mouseOverNodeId', null); - } - if (state.hasIn(['nodes', nodeId]) && includes(state.get('mouseOverEdgeId'), nodeId)) { - state = state.set('mouseOverEdgeId', null); - } state = state.deleteIn(['nodes', nodeId]); }); @@ -605,17 +604,14 @@ export function rootReducer(state = initialState, action) { state = state.setIn(['nodes', node.id], fromJS(node)); }); - // apply pinned searches, filters nodes that dont match - state = applyPinnedSearches(state); - - // Update the nodes cache only if we're not in the resource view mode, as we - // intentionally want to keep it static before we figure how to keep it up-to-date. - if (!isResourceViewModeSelector(state)) { - const nodesForCurrentTopologyKey = ['nodesByTopology', state.get('currentTopologyId')]; - state = state.setIn(nodesForCurrentTopologyKey, state.get('nodes')); - } + return updateStateFromNodes(state); + } - return state; + case ActionTypes.RECEIVE_NODES: { + state = state.set('timeTravelTransitioning', false); + state = state.set('nodes', fromJS(action.nodes)); + state = state.set('nodesLoaded', true); + return updateStateFromNodes(state); } case ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY: { @@ -737,8 +733,7 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.SHUTDOWN: { - state = clearNodes(state); - return state.set('nodesLoaded', false); + return clearNodes(state); } case ActionTypes.REQUEST_SERVICE_IMAGES: { diff --git a/client/app/scripts/selectors/time-travel.js b/client/app/scripts/selectors/time-travel.js index 141543697a..40a70772a8 100644 --- a/client/app/scripts/selectors/time-travel.js +++ b/client/app/scripts/selectors/time-travel.js @@ -3,15 +3,7 @@ import { createSelector } from 'reselect'; export const isPausedSelector = createSelector( [ - state => state.get('updatePausedAt') + state => state.get('pausedAt') ], - updatePausedAt => updatePausedAt !== null -); - -export const isNowSelector = createSelector( - [ - state => state.get('timeTravelMillisecondsInPast') - ], - // true for values 0, undefined, null, etc... - timeTravelMillisecondsInPast => !(timeTravelMillisecondsInPast > 0) + pausedAt => !!pausedAt ); diff --git a/client/app/scripts/utils/__tests__/web-api-utils-test.js b/client/app/scripts/utils/__tests__/web-api-utils-test.js index 4b8257bf0b..9b22cedb53 100644 --- a/client/app/scripts/utils/__tests__/web-api-utils-test.js +++ b/client/app/scripts/utils/__tests__/web-api-utils-test.js @@ -1,4 +1,4 @@ -import MockDate from 'mockdate'; +import moment from 'moment'; import { Map as makeMap, OrderedMap as makeOrderedMap } from 'immutable'; import { buildUrlQuery, basePath, getApiPath, getWebsocketUrl } from '../web-api-utils'; @@ -26,20 +26,11 @@ describe('WebApiUtils', () => { describe('buildUrlQuery', () => { let state = makeMap(); - beforeEach(() => { - MockDate.set(1434319925275); - }); - - afterEach(() => { - MockDate.reset(); - }); - it('should handle empty options', () => { expect(buildUrlQuery(makeOrderedMap([]), state)).toBe(''); }); it('should combine multiple options', () => { - state = state.set('timeTravelMillisecondsInPast', 0); expect(buildUrlQuery(makeOrderedMap([ ['foo', 2], ['bar', 4] @@ -47,7 +38,7 @@ describe('WebApiUtils', () => { }); it('should combine multiple options with a timestamp', () => { - state = state.set('timeTravelMillisecondsInPast', 60 * 60 * 1000); // 1h in the past + state = state.set('pausedAt', moment('2015-06-14T21:12:05.275Z')); expect(buildUrlQuery(makeOrderedMap([ ['foo', 2], ['bar', 4] diff --git a/client/app/scripts/utils/nodes-delta-utils.js b/client/app/scripts/utils/nodes-delta-utils.js deleted file mode 100644 index 0c1f01983c..0000000000 --- a/client/app/scripts/utils/nodes-delta-utils.js +++ /dev/null @@ -1,60 +0,0 @@ -import debug from 'debug'; -import { union, size, map, find, reject, each } from 'lodash'; - -const log = debug('scope:nodes-delta-utils'); - - -// 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); - let toRemove = union(first.remove, second.remove); - log('Consolidating delta buffer', - 'add', size(toAdd), - 'update', size(toUpdate), - 'remove', size(toRemove)); - - // check if an added node in first was updated in second -> add second update - toAdd = map(toAdd, (node) => { - const updateNode = find(second.update, {id: node.id}); - if (updateNode) { - toUpdate = reject(toUpdate, {id: node.id}); - return updateNode; - } - return node; - }); - - // check if an updated node in first was updated in second -> updated second update - // no action needed, successive updates are fine - - // check if an added node in first was removed in second -> dont add, dont remove - each(first.add, (node) => { - const removedNode = find(second.remove, {id: node.id}); - if (removedNode) { - toAdd = reject(toAdd, {id: node.id}); - toRemove = reject(toRemove, {id: node.id}); - } - }); - - // check if an updated node in first was removed in second -> remove - each(first.update, (node) => { - const removedNode = find(second.remove, {id: node.id}); - if (removedNode) { - toUpdate = reject(toUpdate, {id: node.id}); - } - }); - - // check if an removed node in first was added in second -> update - // remove -> add is fine for the store - - log('Consolidated delta buffer', - 'add', size(toAdd), - 'update', size(toUpdate), - 'remove', size(toRemove)); - - return { - add: toAdd.length > 0 ? toAdd : null, - update: toUpdate.length > 0 ? toUpdate : null, - remove: toRemove.length > 0 ? toRemove : null - }; -} diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js index e67287de6d..efce0f6d12 100644 --- a/client/app/scripts/utils/topology-utils.js +++ b/client/app/scripts/utils/topology-utils.js @@ -1,7 +1,7 @@ import { endsWith } from 'lodash'; import { Set as makeSet, List as makeList } from 'immutable'; -import { isNowSelector } from '../selectors/time-travel'; +import { isPausedSelector } from '../selectors/time-travel'; import { isResourceViewModeSelector } from '../selectors/topology'; import { pinnedMetricSelector } from '../selectors/node-metric'; import { shownNodesSelector, shownResourceTopologyIdsSelector } from '../selectors/node-filters'; @@ -139,7 +139,7 @@ export function isTopologyNodeCountZero(state) { const nodeCount = state.getIn(['currentTopology', 'stats', 'node_count'], 0); // If we are browsing the past, assume there would normally be some nodes at different times. // If we are in the resource view, don't rely on these stats at all (for now). - return nodeCount === 0 && isNowSelector(state) && !isResourceViewModeSelector(state); + return nodeCount === 0 && !isPausedSelector(state) && !isResourceViewModeSelector(state); } export function isNodesDisplayEmpty(state) { diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 950b5af4e7..88406b7099 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -1,5 +1,4 @@ import debug from 'debug'; -import moment from 'moment'; import reqwest from 'reqwest'; import { defaults } from 'lodash'; import { Map as makeMap, List } from 'immutable'; @@ -8,12 +7,12 @@ import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveEr receiveApiDetails, receiveNodesDelta, receiveNodeDetails, receiveControlError, receiveControlNodeRemoved, receiveControlPipe, receiveControlPipeStatus, receiveControlSuccess, receiveTopologies, receiveNotFound, - receiveNodesForTopology } from '../actions/app-actions'; + receiveNodesForTopology, receiveNodes } from '../actions/app-actions'; import { getCurrentTopologyUrl } from '../utils/topology-utils'; import { layersTopologyIdsSelector } from '../selectors/resource-view/layout'; import { activeTopologyOptionsSelector } from '../selectors/topology'; -import { isNowSelector } from '../selectors/time-travel'; +import { isPausedSelector } from '../selectors/time-travel'; import { API_REFRESH_INTERVAL, TOPOLOGY_REFRESH_INTERVAL } from '../constants/timer'; @@ -50,10 +49,9 @@ let continuePolling = true; export function getSerializedTimeTravelTimestamp(state) { // The timestamp parameter will be used only if it's in the past. - if (isNowSelector(state)) return null; + if (!isPausedSelector(state)) return null; - const millisecondsInPast = state.get('timeTravelMillisecondsInPast'); - return moment().utc().subtract(millisecondsInPast).toISOString(); + return state.get('pausedAt').toISOString(); } export function buildUrlQuery(params = makeMap(), state) { @@ -114,7 +112,7 @@ function buildWebsocketUrl(topologyUrl, topologyOptions = makeMap(), state) { return `${getWebsocketUrl()}${topologyUrl}/ws?${optionsQuery}`; } -function createWebsocket(websocketUrl, dispatch) { +function createWebsocket(websocketUrl, getState, dispatch) { if (socket) { socket.onclose = null; socket.onerror = null; @@ -140,9 +138,9 @@ function createWebsocket(websocketUrl, dispatch) { socket = null; dispatch(closeWebsocket()); - if (continuePolling) { + if (continuePolling && !isPausedSelector(getState())) { reconnectTimer = setTimeout(() => { - createWebsocket(websocketUrl, dispatch); + createWebsocket(websocketUrl, getState, dispatch); }, reconnectTimerInterval); } }; @@ -199,6 +197,24 @@ function getNodesForTopologies(state, dispatch, topologyIds, topologyOptions = m Promise.resolve()); } +function getNodesOnce(getState, dispatch) { + const state = getState(); + const topologyUrl = getCurrentTopologyUrl(state); + const topologyOptions = activeTopologyOptionsSelector(state); + const optionsQuery = buildUrlQuery(topologyOptions, state); + const url = `${getApiPath()}${topologyUrl}?${optionsQuery}`; + doRequest({ + url, + success: (res) => { + dispatch(receiveNodes(res.nodes)); + }, + error: (req) => { + log(`Error in nodes request: ${req.responseText}`); + dispatch(receiveError(url)); + } + }); +} + /** * Gets nodes for all topologies (for search). */ @@ -217,21 +233,20 @@ export function getResourceViewNodesSnapshot(state, dispatch) { getNodesForTopologies(state, dispatch, topologyIds); } -export function getTopologies(state, dispatch, initialPoll = false) { - // TODO: Remove this once TimeTravel is out of the feature flag. - state = state.scope || state; +// NOTE: getState is called every time to make sure the up-to-date state is used. +export function getTopologies(getState, dispatch, initialPoll = false) { // Used to resume polling when navigating between pages in Weave Cloud. continuePolling = initialPoll === true ? true : continuePolling; clearTimeout(topologyTimer); - const optionsQuery = buildUrlQuery(activeTopologyOptionsSelector(state), state); + const optionsQuery = buildUrlQuery(activeTopologyOptionsSelector(getState()), getState()); const url = `${getApiPath()}/api/topology?${optionsQuery}`; doRequest({ url, success: (res) => { - if (continuePolling) { + if (continuePolling && !isPausedSelector(getState())) { dispatch(receiveTopologies(res)); topologyTimer = setTimeout(() => { - getTopologies(state, dispatch); + getTopologies(getState, dispatch); }, TOPOLOGY_REFRESH_INTERVAL); } }, @@ -239,30 +254,31 @@ export function getTopologies(state, dispatch, initialPoll = false) { log(`Error in topology request: ${req.responseText}`); dispatch(receiveError(url)); // Only retry in stand-alone mode - if (continuePolling) { + if (continuePolling && !isPausedSelector(getState())) { topologyTimer = setTimeout(() => { - getTopologies(state, dispatch); + getTopologies(getState, dispatch); }, TOPOLOGY_REFRESH_INTERVAL); } } }); } -export function updateWebsocketChannel(state, dispatch) { - const topologyUrl = getCurrentTopologyUrl(state); - const topologyOptions = activeTopologyOptionsSelector(state); - const websocketUrl = buildWebsocketUrl(topologyUrl, topologyOptions, state); +function updateWebsocketChannel(getState, dispatch, forceRequest) { + const topologyUrl = getCurrentTopologyUrl(getState()); + const topologyOptions = activeTopologyOptionsSelector(getState()); + const websocketUrl = buildWebsocketUrl(topologyUrl, topologyOptions, getState()); // Only recreate websocket if url changed or if forced (weave cloud instance reload); const isNewUrl = websocketUrl !== currentUrl; // `topologyUrl` can be undefined initially, so only create a socket if it is truthy // and no socket exists, or if we get a new url. - if (topologyUrl && (!socket || isNewUrl)) { - createWebsocket(websocketUrl, dispatch); + if (topologyUrl && (!socket || isNewUrl || forceRequest)) { + createWebsocket(websocketUrl, getState, dispatch); currentUrl = websocketUrl; } } -export function getNodeDetails(state, dispatch) { +export function getNodeDetails(getState, dispatch) { + const state = getState(); const nodeMap = state.get('nodeDetails'); const topologyUrlsById = state.get('topologyUrlsById'); const currentTopologyId = state.get('currentTopologyId'); @@ -302,6 +318,15 @@ export function getNodeDetails(state, dispatch) { } } +export function getNodes(getState, dispatch, forceRequest = false) { + if (isPausedSelector(getState())) { + getNodesOnce(getState, dispatch); + } else { + updateWebsocketChannel(getState, dispatch, forceRequest); + } + getNodeDetails(getState, dispatch); +} + export function getApiDetails(dispatch) { clearTimeout(apiDetailsTimer); const url = `${getApiPath()}/api`; diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 2344836089..bcc62fcf98 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -64,7 +64,12 @@ pointer-events: none; z-index: 2000; - &.faded { opacity: 0.5; } + &.faded { + // NOTE: Not sure if we should block the pointer events here.. + pointer-events: all; + cursor: wait; + opacity: 0.5; + } } .overlay-wrapper { @@ -165,22 +170,31 @@ margin-bottom: 12px; font-weight: 400; } + + &.time-travel-open { + .details-wrapper { + margin-top: 65px; + } + } } .header { + margin-top: 38px; pointer-events: none; - - position: absolute; - top: 32px; width: 100%; - height: 80px; - z-index: 20; - display: flex; - .logo { - margin: -10px 0 0 64px; - height: 64px; - width: 250px; + .selectors { + display: flex; + position: relative; + > * { z-index: 20; } + + .time-control { z-index: 2001; } + + .logo { + margin: -16px 0 0 64px; + height: 64px; + width: 250px; + } } } @@ -225,67 +239,94 @@ } .time-travel { - @extend .overlay-wrapper; - display: block; - right: 530px; + align-items: center; + display: flex; + position: relative; + margin: 0 30px 0 15px; z-index: 2001; - &-status { - display: flex; - align-items: center; - justify-content: flex-end; + transition: all .15s $base-ease; + overflow: hidden; + height: 0; - .time-travel-jump-loader { - font-size: 1rem; - } + &.visible { + height: 50px; + margin-bottom: 15px; + } - .time-travel-timestamp-info, .pause-text { - font-size: 115%; - margin-right: 5px; - } + &-markers { + position: relative; + + &-tick { + text-align: center; + position: absolute; - .button { margin-left: 0.5em; } + .vertical-tick { + border: 1px solid $text-tertiary-color; + border-radius: 1px; + display: block; + margin: 1px auto 2px; + height: 12px; + width: 0; + } - .time-travel-timestamp:not(.current) { - & > * { @extend .blinkable; } - font-weight: bold; + .link { + display: inline-block; + pointer-events: all; + margin-top: 1px; + } } } - &-slider { - width: 355px; + &-slider-wrapper { + margin: 0 50px 20px 10px; + pointer-events: all; + flex-grow: 1; - .slider-tip { - display: inline-block; - font-size: 0.8125rem; - font-style: italic; - padding: 5px 10px; - } + .rc-slider-rail { background-color: $text-tertiary-color; } + } - .options { - display: flex; - padding: 2px 0 10px; + &-jump-controls { + display: flex; - .column { - display: flex; - flex-direction: column; - flex-grow: 1; - padding: 0 7px; + .button.jump { + display: block; + margin: 8px; + font-size: 0.625rem; + pointer-events: all; + text-align: center; + text-transform: uppercase; + word-spacing: -1px; - a { padding: 0 3px; } + .fa { + display: block; + font-size: 150%; + margin-bottom: 3px; } } - .rc-slider { - margin: 0 10px 8px; - width: auto; + &-timestamp { + border: 1px solid #ccc; + border-radius: 4px; + padding: 2px 8px; + pointer-events: all; + margin: 4px 8px 25px; + + input { + border: 0; + background-color: transparent; + font-family: $mono-font; + font-size: 0.875rem; + margin-right: 2px; + outline: 0; + } } } } .topologies { - margin: 8px 4px; + margin: 0 4px; display: flex; .topologies-item { @@ -630,9 +671,9 @@ display: flex; z-index: 1024; right: $details-window-padding-left; - top: 24px; + top: 100px; bottom: 48px; - transition: transform 0.33333s cubic-bezier(0,0,0.21,1); + transition: transform 0.33333s cubic-bezier(0,0,0.21,1), margin-top .15s $base-ease; } } @@ -1357,9 +1398,9 @@ } } -.topology-option, .metric-selector, .network-selector, .view-mode-selector { +.topology-option, .metric-selector, .network-selector, .view-mode-selector, .time-control { color: $text-secondary-color; - margin: 6px 0; + margin-bottom: 6px; &:last-child { margin-bottom: 0; @@ -1403,8 +1444,11 @@ } } -.view-mode-selector { - margin-top: 8px; +.metric-selector { + margin-top: 6px; +} + +.view-mode-selector, .time-control { margin-left: 20px; min-width: 161px; @@ -1435,6 +1479,36 @@ } } +.time-control { + position: absolute; + right: 36px; + + &-controls { + align-items: center; + justify-content: flex-end; + display: flex; + } + + &-spinner { + display: inline-block; + margin-right: 15px; + margin-top: 3px; + + .fa { + color: $text-secondary-color; + font-size: 125%; + } + } + + &-info { + @extend .blinkable; + display: block; + margin-top: 5px; + text-align: right; + pointer-events: all; + } +} + .topology-option { &-action { &-selected { @@ -1443,7 +1517,7 @@ } } -.view-mode-selector-wrapper { +.view-mode-selector-wrapper, .time-control-wrapper { .label { margin-left: 4px; } .fa { margin-left: 0; @@ -1519,7 +1593,7 @@ &-wrapper { flex: 0 1 20%; - margin: 8px; + margin: 0 8px; text-align: right; } @@ -1884,6 +1958,9 @@ // .nodes-grid { + // TODO: Would be good to have relative positioning here. + position: absolute; + top: 0; tr { border-radius: 6px;