Time travel redesign (#2651)

* Initial top level control.

* Added the jump buttons.

* Tiny styling adjustments.

* Massive renaming.

* Pause info

* Added slider marks.

* Improved messaging.

* Freeze all updates when paused.

* Repositioned for Configure button.

* Improved the flow.

* Working browsing through slider.

* Small styling.

* Hide time travel button behind the feature flag.

* Fixed actions.

* Elements positioning corner cases.

* Removed nodes delta buffering code.

* Fixed the flow.

* Fixed almost all API call cases.

* Final touches

* Fixed the tests.

* Fix resource view updates when time travelling.

* Added some comments.

* Addressed some of @foot's comments.
This commit is contained in:
Filip Barl
2017-07-06 16:06:55 +02:00
committed by GitHub
parent cf8a95c878
commit 8f2d47ce4e
20 changed files with 661 additions and 705 deletions
+78 -97
View File
@@ -8,12 +8,12 @@ import {
doControlRequest,
getAllNodes,
getResourceViewNodesSnapshot,
updateWebsocketChannel,
getNodeDetails,
getTopologies,
deletePipe,
stopPolling,
teardownWebsockets,
getNodes,
} from '../utils/web-api-utils';
import { storageSet } from '../utils/storage-utils';
import { loadTheme } from '../utils/contrast-utils';
@@ -29,8 +29,6 @@ import {
resourceViewAvailableSelector,
} from '../selectors/topology';
import { NODES_DELTA_BUFFER_SIZE_LIMIT } from '../constants/limits';
import { NODES_DELTA_BUFFER_FEED_INTERVAL } from '../constants/timer';
import {
GRAPH_VIEW_MODE,
TABLE_VIEW_MODE,
@@ -40,8 +38,6 @@ import {
const log = debug('scope:app-actions');
// TODO: This shouldn't be exposed here as a global variable.
let nodesDeltaBufferUpdateTimer = null;
export function showHelp() {
return { type: ActionTypes.SHOW_HELP };
@@ -75,11 +71,6 @@ export function sortOrderChanged(sortedBy, sortedDesc) {
};
}
function resetNodesDeltaBuffer() {
clearInterval(nodesDeltaBufferUpdateTimer);
return { type: ActionTypes.CLEAR_NODES_DELTA_BUFFER };
}
//
// Networks
@@ -216,11 +207,8 @@ export function changeTopologyOption(option, value, topologyId, addOrRemove) {
});
updateRoute(getState);
// update all request workers with new options
dispatch(resetNodesDeltaBuffer());
const state = getState();
getTopologies(state, dispatch);
updateWebsocketChannel(state, dispatch);
getNodeDetails(state, dispatch);
getTopologies(getState, dispatch);
getNodes(getState, dispatch);
};
}
@@ -344,13 +332,13 @@ export function clickNode(nodeId, label, origin) {
nodeId
});
updateRoute(getState);
getNodeDetails(getState(), dispatch);
getNodeDetails(getState, dispatch);
};
}
export function clickPauseUpdate() {
export function pauseTimeAtNow() {
return {
type: ActionTypes.CLICK_PAUSE_UPDATE
type: ActionTypes.PAUSE_TIME_AT_NOW
};
}
@@ -364,7 +352,7 @@ export function clickRelative(nodeId, topologyId, label, origin) {
topologyId
});
updateRoute(getState);
getNodeDetails(getState(), dispatch);
getNodeDetails(getState, dispatch);
};
}
@@ -375,12 +363,10 @@ function updateTopology(dispatch, getState) {
getResourceViewNodesSnapshot(state, dispatch);
}
updateRoute(getState);
// update all request workers with new options
dispatch(resetNodesDeltaBuffer());
// NOTE: This is currently not needed for our static resource
// view, but we'll need it here later and it's simpler to just
// keep it than to redo the nodes delta updating logic.
updateWebsocketChannel(state, dispatch);
getNodes(getState, dispatch);
}
export function clickShowTopologyForNode(topologyId, nodeId) {
@@ -404,28 +390,6 @@ export function clickTopology(topologyId) {
};
}
export function timeTravelStartTransition() {
return {
type: ActionTypes.TIME_TRAVEL_START_TRANSITION,
};
}
export function timeTravelJumpToPast(millisecondsInPast) {
return (dispatch, getServiceState) => {
dispatch({
type: ActionTypes.TIME_TRAVEL_MILLISECONDS_IN_PAST,
millisecondsInPast,
});
const scopeState = getServiceState().scope;
updateWebsocketChannel(scopeState, dispatch);
dispatch(resetNodesDeltaBuffer());
getTopologies(getServiceState().scope, dispatch);
if (isResourceViewModeSelector(scopeState)) {
getResourceViewNodesSnapshot(scopeState, dispatch);
}
};
}
export function cacheZoomState(zoomState) {
return {
type: ActionTypes.CACHE_ZOOM_STATE,
@@ -573,29 +537,22 @@ export function receiveNodeDetails(details) {
export function receiveNodesDelta(delta) {
return (dispatch, getState) => {
//
// allow css-animation to run smoothly by scheduling it to run on the
// next tick after any potentially expensive canvas re-draws have been
// completed.
//
setTimeout(() => dispatch({ type: ActionTypes.SET_RECEIVED_NODES_DELTA }), 0);
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);
// 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 <CloudFeature /> have a global Service state
// returned by getState(). Since method is called from both contexts, getState()
// will sometimes return Scope state subtree and sometimes the whole Service state.
const state = getState().scope || getState();
const movingInTime = state.get('timeTravelTransitioning');
const hasChanges = delta.add || delta.update || delta.remove;
// When moving in time, we will consider the transition complete
// only when the first batch of nodes delta has been received. We
// do that because we want to keep the previous state blurred instead
// of transitioning over an empty state like when switching topologies.
if (getState().get('timeTravelTransitioning')) {
dispatch({ type: ActionTypes.FINISH_TIME_TRAVEL_TRANSITION });
}
if (hasChanges || movingInTime) {
if (isPausedSelector(state)) {
if (state.get('nodesDeltaBuffer').size >= NODES_DELTA_BUFFER_SIZE_LIMIT) {
dispatch({ type: ActionTypes.CONSOLIDATE_NODES_DELTA_BUFFER });
}
dispatch({ type: ActionTypes.BUFFER_NODES_DELTA, delta });
} else {
const hasChanges = delta.add || delta.update || delta.remove;
if (hasChanges) {
dispatch({
type: ActionTypes.RECEIVE_NODES_DELTA,
delta
@@ -605,30 +562,58 @@ export function receiveNodesDelta(delta) {
};
}
function updateFromNodesDeltaBuffer(dispatch, state) {
const isPaused = isPausedSelector(state);
const isBufferEmpty = state.get('nodesDeltaBuffer').isEmpty();
if (isPaused || isBufferEmpty) {
dispatch(resetNodesDeltaBuffer());
} else {
dispatch(receiveNodesDelta(state.get('nodesDeltaBuffer').first()));
dispatch({ type: ActionTypes.POP_NODES_DELTA_BUFFER });
}
}
export function clickResumeUpdate() {
export function resumeTime() {
return (dispatch, getState) => {
dispatch({
type: ActionTypes.CLICK_RESUME_UPDATE
type: ActionTypes.RESUME_TIME
});
// TODO: Find a better way to do this (see the comment above).
const state = getState().scope || getState();
// Periodically merge buffered nodes deltas until the buffer is emptied.
nodesDeltaBufferUpdateTimer = setInterval(
() => updateFromNodesDeltaBuffer(dispatch, state),
NODES_DELTA_BUFFER_FEED_INTERVAL,
);
// After unpausing, all of the following calls will re-activate polling.
getTopologies(getState, dispatch);
getNodes(getState, dispatch, true);
if (isResourceViewModeSelector(getState())) {
getResourceViewNodesSnapshot(getState(), dispatch);
}
};
}
export function startTimeTravel() {
return (dispatch, getState) => {
dispatch({
type: ActionTypes.START_TIME_TRAVEL
});
if (!getState().get('nodesLoaded')) {
getNodes(getState, dispatch);
if (isResourceViewModeSelector(getState())) {
getResourceViewNodesSnapshot(getState(), dispatch);
}
}
};
}
export function receiveNodes(nodes) {
return {
type: ActionTypes.RECEIVE_NODES,
nodes,
};
}
export function timeTravelStartTransition() {
return {
type: ActionTypes.TIME_TRAVEL_START_TRANSITION,
};
}
export function jumpToTime(timestamp) {
return (dispatch, getState) => {
dispatch({
type: ActionTypes.JUMP_TO_TIME,
timestamp,
});
getTopologies(getState, dispatch);
getNodes(getState, dispatch);
if (isResourceViewModeSelector(getState())) {
getResourceViewNodesSnapshot(getState(), dispatch);
}
};
}
@@ -641,18 +626,15 @@ export function receiveNodesForTopology(nodes, topologyId) {
}
export function receiveTopologies(topologies) {
return (dispatch, getGlobalState) => {
// NOTE: Fortunately, this will go when Time Travel is out of <CloudFeature />.
const getState = () => getGlobalState().scope || getGlobalState();
return (dispatch, getState) => {
const firstLoad = !getState().get('topologiesLoaded');
dispatch({
type: ActionTypes.RECEIVE_TOPOLOGIES,
topologies
});
const state = getState();
updateWebsocketChannel(state, dispatch);
getNodeDetails(state, dispatch);
getNodes(getState, dispatch);
// Populate search matches on first load
const state = getState();
if (firstLoad && state.get('searchQuery')) {
dispatch(focusSearch());
}
@@ -754,7 +736,7 @@ export function setContrastMode(enabled) {
export function getTopologiesWithInitialPoll() {
return (dispatch, getState) => {
getTopologies(getState(), dispatch, true);
getTopologies(getState, dispatch, true);
};
}
@@ -765,13 +747,12 @@ export function route(urlState) {
type: ActionTypes.ROUTE_TOPOLOGY
});
// update all request workers with new options
const state = getState();
getTopologies(state, dispatch);
updateWebsocketChannel(state, dispatch);
getNodeDetails(state, dispatch);
getTopologies(getState, dispatch);
getNodes(getState, dispatch);
// If we are landing on the resource view page, we need to fetch not only all the
// nodes for the current topology, but also the nodes of all the topologies that make
// the layers in the resource view.
const state = getState();
if (isResourceViewModeSelector(state)) {
getResourceViewNodesSnapshot(state, dispatch);
}
+20 -15
View File
@@ -1,5 +1,6 @@
import debug from 'debug';
import React from 'react';
import classNames from 'classnames';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
@@ -12,7 +13,6 @@ import Search from './search';
import Status from './status';
import Topologies from './topologies';
import TopologyOptions from './topology-options';
import CloudFeature from './cloud-feature';
import Overlay from './overlay';
import { getApiDetails } from '../utils/web-api-utils';
import {
@@ -33,6 +33,7 @@ import {
import Details from './details';
import Nodes from './nodes';
import TimeTravel from './time-travel';
import TimeControl from './time-control';
import ViewModeSelector from './view-mode-selector';
import NetworkSelector from './networks-selector';
import DebugToolbar, { showingDebugToolbar, toggleDebugToolbar } from './debug-toolbar';
@@ -166,12 +167,15 @@ class App extends React.Component {
}
render() {
const { isTableViewMode, isGraphViewMode, isResourceViewMode, showingDetails, showingHelp,
showingNetworkSelector, showingTroubleshootingMenu, timeTravelTransitioning } = this.props;
const { isTableViewMode, isGraphViewMode, isResourceViewMode, showingDetails,
showingHelp, showingNetworkSelector, showingTroubleshootingMenu,
timeTravelTransitioning, showingTimeTravel } = this.props;
const className = classNames('scope-app', { 'time-travel-open': showingTimeTravel });
const isIframe = window !== window.top;
return (
<div className="scope-app" ref={this.saveAppRef}>
<div className={className} ref={this.saveAppRef}>
{showingDebugToolbar() && <DebugToolbar />}
{showingHelp && <HelpPanel />}
@@ -181,22 +185,22 @@ class App extends React.Component {
{showingDetails && <Details />}
<div className="header">
<div className="logo">
{!isIframe && <svg width="100%" height="100%" viewBox="0 0 1089 217">
<Logo />
</svg>}
<TimeTravel />
<div className="selectors">
<div className="logo">
{!isIframe && <svg width="100%" height="100%" viewBox="0 0 1089 217">
<Logo />
</svg>}
</div>
<Search />
<Topologies />
<ViewModeSelector />
<TimeControl />
</div>
<Search />
<Topologies />
<ViewModeSelector />
</div>
<Nodes />
<CloudFeature>
{!isResourceViewMode && <TimeTravel />}
</CloudFeature>
<Sidebar classNames={isTableViewMode ? 'sidebar-gridmode' : ''}>
{showingNetworkSelector && isGraphViewMode && <NetworkSelector />}
{!isResourceViewMode && <Status />}
@@ -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,
-2
View File
@@ -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 {
</div>
<div className="footer-tools">
<PauseButton />
<a
className="footer-icon"
onClick={this.handleRelayoutClick}
@@ -1,80 +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 { trackMixpanelEvent } from '../utils/tracking-utils';
import { clickPauseUpdate, clickResumeUpdate } from '../actions/app-actions';
class PauseButton extends React.Component {
constructor(props, context) {
super(props, context);
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
if (this.props.isPaused) {
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();
} else {
trackMixpanelEvent('scope.time.pause.click', {
layout: this.props.topologyViewMode,
topologyId: this.props.currentTopology.get('id'),
parentTopologyId: this.props.currentTopology.get('parentId'),
});
this.props.clickPauseUpdate();
}
}
render() {
const { isPaused, hasUpdates, updateCount, updatePausedAt } = this.props;
const className = classNames('button pause-button', { active: isPaused });
const title = isPaused ?
`Paused ${moment(updatePausedAt).fromNow()}` :
'Pause updates (freezes the nodes in their current layout)';
let label = '';
if (hasUpdates && isPaused) {
label = `Paused +${updateCount}`;
} else if (hasUpdates && !isPaused) {
label = `Resuming +${updateCount}`;
} else if (!hasUpdates && isPaused) {
label = 'Paused';
}
return (
<a className={className} onClick={this.handleClick} title={title}>
{label !== '' && <span className="pause-text">{label}</span>}
<span className="fa fa-pause" />
</a>
);
}
}
function mapStateToProps(state) {
return {
hasUpdates: !state.get('nodesDeltaBuffer').isEmpty(),
updateCount: state.get('nodesDeltaBuffer').size,
updatePausedAt: state.get('updatePausedAt'),
topologyViewMode: state.get('topologyViewMode'),
currentTopology: state.get('currentTopology'),
isPaused: isPausedSelector(state),
};
}
export default connect(
mapStateToProps,
{
clickPauseUpdate,
clickResumeUpdate,
}
)(PauseButton);
+2 -2
View File
@@ -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'),
@@ -0,0 +1,128 @@
import React from 'react';
import moment from 'moment';
import classNames from 'classnames';
import { connect } from 'react-redux';
import CloudFeature from './cloud-feature';
import TimeTravelButton from './time-travel-button';
import { trackMixpanelEvent } from '../utils/tracking-utils';
import { pauseTimeAtNow, resumeTime, startTimeTravel } from '../actions/app-actions';
const className = isSelected => (
classNames('time-control-action', { 'time-control-action-selected': isSelected })
);
class TimeControl extends React.Component {
constructor(props, context) {
super(props, context);
this.handleNowClick = this.handleNowClick.bind(this);
this.handlePauseClick = this.handlePauseClick.bind(this);
this.handleTravelClick = this.handleTravelClick.bind(this);
this.getTrackingMetadata = this.getTrackingMetadata.bind(this);
}
getTrackingMetadata(data = {}) {
const { currentTopology } = this.props;
return {
layout: this.props.topologyViewMode,
topologyId: currentTopology && currentTopology.get('id'),
parentTopologyId: currentTopology && currentTopology.get('parentId'),
...data
};
}
handleNowClick() {
trackMixpanelEvent('scope.time.resume.click', this.getTrackingMetadata());
this.props.resumeTime();
}
handlePauseClick() {
trackMixpanelEvent('scope.time.pause.click', this.getTrackingMetadata());
this.props.pauseTimeAtNow();
}
handleTravelClick() {
if (!this.props.showingTimeTravel) {
trackMixpanelEvent('scope.time.travel.click', this.getTrackingMetadata({ open: true }));
this.props.startTimeTravel();
} else {
trackMixpanelEvent('scope.time.travel.click', this.getTrackingMetadata({ open: false }));
this.props.resumeTime();
}
}
render() {
const { showingTimeTravel, pausedAt, timeTravelTransitioning, topologiesLoaded } = this.props;
const isPausedNow = pausedAt && !showingTimeTravel;
const isTimeTravelling = showingTimeTravel;
const isRunningNow = !pausedAt;
if (!topologiesLoaded) return null;
return (
<div className="time-control">
<div className="time-control-controls">
<div className="time-control-spinner">
{timeTravelTransitioning && <span className="fa fa-circle-o-notch fa-spin" />}
</div>
<div className="time-control-wrapper">
<span
className={className(isRunningNow)}
onClick={this.handleNowClick}
title="Show live state of the system">
{isRunningNow && <span className="fa fa-play" />}
<span className="label">Live</span>
</span>
<span
className={className(isPausedNow)}
onClick={!isTimeTravelling && this.handlePauseClick}
disabled={isTimeTravelling}
title="Pause updates (freezes the nodes in their current layout)">
{isPausedNow && <span className="fa fa-pause" />}
<span className="label">{isPausedNow ? 'Paused' : 'Pause'}</span>
</span>
<CloudFeature>
<TimeTravelButton
className={className(isTimeTravelling)}
onClick={this.handleTravelClick}
isTimeTravelling={isTimeTravelling}
/>
</CloudFeature>
</div>
</div>
{(isPausedNow || isTimeTravelling) && <span
className="time-control-info"
title={moment(pausedAt).toISOString()}>
Showing state from {moment(pausedAt).fromNow()}
</span>}
{isRunningNow && timeTravelTransitioning && <span
className="time-control-info">
Resuming the live state
</span>}
</div>
);
}
}
function mapStateToProps(state) {
return {
topologyViewMode: state.get('topologyViewMode'),
topologiesLoaded: state.get('topologiesLoaded'),
currentTopology: state.get('currentTopology'),
showingTimeTravel: state.get('showingTimeTravel'),
timeTravelTransitioning: state.get('timeTravelTransitioning'),
pausedAt: state.get('pausedAt'),
};
}
export default connect(
mapStateToProps,
{
resumeTime,
pauseTimeAtNow,
startTimeTravel,
}
)(TimeControl);
@@ -0,0 +1,29 @@
import React from 'react';
import { connect } from 'react-redux';
// TODO: Move this back into TimeTravelControls once we move away from CloudFeature.
class TimeTravelButton extends React.Component {
render() {
const { className, onClick, isTimeTravelling, hasTimeTravel } = this.props;
if (!hasTimeTravel) return null;
return (
<span className={className} onClick={onClick} title="Travel back in time">
{isTimeTravelling && <span className="fa fa-clock-o" />}
<span className="label">Time Travel</span>
</span>
);
}
}
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);
@@ -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 (
<time>{timestamp.format()}</time>
);
}
render() {
const { selected, onClick, millisecondsInPast } = this.props;
const isCurrent = (millisecondsInPast === 0);
const className = classNames('button time-travel-timestamp', {
selected, current: isCurrent
});
return (
<a className={className} onClick={onClick}>
<span className="time-travel-timestamp-info">
{isCurrent ? 'now' : this.renderTimestamp()}
</span>
<span className="fa fa-clock-o" />
</a>
);
}
}
function mapStateToProps({ scope }) {
return {
isPaused: isPausedSelector(scope),
updatePausedAt: scope.get('updatePausedAt'),
};
}
export default connect(mapStateToProps)(TimeTravelTimestamp);
+134 -196
View File
@@ -1,89 +1,52 @@
import React from 'react';
import moment from 'moment';
import Slider from 'rc-slider';
import moment from 'moment';
import classNames from 'classnames';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { debounce, map } from 'lodash';
import TimeTravelTimestamp from './time-travel-timestamp';
import { trackMixpanelEvent } from '../utils/tracking-utils';
import {
timeTravelJumpToPast,
jumpToTime,
resumeTime,
timeTravelStartTransition,
clickResumeUpdate,
} from '../actions/app-actions';
import {
TIMELINE_SLIDER_UPDATE_INTERVAL,
TIMELINE_TICK_INTERVAL,
TIMELINE_DEBOUNCE_INTERVAL,
} from '../constants/timer';
const sliderRanges = {
last15Minutes: {
label: 'Last 15 minutes',
getStart: () => moment().utc().subtract(15, 'minutes'),
},
last1Hour: {
label: 'Last 1 hour',
getStart: () => moment().utc().subtract(1, 'hour'),
},
last6Hours: {
label: 'Last 6 hours',
getStart: () => moment().utc().subtract(6, 'hours'),
},
last24Hours: {
label: 'Last 24 hours',
getStart: () => moment().utc().subtract(24, 'hours'),
},
last7Days: {
label: 'Last 7 days',
getStart: () => moment().utc().subtract(7, 'days'),
},
last30Days: {
label: 'Last 30 days',
getStart: () => moment().utc().subtract(30, 'days'),
},
last90Days: {
label: 'Last 90 days',
getStart: () => moment().utc().subtract(90, 'days'),
},
last1Year: {
label: 'Last 1 year',
getStart: () => moment().subtract(1, 'year'),
},
todaySoFar: {
label: 'Today so far',
getStart: () => moment().utc().startOf('day'),
},
thisWeekSoFar: {
label: 'This week so far',
getStart: () => moment().utc().startOf('week'),
},
thisMonthSoFar: {
label: 'This month so far',
getStart: () => moment().utc().startOf('month'),
},
thisYearSoFar: {
label: 'This year so far',
getStart: () => moment().utc().startOf('year'),
},
const getTimestampStates = (timestamp) => {
timestamp = timestamp || moment();
return {
sliderValue: moment(timestamp).valueOf(),
inputValue: moment(timestamp).utc().format(),
};
};
const ONE_HOUR_MS = moment.duration(1, 'hour');
const FIVE_MINUTES_MS = moment.duration(5, 'minutes');
class TimeTravel extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
showSliderPanel: false,
millisecondsInPast: 0,
rangeOptionSelected: sliderRanges.last1Hour,
// TODO: Showing a three months of history is quite arbitrary;
// we should instead get some meaningful 'beginning of time' from
// the backend and make the slider show whole active history.
sliderMinValue: moment().subtract(6, 'months').valueOf(),
...getTimestampStates(props.pausedAt),
};
this.renderRangeOption = this.renderRangeOption.bind(this);
this.handleTimestampClick = this.handleTimestampClick.bind(this);
this.handleJumpToNowClick = this.handleJumpToNowClick.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleSliderChange = this.handleSliderChange.bind(this);
this.handleJumpClick = this.handleJumpClick.bind(this);
this.renderMarks = this.renderMarks.bind(this);
this.renderMark = this.renderMark.bind(this);
this.travelTo = this.travelTo.bind(this);
this.debouncedUpdateTimestamp = debounce(
this.updateTimestamp.bind(this), TIMELINE_DEBOUNCE_INTERVAL);
@@ -93,84 +56,60 @@ class TimeTravel extends React.Component {
componentDidMount() {
// Force periodic re-renders to update the slider position as time goes by.
this.timer = setInterval(() => { this.forceUpdate(); }, TIMELINE_SLIDER_UPDATE_INTERVAL);
this.timer = setInterval(() => { this.forceUpdate(); }, TIMELINE_TICK_INTERVAL);
}
componentWillReceiveProps(props) {
this.setState(getTimestampStates(props.pausedAt));
}
componentWillUnmount() {
clearInterval(this.timer);
this.updateTimestamp();
this.props.resumeTime();
}
handleSliderChange(sliderValue) {
let millisecondsInPast = this.getRangeMilliseconds() - sliderValue;
// If the slider value is less than 1s away from the right-end (current time),
// assume we meant the current time - this is important for the '... so far'
// ranges where the range of values changes over time.
if (millisecondsInPast < 1000) {
millisecondsInPast = 0;
}
this.setState({ millisecondsInPast });
this.props.timeTravelStartTransition();
this.debouncedUpdateTimestamp(millisecondsInPast);
handleSliderChange(timestamp) {
this.travelTo(timestamp, true);
this.debouncedTrackSliderChange();
}
handleRangeOptionClick(rangeOption) {
this.setState({ rangeOptionSelected: rangeOption });
handleInputChange(ev) {
let timestamp = moment(ev.target.value);
this.setState({ inputValue: ev.target.value });
const rangeMilliseconds = this.getRangeMilliseconds(rangeOption);
if (this.state.millisecondsInPast > rangeMilliseconds) {
this.setState({ millisecondsInPast: rangeMilliseconds });
this.updateTimestamp(rangeMilliseconds);
this.props.timeTravelStartTransition();
if (timestamp.isValid()) {
timestamp = Math.max(timestamp, this.state.sliderMinValue);
timestamp = Math.min(timestamp, moment().valueOf());
this.travelTo(timestamp, true);
trackMixpanelEvent('scope.time.timestamp.edit', {
layout: this.props.topologyViewMode,
topologyId: this.props.currentTopology.get('id'),
parentTopologyId: this.props.currentTopology.get('parentId'),
});
}
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();
handleJumpClick(millisecondsDelta) {
let timestamp = this.state.sliderValue + millisecondsDelta;
timestamp = Math.max(timestamp, this.state.sliderMinValue);
timestamp = Math.min(timestamp, moment().valueOf());
this.travelTo(timestamp, true);
}
updateTimestamp(timestamp) {
this.props.jumpToTime(moment(timestamp));
}
travelTo(timestamp, debounced = false) {
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());
this.setState(getTimestampStates(timestamp));
if (debounced) {
this.debouncedUpdateTimestamp(timestamp);
} else {
this.debouncedUpdateTimestamp.cancel();
this.updateTimestamp(timestamp);
}
}
trackSliderChange() {
@@ -181,106 +120,105 @@ class TimeTravel extends React.Component {
});
}
renderRangeOption(rangeOption) {
const handleClick = () => { this.handleRangeOptionClick(rangeOption); };
const selected = (this.state.rangeOptionSelected.label === rangeOption.label);
const className = classNames('option', { selected });
renderMark({ timestampValue, label }) {
const sliderMaxValue = moment().valueOf();
const pos = (sliderMaxValue - timestampValue) / (sliderMaxValue - this.state.sliderMinValue);
// 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' };
return (
<a key={rangeOption.label} className={className} onClick={handleClick}>
{rangeOption.label}
</a>
<div
style={style}
className="time-travel-markers-tick"
key={timestampValue}>
<span className="vertical-tick" />
<a className="link" onClick={() => this.travelTo(timestampValue)}>{label}</a>
</div>
);
}
renderJumpToNowButton() {
return (
<a className="button jump-to-now" title="Jump to now" onClick={this.handleJumpToNowClick}>
<span className="fa fa-step-forward" />
</a>
);
}
renderMarks() {
const { sliderMinValue } = this.state;
const sliderMaxValue = moment().valueOf();
const ticks = [{ timestampValue: sliderMaxValue, label: 'Now' }];
let monthsBack = 0;
let timestamp;
renderTimeSlider() {
const { millisecondsInPast } = this.state;
const rangeMilliseconds = this.getRangeMilliseconds();
do {
timestamp = moment().utc().subtract(monthsBack, 'months').startOf('month');
if (timestamp.valueOf() >= sliderMinValue) {
// Months are broken by the year tag, e.g. November, December, 2016, February, etc...
let label = timestamp.format('MMMM');
if (label === 'January') {
label = timestamp.format('YYYY');
}
ticks.push({ timestampValue: timestamp.valueOf(), label });
}
monthsBack += 1;
} while (timestamp.valueOf() >= sliderMinValue);
return (
<Slider
onChange={this.handleSliderChange}
value={rangeMilliseconds - millisecondsInPast}
max={rangeMilliseconds}
/>
<div className="time-travel-markers">
{map(ticks, tick => this.renderMark(tick))}
</div>
);
}
render() {
const { timeTravelTransitioning, hasTimeTravel } = this.props;
const { showSliderPanel, millisecondsInPast, rangeOptionSelected } = this.state;
const lowerCaseLabel = rangeOptionSelected.label.toLowerCase();
const isCurrent = (millisecondsInPast === 0);
const { sliderValue, sliderMinValue, inputValue } = this.state;
const sliderMaxValue = moment().valueOf();
// Don't render the time travel control if it's not explicitly enabled for this instance.
if (!hasTimeTravel) return null;
const className = classNames('time-travel', { visible: this.props.showingTimeTravel });
return (
<div className="time-travel">
{showSliderPanel && <div className="time-travel-slider">
<div className="options">
<div className="column">
{this.renderRangeOption(sliderRanges.last15Minutes)}
{this.renderRangeOption(sliderRanges.last1Hour)}
{this.renderRangeOption(sliderRanges.last6Hours)}
{this.renderRangeOption(sliderRanges.last24Hours)}
</div>
<div className="column">
{this.renderRangeOption(sliderRanges.last7Days)}
{this.renderRangeOption(sliderRanges.last30Days)}
{this.renderRangeOption(sliderRanges.last90Days)}
{this.renderRangeOption(sliderRanges.last1Year)}
</div>
<div className="column">
{this.renderRangeOption(sliderRanges.todaySoFar)}
{this.renderRangeOption(sliderRanges.thisWeekSoFar)}
{this.renderRangeOption(sliderRanges.thisMonthSoFar)}
{this.renderRangeOption(sliderRanges.thisYearSoFar)}
</div>
</div>
<span className="slider-tip">Move the slider to explore {lowerCaseLabel}</span>
{this.renderTimeSlider()}
</div>}
<div className="time-travel-status">
{timeTravelTransitioning && <div className="time-travel-jump-loader">
<span className="fa fa-circle-o-notch fa-spin" />
</div>}
<TimeTravelTimestamp
onClick={this.handleTimestampClick}
millisecondsInPast={millisecondsInPast}
selected={showSliderPanel}
<div className={className}>
<div className="time-travel-slider-wrapper">
{this.renderMarks()}
<Slider
onChange={this.handleSliderChange}
value={sliderValue}
min={sliderMinValue}
max={sliderMaxValue}
/>
{!isCurrent && this.renderJumpToNowButton()}
</div>
<div className="time-travel-jump-controls">
<a className="button jump" onClick={() => this.handleJumpClick(-ONE_HOUR_MS)}>
<span className="fa fa-fast-backward" /> 1 hour
</a>
<a className="button jump" onClick={() => this.handleJumpClick(-FIVE_MINUTES_MS)}>
<span className="fa fa-step-backward" /> 5 mins
</a>
<span className="time-travel-jump-controls-timestamp">
<input value={inputValue} onChange={this.handleInputChange} /> UTC
</span>
<a className="button jump" onClick={() => this.handleJumpClick(+FIVE_MINUTES_MS)}>
<span className="fa fa-step-forward" /> 5 mins
</a>
<a className="button jump" onClick={() => this.handleJumpClick(+ONE_HOUR_MS)}>
<span className="fa fa-fast-forward" /> 1 hour
</a>
</div>
</div>
);
}
}
function mapStateToProps({ scope, root }, { params }) {
const cloudInstance = root.instances[params.orgId] || {};
const featureFlags = cloudInstance.featureFlags || [];
function mapStateToProps(state) {
return {
hasTimeTravel: featureFlags.includes('time-travel'),
timeTravelTransitioning: scope.get('timeTravelTransitioning'),
topologyViewMode: scope.get('topologyViewMode'),
currentTopology: scope.get('currentTopology'),
showingTimeTravel: state.get('showingTimeTravel'),
topologyViewMode: state.get('topologyViewMode'),
currentTopology: state.get('currentTopology'),
pausedAt: state.get('pausedAt'),
};
}
export default connect(
mapStateToProps,
{
timeTravelJumpToPast,
jumpToTime,
resumeTime,
timeTravelStartTransition,
clickResumeUpdate,
}
)(TimeTravel);
+5 -7
View File
@@ -3,25 +3,20 @@ import { zipObject } from 'lodash';
const ACTION_TYPES = [
'ADD_QUERY_FILTER',
'BLUR_SEARCH',
'BUFFER_NODES_DELTA',
'CACHE_ZOOM_STATE',
'CHANGE_INSTANCE',
'CHANGE_TOPOLOGY_OPTION',
'CLEAR_CONTROL_ERROR',
'CLEAR_NODES_DELTA_BUFFER',
'CLICK_BACKGROUND',
'CLICK_CLOSE_DETAILS',
'CLICK_CLOSE_TERMINAL',
'CLICK_FORCE_RELAYOUT',
'CLICK_NODE',
'CLICK_PAUSE_UPDATE',
'CLICK_RELATIVE',
'CLICK_RESUME_UPDATE',
'CLICK_SHOW_TOPOLOGY_FOR_NODE',
'CLICK_TERMINAL',
'CLICK_TOPOLOGY',
'CLOSE_WEBSOCKET',
'CONSOLIDATE_NODES_DELTA_BUFFER',
'DEBUG_TOOLBAR_INTERFERING',
'DESELECT_NODE',
'DO_CONTROL_ERROR',
@@ -30,16 +25,18 @@ const ACTION_TYPES = [
'DO_SEARCH',
'ENTER_EDGE',
'ENTER_NODE',
'FINISH_TIME_TRAVEL_TRANSITION',
'FOCUS_SEARCH',
'HIDE_HELP',
'HOVER_METRIC',
'JUMP_TO_TIME',
'LEAVE_EDGE',
'LEAVE_NODE',
'OPEN_WEBSOCKET',
'PAUSE_TIME_AT_NOW',
'PIN_METRIC',
'PIN_NETWORK',
'PIN_SEARCH',
'POP_NODES_DELTA_BUFFER',
'RECEIVE_API_DETAILS',
'RECEIVE_CONTROL_NODE_REMOVED',
'RECEIVE_CONTROL_PIPE_STATUS',
@@ -54,6 +51,7 @@ const ACTION_TYPES = [
'RECEIVE_TOPOLOGIES',
'REQUEST_SERVICE_IMAGES',
'RESET_LOCAL_VIEW_STATE',
'RESUME_TIME',
'ROUTE_TOPOLOGY',
'SELECT_NETWORK',
'SET_EXPORTING_GRAPH',
@@ -64,7 +62,7 @@ const ACTION_TYPES = [
'SHOW_NETWORKS',
'SHUTDOWN',
'SORT_ORDER_CHANGED',
'TIME_TRAVEL_MILLISECONDS_IN_PAST',
'START_TIME_TRAVEL',
'TIME_TRAVEL_START_TRANSITION',
'TOGGLE_CONTRAST_MODE',
'TOGGLE_TROUBLESHOOTING_MENU',
-1
View File
@@ -1,3 +1,2 @@
export const NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT = 5;
export const NODES_DELTA_BUFFER_SIZE_LIMIT = 100;
+1 -1
View File
@@ -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 },
};
+1 -3
View File
@@ -1,7 +1,6 @@
/* Intervals in ms */
export const API_REFRESH_INTERVAL = 30000;
export const TOPOLOGY_REFRESH_INTERVAL = 5000;
export const NODES_DELTA_BUFFER_FEED_INTERVAL = 1000;
export const TOPOLOGY_LOADER_DELAY = 100;
@@ -10,5 +9,4 @@ export const VIEWPORT_RESIZE_DEBOUNCE_INTERVAL = 200;
export const ZOOM_CACHE_DEBOUNCE_INTERVAL = 500;
export const TIMELINE_DEBOUNCE_INTERVAL = 500;
export const TIMELINE_TICK_INTERVAL = 100;
export const TIMELINE_SLIDER_UPDATE_INTERVAL = 10000;
export const TIMELINE_TICK_INTERVAL = 500;
+73 -78
View File
@@ -20,7 +20,6 @@ import {
isResourceViewModeSelector,
} from '../selectors/topology';
import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming';
import { consolidateNodesDeltas } from '../utils/nodes-delta-utils';
import { applyPinnedSearches } from '../utils/search-utils';
import {
findTopologyById,
@@ -58,12 +57,12 @@ export const initialState = makeMap({
mouseOverNodeId: null,
nodeDetails: makeOrderedMap(), // nodeId -> details
nodes: makeOrderedMap(), // nodeId -> node
nodesDeltaBuffer: makeList(),
nodesLoaded: false,
// nodes cache, infrequently updated, used for search & resource view
nodesByTopology: makeMap(), // topologyId -> nodes
// class of metric, e.g. 'cpu', rather than 'host_cpu' or 'process_cpu'.
// allows us to keep the same metric "type" selected when the topology changes.
pausedAt: null,
pinnedMetricType: null,
pinnedNetwork: null,
plugins: makeList(),
@@ -74,16 +73,15 @@ export const initialState = makeMap({
selectedNetwork: null,
selectedNodeId: null,
showingHelp: false,
showingTimeTravel: false,
showingTroubleshootingMenu: false,
showingNetworks: false,
timeTravelTransitioning: false,
timeTravelMillisecondsInPast: 0,
topologies: makeList(),
topologiesLoaded: false,
topologyOptions: makeOrderedMap(), // topologyId -> options
topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl
topologyViewMode: GRAPH_VIEW_MODE,
updatePausedAt: null,
version: '...',
versionUpdate: null,
viewport: makeMap(),
@@ -174,16 +172,39 @@ function closeAllNodeDetails(state) {
return state;
}
function resumeUpdate(state) {
return state.set('updatePausedAt', null);
}
function clearNodes(state) {
return state
.update('nodes', nodes => nodes.clear())
.set('nodesLoaded', false);
}
// TODO: These state changes should probably be calculated from selectors.
function updateStateFromNodes(state) {
// Apply pinned searches, filters nodes that dont match.
state = applyPinnedSearches(state);
// In case node or edge disappears before mouseleave event.
const nodesIds = state.get('nodes').keySeq();
if (!nodesIds.contains(state.get('mouseOverNodeId'))) {
state = state.set('mouseOverNodeId', null);
}
if (!nodesIds.some(nodeId => includes(state.get('mouseOverEdgeId'), nodeId))) {
state = state.set('mouseOverEdgeId', null);
}
// Update the nodes cache only if we're not in the resource view mode, as we
// intentionally want to keep it static before we figure how to keep it up-to-date.
if (!isResourceViewModeSelector(state)) {
const nodesForCurrentTopologyKey = ['nodesByTopology', state.get('currentTopologyId')];
state = state.setIn(nodesForCurrentTopologyKey, state.get('nodes'));
}
// Clear the error.
state = state.set('errorUrl', null);
return state;
}
export function rootReducer(state = initialState, action) {
if (!action.type) {
error('Payload missing a type!', action);
@@ -195,7 +216,6 @@ export function rootReducer(state = initialState, action) {
}
case ActionTypes.CHANGE_TOPOLOGY_OPTION: {
state = resumeUpdate(state);
// set option on parent topology
const topology = findTopologyById(state.get('topologies'), action.topologyId);
if (topology) {
@@ -289,11 +309,6 @@ export function rootReducer(state = initialState, action) {
return state;
}
case ActionTypes.CLICK_PAUSE_UPDATE: {
const millisecondsInPast = state.get('timeTravelMillisecondsInPast');
return state.set('updatePausedAt', moment().utc().subtract(millisecondsInPast));
}
case ActionTypes.CLICK_RELATIVE: {
if (state.hasIn(['nodeDetails', action.nodeId])) {
// bring to front
@@ -313,13 +328,7 @@ export function rootReducer(state = initialState, action) {
return state;
}
case ActionTypes.CLICK_RESUME_UPDATE: {
return resumeUpdate(state);
}
case ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE: {
state = resumeUpdate(state);
state = state.update('nodeDetails',
nodeDetails => nodeDetails.filter((v, k) => k === action.nodeId));
state = state.update('controlPipes', controlPipes => controlPipes.clear());
@@ -334,7 +343,6 @@ export function rootReducer(state = initialState, action) {
}
case ActionTypes.CLICK_TOPOLOGY: {
state = resumeUpdate(state);
state = closeAllNodeDetails(state);
const currentTopologyId = state.get('currentTopologyId');
@@ -347,15 +355,37 @@ export function rootReducer(state = initialState, action) {
}
//
// time travel
// time control
//
case ActionTypes.RESUME_TIME: {
state = state.set('timeTravelTransitioning', true);
state = state.set('showingTimeTravel', false);
return state.set('pausedAt', null);
}
case ActionTypes.PAUSE_TIME_AT_NOW: {
state = state.set('showingTimeTravel', false);
return state.set('pausedAt', moment().utc());
}
case ActionTypes.START_TIME_TRAVEL: {
state = state.set('timeTravelTransitioning', false);
state = state.set('showingTimeTravel', true);
return state.set('pausedAt', moment().utc());
}
case ActionTypes.JUMP_TO_TIME: {
return state.set('pausedAt', action.timestamp);
}
case ActionTypes.TIME_TRAVEL_START_TRANSITION: {
return state.set('timeTravelTransitioning', true);
}
case ActionTypes.TIME_TRAVEL_MILLISECONDS_IN_PAST: {
return state.set('timeTravelMillisecondsInPast', action.millisecondsInPast);
case ActionTypes.FINISH_TIME_TRAVEL_TRANSITION: {
state = state.set('timeTravelTransitioning', false);
return clearNodes(state);
}
//
@@ -370,29 +400,6 @@ export function rootReducer(state = initialState, action) {
return state.set('websocketClosed', true);
}
//
// nodes delta buffer
//
case ActionTypes.CLEAR_NODES_DELTA_BUFFER: {
return state.update('nodesDeltaBuffer', buffer => buffer.clear());
}
case ActionTypes.CONSOLIDATE_NODES_DELTA_BUFFER: {
const firstDelta = state.getIn(['nodesDeltaBuffer', 0]);
const secondDelta = state.getIn(['nodesDeltaBuffer', 1]);
const deltaUnion = consolidateNodesDeltas(firstDelta, secondDelta);
return state.update('nodesDeltaBuffer', buffer => buffer.shift().set(0, deltaUnion));
}
case ActionTypes.POP_NODES_DELTA_BUFFER: {
return state.update('nodesDeltaBuffer', buffer => buffer.shift());
}
case ActionTypes.BUFFER_NODES_DELTA: {
return state.update('nodesDeltaBuffer', buffer => buffer.push(action.delta));
}
//
// networks
//
@@ -534,6 +541,11 @@ export function rootReducer(state = initialState, action) {
}
case ActionTypes.RECEIVE_NODE_DETAILS: {
// Freeze node details data updates after the first load when paused.
if (state.getIn(['nodeDetails', action.details.id, 'details']) && state.get('pausedAt')) {
return state;
}
state = state.set('errorUrl', null);
// disregard if node is not selected anymore
@@ -562,31 +574,18 @@ export function rootReducer(state = initialState, action) {
}
case ActionTypes.RECEIVE_NODES_DELTA: {
// Ignore periodic nodes updates after the first load when paused.
if (state.get('nodesLoaded') && state.get('pausedAt')) {
return state;
}
log('RECEIVE_NODES_DELTA',
'remove', size(action.delta.remove),
'update', size(action.delta.update),
'add', size(action.delta.add));
state = state.set('errorUrl', null);
// When moving in time, we will consider the transition complete
// only when the first batch of nodes delta has been received. We
// do that because we want to keep the previous state blurred instead
// of transitioning over an empty state like when switching topologies.
if (state.get('timeTravelTransitioning')) {
state = state.set('timeTravelTransitioning', false);
state = clearNodes(state);
}
// nodes that no longer exist
// remove nodes that no longer exist
each(action.delta.remove, (nodeId) => {
// in case node disappears before mouseleave event
if (state.get('mouseOverNodeId') === nodeId) {
state = state.set('mouseOverNodeId', null);
}
if (state.hasIn(['nodes', nodeId]) && includes(state.get('mouseOverEdgeId'), nodeId)) {
state = state.set('mouseOverEdgeId', null);
}
state = state.deleteIn(['nodes', nodeId]);
});
@@ -605,17 +604,14 @@ export function rootReducer(state = initialState, action) {
state = state.setIn(['nodes', node.id], fromJS(node));
});
// apply pinned searches, filters nodes that dont match
state = applyPinnedSearches(state);
return updateStateFromNodes(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 state;
case ActionTypes.RECEIVE_NODES: {
state = state.set('timeTravelTransitioning', false);
state = state.set('nodes', fromJS(action.nodes));
state = state.set('nodesLoaded', true);
return updateStateFromNodes(state);
}
case ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY: {
@@ -737,8 +733,7 @@ export function rootReducer(state = initialState, action) {
}
case ActionTypes.SHUTDOWN: {
state = clearNodes(state);
return state.set('nodesLoaded', false);
return clearNodes(state);
}
case ActionTypes.REQUEST_SERVICE_IMAGES: {
+2 -10
View File
@@ -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
);
@@ -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]
@@ -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
};
}
+2 -2
View File
@@ -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) {
+49 -24
View File
@@ -1,5 +1,4 @@
import debug from 'debug';
import moment from 'moment';
import reqwest from 'reqwest';
import { defaults } from 'lodash';
import { Map as makeMap, List } from 'immutable';
@@ -8,12 +7,12 @@ import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveEr
receiveApiDetails, receiveNodesDelta, receiveNodeDetails, receiveControlError,
receiveControlNodeRemoved, receiveControlPipe, receiveControlPipeStatus,
receiveControlSuccess, receiveTopologies, receiveNotFound,
receiveNodesForTopology } from '../actions/app-actions';
receiveNodesForTopology, receiveNodes } from '../actions/app-actions';
import { getCurrentTopologyUrl } from '../utils/topology-utils';
import { layersTopologyIdsSelector } from '../selectors/resource-view/layout';
import { activeTopologyOptionsSelector } from '../selectors/topology';
import { isNowSelector } from '../selectors/time-travel';
import { isPausedSelector } from '../selectors/time-travel';
import { API_REFRESH_INTERVAL, TOPOLOGY_REFRESH_INTERVAL } from '../constants/timer';
@@ -50,10 +49,9 @@ let continuePolling = true;
export function getSerializedTimeTravelTimestamp(state) {
// The timestamp parameter will be used only if it's in the past.
if (isNowSelector(state)) return null;
if (!isPausedSelector(state)) return null;
const millisecondsInPast = state.get('timeTravelMillisecondsInPast');
return moment().utc().subtract(millisecondsInPast).toISOString();
return state.get('pausedAt').toISOString();
}
export function buildUrlQuery(params = makeMap(), state) {
@@ -114,7 +112,7 @@ function buildWebsocketUrl(topologyUrl, topologyOptions = makeMap(), state) {
return `${getWebsocketUrl()}${topologyUrl}/ws?${optionsQuery}`;
}
function createWebsocket(websocketUrl, dispatch) {
function createWebsocket(websocketUrl, getState, dispatch) {
if (socket) {
socket.onclose = null;
socket.onerror = null;
@@ -140,9 +138,9 @@ function createWebsocket(websocketUrl, dispatch) {
socket = null;
dispatch(closeWebsocket());
if (continuePolling) {
if (continuePolling && !isPausedSelector(getState())) {
reconnectTimer = setTimeout(() => {
createWebsocket(websocketUrl, dispatch);
createWebsocket(websocketUrl, getState, dispatch);
}, reconnectTimerInterval);
}
};
@@ -199,6 +197,24 @@ function getNodesForTopologies(state, dispatch, topologyIds, topologyOptions = m
Promise.resolve());
}
function getNodesOnce(getState, dispatch) {
const state = getState();
const topologyUrl = getCurrentTopologyUrl(state);
const topologyOptions = activeTopologyOptionsSelector(state);
const optionsQuery = buildUrlQuery(topologyOptions, state);
const url = `${getApiPath()}${topologyUrl}?${optionsQuery}`;
doRequest({
url,
success: (res) => {
dispatch(receiveNodes(res.nodes));
},
error: (req) => {
log(`Error in nodes request: ${req.responseText}`);
dispatch(receiveError(url));
}
});
}
/**
* Gets nodes for all topologies (for search).
*/
@@ -217,21 +233,20 @@ export function getResourceViewNodesSnapshot(state, dispatch) {
getNodesForTopologies(state, dispatch, topologyIds);
}
export function getTopologies(state, dispatch, initialPoll = false) {
// TODO: Remove this once TimeTravel is out of the feature flag.
state = state.scope || state;
// NOTE: getState is called every time to make sure the up-to-date state is used.
export function getTopologies(getState, dispatch, initialPoll = false) {
// Used to resume polling when navigating between pages in Weave Cloud.
continuePolling = initialPoll === true ? true : continuePolling;
clearTimeout(topologyTimer);
const optionsQuery = buildUrlQuery(activeTopologyOptionsSelector(state), state);
const optionsQuery = buildUrlQuery(activeTopologyOptionsSelector(getState()), getState());
const url = `${getApiPath()}/api/topology?${optionsQuery}`;
doRequest({
url,
success: (res) => {
if (continuePolling) {
if (continuePolling && !isPausedSelector(getState())) {
dispatch(receiveTopologies(res));
topologyTimer = setTimeout(() => {
getTopologies(state, dispatch);
getTopologies(getState, dispatch);
}, TOPOLOGY_REFRESH_INTERVAL);
}
},
@@ -239,30 +254,31 @@ export function getTopologies(state, dispatch, initialPoll = false) {
log(`Error in topology request: ${req.responseText}`);
dispatch(receiveError(url));
// Only retry in stand-alone mode
if (continuePolling) {
if (continuePolling && !isPausedSelector(getState())) {
topologyTimer = setTimeout(() => {
getTopologies(state, dispatch);
getTopologies(getState, dispatch);
}, TOPOLOGY_REFRESH_INTERVAL);
}
}
});
}
export function updateWebsocketChannel(state, dispatch) {
const topologyUrl = getCurrentTopologyUrl(state);
const topologyOptions = activeTopologyOptionsSelector(state);
const websocketUrl = buildWebsocketUrl(topologyUrl, topologyOptions, state);
function updateWebsocketChannel(getState, dispatch, forceRequest) {
const topologyUrl = getCurrentTopologyUrl(getState());
const topologyOptions = activeTopologyOptionsSelector(getState());
const websocketUrl = buildWebsocketUrl(topologyUrl, topologyOptions, getState());
// Only recreate websocket if url changed or if forced (weave cloud instance reload);
const isNewUrl = websocketUrl !== currentUrl;
// `topologyUrl` can be undefined initially, so only create a socket if it is truthy
// and no socket exists, or if we get a new url.
if (topologyUrl && (!socket || isNewUrl)) {
createWebsocket(websocketUrl, dispatch);
if (topologyUrl && (!socket || isNewUrl || forceRequest)) {
createWebsocket(websocketUrl, getState, dispatch);
currentUrl = websocketUrl;
}
}
export function getNodeDetails(state, dispatch) {
export function getNodeDetails(getState, dispatch) {
const state = getState();
const nodeMap = state.get('nodeDetails');
const topologyUrlsById = state.get('topologyUrlsById');
const currentTopologyId = state.get('currentTopologyId');
@@ -302,6 +318,15 @@ export function getNodeDetails(state, dispatch) {
}
}
export function getNodes(getState, dispatch, forceRequest = false) {
if (isPausedSelector(getState())) {
getNodesOnce(getState, dispatch);
} else {
updateWebsocketChannel(getState, dispatch, forceRequest);
}
getNodeDetails(getState, dispatch);
}
export function getApiDetails(dispatch) {
clearTimeout(apiDetailsTimer);
const url = `${getApiPath()}/api`;