From d8e87980c7e8c2041ade2a57f98165593cc1e5ce Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Thu, 22 Jun 2017 17:31:25 +0200 Subject: [PATCH 01/23] Initial top level control. --- client/app/scripts/components/app.js | 8 +- client/app/scripts/components/time-travel.js | 177 ++----------------- client/app/styles/_base.scss | 16 +- 3 files changed, 29 insertions(+), 172 deletions(-) diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 1b01062cc2..38ee57d0e2 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -180,6 +180,10 @@ class App extends React.Component { {showingDetails &&
} + + + +
{!isIframe && @@ -193,10 +197,6 @@ class App extends React.Component { - - {!isResourceViewMode && } - - {showingNetworkSelector && isGraphViewMode && } {!isResourceViewMode && } diff --git a/client/app/scripts/components/time-travel.js b/client/app/scripts/components/time-travel.js index 282e7dc634..ada46b702e 100644 --- a/client/app/scripts/components/time-travel.js +++ b/client/app/scripts/components/time-travel.js @@ -1,7 +1,5 @@ import React from 'react'; -import moment from 'moment'; import Slider from 'rc-slider'; -import classNames from 'classnames'; import { connect } from 'react-redux'; import { debounce } from 'lodash'; @@ -10,7 +8,6 @@ import { trackMixpanelEvent } from '../utils/tracking-utils'; import { timeTravelJumpToPast, timeTravelStartTransition, - clickResumeUpdate, } from '../actions/app-actions'; import { @@ -19,56 +16,9 @@ import { } 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'), - }, -}; +function getRangeMilliseconds() { + return 90 * 24 * 60 * 60 * 1000; +} class TimeTravel extends React.Component { constructor(props, context) { @@ -77,12 +27,9 @@ class TimeTravel extends React.Component { this.state = { showSliderPanel: false, millisecondsInPast: 0, - rangeOptionSelected: sliderRanges.last1Hour, }; - 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( @@ -102,7 +49,7 @@ class TimeTravel extends React.Component { } handleSliderChange(sliderValue) { - let millisecondsInPast = this.getRangeMilliseconds() - sliderValue; + let millisecondsInPast = 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' @@ -118,59 +65,16 @@ class TimeTravel extends React.Component { 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, - }); - } - - handleJumpToNowClick() { - this.setState({ - showSliderPanel: false, - millisecondsInPast: 0, - rangeOptionSelected: sliderRanges.last1Hour, - }); - this.updateTimestamp(); - this.props.timeTravelStartTransition(); - - trackMixpanelEvent('scope.time.now.click', { - 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, }); } updateTimestamp(millisecondsInPast = 0) { this.props.timeTravelJumpToPast(millisecondsInPast); - this.props.clickResumeUpdate(); - } - - getRangeMilliseconds(rangeOption = this.state.rangeOptionSelected) { - return moment().diff(rangeOption.getStart()); } trackSliderChange() { @@ -181,75 +85,24 @@ 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 }); - - return ( - - {rangeOption.label} - - ); - } - - renderJumpToNowButton() { - return ( - - - - ); - } - - renderTimeSlider() { - const { millisecondsInPast } = this.state; - const rangeMilliseconds = this.getRangeMilliseconds(); - - return ( - - ); - } - render() { const { timeTravelTransitioning, hasTimeTravel } = this.props; - const { showSliderPanel, millisecondsInPast, rangeOptionSelected } = this.state; - const lowerCaseLabel = rangeOptionSelected.label.toLowerCase(); - const isCurrent = (millisecondsInPast === 0); + const { showSliderPanel, millisecondsInPast } = this.state; + const rangeMilliseconds = getRangeMilliseconds(); // Don't render the time travel control if it's not explicitly enabled for this instance. if (!hasTimeTravel) return null; 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()} -
} -
+
+ +
+
{timeTravelTransitioning &&
} @@ -258,7 +111,6 @@ class TimeTravel extends React.Component { millisecondsInPast={millisecondsInPast} selected={showSliderPanel} /> - {!isCurrent && this.renderJumpToNowButton()}
); @@ -281,6 +133,5 @@ export default connect( { timeTravelJumpToPast, timeTravelStartTransition, - clickResumeUpdate, } )(TimeTravel); diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 2344836089..92b8c4d119 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -171,7 +171,7 @@ pointer-events: none; position: absolute; - top: 32px; + top: 60px; width: 100%; height: 80px; z-index: 20; @@ -225,10 +225,16 @@ } .time-travel { - @extend .overlay-wrapper; - display: block; - right: 530px; - z-index: 2001; + display: flex; + position: relative; + padding: 15px; + width: 100%; + z-index: 50; + + &-slider-wrapper { + flex-grow: 1; + margin-right: 50px; + } &-status { display: flex; From 1bc2bf95f67ed0da540ebd51e5d194ad3d9cb0f8 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Thu, 22 Jun 2017 18:52:43 +0200 Subject: [PATCH 02/23] Added the jump buttons. --- .../components/time-travel-timestamp.js | 58 ------------------- client/app/scripts/components/time-travel.js | 46 +++++++++++---- client/app/scripts/constants/timer.js | 3 +- client/app/styles/_base.scss | 24 +++++++- 4 files changed, 60 insertions(+), 71 deletions(-) delete mode 100644 client/app/scripts/components/time-travel-timestamp.js 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 ada46b702e..370fd65686 100644 --- a/client/app/scripts/components/time-travel.js +++ b/client/app/scripts/components/time-travel.js @@ -1,9 +1,9 @@ import React from 'react'; import Slider from 'rc-slider'; +import moment from 'moment'; import { connect } from 'react-redux'; import { debounce } from 'lodash'; -import TimeTravelTimestamp from './time-travel-timestamp'; import { trackMixpanelEvent } from '../utils/tracking-utils'; import { timeTravelJumpToPast, @@ -11,13 +11,16 @@ import { } from '../actions/app-actions'; import { - TIMELINE_SLIDER_UPDATE_INTERVAL, + TIMELINE_TICK_INTERVAL, TIMELINE_DEBOUNCE_INTERVAL, } from '../constants/timer'; +const ONE_HOUR_MS = 60 * 60 * 1000; +const FIVE_MINUTES_MS = 5 * 60 * 1000; + function getRangeMilliseconds() { - return 90 * 24 * 60 * 60 * 1000; + return 90 * 24 * ONE_HOUR_MS; } class TimeTravel extends React.Component { @@ -31,6 +34,7 @@ class TimeTravel extends React.Component { this.handleTimestampClick = this.handleTimestampClick.bind(this); this.handleSliderChange = this.handleSliderChange.bind(this); + this.jumpInTime = this.jumpInTime.bind(this); this.debouncedUpdateTimestamp = debounce( this.updateTimestamp.bind(this), TIMELINE_DEBOUNCE_INTERVAL); @@ -40,7 +44,7 @@ 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); } componentWillUnmount() { @@ -77,6 +81,15 @@ class TimeTravel extends React.Component { this.props.timeTravelJumpToPast(millisecondsInPast); } + jumpInTime(millisecondsDelta) { + let millisecondsInPast = this.state.millisecondsInPast - millisecondsDelta; + millisecondsInPast = Math.min(millisecondsInPast, getRangeMilliseconds()); + millisecondsInPast = Math.max(millisecondsInPast, 0); + this.debouncedUpdateTimestamp(millisecondsInPast); + this.props.timeTravelStartTransition(); + this.setState({ millisecondsInPast }); + } + trackSliderChange() { trackMixpanelEvent('scope.time.slider.change', { layout: this.props.topologyViewMode, @@ -86,8 +99,9 @@ class TimeTravel extends React.Component { } render() { + const { millisecondsInPast } = this.state; const { timeTravelTransitioning, hasTimeTravel } = this.props; - const { showSliderPanel, millisecondsInPast } = this.state; + const timestamp = moment().utc().subtract(millisecondsInPast); const rangeMilliseconds = getRangeMilliseconds(); // Don't render the time travel control if it's not explicitly enabled for this instance. @@ -106,11 +120,23 @@ class TimeTravel extends React.Component { {timeTravelTransitioning &&
} - + this.jumpInTime(-ONE_HOUR_MS)}> + 1 hour + + this.jumpInTime(-FIVE_MINUTES_MS)}> + 5 mins + + + + + + + this.jumpInTime(FIVE_MINUTES_MS)}> + 5 mins + + this.jumpInTime(ONE_HOUR_MS)}> + 1 hour +
); diff --git a/client/app/scripts/constants/timer.js b/client/app/scripts/constants/timer.js index fdb9d2e069..4646b74e43 100644 --- a/client/app/scripts/constants/timer.js +++ b/client/app/scripts/constants/timer.js @@ -10,5 +10,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/styles/_base.scss b/client/app/styles/_base.scss index 92b8c4d119..ed2e6505f0 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -225,17 +225,39 @@ } .time-travel { + align-items: center; display: flex; position: relative; padding: 15px; width: 100%; - z-index: 50; + z-index: 2001; &-slider-wrapper { flex-grow: 1; margin-right: 50px; } + &-timestamp-controls-wrapper { + align-items: center; + display: flex; + + .button.jump { + display: block; + margin: 8px; + font-size: 0.625rem; + text-align: center; + text-transform: uppercase; + word-spacing: -1px; + + .fa { + display: block; + font-size: 150%; + margin-bottom: 3px; + } + } + } + + // blu &-status { display: flex; align-items: center; From d090beb376eb72189003da76e3f42e375da6e799 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Mon, 26 Jun 2017 22:07:54 +0200 Subject: [PATCH 03/23] Tiny styling adjustments. --- client/app/scripts/components/time-travel.js | 10 ++++----- client/app/styles/_base.scss | 22 +++++++++++++++++--- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/client/app/scripts/components/time-travel.js b/client/app/scripts/components/time-travel.js index 370fd65686..61f02d533b 100644 --- a/client/app/scripts/components/time-travel.js +++ b/client/app/scripts/components/time-travel.js @@ -116,7 +116,7 @@ class TimeTravel extends React.Component { max={rangeMilliseconds} />
-
+
{timeTravelTransitioning &&
} @@ -126,11 +126,9 @@ class TimeTravel extends React.Component { this.jumpInTime(-FIVE_MINUTES_MS)}> 5 mins - - - - - + + UTC + this.jumpInTime(FIVE_MINUTES_MS)}> 5 mins diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index ed2e6505f0..a16e2d8a0a 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -228,17 +228,17 @@ align-items: center; display: flex; position: relative; - padding: 15px; + padding: 15px 20px; width: 100%; z-index: 2001; &-slider-wrapper { flex-grow: 1; margin-right: 50px; + margin-bottom: 20px; } - &-timestamp-controls-wrapper { - align-items: center; + &-jump-controls { display: flex; .button.jump { @@ -255,6 +255,22 @@ margin-bottom: 3px; } } + + &-timestamp { + border: 1px solid #ccc; + border-radius: 4px; + padding: 2px 8px; + margin: 4px 8px 25px; + + input { + border: 0; + background-color: transparent; + font-family: $mono-font; + font-size: 0.875rem; + margin-right: 2px; + outline: 0; + } + } } // blu From b4250d7b20e0bda2b3f09ca82d14a56102351bad Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Wed, 28 Jun 2017 14:55:37 +0200 Subject: [PATCH 04/23] Massive renaming. --- client/app/scripts/actions/app-actions.js | 24 +++- client/app/scripts/components/app.js | 2 + client/app/scripts/components/pause-button.js | 6 +- client/app/scripts/components/status.js | 4 +- client/app/scripts/components/time-control.js | 108 ++++++++++++++++++ client/app/scripts/components/time-travel.js | 88 +++++++------- client/app/scripts/constants/action-types.js | 8 +- client/app/scripts/reducers/root.js | 57 ++++----- client/app/scripts/selectors/time-travel.js | 12 +- client/app/scripts/utils/topology-utils.js | 4 +- client/app/scripts/utils/web-api-utils.js | 8 +- client/app/styles/_base.scss | 26 ++++- 12 files changed, 244 insertions(+), 103 deletions(-) create mode 100644 client/app/scripts/components/time-control.js diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index a06479960e..01c4925a4c 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -350,7 +350,7 @@ export function clickNode(nodeId, label, origin) { export function clickPauseUpdate() { return { - type: ActionTypes.CLICK_PAUSE_UPDATE + type: ActionTypes.PAUSE_TIME_AT_NOW }; } @@ -410,11 +410,11 @@ export function timeTravelStartTransition() { }; } -export function timeTravelJumpToPast(millisecondsInPast) { +export function jumpToTime(timestamp) { return (dispatch, getServiceState) => { dispatch({ - type: ActionTypes.TIME_TRAVEL_MILLISECONDS_IN_PAST, - millisecondsInPast, + type: ActionTypes.JUMP_TO_TIME, + timestamp, }); const scopeState = getServiceState().scope; updateWebsocketChannel(scopeState, dispatch); @@ -589,6 +589,14 @@ export function receiveNodesDelta(delta) { const movingInTime = state.get('timeTravelTransitioning'); const hasChanges = delta.add || delta.update || delta.remove; + // 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')) { + dispatch({ type: ActionTypes.FINISH_TIME_TRAVEL_TRANSITION }); + } + if (hasChanges || movingInTime) { if (isPausedSelector(state)) { if (state.get('nodesDeltaBuffer').size >= NODES_DELTA_BUFFER_SIZE_LIMIT) { @@ -620,7 +628,7 @@ function updateFromNodesDeltaBuffer(dispatch, state) { export function clickResumeUpdate() { return (dispatch, getState) => { dispatch({ - type: ActionTypes.CLICK_RESUME_UPDATE + type: ActionTypes.RESUME_TIME_FROM_NOW }); // TODO: Find a better way to do this (see the comment above). const state = getState().scope || getState(); @@ -632,6 +640,12 @@ export function clickResumeUpdate() { }; } +export function clickTimeTravel() { + return { + type: ActionTypes.START_TIME_TRAVEL + }; +} + export function receiveNodesForTopology(nodes, topologyId) { return { type: ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY, diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 38ee57d0e2..0bf3ebd3fd 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -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'; @@ -193,6 +194,7 @@ class App extends React.Component { +
diff --git a/client/app/scripts/components/pause-button.js b/client/app/scripts/components/pause-button.js index 1348e206d8..df2fc5bf1c 100644 --- a/client/app/scripts/components/pause-button.js +++ b/client/app/scripts/components/pause-button.js @@ -35,11 +35,11 @@ class PauseButton extends React.Component { } render() { - const { isPaused, hasUpdates, updateCount, updatePausedAt } = this.props; + const { isPaused, hasUpdates, updateCount, pausedAt } = this.props; const className = classNames('button pause-button', { active: isPaused }); const title = isPaused ? - `Paused ${moment(updatePausedAt).fromNow()}` : + `Paused ${moment(pausedAt).fromNow()}` : 'Pause updates (freezes the nodes in their current layout)'; let label = ''; @@ -64,7 +64,7 @@ function mapStateToProps(state) { return { hasUpdates: !state.get('nodesDeltaBuffer').isEmpty(), updateCount: state.get('nodesDeltaBuffer').size, - updatePausedAt: state.get('updatePausedAt'), + pausedAt: state.get('pausedAt'), topologyViewMode: state.get('topologyViewMode'), currentTopology: state.get('currentTopology'), isPaused: isPausedSelector(state), 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..403ac0b10b --- /dev/null +++ b/client/app/scripts/components/time-control.js @@ -0,0 +1,108 @@ +import React from 'react'; +import classNames from 'classnames'; +import { connect } from 'react-redux'; + +// import CloudFeature from './cloud-feature'; +import { trackMixpanelEvent } from '../utils/tracking-utils'; +import { clickPauseUpdate, clickResumeUpdate, clickTimeTravel } 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); + } + + handleNowClick() { + trackMixpanelEvent('scope.time.resume.click', { + layout: this.props.topologyViewMode, + topologyId: this.props.currentTopology.get('id'), + parentTopologyId: this.props.currentTopology.get('parentId'), + nodesDeltaBufferSize: this.props.updateCount, + }); + this.props.clickResumeUpdate(); + } + + handlePauseClick() { + trackMixpanelEvent('scope.time.pause.click', { + layout: this.props.topologyViewMode, + topologyId: this.props.currentTopology.get('id'), + parentTopologyId: this.props.currentTopology.get('parentId'), + }); + this.props.clickPauseUpdate(); + } + + handleTravelClick() { + trackMixpanelEvent('scope.time.travel.click', { + layout: this.props.topologyViewMode, + topologyId: this.props.currentTopology.get('id'), + parentTopologyId: this.props.currentTopology.get('parentId'), + }); + this.props.clickTimeTravel(); + } + + render() { + const { showingTimeTravel, pausedAt, timeTravelTransitioning } = this.props; + const isPausedNow = pausedAt && !showingTimeTravel; + const isTimeTravelling = showingTimeTravel; + const isRunningNow = !pausedAt; + + return ( +
+
+ {timeTravelTransitioning && } +
+
+ + {isRunningNow && } + Now + + + {isPausedNow && } + {isPausedNow ? 'Paused' : 'Pause'} + + + Time Travel + +
+
+ ); + } +} + +function mapStateToProps(state) { + // const cloudInstance = root.instances[params.orgId] || {}; + // const featureFlags = cloudInstance.featureFlags || []; + return { + // hasTimeTravel: featureFlags.includes('time-travel'), + update: state.get('topologyViewMode'), + topologyViewMode: state.get('topologyViewMode'), + currentTopology: state.get('currentTopology'), + showingTimeTravel: state.get('showingTimeTravel'), + timeTravelTransitioning: state.get('timeTravelTransitioning'), + pausedAt: state.get('pausedAt'), + }; +} + +export default connect( + mapStateToProps, + { + clickPauseUpdate, + clickResumeUpdate, + clickTimeTravel, + } +)(TimeControl); diff --git a/client/app/scripts/components/time-travel.js b/client/app/scripts/components/time-travel.js index 61f02d533b..2c7b2b5b73 100644 --- a/client/app/scripts/components/time-travel.js +++ b/client/app/scripts/components/time-travel.js @@ -6,7 +6,8 @@ import { debounce } from 'lodash'; import { trackMixpanelEvent } from '../utils/tracking-utils'; import { - timeTravelJumpToPast, + jumpToTime, + clickResumeUpdate, timeTravelStartTransition, } from '../actions/app-actions'; @@ -19,19 +20,20 @@ import { const ONE_HOUR_MS = 60 * 60 * 1000; const FIVE_MINUTES_MS = 5 * 60 * 1000; -function getRangeMilliseconds() { - return 90 * 24 * ONE_HOUR_MS; -} - class TimeTravel extends React.Component { constructor(props, context) { super(props, context); this.state = { - showSliderPanel: false, - millisecondsInPast: 0, + // 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(3, 'months').valueOf(), + sliderValue: props.pausedAt && props.pausedAt.valueOf(), + inputValue: props.pausedAt && moment(props.pausedAt).utc().format(), }; + this.handleTimestampInputChange = this.handleTimestampInputChange.bind(this); this.handleTimestampClick = this.handleTimestampClick.bind(this); this.handleSliderChange = this.handleSliderChange.bind(this); this.jumpInTime = this.jumpInTime.bind(this); @@ -47,25 +49,28 @@ class TimeTravel extends React.Component { this.timer = setInterval(() => { this.forceUpdate(); }, TIMELINE_TICK_INTERVAL); } + componentWillReceiveProps(props) { + this.setState({ + sliderValue: props.pausedAt && props.pausedAt.valueOf(), + inputValue: props.pausedAt && moment(props.pausedAt).utc().format(), + }); + } + componentWillUnmount() { clearInterval(this.timer); - this.updateTimestamp(); + this.props.clickResumeUpdate(); } - handleSliderChange(sliderValue) { - let millisecondsInPast = getRangeMilliseconds() - sliderValue; + handleSliderChange(value) { + const timestamp = moment(value).utc(); - // 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({ + inputValue: timestamp.format(), + sliderValue: value, + }); - this.setState({ millisecondsInPast }); this.props.timeTravelStartTransition(); - this.debouncedUpdateTimestamp(millisecondsInPast); - + this.debouncedUpdateTimestamp(timestamp); this.debouncedTrackSliderChange(); } @@ -77,17 +82,21 @@ class TimeTravel extends React.Component { }); } - updateTimestamp(millisecondsInPast = 0) { - this.props.timeTravelJumpToPast(millisecondsInPast); + handleTimestampInputChange(ev) { + this.setState({ inputValue: ev.target.value }); + } + + updateTimestamp(timestamp) { + this.props.jumpToTime(timestamp); } jumpInTime(millisecondsDelta) { - let millisecondsInPast = this.state.millisecondsInPast - millisecondsDelta; - millisecondsInPast = Math.min(millisecondsInPast, getRangeMilliseconds()); - millisecondsInPast = Math.max(millisecondsInPast, 0); - this.debouncedUpdateTimestamp(millisecondsInPast); + let timestamp = this.state.sliderValue - millisecondsDelta; + timestamp = Math.min(timestamp, this.state.sliderStartTimestamp); + timestamp = Math.max(timestamp, moment().valueOf()); + this.props.timeTravelStartTransition(); - this.setState({ millisecondsInPast }); + this.debouncedUpdateTimestamp(moment(timestamp)); } trackSliderChange() { @@ -99,27 +108,23 @@ class TimeTravel extends React.Component { } render() { - const { millisecondsInPast } = this.state; - const { timeTravelTransitioning, hasTimeTravel } = this.props; - const timestamp = moment().utc().subtract(millisecondsInPast); - const rangeMilliseconds = getRangeMilliseconds(); - // Don't render the time travel control if it's not explicitly enabled for this instance. - if (!hasTimeTravel) return null; + if (!this.props.showingTimeTravel) return null; + + const { sliderValue, sliderMinValue, inputValue } = this.state; + const sliderMaxValue = moment().valueOf(); return (
- {timeTravelTransitioning &&
- -
} this.jumpInTime(-ONE_HOUR_MS)}> 1 hour @@ -127,7 +132,7 @@ class TimeTravel extends React.Component { 5 mins - UTC + UTC this.jumpInTime(FIVE_MINUTES_MS)}> 5 mins @@ -145,17 +150,18 @@ function mapStateToProps({ scope, root }, { params }) { const cloudInstance = root.instances[params.orgId] || {}; const featureFlags = cloudInstance.featureFlags || []; return { - hasTimeTravel: featureFlags.includes('time-travel'), - timeTravelTransitioning: scope.get('timeTravelTransitioning'), + showingTimeTravel: featureFlags.includes('time-travel') && scope.get('showingTimeTravel'), topologyViewMode: scope.get('topologyViewMode'), currentTopology: scope.get('currentTopology'), + pausedAt: scope.get('pausedAt'), }; } export default connect( mapStateToProps, { - timeTravelJumpToPast, + jumpToTime, + clickResumeUpdate, timeTravelStartTransition, } )(TimeTravel); diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index fc48dc6494..b971987066 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -14,11 +14,13 @@ const ACTION_TYPES = [ 'CLICK_CLOSE_TERMINAL', 'CLICK_FORCE_RELAYOUT', 'CLICK_NODE', - 'CLICK_PAUSE_UPDATE', + 'PAUSE_TIME_AT_NOW', 'CLICK_RELATIVE', - 'CLICK_RESUME_UPDATE', + 'RESUME_TIME_FROM_NOW', + 'JUMP_TO_TIME', 'CLICK_SHOW_TOPOLOGY_FOR_NODE', 'CLICK_TERMINAL', + 'START_TIME_TRAVEL', 'CLICK_TOPOLOGY', 'CLOSE_WEBSOCKET', 'CONSOLIDATE_NODES_DELTA_BUFFER', @@ -30,6 +32,7 @@ const ACTION_TYPES = [ 'DO_SEARCH', 'ENTER_EDGE', 'ENTER_NODE', + 'FINISH_TIME_TRAVEL_TRANSITION', 'FOCUS_SEARCH', 'HIDE_HELP', 'HOVER_METRIC', @@ -64,7 +67,6 @@ const ACTION_TYPES = [ 'SHOW_NETWORKS', 'SHUTDOWN', 'SORT_ORDER_CHANGED', - 'TIME_TRAVEL_MILLISECONDS_IN_PAST', 'TIME_TRAVEL_START_TRANSITION', 'TOGGLE_CONTRAST_MODE', 'TOGGLE_TROUBLESHOOTING_MENU', diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index f1f2a164ce..42792e7155 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -64,6 +64,7 @@ export const initialState = makeMap({ 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 +75,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,8 +174,9 @@ function closeAllNodeDetails(state) { return state; } -function resumeUpdate(state) { - return state.set('updatePausedAt', null); +function resumeTimeFromNow(state) { + state = state.set('showingTimeTravel', false); + return state.set('pausedAt', null); } function clearNodes(state) { @@ -195,7 +196,7 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.CHANGE_TOPOLOGY_OPTION: { - state = resumeUpdate(state); + state = resumeTimeFromNow(state); // set option on parent topology const topology = findTopologyById(state.get('topologies'), action.topologyId); if (topology) { @@ -289,11 +290,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,12 +309,13 @@ export function rootReducer(state = initialState, action) { return state; } - case ActionTypes.CLICK_RESUME_UPDATE: { - return resumeUpdate(state); + case ActionTypes.FINISH_TIME_TRAVEL_TRANSITION: { + state = state.set('timeTravelTransitioning', false); + return clearNodes(state); } case ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE: { - state = resumeUpdate(state); + state = resumeTimeFromNow(state); state = state.update('nodeDetails', nodeDetails => nodeDetails.filter((v, k) => k === action.nodeId)); @@ -334,7 +331,7 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.CLICK_TOPOLOGY: { - state = resumeUpdate(state); + state = resumeTimeFromNow(state); state = closeAllNodeDetails(state); const currentTopologyId = state.get('currentTopologyId'); @@ -347,15 +344,28 @@ export function rootReducer(state = initialState, action) { } // - // time travel + // time control // - case ActionTypes.TIME_TRAVEL_START_TRANSITION: { - return state.set('timeTravelTransitioning', true); + case ActionTypes.RESUME_TIME_FROM_NOW: { + return resumeTimeFromNow(state); + } + + case ActionTypes.PAUSE_TIME_AT_NOW: { + return state.set('pausedAt', moment().utc()); + } + + case ActionTypes.START_TIME_TRAVEL: { + state = state.set('showingTimeTravel', true); + return state.set('pausedAt', moment().utc()); } - case ActionTypes.TIME_TRAVEL_MILLISECONDS_IN_PAST: { - return state.set('timeTravelMillisecondsInPast', action.millisecondsInPast); + case ActionTypes.JUMP_TO_TIME: { + return state.set('pausedAt', action.timestamp); + } + + case ActionTypes.TIME_TRAVEL_START_TRANSITION: { + return state.set('timeTravelTransitioning', true); } // @@ -569,15 +579,6 @@ export function rootReducer(state = initialState, action) { 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 each(action.delta.remove, (nodeId) => { // in case node disappears before mouseleave event diff --git a/client/app/scripts/selectors/time-travel.js b/client/app/scripts/selectors/time-travel.js index 141543697a..eefdecbefa 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 !== 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..26446b3df2 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'; @@ -13,7 +12,7 @@ import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveEr 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) { diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index a16e2d8a0a..439e6d9e04 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -228,7 +228,7 @@ align-items: center; display: flex; position: relative; - padding: 15px 20px; + padding: 10px 20px 0; width: 100%; z-index: 2001; @@ -1401,7 +1401,7 @@ } } -.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; @@ -1447,7 +1447,9 @@ } } -.view-mode-selector { +.setup-nav-button { display: none; } + +.view-mode-selector, .time-control { margin-top: 8px; margin-left: 20px; min-width: 161px; @@ -1479,6 +1481,22 @@ } } +.time-control { + display: flex; + align-items: center; + position: absolute; + right: 36px; + + &-icon { + margin-right: 15px; + + .fa { + color: $text-secondary-color; + font-size: 125%; + } + } +} + .topology-option { &-action { &-selected { @@ -1487,7 +1505,7 @@ } } -.view-mode-selector-wrapper { +.view-mode-selector-wrapper, .time-control-wrapper { .label { margin-left: 4px; } .fa { margin-left: 0; From 73c1405763a4a6ecb65c0299ca29bfcc8a4244bc Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Wed, 28 Jun 2017 15:35:24 +0200 Subject: [PATCH 05/23] Pause info --- client/app/scripts/components/footer.js | 2 - client/app/scripts/components/pause-button.js | 80 ------------------- client/app/scripts/components/time-control.js | 24 +++++- client/app/scripts/components/time-travel.js | 8 +- client/app/scripts/reducers/root.js | 13 +-- client/app/styles/_base.scss | 9 +-- 6 files changed, 33 insertions(+), 103 deletions(-) delete mode 100644 client/app/scripts/components/pause-button.js 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, - pausedAt: state.get('pausedAt'), - 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/time-control.js b/client/app/scripts/components/time-control.js index 403ac0b10b..a4922fc938 100644 --- a/client/app/scripts/components/time-control.js +++ b/client/app/scripts/components/time-control.js @@ -1,4 +1,5 @@ import React from 'react'; +import moment from 'moment'; import classNames from 'classnames'; import { connect } from 'react-redux'; @@ -49,11 +50,26 @@ class TimeControl extends React.Component { } render() { - const { showingTimeTravel, pausedAt, timeTravelTransitioning } = this.props; + const { showingTimeTravel, pausedAt, timeTravelTransitioning, + hasUpdates, updateCount } = this.props; + const isPausedNow = pausedAt && !showingTimeTravel; const isTimeTravelling = showingTimeTravel; const isRunningNow = !pausedAt; + const pauseTitle = isPausedNow ? + `Paused ${moment(pausedAt).fromNow()}` : + 'Pause updates (freezes the nodes in their current layout)'; + + let info = ''; + if (hasUpdates && isPausedNow) { + info = `Paused +${updateCount}`; + } else if (hasUpdates && !isPausedNow) { + info = `Resuming +${updateCount}`; + } else if (!hasUpdates && isPausedNow) { + info = 'Paused'; + } + return (
@@ -69,7 +85,8 @@ class TimeControl extends React.Component { + disabled={isTimeTravelling} + title={pauseTitle}> {isPausedNow && } {isPausedNow ? 'Paused' : 'Pause'} @@ -79,6 +96,7 @@ class TimeControl extends React.Component { Time Travel
+ {info}
); } @@ -95,6 +113,8 @@ function mapStateToProps(state) { showingTimeTravel: state.get('showingTimeTravel'), timeTravelTransitioning: state.get('timeTravelTransitioning'), pausedAt: state.get('pausedAt'), + hasUpdates: !state.get('nodesDeltaBuffer').isEmpty(), + updateCount: state.get('nodesDeltaBuffer').size, }; } diff --git a/client/app/scripts/components/time-travel.js b/client/app/scripts/components/time-travel.js index 2c7b2b5b73..e549092fa9 100644 --- a/client/app/scripts/components/time-travel.js +++ b/client/app/scripts/components/time-travel.js @@ -17,8 +17,8 @@ import { } from '../constants/timer'; -const ONE_HOUR_MS = 60 * 60 * 1000; -const FIVE_MINUTES_MS = 5 * 60 * 1000; +const ONE_HOUR_MS = moment.duration(1, 'hour'); +const FIVE_MINUTES_MS = moment.duration(5, 'minutes'); class TimeTravel extends React.Component { constructor(props, context) { @@ -134,10 +134,10 @@ class TimeTravel extends React.Component { UTC - this.jumpInTime(FIVE_MINUTES_MS)}> + this.jumpInTime(+FIVE_MINUTES_MS)}> 5 mins - this.jumpInTime(ONE_HOUR_MS)}> + this.jumpInTime(+ONE_HOUR_MS)}> 1 hour
diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 42792e7155..c6bce39cc7 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -174,11 +174,6 @@ function closeAllNodeDetails(state) { return state; } -function resumeTimeFromNow(state) { - state = state.set('showingTimeTravel', false); - return state.set('pausedAt', null); -} - function clearNodes(state) { return state .update('nodes', nodes => nodes.clear()) @@ -196,7 +191,6 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.CHANGE_TOPOLOGY_OPTION: { - state = resumeTimeFromNow(state); // set option on parent topology const topology = findTopologyById(state.get('topologies'), action.topologyId); if (topology) { @@ -315,8 +309,6 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE: { - state = resumeTimeFromNow(state); - state = state.update('nodeDetails', nodeDetails => nodeDetails.filter((v, k) => k === action.nodeId)); state = state.update('controlPipes', controlPipes => controlPipes.clear()); @@ -331,7 +323,6 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.CLICK_TOPOLOGY: { - state = resumeTimeFromNow(state); state = closeAllNodeDetails(state); const currentTopologyId = state.get('currentTopologyId'); @@ -348,10 +339,12 @@ export function rootReducer(state = initialState, action) { // case ActionTypes.RESUME_TIME_FROM_NOW: { - return resumeTimeFromNow(state); + 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()); } diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 439e6d9e04..40782665c0 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -168,14 +168,12 @@ } .header { + display: flex; + margin-top: 15px; pointer-events: none; - - position: absolute; - top: 60px; + position: relative; width: 100%; - height: 80px; z-index: 20; - display: flex; .logo { margin: -10px 0 0 64px; @@ -227,6 +225,7 @@ .time-travel { align-items: center; display: flex; + margin-bottom: -15px; position: relative; padding: 10px 20px 0; width: 100%; From 23859c474c98925e2ebcd97bad7aadac3d08ddac Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Thu, 29 Jun 2017 14:49:23 +0200 Subject: [PATCH 06/23] Added slider marks. --- client/app/scripts/components/time-control.js | 12 +++-- client/app/scripts/components/time-travel.js | 54 +++++++++++++++++-- client/app/styles/_base.scss | 28 +++++++++- 3 files changed, 82 insertions(+), 12 deletions(-) diff --git a/client/app/scripts/components/time-control.js b/client/app/scripts/components/time-control.js index a4922fc938..cc029687e2 100644 --- a/client/app/scripts/components/time-control.js +++ b/client/app/scripts/components/time-control.js @@ -41,11 +41,13 @@ class TimeControl extends React.Component { } handleTravelClick() { - trackMixpanelEvent('scope.time.travel.click', { - layout: this.props.topologyViewMode, - topologyId: this.props.currentTopology.get('id'), - parentTopologyId: this.props.currentTopology.get('parentId'), - }); + if (this.props.currentTopology) { + trackMixpanelEvent('scope.time.travel.click', { + layout: this.props.topologyViewMode, + topologyId: this.props.currentTopology.get('id'), + parentTopologyId: this.props.currentTopology.get('parentId'), + }); + } this.props.clickTimeTravel(); } diff --git a/client/app/scripts/components/time-travel.js b/client/app/scripts/components/time-travel.js index e549092fa9..5dd75c75e8 100644 --- a/client/app/scripts/components/time-travel.js +++ b/client/app/scripts/components/time-travel.js @@ -2,7 +2,7 @@ import React from 'react'; import Slider from 'rc-slider'; import moment from 'moment'; import { connect } from 'react-redux'; -import { debounce } from 'lodash'; +import { debounce, map } from 'lodash'; import { trackMixpanelEvent } from '../utils/tracking-utils'; import { @@ -28,7 +28,7 @@ class TimeTravel extends React.Component { // 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(3, 'months').valueOf(), + sliderMinValue: moment().subtract(6, 'months').valueOf(), sliderValue: props.pausedAt && props.pausedAt.valueOf(), inputValue: props.pausedAt && moment(props.pausedAt).utc().format(), }; @@ -37,6 +37,8 @@ class TimeTravel extends React.Component { this.handleTimestampClick = this.handleTimestampClick.bind(this); this.handleSliderChange = this.handleSliderChange.bind(this); this.jumpInTime = this.jumpInTime.bind(this); + this.renderMarks = this.renderMarks.bind(this); + this.renderMark = this.renderMark.bind(this); this.debouncedUpdateTimestamp = debounce( this.updateTimestamp.bind(this), TIMELINE_DEBOUNCE_INTERVAL); @@ -87,16 +89,16 @@ class TimeTravel extends React.Component { } updateTimestamp(timestamp) { - this.props.jumpToTime(timestamp); + this.props.jumpToTime(moment(timestamp)); } jumpInTime(millisecondsDelta) { let timestamp = this.state.sliderValue - millisecondsDelta; - timestamp = Math.min(timestamp, this.state.sliderStartTimestamp); + timestamp = Math.min(timestamp, this.state.sliderMinValue); timestamp = Math.max(timestamp, moment().valueOf()); this.props.timeTravelStartTransition(); - this.debouncedUpdateTimestamp(moment(timestamp)); + this.debouncedUpdateTimestamp(timestamp); } trackSliderChange() { @@ -107,6 +109,47 @@ class TimeTravel extends React.Component { }); } + renderMark({ timestampValue, label }) { + const sliderMaxValue = moment().valueOf(); + const pos = (sliderMaxValue - timestampValue) / (sliderMaxValue - this.state.sliderMinValue); + const style = { marginLeft: `calc(${(1 - pos) * 100}% - 50px)`, width: '100px' }; + return ( + + ); + } + + 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) { + 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() { // Don't render the time travel control if it's not explicitly enabled for this instance. if (!this.props.showingTimeTravel) return null; @@ -117,6 +160,7 @@ class TimeTravel extends React.Component { return (
+ {this.renderMarks()} Date: Thu, 29 Jun 2017 15:27:57 +0200 Subject: [PATCH 07/23] Improved messaging. --- client/app/scripts/components/time-control.js | 31 ++++++------------- client/app/styles/_base.scss | 12 +++++-- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/client/app/scripts/components/time-control.js b/client/app/scripts/components/time-control.js index cc029687e2..0a213685ac 100644 --- a/client/app/scripts/components/time-control.js +++ b/client/app/scripts/components/time-control.js @@ -52,26 +52,12 @@ class TimeControl extends React.Component { } render() { - const { showingTimeTravel, pausedAt, timeTravelTransitioning, - hasUpdates, updateCount } = this.props; + const { showingTimeTravel, pausedAt, timeTravelTransitioning } = this.props; const isPausedNow = pausedAt && !showingTimeTravel; const isTimeTravelling = showingTimeTravel; const isRunningNow = !pausedAt; - const pauseTitle = isPausedNow ? - `Paused ${moment(pausedAt).fromNow()}` : - 'Pause updates (freezes the nodes in their current layout)'; - - let info = ''; - if (hasUpdates && isPausedNow) { - info = `Paused +${updateCount}`; - } else if (hasUpdates && !isPausedNow) { - info = `Resuming +${updateCount}`; - } else if (!hasUpdates && isPausedNow) { - info = 'Paused'; - } - return (
@@ -81,24 +67,27 @@ class TimeControl extends React.Component { - {isRunningNow && } - Now + {isRunningNow && } + {isRunningNow ? 'Live' : 'Sync'} + title="Pause updates (freezes the nodes in their current layout)"> {isPausedNow && } {isPausedNow ? 'Paused' : 'Pause'} + {isTimeTravelling && } Time Travel
- {info} + + {isPausedNow && `Live updates paused ${moment(pausedAt).fromNow()}`} +
); } @@ -115,8 +104,6 @@ function mapStateToProps(state) { showingTimeTravel: state.get('showingTimeTravel'), timeTravelTransitioning: state.get('timeTravelTransitioning'), pausedAt: state.get('pausedAt'), - hasUpdates: !state.get('nodesDeltaBuffer').isEmpty(), - updateCount: state.get('nodesDeltaBuffer').size, }; } diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 8b3c6c9230..366d2ed550 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -296,7 +296,6 @@ } } - // blu &-status { display: flex; align-items: center; @@ -1505,12 +1504,13 @@ } .time-control { - display: flex; - align-items: center; + // display: flex; + // align-items: center; position: absolute; right: 36px; &-icon { + display: inline-block; margin-right: 15px; .fa { @@ -1518,6 +1518,12 @@ font-size: 125%; } } + + &-info { + @extend .blinkable; + display: block; + text-align: right; + } } .topology-option { From a9423515522d1772e19316185376a8161bbc8ae3 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Thu, 29 Jun 2017 16:32:11 +0200 Subject: [PATCH 08/23] Freeze all updates when paused. --- client/app/scripts/reducers/root.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index c6bce39cc7..01e87a300e 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -537,6 +537,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 @@ -565,6 +570,11 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.RECEIVE_NODES_DELTA: { + // Freeze 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), @@ -628,6 +638,11 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.RECEIVE_TOPOLOGIES: { + // Freeze topologies updates after the first load if paused. + if (state.get('topologiesLoaded') && state.get('pausedAt')) { + return state; + } + state = state.set('errorUrl', null); state = state.update('topologyUrlsById', topologyUrlsById => topologyUrlsById.clear()); state = processTopologies(state, action.topologies); From d1998b2531fef688e3409ca9ec31514a23c37343 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Thu, 29 Jun 2017 17:51:16 +0200 Subject: [PATCH 09/23] Repositioned for Configure button. --- client/app/scripts/components/app.js | 25 ++++---- client/app/styles/_base.scss | 91 +++++++--------------------- 2 files changed, 36 insertions(+), 80 deletions(-) diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 0bf3ebd3fd..ca0ea37a12 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -181,20 +181,21 @@ class App extends React.Component { {showingDetails &&
} - - - -
-
- {!isIframe && - - } + + + +
+
+ {!isIframe && + + } +
+ + + +
- - - -
diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 366d2ed550..a347041c49 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -168,17 +168,20 @@ } .header { - display: flex; - margin-top: 15px; + margin-top: 38px; pointer-events: none; - position: relative; width: 100%; - z-index: 20; - .logo { - margin: -10px 0 0 64px; - height: 64px; - width: 250px; + .selectors { + display: flex; + position: relative; + z-index: 20; + + .logo { + margin: -16px 0 0 64px; + height: 64px; + width: 250px; + } } } @@ -225,9 +228,8 @@ .time-travel { align-items: center; display: flex; - margin-bottom: -15px; position: relative; - padding: 10px 20px 0; + padding: 0 30px 7px; width: 100%; z-index: 2001; @@ -249,13 +251,15 @@ .link { display: inline-block; + pointer-events: all; margin-top: 1px; } } } &-slider-wrapper { - margin: 0 50px 20px 18px; + margin: 0 50px 20px 0; + pointer-events: all; flex-grow: 1; .rc-slider-rail { background-color: $text-tertiary-color; } @@ -268,6 +272,7 @@ display: block; margin: 8px; font-size: 0.625rem; + pointer-events: all; text-align: center; text-transform: uppercase; word-spacing: -1px; @@ -283,6 +288,7 @@ border: 1px solid #ccc; border-radius: 4px; padding: 2px 8px; + pointer-events: all; margin: 4px 8px 25px; input { @@ -295,63 +301,11 @@ } } } - - &-status { - display: flex; - align-items: center; - justify-content: flex-end; - - .time-travel-jump-loader { - font-size: 1rem; - } - - .time-travel-timestamp-info, .pause-text { - font-size: 115%; - margin-right: 5px; - } - - .button { margin-left: 0.5em; } - - .time-travel-timestamp:not(.current) { - & > * { @extend .blinkable; } - font-weight: bold; - } - } - - &-slider { - width: 355px; - - .slider-tip { - display: inline-block; - font-size: 0.8125rem; - font-style: italic; - padding: 5px 10px; - } - - .options { - display: flex; - padding: 2px 0 10px; - - .column { - display: flex; - flex-direction: column; - flex-grow: 1; - padding: 0 7px; - - a { padding: 0 3px; } - } - } - - .rc-slider { - margin: 0 10px 8px; - width: auto; - } - } } .topologies { - margin: 8px 4px; + margin: 0 4px; display: flex; .topologies-item { @@ -1425,7 +1379,7 @@ .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; @@ -1469,10 +1423,11 @@ } } -.setup-nav-button { display: none; } +.metric-selector { + margin-top: 6px; +} .view-mode-selector, .time-control { - margin-top: 8px; margin-left: 20px; min-width: 161px; @@ -1610,7 +1565,7 @@ &-wrapper { flex: 0 1 20%; - margin: 8px; + margin: 0 8px; text-align: right; } From 637f2e983e7f8891be755b1bf3c3ba386c47e6f6 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Thu, 29 Jun 2017 18:47:54 +0200 Subject: [PATCH 10/23] Improved the flow. --- client/app/scripts/components/app.js | 1 + client/app/scripts/components/time-travel.js | 90 +++++++++++--------- client/app/styles/_base.scss | 14 ++- 3 files changed, 60 insertions(+), 45 deletions(-) diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index ca0ea37a12..cba4260f49 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -185,6 +185,7 @@ class App extends React.Component { +
{!isIframe && diff --git a/client/app/scripts/components/time-travel.js b/client/app/scripts/components/time-travel.js index 5dd75c75e8..f0735004c5 100644 --- a/client/app/scripts/components/time-travel.js +++ b/client/app/scripts/components/time-travel.js @@ -1,6 +1,7 @@ import React from 'react'; import Slider from 'rc-slider'; import moment from 'moment'; +import classNames from 'classnames'; import { connect } from 'react-redux'; import { debounce, map } from 'lodash'; @@ -17,6 +18,14 @@ import { } from '../constants/timer'; +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'); @@ -29,14 +38,12 @@ class TimeTravel extends React.Component { // 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(), - sliderValue: props.pausedAt && props.pausedAt.valueOf(), - inputValue: props.pausedAt && moment(props.pausedAt).utc().format(), + ...getTimestampStates(props.pausedAt), }; - this.handleTimestampInputChange = this.handleTimestampInputChange.bind(this); - this.handleTimestampClick = this.handleTimestampClick.bind(this); + this.handleInputChange = this.handleInputChange.bind(this); this.handleSliderChange = this.handleSliderChange.bind(this); - this.jumpInTime = this.jumpInTime.bind(this); + this.handleJumpClick = this.handleJumpClick.bind(this); this.renderMarks = this.renderMarks.bind(this); this.renderMark = this.renderMark.bind(this); @@ -52,10 +59,7 @@ class TimeTravel extends React.Component { } componentWillReceiveProps(props) { - this.setState({ - sliderValue: props.pausedAt && props.pausedAt.valueOf(), - inputValue: props.pausedAt && moment(props.pausedAt).utc().format(), - }); + this.setState(getTimestampStates(props.pausedAt)); } componentWillUnmount() { @@ -63,44 +67,47 @@ class TimeTravel extends React.Component { this.props.clickResumeUpdate(); } - handleSliderChange(value) { - const timestamp = moment(value).utc(); - - this.setState({ - inputValue: timestamp.format(), - sliderValue: value, - }); - + handleSliderChange(timestamp) { this.props.timeTravelStartTransition(); + this.setState(getTimestampStates(timestamp)); this.debouncedUpdateTimestamp(timestamp); this.debouncedTrackSliderChange(); } - handleTimestampClick() { - trackMixpanelEvent('scope.time.timestamp.click', { - layout: this.props.topologyViewMode, - topologyId: this.props.currentTopology.get('id'), - parentTopologyId: this.props.currentTopology.get('parentId'), - }); - } - - handleTimestampInputChange(ev) { + handleInputChange(ev) { + let timestamp = moment(ev.target.value); this.setState({ inputValue: ev.target.value }); - } - updateTimestamp(timestamp) { - this.props.jumpToTime(moment(timestamp)); + if (timestamp.isValid()) { + timestamp = Math.max(timestamp, this.state.sliderMinValue); + timestamp = Math.min(timestamp, moment().valueOf()); + + this.props.timeTravelStartTransition(); + this.setState(getTimestampStates(timestamp)); + this.updateTimestamp(timestamp); + + trackMixpanelEvent('scope.time.timestamp.edit', { + layout: this.props.topologyViewMode, + topologyId: this.props.currentTopology.get('id'), + parentTopologyId: this.props.currentTopology.get('parentId'), + }); + } } - jumpInTime(millisecondsDelta) { - let timestamp = this.state.sliderValue - millisecondsDelta; - timestamp = Math.min(timestamp, this.state.sliderMinValue); - timestamp = Math.max(timestamp, moment().valueOf()); + handleJumpClick(millisecondsDelta) { + let timestamp = this.state.sliderValue + millisecondsDelta; + timestamp = Math.max(timestamp, this.state.sliderMinValue); + timestamp = Math.min(timestamp, moment().valueOf()); this.props.timeTravelStartTransition(); + this.setState(getTimestampStates(timestamp)); this.debouncedUpdateTimestamp(timestamp); } + updateTimestamp(timestamp) { + this.props.jumpToTime(moment(timestamp)); + } + trackSliderChange() { trackMixpanelEvent('scope.time.slider.change', { layout: this.props.topologyViewMode, @@ -151,14 +158,13 @@ class TimeTravel extends React.Component { } render() { - // Don't render the time travel control if it's not explicitly enabled for this instance. - if (!this.props.showingTimeTravel) return null; - const { sliderValue, sliderMinValue, inputValue } = this.state; const sliderMaxValue = moment().valueOf(); + const className = classNames('time-travel', { visible: this.props.showingTimeTravel }); + return ( -
+
{this.renderMarks()}
diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index a347041c49..4d9132dbaa 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -229,10 +229,18 @@ align-items: center; display: flex; position: relative; - padding: 0 30px 7px; - width: 100%; + margin: 0 30px 0 15px; z-index: 2001; + transition: height .15s $base-ease; + overflow: hidden; + height: 0; + + &.visible { + height: 50px; + margin-bottom: 7px; + } + &-markers { position: relative; @@ -258,7 +266,7 @@ } &-slider-wrapper { - margin: 0 50px 20px 0; + margin: 0 50px 20px 10px; pointer-events: all; flex-grow: 1; From fb6566f7d184b37ac45350d2d391e2d023bbc078 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Mon, 3 Jul 2017 15:02:26 +0200 Subject: [PATCH 11/23] Working browsing through slider. --- client/app/scripts/actions/app-actions.js | 59 ++++++++++++-------- client/app/scripts/charts/nodes-layout.js | 2 +- client/app/scripts/components/time-travel.js | 29 +++++----- client/app/scripts/reducers/root.js | 52 ++++++++++------- client/app/scripts/utils/web-api-utils.js | 19 ++++++- 5 files changed, 103 insertions(+), 58 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 01c4925a4c..b903c69a33 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -14,6 +14,7 @@ import { deletePipe, stopPolling, teardownWebsockets, + getNodes, } from '../utils/web-api-utils'; import { storageSet } from '../utils/storage-utils'; import { loadTheme } from '../utils/contrast-utils'; @@ -404,28 +405,6 @@ export function clickTopology(topologyId) { }; } -export function timeTravelStartTransition() { - return { - type: ActionTypes.TIME_TRAVEL_START_TRANSITION, - }; -} - -export function jumpToTime(timestamp) { - return (dispatch, getServiceState) => { - dispatch({ - type: ActionTypes.JUMP_TO_TIME, - timestamp, - }); - 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, @@ -641,8 +620,42 @@ export function clickResumeUpdate() { } export function clickTimeTravel() { + return (dispatch) => { + stopPolling(); + teardownWebsockets(); + dispatch({ + type: ActionTypes.START_TIME_TRAVEL + }); + }; +} + +export function receiveNodes(nodes) { + return { + type: ActionTypes.RECEIVE_NODES, + nodes, + }; +} + +export function timeTravelStartTransition() { return { - type: ActionTypes.START_TIME_TRAVEL + type: ActionTypes.TIME_TRAVEL_START_TRANSITION, + }; +} + +export function jumpToTime(timestamp) { + return (dispatch, getServiceState) => { + dispatch({ + type: ActionTypes.JUMP_TO_TIME, + timestamp, + }); + const scopeState = getServiceState().scope; + getNodes(scopeState, dispatch); + // updateWebsocketChannel(scopeState, dispatch); + // dispatch(resetNodesDeltaBuffer()); + getTopologies(scopeState, dispatch); + if (isResourceViewModeSelector(scopeState)) { + getResourceViewNodesSnapshot(scopeState, dispatch); + } }; } diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index f061f83156..4a7ba17424 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -434,7 +434,7 @@ export function doLayout(immNodes, immEdges, opts) { const cachedLayout = options.cachedLayout || cache.cachedLayout; const nodeCache = options.nodeCache || cache.nodeCache; const edgeCache = options.edgeCache || cache.edgeCache; - const useCache = !options.forceRelayout && cachedLayout && nodeCache && edgeCache; + const useCache = false && !options.forceRelayout && cachedLayout && nodeCache && edgeCache; let layout; layoutRuns += 1; diff --git a/client/app/scripts/components/time-travel.js b/client/app/scripts/components/time-travel.js index f0735004c5..6310258fb0 100644 --- a/client/app/scripts/components/time-travel.js +++ b/client/app/scripts/components/time-travel.js @@ -37,13 +37,14 @@ class TimeTravel extends React.Component { // 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(), + sliderMinValue: moment().subtract(15, 'months').valueOf(), ...getTimestampStates(props.pausedAt), }; this.handleInputChange = this.handleInputChange.bind(this); this.handleSliderChange = this.handleSliderChange.bind(this); this.handleJumpClick = this.handleJumpClick.bind(this); + this.debouncedJumpTo = this.debouncedJumpTo.bind(this); this.renderMarks = this.renderMarks.bind(this); this.renderMark = this.renderMark.bind(this); @@ -68,9 +69,7 @@ class TimeTravel extends React.Component { } handleSliderChange(timestamp) { - this.props.timeTravelStartTransition(); - this.setState(getTimestampStates(timestamp)); - this.debouncedUpdateTimestamp(timestamp); + this.debouncedJumpTo(timestamp); this.debouncedTrackSliderChange(); } @@ -81,10 +80,7 @@ class TimeTravel extends React.Component { if (timestamp.isValid()) { timestamp = Math.max(timestamp, this.state.sliderMinValue); timestamp = Math.min(timestamp, moment().valueOf()); - - this.props.timeTravelStartTransition(); - this.setState(getTimestampStates(timestamp)); - this.updateTimestamp(timestamp); + this.debouncedJumpTo(timestamp); trackMixpanelEvent('scope.time.timestamp.edit', { layout: this.props.topologyViewMode, @@ -98,16 +94,19 @@ class TimeTravel extends React.Component { let timestamp = this.state.sliderValue + millisecondsDelta; timestamp = Math.max(timestamp, this.state.sliderMinValue); timestamp = Math.min(timestamp, moment().valueOf()); - - this.props.timeTravelStartTransition(); - this.setState(getTimestampStates(timestamp)); - this.debouncedUpdateTimestamp(timestamp); + this.debouncedJumpTo(timestamp); } updateTimestamp(timestamp) { this.props.jumpToTime(moment(timestamp)); } + debouncedJumpTo(timestamp) { + this.props.timeTravelStartTransition(); + this.setState(getTimestampStates(timestamp)); + this.debouncedUpdateTimestamp(timestamp); + } + trackSliderChange() { trackMixpanelEvent('scope.time.slider.change', { layout: this.props.topologyViewMode, @@ -119,14 +118,16 @@ class TimeTravel extends React.Component { renderMark({ timestampValue, label }) { const sliderMaxValue = moment().valueOf(); const pos = (sliderMaxValue - timestampValue) / (sliderMaxValue - this.state.sliderMinValue); - const style = { marginLeft: `calc(${(1 - pos) * 100}% - 50px)`, width: '100px' }; + if (label !== 'Now' && pos < 0.05) return null; + + const style = { marginLeft: `calc(${(1 - pos) * 100}% - 32px)`, width: '64px' }; return ( ); } diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 01e87a300e..c641c89d0e 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -180,6 +180,33 @@ function clearNodes(state) { .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); @@ -580,17 +607,8 @@ export function rootReducer(state = initialState, action) { 'update', size(action.delta.update), 'add', size(action.delta.add)); - state = state.set('errorUrl', null); - // 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]); }); @@ -609,17 +627,13 @@ 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)); + return updateStateFromNodes(state); } case ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY: { diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 26446b3df2..c081365e60 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -7,7 +7,7 @@ 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'; @@ -197,6 +197,23 @@ function getNodesForTopologies(state, dispatch, topologyIds, topologyOptions = m Promise.resolve()); } +export function getNodes(state, dispatch) { + 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). */ From 106455c6e282d8cbecac2186bd03be25e2aa66ad Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Mon, 3 Jul 2017 17:35:41 +0200 Subject: [PATCH 12/23] Small styling. --- client/app/scripts/components/time-control.js | 50 ++++++++++--------- client/app/scripts/reducers/root.js | 5 -- client/app/scripts/utils/web-api-utils.js | 4 +- client/app/styles/_base.scss | 23 +++++---- 4 files changed, 40 insertions(+), 42 deletions(-) diff --git a/client/app/scripts/components/time-control.js b/client/app/scripts/components/time-control.js index 0a213685ac..d489031e92 100644 --- a/client/app/scripts/components/time-control.js +++ b/client/app/scripts/components/time-control.js @@ -60,30 +60,32 @@ class TimeControl extends React.Component { return (
-
- {timeTravelTransitioning && } -
-
- - {isRunningNow && } - {isRunningNow ? 'Live' : 'Sync'} - - - {isPausedNow && } - {isPausedNow ? 'Paused' : 'Pause'} - - - {isTimeTravelling && } - Time Travel - +
+
+ + {isRunningNow && } + Live + + + {isPausedNow && } + {isPausedNow ? 'Paused' : 'Pause'} + + + {isTimeTravelling && } + Time Travel + +
+
+ {timeTravelTransitioning && } +
{isPausedNow && `Live updates paused ${moment(pausedAt).fromNow()}`} diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index c641c89d0e..7057f0ab6a 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -652,11 +652,6 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.RECEIVE_TOPOLOGIES: { - // Freeze topologies updates after the first load if paused. - if (state.get('topologiesLoaded') && state.get('pausedAt')) { - return state; - } - state = state.set('errorUrl', null); state = state.update('topologyUrlsById', topologyUrlsById => topologyUrlsById.clear()); state = processTopologies(state, action.topologies); diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index c081365e60..82958fbb50 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -243,7 +243,7 @@ export function getTopologies(state, dispatch, initialPoll = false) { doRequest({ url, success: (res) => { - if (continuePolling) { + if (continuePolling && !state.get('pausedAt')) { dispatch(receiveTopologies(res)); topologyTimer = setTimeout(() => { getTopologies(state, dispatch); @@ -254,7 +254,7 @@ 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 && !state.get('pausedAt')) { topologyTimer = setTimeout(() => { getTopologies(state, dispatch); }, TOPOLOGY_REFRESH_INTERVAL); diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 4d9132dbaa..5ec205c378 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -232,13 +232,13 @@ margin: 0 30px 0 15px; z-index: 2001; - transition: height .15s $base-ease; + transition: all .15s $base-ease; overflow: hidden; height: 0; &.visible { height: 50px; - margin-bottom: 7px; + margin-bottom: 15px; } &-markers { @@ -252,8 +252,8 @@ border: 1px solid $text-tertiary-color; border-radius: 1px; display: block; - margin: auto; - height: 14px; + margin: 1px auto 2px; + height: 12px; width: 0; } @@ -1467,14 +1467,15 @@ } .time-control { - // display: flex; - // align-items: center; - position: absolute; - right: 36px; + &-controls { + align-items: center; + display: flex; + } - &-icon { + &-spinner { display: inline-block; - margin-right: 15px; + margin-left: 12px; + margin-top: 3px; .fa { color: $text-secondary-color; @@ -1485,7 +1486,7 @@ &-info { @extend .blinkable; display: block; - text-align: right; + margin-top: 5px; } } From c24015cf24820b910c5bb3c5621786db073c6812 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Mon, 3 Jul 2017 18:13:44 +0200 Subject: [PATCH 13/23] Hide time travel button behind the feature flag. --- client/app/scripts/components/app.js | 6 +- client/app/scripts/components/time-control.js | 59 ++++++++++--------- .../scripts/components/time-travel-button.js | 28 +++++++++ client/app/scripts/components/time-travel.js | 12 ++-- client/app/styles/_base.scss | 12 +++- 5 files changed, 75 insertions(+), 42 deletions(-) create mode 100644 client/app/scripts/components/time-travel-button.js diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index cba4260f49..1bd00e3ca8 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -12,7 +12,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 { @@ -182,10 +181,7 @@ class App extends React.Component { {showingDetails &&
}
- - - - +
{!isIframe && diff --git a/client/app/scripts/components/time-control.js b/client/app/scripts/components/time-control.js index d489031e92..d1e4640d47 100644 --- a/client/app/scripts/components/time-control.js +++ b/client/app/scripts/components/time-control.js @@ -3,7 +3,8 @@ import moment from 'moment'; import classNames from 'classnames'; import { connect } from 'react-redux'; -// import CloudFeature from './cloud-feature'; +import CloudFeature from './cloud-feature'; +import TimeTravelButton from './time-travel-button'; import { trackMixpanelEvent } from '../utils/tracking-utils'; import { clickPauseUpdate, clickResumeUpdate, clickTimeTravel } from '../actions/app-actions'; @@ -22,32 +23,33 @@ class TimeControl extends React.Component { } handleNowClick() { + const { currentTopology } = this.props; trackMixpanelEvent('scope.time.resume.click', { layout: this.props.topologyViewMode, - topologyId: this.props.currentTopology.get('id'), - parentTopologyId: this.props.currentTopology.get('parentId'), + topologyId: currentTopology && currentTopology.get('id'), + parentTopologyId: currentTopology && currentTopology.get('parentId'), nodesDeltaBufferSize: this.props.updateCount, }); this.props.clickResumeUpdate(); } handlePauseClick() { + const { currentTopology } = this.props; trackMixpanelEvent('scope.time.pause.click', { layout: this.props.topologyViewMode, - topologyId: this.props.currentTopology.get('id'), - parentTopologyId: this.props.currentTopology.get('parentId'), + topologyId: currentTopology && currentTopology.get('id'), + parentTopologyId: currentTopology && currentTopology.get('parentId'), }); this.props.clickPauseUpdate(); } handleTravelClick() { - if (this.props.currentTopology) { - trackMixpanelEvent('scope.time.travel.click', { - layout: this.props.topologyViewMode, - topologyId: this.props.currentTopology.get('id'), - parentTopologyId: this.props.currentTopology.get('parentId'), - }); - } + const { currentTopology } = this.props; + trackMixpanelEvent('scope.time.travel.click', { + layout: this.props.topologyViewMode, + topologyId: currentTopology && currentTopology.get('id'), + parentTopologyId: currentTopology && currentTopology.get('parentId'), + }); this.props.clickTimeTravel(); } @@ -61,10 +63,14 @@ class TimeControl extends React.Component { return (
+
+ {timeTravelTransitioning && } +
+ onClick={this.handleNowClick} + title="Show live state of the system"> {isRunningNow && } Live @@ -76,30 +82,27 @@ class TimeControl extends React.Component { {isPausedNow && } {isPausedNow ? 'Paused' : 'Pause'} - - {isTimeTravelling && } - Time Travel - -
-
- {timeTravelTransitioning && } + + +
- - {isPausedNow && `Live updates paused ${moment(pausedAt).fromNow()}`} - + {isPausedNow && + Paused {moment(pausedAt).fromNow()} + }
); } } function mapStateToProps(state) { - // const cloudInstance = root.instances[params.orgId] || {}; - // const featureFlags = cloudInstance.featureFlags || []; return { - // hasTimeTravel: featureFlags.includes('time-travel'), update: state.get('topologyViewMode'), topologyViewMode: state.get('topologyViewMode'), currentTopology: state.get('currentTopology'), 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..444e5c33f9 --- /dev/null +++ b/client/app/scripts/components/time-travel-button.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { connect } from 'react-redux'; + + +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.js b/client/app/scripts/components/time-travel.js index 6310258fb0..263836fe79 100644 --- a/client/app/scripts/components/time-travel.js +++ b/client/app/scripts/components/time-travel.js @@ -197,14 +197,12 @@ class TimeTravel extends React.Component { } } -function mapStateToProps({ scope, root }, { params }) { - const cloudInstance = root.instances[params.orgId] || {}; - const featureFlags = cloudInstance.featureFlags || []; +function mapStateToProps(state) { return { - showingTimeTravel: featureFlags.includes('time-travel') && scope.get('showingTimeTravel'), - topologyViewMode: scope.get('topologyViewMode'), - currentTopology: scope.get('currentTopology'), - pausedAt: scope.get('pausedAt'), + showingTimeTravel: state.get('showingTimeTravel'), + topologyViewMode: state.get('topologyViewMode'), + currentTopology: state.get('currentTopology'), + pausedAt: state.get('pausedAt'), }; } diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 5ec205c378..894732ae10 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -175,7 +175,9 @@ .selectors { display: flex; position: relative; - z-index: 20; + > * { z-index: 20; } + + .time-control { z-index: 2001; } .logo { margin: -16px 0 0 64px; @@ -1467,14 +1469,18 @@ } .time-control { + position: absolute; + right: 36px; + &-controls { align-items: center; + justify-content: flex-end; display: flex; } &-spinner { display: inline-block; - margin-left: 12px; + margin-right: 15px; margin-top: 3px; .fa { @@ -1487,6 +1493,8 @@ @extend .blinkable; display: block; margin-top: 5px; + text-align: right; + pointer-events: all; } } From b2fbdd74eaf75bfb42caf9eac793b7b1db79da35 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Mon, 3 Jul 2017 18:24:45 +0200 Subject: [PATCH 14/23] Fixed actions. --- client/app/scripts/actions/app-actions.js | 28 ++++++++--------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index b903c69a33..d1aa51cade 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -559,12 +559,7 @@ export function receiveNodesDelta(delta) { // 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 state = getState(); const movingInTime = state.get('timeTravelTransitioning'); const hasChanges = delta.add || delta.update || delta.remove; @@ -609,11 +604,9 @@ export function clickResumeUpdate() { dispatch({ type: ActionTypes.RESUME_TIME_FROM_NOW }); - // 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), + () => updateFromNodesDeltaBuffer(dispatch, getState()), NODES_DELTA_BUFFER_FEED_INTERVAL, ); }; @@ -643,18 +636,17 @@ export function timeTravelStartTransition() { } export function jumpToTime(timestamp) { - return (dispatch, getServiceState) => { + return (dispatch, getState) => { dispatch({ type: ActionTypes.JUMP_TO_TIME, timestamp, }); - const scopeState = getServiceState().scope; - getNodes(scopeState, dispatch); - // updateWebsocketChannel(scopeState, dispatch); + getNodes(getState(), dispatch); + // updateWebsocketChannel(getState(), dispatch); // dispatch(resetNodesDeltaBuffer()); - getTopologies(scopeState, dispatch); - if (isResourceViewModeSelector(scopeState)) { - getResourceViewNodesSnapshot(scopeState, dispatch); + getTopologies(getState(), dispatch); + if (isResourceViewModeSelector(getState())) { + getResourceViewNodesSnapshot(getState(), dispatch); } }; } @@ -668,9 +660,7 @@ 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, From ae42f4681866e5902cd9891e7f43049f9c0c8cef Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Mon, 3 Jul 2017 19:13:35 +0200 Subject: [PATCH 15/23] Elements positioning corner cases. --- client/app/scripts/components/app.js | 11 ++++++++--- client/app/scripts/components/time-travel.js | 2 +- client/app/scripts/constants/styles.js | 2 +- client/app/styles/_base.scss | 20 +++++++++++++++++--- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 1bd00e3ca8..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'; @@ -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 && } @@ -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/time-travel.js b/client/app/scripts/components/time-travel.js index 263836fe79..9d3f4466f5 100644 --- a/client/app/scripts/components/time-travel.js +++ b/client/app/scripts/components/time-travel.js @@ -37,7 +37,7 @@ class TimeTravel extends React.Component { // 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(15, 'months').valueOf(), + sliderMinValue: moment().subtract(6, 'months').valueOf(), ...getTimestampStates(props.pausedAt), }; 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/styles/_base.scss b/client/app/styles/_base.scss index 894732ae10..437bf8d034 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,6 +170,12 @@ margin-bottom: 12px; font-weight: 400; } + + &.time-travel-open { + .details-wrapper { + margin-top: 50px; + } + } } .header { @@ -660,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; } } @@ -1947,6 +1958,9 @@ // .nodes-grid { + // TODO: Use relative positioning here. + position: absolute; + top: 0; tr { border-radius: 6px; From 72524064fc07ce1935094a0a170f111faa6b91c3 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Mon, 3 Jul 2017 19:30:11 +0200 Subject: [PATCH 16/23] Removed nodes delta buffering code. --- client/app/scripts/actions/app-actions.js | 49 +-------------- client/app/scripts/components/time-control.js | 1 - client/app/scripts/constants/action-types.js | 4 -- client/app/scripts/constants/limits.js | 1 - client/app/scripts/constants/timer.js | 1 - client/app/scripts/reducers/root.js | 27 +-------- client/app/scripts/utils/nodes-delta-utils.js | 60 ------------------- client/app/scripts/utils/web-api-utils.js | 6 +- 8 files changed, 7 insertions(+), 142 deletions(-) delete mode 100644 client/app/scripts/utils/nodes-delta-utils.js diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index d1aa51cade..db7dd0f316 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -30,8 +30,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, @@ -41,8 +39,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 }; @@ -76,11 +72,6 @@ export function sortOrderChanged(sortedBy, sortedDesc) { }; } -function resetNodesDeltaBuffer() { - clearInterval(nodesDeltaBufferUpdateTimer); - return { type: ActionTypes.CLEAR_NODES_DELTA_BUFFER }; -} - // // Networks @@ -217,7 +208,6 @@ 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); @@ -376,8 +366,6 @@ 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. @@ -560,23 +548,11 @@ export function receiveNodesDelta(delta) { setTimeout(() => dispatch({ type: ActionTypes.SET_RECEIVED_NODES_DELTA }), 0); const state = getState(); - const movingInTime = state.get('timeTravelTransitioning'); const hasChanges = delta.add || delta.update || delta.remove; - // 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')) { - dispatch({ type: ActionTypes.FINISH_TIME_TRAVEL_TRANSITION }); - } - - if (hasChanges || movingInTime) { + if (hasChanges) { 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 }); + // dispatch({ type: ActionTypes.BUFFER_NODES_DELTA, delta }); } else { dispatch({ type: ActionTypes.RECEIVE_NODES_DELTA, @@ -587,35 +563,16 @@ export function receiveNodesDelta(delta) { }; } -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() { - return (dispatch, getState) => { + return (dispatch) => { dispatch({ type: ActionTypes.RESUME_TIME_FROM_NOW }); - // Periodically merge buffered nodes deltas until the buffer is emptied. - nodesDeltaBufferUpdateTimer = setInterval( - () => updateFromNodesDeltaBuffer(dispatch, getState()), - NODES_DELTA_BUFFER_FEED_INTERVAL, - ); }; } export function clickTimeTravel() { return (dispatch) => { - stopPolling(); - teardownWebsockets(); dispatch({ type: ActionTypes.START_TIME_TRAVEL }); diff --git a/client/app/scripts/components/time-control.js b/client/app/scripts/components/time-control.js index d1e4640d47..12c8511818 100644 --- a/client/app/scripts/components/time-control.js +++ b/client/app/scripts/components/time-control.js @@ -28,7 +28,6 @@ class TimeControl extends React.Component { layout: this.props.topologyViewMode, topologyId: currentTopology && currentTopology.get('id'), parentTopologyId: currentTopology && currentTopology.get('parentId'), - nodesDeltaBufferSize: this.props.updateCount, }); this.props.clickResumeUpdate(); } diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index b971987066..088556152a 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -3,12 +3,10 @@ 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', @@ -23,7 +21,6 @@ const ACTION_TYPES = [ 'START_TIME_TRAVEL', 'CLICK_TOPOLOGY', 'CLOSE_WEBSOCKET', - 'CONSOLIDATE_NODES_DELTA_BUFFER', 'DEBUG_TOOLBAR_INTERFERING', 'DESELECT_NODE', 'DO_CONTROL_ERROR', @@ -42,7 +39,6 @@ const ACTION_TYPES = [ 'PIN_METRIC', 'PIN_NETWORK', 'PIN_SEARCH', - 'POP_NODES_DELTA_BUFFER', 'RECEIVE_API_DETAILS', 'RECEIVE_CONTROL_NODE_REMOVED', 'RECEIVE_CONTROL_PIPE_STATUS', 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/timer.js b/client/app/scripts/constants/timer.js index 4646b74e43..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; diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 7057f0ab6a..14fabe5e32 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,7 +57,6 @@ 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 @@ -400,29 +398,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 // @@ -597,7 +572,7 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.RECEIVE_NODES_DELTA: { - // Freeze nodes updates after the first load when paused. + // Ignore periodic nodes updates after the first load when paused. if (state.get('nodesLoaded') && state.get('pausedAt')) { return state; } 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/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 82958fbb50..5e943e9a32 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -51,7 +51,7 @@ export function getSerializedTimeTravelTimestamp(state) { // The timestamp parameter will be used only if it's in the past. if (!isPausedSelector(state)) return null; - return state.get('pausedAt').toISOString(); + return state.get('pausedAt').utc().toISOString(); } export function buildUrlQuery(params = makeMap(), state) { @@ -243,7 +243,7 @@ export function getTopologies(state, dispatch, initialPoll = false) { doRequest({ url, success: (res) => { - if (continuePolling && !state.get('pausedAt')) { + if (continuePolling) { dispatch(receiveTopologies(res)); topologyTimer = setTimeout(() => { getTopologies(state, dispatch); @@ -254,7 +254,7 @@ 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 && !state.get('pausedAt')) { + if (continuePolling) { topologyTimer = setTimeout(() => { getTopologies(state, dispatch); }, TOPOLOGY_REFRESH_INTERVAL); From ceb42c73b0b8c2ee5f6be862d6e23a2cd465576f Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Mon, 3 Jul 2017 20:03:15 +0200 Subject: [PATCH 17/23] Fixed the flow. --- client/app/scripts/actions/app-actions.js | 18 ++++++----- client/app/scripts/components/time-control.js | 30 +++++++++---------- client/app/scripts/reducers/root.js | 3 +- client/app/scripts/utils/web-api-utils.js | 2 -- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index db7dd0f316..595dd50552 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -548,12 +548,18 @@ export function receiveNodesDelta(delta) { setTimeout(() => dispatch({ type: ActionTypes.SET_RECEIVED_NODES_DELTA }), 0); const state = getState(); - const hasChanges = delta.add || delta.update || delta.remove; - if (hasChanges) { - if (isPausedSelector(state)) { - // dispatch({ type: ActionTypes.BUFFER_NODES_DELTA, delta }); - } else { + if (!isPausedSelector(state)) { + // 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')) { + dispatch({ type: ActionTypes.FINISH_TIME_TRAVEL_TRANSITION }); + } + + const hasChanges = delta.add || delta.update || delta.remove; + if (hasChanges) { dispatch({ type: ActionTypes.RECEIVE_NODES_DELTA, delta @@ -599,8 +605,6 @@ export function jumpToTime(timestamp) { timestamp, }); getNodes(getState(), dispatch); - // updateWebsocketChannel(getState(), dispatch); - // dispatch(resetNodesDeltaBuffer()); getTopologies(getState(), dispatch); if (isResourceViewModeSelector(getState())) { getResourceViewNodesSnapshot(getState(), dispatch); diff --git a/client/app/scripts/components/time-control.js b/client/app/scripts/components/time-control.js index 12c8511818..a565b18e32 100644 --- a/client/app/scripts/components/time-control.js +++ b/client/app/scripts/components/time-control.js @@ -20,35 +20,30 @@ class TimeControl extends React.Component { this.handleNowClick = this.handleNowClick.bind(this); this.handlePauseClick = this.handlePauseClick.bind(this); this.handleTravelClick = this.handleTravelClick.bind(this); + this.getTrackingMetadata = this.getTrackingMetadata.bind(this); } - handleNowClick() { + getTrackingMetadata() { const { currentTopology } = this.props; - trackMixpanelEvent('scope.time.resume.click', { + return { layout: this.props.topologyViewMode, topologyId: currentTopology && currentTopology.get('id'), parentTopologyId: currentTopology && currentTopology.get('parentId'), - }); + }; + } + + handleNowClick() { + trackMixpanelEvent('scope.time.resume.click', this.getTrackingMetadata()); this.props.clickResumeUpdate(); } handlePauseClick() { - const { currentTopology } = this.props; - trackMixpanelEvent('scope.time.pause.click', { - layout: this.props.topologyViewMode, - topologyId: currentTopology && currentTopology.get('id'), - parentTopologyId: currentTopology && currentTopology.get('parentId'), - }); + trackMixpanelEvent('scope.time.pause.click', this.getTrackingMetadata()); this.props.clickPauseUpdate(); } handleTravelClick() { - const { currentTopology } = this.props; - trackMixpanelEvent('scope.time.travel.click', { - layout: this.props.topologyViewMode, - topologyId: currentTopology && currentTopology.get('id'), - parentTopologyId: currentTopology && currentTopology.get('parentId'), - }); + trackMixpanelEvent('scope.time.travel.click', this.getTrackingMetadata()); this.props.clickTimeTravel(); } @@ -95,6 +90,10 @@ class TimeControl extends React.Component { title={moment(pausedAt).utc().toISOString()}> Paused {moment(pausedAt).fromNow()} } + {isRunningNow && timeTravelTransitioning && + Resuming the live state + }
); } @@ -102,7 +101,6 @@ class TimeControl extends React.Component { function mapStateToProps(state) { return { - update: state.get('topologyViewMode'), topologyViewMode: state.get('topologyViewMode'), currentTopology: state.get('currentTopology'), showingTimeTravel: state.get('showingTimeTravel'), diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 14fabe5e32..b828f7388a 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -364,6 +364,7 @@ export function rootReducer(state = initialState, action) { // case ActionTypes.RESUME_TIME_FROM_NOW: { + state = state.set('timeTravelTransitioning', true); state = state.set('showingTimeTravel', false); return state.set('pausedAt', null); } @@ -582,7 +583,7 @@ export function rootReducer(state = initialState, action) { 'update', size(action.delta.update), 'add', size(action.delta.add)); - // nodes that no longer exist + // remove nodes that no longer exist each(action.delta.remove, (nodeId) => { state = state.deleteIn(['nodes', nodeId]); }); diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 5e943e9a32..6cdbb55f17 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -233,8 +233,6 @@ export function getResourceViewNodesSnapshot(state, dispatch) { } export function getTopologies(state, dispatch, initialPoll = false) { - // TODO: Remove this once TimeTravel is out of the feature flag. - state = state.scope || state; // Used to resume polling when navigating between pages in Weave Cloud. continuePolling = initialPoll === true ? true : continuePolling; clearTimeout(topologyTimer); From c758b7bafc42c76ccb474f35d05d6f3e9b8f2cea Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Tue, 4 Jul 2017 12:56:39 +0200 Subject: [PATCH 18/23] Fixed almost all API call cases. --- client/app/scripts/actions/app-actions.js | 51 +++++++++++-------- client/app/scripts/components/time-control.js | 9 ++-- client/app/scripts/utils/topology-utils.js | 2 +- client/app/scripts/utils/web-api-utils.js | 37 +++++++------- client/app/styles/_base.scss | 2 +- 5 files changed, 59 insertions(+), 42 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 595dd50552..302116d377 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -14,7 +14,7 @@ import { deletePipe, stopPolling, teardownWebsockets, - getNodes, + getNodesOnce, } from '../utils/web-api-utils'; import { storageSet } from '../utils/storage-utils'; import { loadTheme } from '../utils/contrast-utils'; @@ -40,6 +40,15 @@ import { const log = debug('scope:app-actions'); +function getNodes(getState, dispatch, justUnpaused = false) { + if (isPausedSelector(getState())) { + getNodesOnce(getState, dispatch); + } else { + updateWebsocketChannel(getState, dispatch, justUnpaused); + } + getNodeDetails(getState, dispatch); +} + export function showHelp() { return { type: ActionTypes.SHOW_HELP }; } @@ -208,10 +217,8 @@ export function changeTopologyOption(option, value, topologyId, addOrRemove) { }); updateRoute(getState); // 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); }; } @@ -335,7 +342,7 @@ export function clickNode(nodeId, label, origin) { nodeId }); updateRoute(getState); - getNodeDetails(getState(), dispatch); + getNodeDetails(getState, dispatch); }; } @@ -355,7 +362,7 @@ export function clickRelative(nodeId, topologyId, label, origin) { topologyId }); updateRoute(getState); - getNodeDetails(getState(), dispatch); + getNodeDetails(getState, dispatch); }; } @@ -369,7 +376,7 @@ function updateTopology(dispatch, getState) { // 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) { @@ -570,18 +577,24 @@ export function receiveNodesDelta(delta) { } export function clickResumeUpdate() { - return (dispatch) => { + return (dispatch, getState) => { dispatch({ type: ActionTypes.RESUME_TIME_FROM_NOW }); + // After unpausing, all of the following calls will re-activate polling. + getTopologies(getState, dispatch); + getNodes(getState, dispatch, true); }; } export function clickTimeTravel() { - return (dispatch) => { + return (dispatch, getState) => { dispatch({ type: ActionTypes.START_TIME_TRAVEL }); + if (!getState().get('nodesLoaded')) { + getNodes(getState, dispatch); + } }; } @@ -604,8 +617,8 @@ export function jumpToTime(timestamp) { type: ActionTypes.JUMP_TO_TIME, timestamp, }); - getNodes(getState(), dispatch); - getTopologies(getState(), dispatch); + getTopologies(getState, dispatch); + getNodes(getState, dispatch); if (isResourceViewModeSelector(getState())) { getResourceViewNodesSnapshot(getState(), dispatch); } @@ -627,10 +640,9 @@ export function receiveTopologies(topologies) { 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()); } @@ -732,7 +744,7 @@ export function setContrastMode(enabled) { export function getTopologiesWithInitialPoll() { return (dispatch, getState) => { - getTopologies(getState(), dispatch, true); + getTopologies(getState, dispatch, true); }; } @@ -743,13 +755,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/time-control.js b/client/app/scripts/components/time-control.js index a565b18e32..6b8ee8e3ef 100644 --- a/client/app/scripts/components/time-control.js +++ b/client/app/scripts/components/time-control.js @@ -48,12 +48,14 @@ class TimeControl extends React.Component { } render() { - const { showingTimeTravel, pausedAt, timeTravelTransitioning } = this.props; + const { showingTimeTravel, pausedAt, timeTravelTransitioning, topologiesLoaded } = this.props; const isPausedNow = pausedAt && !showingTimeTravel; const isTimeTravelling = showingTimeTravel; const isRunningNow = !pausedAt; + if (!topologiesLoaded) return null; + return (
@@ -85,10 +87,10 @@ class TimeControl extends React.Component {
- {isPausedNow && - Paused {moment(pausedAt).fromNow()} + Showing state from {moment(pausedAt).fromNow()} } {isRunningNow && timeTravelTransitioning && @@ -102,6 +104,7 @@ class TimeControl extends React.Component { function mapStateToProps(state) { return { topologyViewMode: state.get('topologyViewMode'), + topologiesLoaded: state.get('topologiesLoaded'), currentTopology: state.get('currentTopology'), showingTimeTravel: state.get('showingTimeTravel'), timeTravelTransitioning: state.get('timeTravelTransitioning'), diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js index efce0f6d12..540cef74ff 100644 --- a/client/app/scripts/utils/topology-utils.js +++ b/client/app/scripts/utils/topology-utils.js @@ -148,7 +148,7 @@ export function isNodesDisplayEmpty(state) { return !pinnedMetricSelector(state) || shownResourceTopologyIdsSelector(state).isEmpty(); } // Otherwise (in graph and table view), we only look at the nodes content. - return shownNodesSelector(state).isEmpty(); + return state.get('nodesLoaded') && shownNodesSelector(state).isEmpty(); } export function getAdjacentNodes(state, originNodeId) { diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 6cdbb55f17..2418e1bbde 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -112,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; @@ -138,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); } }; @@ -197,7 +197,8 @@ function getNodesForTopologies(state, dispatch, topologyIds, topologyOptions = m Promise.resolve()); } -export function getNodes(state, dispatch) { +export function getNodesOnce(getState, dispatch) { + const state = getState(); const topologyUrl = getCurrentTopologyUrl(state); const topologyOptions = activeTopologyOptionsSelector(state); const optionsQuery = buildUrlQuery(topologyOptions, state); @@ -232,19 +233,20 @@ export function getResourceViewNodesSnapshot(state, dispatch) { getNodesForTopologies(state, dispatch, topologyIds); } -export function getTopologies(state, dispatch, initialPoll = false) { +// 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); } }, @@ -252,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); +export function updateWebsocketChannel(getState, dispatch, justUnpaused = false) { + 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 || justUnpaused)) { + 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'); diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss index 437bf8d034..eabb74e91c 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -173,7 +173,7 @@ &.time-travel-open { .details-wrapper { - margin-top: 50px; + margin-top: 65px; } } } From 08dea44ae56d63751ed80530ee946089cac658c4 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Tue, 4 Jul 2017 15:07:48 +0200 Subject: [PATCH 19/23] Final touches --- client/app/scripts/actions/app-actions.js | 16 ++++++---------- client/app/scripts/components/time-travel.js | 19 ++++++++++++------- client/app/scripts/reducers/root.js | 4 ++-- client/app/scripts/utils/topology-utils.js | 2 +- 4 files changed, 21 insertions(+), 20 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 302116d377..6fb58ea6b4 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -547,21 +547,17 @@ 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); + 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); - const state = getState(); - - if (!isPausedSelector(state)) { // 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')) { + if (getState().get('timeTravelTransitioning')) { dispatch({ type: ActionTypes.FINISH_TIME_TRAVEL_TRANSITION }); } diff --git a/client/app/scripts/components/time-travel.js b/client/app/scripts/components/time-travel.js index 9d3f4466f5..0c1296d571 100644 --- a/client/app/scripts/components/time-travel.js +++ b/client/app/scripts/components/time-travel.js @@ -44,9 +44,9 @@ class TimeTravel extends React.Component { this.handleInputChange = this.handleInputChange.bind(this); this.handleSliderChange = this.handleSliderChange.bind(this); this.handleJumpClick = this.handleJumpClick.bind(this); - this.debouncedJumpTo = this.debouncedJumpTo.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); @@ -69,7 +69,7 @@ class TimeTravel extends React.Component { } handleSliderChange(timestamp) { - this.debouncedJumpTo(timestamp); + this.travelTo(timestamp, true); this.debouncedTrackSliderChange(); } @@ -80,7 +80,7 @@ class TimeTravel extends React.Component { if (timestamp.isValid()) { timestamp = Math.max(timestamp, this.state.sliderMinValue); timestamp = Math.min(timestamp, moment().valueOf()); - this.debouncedJumpTo(timestamp); + this.travelTo(timestamp, true); trackMixpanelEvent('scope.time.timestamp.edit', { layout: this.props.topologyViewMode, @@ -94,17 +94,22 @@ class TimeTravel extends React.Component { let timestamp = this.state.sliderValue + millisecondsDelta; timestamp = Math.max(timestamp, this.state.sliderMinValue); timestamp = Math.min(timestamp, moment().valueOf()); - this.debouncedJumpTo(timestamp); + this.travelTo(timestamp, true); } updateTimestamp(timestamp) { this.props.jumpToTime(moment(timestamp)); } - debouncedJumpTo(timestamp) { + travelTo(timestamp, debounced = false) { this.props.timeTravelStartTransition(); this.setState(getTimestampStates(timestamp)); - this.debouncedUpdateTimestamp(timestamp); + if (debounced) { + this.debouncedUpdateTimestamp(timestamp); + } else { + this.debouncedUpdateTimestamp.cancel(); + this.updateTimestamp(timestamp); + } } trackSliderChange() { @@ -127,7 +132,7 @@ class TimeTravel extends React.Component { className="time-travel-markers-tick" key={timestampValue}> - this.debouncedJumpTo(timestampValue)}>{label} + this.travelTo(timestampValue)}>{label}
); } diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index b828f7388a..891866fabd 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -609,6 +609,7 @@ export function rootReducer(state = initialState, action) { case ActionTypes.RECEIVE_NODES: { state = state.set('timeTravelTransitioning', false); state = state.set('nodes', fromJS(action.nodes)); + state = state.set('nodesLoaded', true); return updateStateFromNodes(state); } @@ -731,8 +732,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/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js index 540cef74ff..efce0f6d12 100644 --- a/client/app/scripts/utils/topology-utils.js +++ b/client/app/scripts/utils/topology-utils.js @@ -148,7 +148,7 @@ export function isNodesDisplayEmpty(state) { return !pinnedMetricSelector(state) || shownResourceTopologyIdsSelector(state).isEmpty(); } // Otherwise (in graph and table view), we only look at the nodes content. - return state.get('nodesLoaded') && shownNodesSelector(state).isEmpty(); + return shownNodesSelector(state).isEmpty(); } export function getAdjacentNodes(state, originNodeId) { From 6232550ba639477009140db111099a1469230c86 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Tue, 4 Jul 2017 15:34:37 +0200 Subject: [PATCH 20/23] Fixed the tests. --- client/app/scripts/charts/nodes-layout.js | 2 +- client/app/scripts/components/time-control.js | 2 +- client/app/scripts/selectors/time-travel.js | 2 +- .../scripts/utils/__tests__/web-api-utils-test.js | 13 ++----------- client/app/scripts/utils/web-api-utils.js | 2 +- 5 files changed, 6 insertions(+), 15 deletions(-) diff --git a/client/app/scripts/charts/nodes-layout.js b/client/app/scripts/charts/nodes-layout.js index 4a7ba17424..f061f83156 100644 --- a/client/app/scripts/charts/nodes-layout.js +++ b/client/app/scripts/charts/nodes-layout.js @@ -434,7 +434,7 @@ export function doLayout(immNodes, immEdges, opts) { const cachedLayout = options.cachedLayout || cache.cachedLayout; const nodeCache = options.nodeCache || cache.nodeCache; const edgeCache = options.edgeCache || cache.edgeCache; - const useCache = false && !options.forceRelayout && cachedLayout && nodeCache && edgeCache; + const useCache = !options.forceRelayout && cachedLayout && nodeCache && edgeCache; let layout; layoutRuns += 1; diff --git a/client/app/scripts/components/time-control.js b/client/app/scripts/components/time-control.js index 6b8ee8e3ef..db6488ced2 100644 --- a/client/app/scripts/components/time-control.js +++ b/client/app/scripts/components/time-control.js @@ -89,7 +89,7 @@ class TimeControl extends React.Component {
{(isPausedNow || isTimeTravelling) && + title={moment(pausedAt).toISOString()}> Showing state from {moment(pausedAt).fromNow()} } {isRunningNow && timeTravelTransitioning && state.get('pausedAt') ], - pausedAt => pausedAt !== null + 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/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 2418e1bbde..b802b26584 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -51,7 +51,7 @@ export function getSerializedTimeTravelTimestamp(state) { // The timestamp parameter will be used only if it's in the past. if (!isPausedSelector(state)) return null; - return state.get('pausedAt').utc().toISOString(); + return state.get('pausedAt').toISOString(); } export function buildUrlQuery(params = makeMap(), state) { From f59b16d88d981c7b16e05b3f533591bce98eb3f1 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Tue, 4 Jul 2017 15:49:40 +0200 Subject: [PATCH 21/23] Fix resource view updates when time travelling. --- client/app/scripts/actions/app-actions.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 6fb58ea6b4..2a2fb3f9f3 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -580,6 +580,9 @@ export function clickResumeUpdate() { // After unpausing, all of the following calls will re-activate polling. getTopologies(getState, dispatch); getNodes(getState, dispatch, true); + if (isResourceViewModeSelector(getState())) { + getResourceViewNodesSnapshot(getState(), dispatch); + } }; } @@ -590,6 +593,9 @@ export function clickTimeTravel() { }); if (!getState().get('nodesLoaded')) { getNodes(getState, dispatch); + if (isResourceViewModeSelector(getState())) { + getResourceViewNodesSnapshot(getState(), dispatch); + } } }; } From b7b4afa168d3cb562cdbd63eb9ad19e57d0b72b2 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Tue, 4 Jul 2017 16:57:49 +0200 Subject: [PATCH 22/23] Added some comments. --- client/app/scripts/actions/app-actions.js | 18 ++++-------------- client/app/scripts/components/time-control.js | 10 +++++----- .../scripts/components/time-travel-button.js | 1 + client/app/scripts/components/time-travel.js | 9 ++++++--- client/app/scripts/constants/action-types.js | 8 ++++---- client/app/scripts/reducers/root.js | 2 +- client/app/scripts/utils/web-api-utils.js | 15 ++++++++++++--- client/app/styles/_base.scss | 2 +- 8 files changed, 34 insertions(+), 31 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 2a2fb3f9f3..429124e8b8 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -8,13 +8,12 @@ import { doControlRequest, getAllNodes, getResourceViewNodesSnapshot, - updateWebsocketChannel, getNodeDetails, getTopologies, deletePipe, stopPolling, teardownWebsockets, - getNodesOnce, + getNodes, } from '../utils/web-api-utils'; import { storageSet } from '../utils/storage-utils'; import { loadTheme } from '../utils/contrast-utils'; @@ -40,15 +39,6 @@ import { const log = debug('scope:app-actions'); -function getNodes(getState, dispatch, justUnpaused = false) { - if (isPausedSelector(getState())) { - getNodesOnce(getState, dispatch); - } else { - updateWebsocketChannel(getState, dispatch, justUnpaused); - } - getNodeDetails(getState, dispatch); -} - export function showHelp() { return { type: ActionTypes.SHOW_HELP }; } @@ -346,7 +336,7 @@ export function clickNode(nodeId, label, origin) { }; } -export function clickPauseUpdate() { +export function pauseTimeAtNow() { return { type: ActionTypes.PAUSE_TIME_AT_NOW }; @@ -572,10 +562,10 @@ export function receiveNodesDelta(delta) { }; } -export function clickResumeUpdate() { +export function resumeTime() { return (dispatch, getState) => { dispatch({ - type: ActionTypes.RESUME_TIME_FROM_NOW + type: ActionTypes.RESUME_TIME }); // After unpausing, all of the following calls will re-activate polling. getTopologies(getState, dispatch); diff --git a/client/app/scripts/components/time-control.js b/client/app/scripts/components/time-control.js index db6488ced2..fd3680a8e2 100644 --- a/client/app/scripts/components/time-control.js +++ b/client/app/scripts/components/time-control.js @@ -6,7 +6,7 @@ import { connect } from 'react-redux'; import CloudFeature from './cloud-feature'; import TimeTravelButton from './time-travel-button'; import { trackMixpanelEvent } from '../utils/tracking-utils'; -import { clickPauseUpdate, clickResumeUpdate, clickTimeTravel } from '../actions/app-actions'; +import { pauseTimeAtNow, resumeTime, clickTimeTravel } from '../actions/app-actions'; const className = isSelected => ( @@ -34,12 +34,12 @@ class TimeControl extends React.Component { handleNowClick() { trackMixpanelEvent('scope.time.resume.click', this.getTrackingMetadata()); - this.props.clickResumeUpdate(); + this.props.resumeTime(); } handlePauseClick() { trackMixpanelEvent('scope.time.pause.click', this.getTrackingMetadata()); - this.props.clickPauseUpdate(); + this.props.pauseTimeAtNow(); } handleTravelClick() { @@ -115,8 +115,8 @@ function mapStateToProps(state) { export default connect( mapStateToProps, { - clickPauseUpdate, - clickResumeUpdate, + resumeTime, + pauseTimeAtNow, clickTimeTravel, } )(TimeControl); diff --git a/client/app/scripts/components/time-travel-button.js b/client/app/scripts/components/time-travel-button.js index 444e5c33f9..3bb1764819 100644 --- a/client/app/scripts/components/time-travel-button.js +++ b/client/app/scripts/components/time-travel-button.js @@ -2,6 +2,7 @@ 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; diff --git a/client/app/scripts/components/time-travel.js b/client/app/scripts/components/time-travel.js index 0c1296d571..979913d799 100644 --- a/client/app/scripts/components/time-travel.js +++ b/client/app/scripts/components/time-travel.js @@ -8,7 +8,7 @@ import { debounce, map } from 'lodash'; import { trackMixpanelEvent } from '../utils/tracking-utils'; import { jumpToTime, - clickResumeUpdate, + resumeTime, timeTravelStartTransition, } from '../actions/app-actions'; @@ -65,7 +65,7 @@ class TimeTravel extends React.Component { componentWillUnmount() { clearInterval(this.timer); - this.props.clickResumeUpdate(); + this.props.resumeTime(); } handleSliderChange(timestamp) { @@ -123,6 +123,8 @@ class TimeTravel extends React.Component { renderMark({ timestampValue, label }) { const sliderMaxValue = moment().valueOf(); const pos = (sliderMaxValue - timestampValue) / (sliderMaxValue - this.state.sliderMinValue); + + // Ignore the month marks that are very close to 'Now' if (label !== 'Now' && pos < 0.05) return null; const style = { marginLeft: `calc(${(1 - pos) * 100}% - 32px)`, width: '64px' }; @@ -147,6 +149,7 @@ class TimeTravel extends React.Component { 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'); @@ -215,7 +218,7 @@ export default connect( mapStateToProps, { jumpToTime, - clickResumeUpdate, + resumeTime, timeTravelStartTransition, } )(TimeTravel); diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index 088556152a..5238ad391f 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -12,13 +12,9 @@ const ACTION_TYPES = [ 'CLICK_CLOSE_TERMINAL', 'CLICK_FORCE_RELAYOUT', 'CLICK_NODE', - 'PAUSE_TIME_AT_NOW', 'CLICK_RELATIVE', - 'RESUME_TIME_FROM_NOW', - 'JUMP_TO_TIME', 'CLICK_SHOW_TOPOLOGY_FOR_NODE', 'CLICK_TERMINAL', - 'START_TIME_TRAVEL', 'CLICK_TOPOLOGY', 'CLOSE_WEBSOCKET', 'DEBUG_TOOLBAR_INTERFERING', @@ -33,9 +29,11 @@ const ACTION_TYPES = [ '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', @@ -53,6 +51,7 @@ const ACTION_TYPES = [ 'RECEIVE_TOPOLOGIES', 'REQUEST_SERVICE_IMAGES', 'RESET_LOCAL_VIEW_STATE', + 'RESUME_TIME', 'ROUTE_TOPOLOGY', 'SELECT_NETWORK', 'SET_EXPORTING_GRAPH', @@ -63,6 +62,7 @@ const ACTION_TYPES = [ 'SHOW_NETWORKS', 'SHUTDOWN', 'SORT_ORDER_CHANGED', + 'START_TIME_TRAVEL', 'TIME_TRAVEL_START_TRANSITION', 'TOGGLE_CONTRAST_MODE', 'TOGGLE_TROUBLESHOOTING_MENU', diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 891866fabd..24103c6b1d 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -363,7 +363,7 @@ export function rootReducer(state = initialState, action) { // time control // - case ActionTypes.RESUME_TIME_FROM_NOW: { + case ActionTypes.RESUME_TIME: { state = state.set('timeTravelTransitioning', true); state = state.set('showingTimeTravel', false); return state.set('pausedAt', null); diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index b802b26584..88406b7099 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -197,7 +197,7 @@ function getNodesForTopologies(state, dispatch, topologyIds, topologyOptions = m Promise.resolve()); } -export function getNodesOnce(getState, dispatch) { +function getNodesOnce(getState, dispatch) { const state = getState(); const topologyUrl = getCurrentTopologyUrl(state); const topologyOptions = activeTopologyOptionsSelector(state); @@ -263,7 +263,7 @@ export function getTopologies(getState, dispatch, initialPoll = false) { }); } -export function updateWebsocketChannel(getState, dispatch, justUnpaused = false) { +function updateWebsocketChannel(getState, dispatch, forceRequest) { const topologyUrl = getCurrentTopologyUrl(getState()); const topologyOptions = activeTopologyOptionsSelector(getState()); const websocketUrl = buildWebsocketUrl(topologyUrl, topologyOptions, getState()); @@ -271,7 +271,7 @@ export function updateWebsocketChannel(getState, dispatch, justUnpaused = false) 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 || justUnpaused)) { + if (topologyUrl && (!socket || isNewUrl || forceRequest)) { createWebsocket(websocketUrl, getState, dispatch); currentUrl = websocketUrl; } @@ -318,6 +318,15 @@ export function getNodeDetails(getState, 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 eabb74e91c..bcc62fcf98 100644 --- a/client/app/styles/_base.scss +++ b/client/app/styles/_base.scss @@ -1958,7 +1958,7 @@ // .nodes-grid { - // TODO: Use relative positioning here. + // TODO: Would be good to have relative positioning here. position: absolute; top: 0; From 94da5e934d7bef6bf886ed564f6beae70c796170 Mon Sep 17 00:00:00 2001 From: Filip Barl Date: Thu, 6 Jul 2017 14:52:10 +0200 Subject: [PATCH 23/23] Addressed some of @foot's comments. --- client/app/scripts/actions/app-actions.js | 2 +- client/app/scripts/components/time-control.js | 16 +++++++++++----- client/app/scripts/reducers/root.js | 11 ++++++----- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 429124e8b8..854a1c1d45 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -576,7 +576,7 @@ export function resumeTime() { }; } -export function clickTimeTravel() { +export function startTimeTravel() { return (dispatch, getState) => { dispatch({ type: ActionTypes.START_TIME_TRAVEL diff --git a/client/app/scripts/components/time-control.js b/client/app/scripts/components/time-control.js index fd3680a8e2..2cfe27dd7b 100644 --- a/client/app/scripts/components/time-control.js +++ b/client/app/scripts/components/time-control.js @@ -6,7 +6,7 @@ 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, clickTimeTravel } from '../actions/app-actions'; +import { pauseTimeAtNow, resumeTime, startTimeTravel } from '../actions/app-actions'; const className = isSelected => ( @@ -23,12 +23,13 @@ class TimeControl extends React.Component { this.getTrackingMetadata = this.getTrackingMetadata.bind(this); } - getTrackingMetadata() { + getTrackingMetadata(data = {}) { const { currentTopology } = this.props; return { layout: this.props.topologyViewMode, topologyId: currentTopology && currentTopology.get('id'), parentTopologyId: currentTopology && currentTopology.get('parentId'), + ...data }; } @@ -43,8 +44,13 @@ class TimeControl extends React.Component { } handleTravelClick() { - trackMixpanelEvent('scope.time.travel.click', this.getTrackingMetadata()); - this.props.clickTimeTravel(); + 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() { @@ -117,6 +123,6 @@ export default connect( { resumeTime, pauseTimeAtNow, - clickTimeTravel, + startTimeTravel, } )(TimeControl); diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js index 24103c6b1d..81e6b60075 100644 --- a/client/app/scripts/reducers/root.js +++ b/client/app/scripts/reducers/root.js @@ -328,11 +328,6 @@ export function rootReducer(state = initialState, action) { return state; } - case ActionTypes.FINISH_TIME_TRAVEL_TRANSITION: { - state = state.set('timeTravelTransitioning', false); - return clearNodes(state); - } - case ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE: { state = state.update('nodeDetails', nodeDetails => nodeDetails.filter((v, k) => k === action.nodeId)); @@ -375,6 +370,7 @@ export function rootReducer(state = initialState, action) { } case ActionTypes.START_TIME_TRAVEL: { + state = state.set('timeTravelTransitioning', false); state = state.set('showingTimeTravel', true); return state.set('pausedAt', moment().utc()); } @@ -387,6 +383,11 @@ export function rootReducer(state = initialState, action) { return state.set('timeTravelTransitioning', true); } + case ActionTypes.FINISH_TIME_TRAVEL_TRANSITION: { + state = state.set('timeTravelTransitioning', false); + return clearNodes(state); + } + // // websockets //