mirror of
https://github.com/weaveworks/scope.git
synced 2026-08-18 03:46:45 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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,3 +1,2 @@
|
||||
|
||||
export const NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT = 5;
|
||||
export const NODES_DELTA_BUFFER_SIZE_LIMIT = 100;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user