+
- {EmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)}
+
+ {topologiesLoaded && nodesLoaded && this.renderConditionalEmptyTopologyError()}
{isGraphViewMode &&
}
{isTableViewMode &&
}
@@ -59,11 +82,14 @@ function mapStateToProps(state) {
isGraphViewMode: isGraphViewModeSelector(state),
isTableViewMode: isTableViewModeSelector(state),
isResourceViewMode: isResourceViewModeSelector(state),
+ topologyNodeCountZero: isTopologyNodeCountZero(state),
+ nodesDisplayEmpty: isNodesDisplayEmpty(state),
+ topologyEmpty: isTopologyEmpty(state),
+ websocketTransitioning: state.get('websocketTransitioning'),
currentTopology: state.get('currentTopology'),
nodesLoaded: state.get('nodesLoaded'),
topologies: state.get('topologies'),
topologiesLoaded: state.get('topologiesLoaded'),
- topologyEmpty: isTopologyEmpty(state),
};
}
diff --git a/client/app/scripts/components/pause-button.js b/client/app/scripts/components/pause-button.js
new file mode 100644
index 0000000000..7b88f323fe
--- /dev/null
+++ b/client/app/scripts/components/pause-button.js
@@ -0,0 +1,80 @@
+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 (
+
+ {label !== '' && {label}}
+
+
+ );
+ }
+}
+
+function mapStateToProps({ scope }) {
+ return {
+ hasUpdates: !scope.get('nodesDeltaBuffer').isEmpty(),
+ updateCount: scope.get('nodesDeltaBuffer').size,
+ updatePausedAt: scope.get('updatePausedAt'),
+ topologyViewMode: scope.get('topologyViewMode'),
+ currentTopology: scope.get('currentTopology'),
+ isPaused: isPausedSelector(scope),
+ };
+}
+
+export default connect(
+ mapStateToProps,
+ {
+ clickPauseUpdate,
+ clickResumeUpdate,
+ }
+)(PauseButton);
diff --git a/client/app/scripts/components/status.js b/client/app/scripts/components/status.js
index c595b7bce9..cb0c0861d3 100644
--- a/client/app/scripts/components/status.js
+++ b/client/app/scripts/components/status.js
@@ -1,9 +1,13 @@
import React from 'react';
import { connect } from 'react-redux';
+import { isWebsocketQueryingCurrentSelector } from '../selectors/time-travel';
+
+
class Status extends React.Component {
render() {
- const {errorUrl, topologiesLoaded, filteredNodeCount, topology, websocketClosed} = this.props;
+ const { errorUrl, topologiesLoaded, filteredNodeCount, topology,
+ websocketClosed, showingCurrentState } = this.props;
let title = '';
let text = 'Trying to reconnect...';
@@ -29,6 +33,11 @@ class Status extends React.Component {
}
classNames += ' status-stats';
showWarningIcon = false;
+ // TODO: Currently the stats are always pulled for the current state of the system,
+ // so they are incorrect when showing the past. This should be addressed somehow.
+ if (!showingCurrentState) {
+ text = '';
+ }
}
return (
@@ -44,9 +53,10 @@ function mapStateToProps(state) {
return {
errorUrl: state.get('errorUrl'),
filteredNodeCount: state.get('nodes').filter(node => node.get('filtered')).size,
+ showingCurrentState: isWebsocketQueryingCurrentSelector(state),
topologiesLoaded: state.get('topologiesLoaded'),
topology: state.get('currentTopology'),
- websocketClosed: state.get('websocketClosed')
+ websocketClosed: state.get('websocketClosed'),
};
}
diff --git a/client/app/scripts/components/time-travel-timestamp.js b/client/app/scripts/components/time-travel-timestamp.js
new file mode 100644
index 0000000000..5b44a6e4e8
--- /dev/null
+++ b/client/app/scripts/components/time-travel-timestamp.js
@@ -0,0 +1,58 @@
+import React from 'react';
+import moment from 'moment';
+import classNames from 'classnames';
+import { connect } from 'react-redux';
+
+import { isPausedSelector } from '../selectors/time-travel';
+import { TIMELINE_TICK_INTERVAL } from '../constants/timer';
+
+
+class TimeTravelTimestamp extends React.Component {
+ componentDidMount() {
+ this.timer = setInterval(() => {
+ if (!this.props.isPaused) {
+ this.forceUpdate();
+ }
+ }, TIMELINE_TICK_INTERVAL);
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.timer);
+ }
+
+ renderTimestamp() {
+ const { isPaused, updatePausedAt, millisecondsInPast } = this.props;
+ const timestamp = isPaused ? updatePausedAt : moment().utc().subtract(millisecondsInPast);
+
+ return (
+
+ );
+ }
+
+ render() {
+ const { selected, onClick, millisecondsInPast } = this.props;
+ const isCurrent = (millisecondsInPast === 0);
+
+ const className = classNames('button time-travel-timestamp', {
+ selected, current: isCurrent
+ });
+
+ return (
+
+
+ {isCurrent ? 'now' : this.renderTimestamp()}
+
+
+
+ );
+ }
+}
+
+function mapStateToProps({ scope }) {
+ return {
+ isPaused: isPausedSelector(scope),
+ updatePausedAt: scope.get('updatePausedAt'),
+ };
+}
+
+export default connect(mapStateToProps)(TimeTravelTimestamp);
diff --git a/client/app/scripts/components/time-travel.js b/client/app/scripts/components/time-travel.js
new file mode 100644
index 0000000000..cb206acf41
--- /dev/null
+++ b/client/app/scripts/components/time-travel.js
@@ -0,0 +1,288 @@
+import React from 'react';
+import moment from 'moment';
+import Slider from 'rc-slider';
+import classNames from 'classnames';
+import { connect } from 'react-redux';
+import { debounce } from 'lodash';
+
+import PauseButton from './pause-button';
+import TimeTravelTimestamp from './time-travel-timestamp';
+import { trackMixpanelEvent } from '../utils/tracking-utils';
+import {
+ websocketQueryInPast,
+ startWebsocketTransitionLoader,
+ clickResumeUpdate,
+} from '../actions/app-actions';
+
+import {
+ TIMELINE_SLIDER_UPDATE_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'),
+ },
+};
+
+class TimeTravel extends React.Component {
+ constructor(props, context) {
+ super(props, context);
+
+ this.state = {
+ showSliderPanel: false,
+ millisecondsInPast: 0,
+ rangeOptionSelected: sliderRanges.last1Hour,
+ };
+
+ this.renderRangeOption = this.renderRangeOption.bind(this);
+ this.handleTimestampClick = this.handleTimestampClick.bind(this);
+ this.handleJumpToNowClick = this.handleJumpToNowClick.bind(this);
+ this.handleSliderChange = this.handleSliderChange.bind(this);
+
+ this.debouncedUpdateTimestamp = debounce(
+ this.updateTimestamp.bind(this), TIMELINE_DEBOUNCE_INTERVAL);
+ this.debouncedTrackSliderChange = debounce(
+ this.trackSliderChange.bind(this), TIMELINE_DEBOUNCE_INTERVAL);
+ }
+
+ componentDidMount() {
+ // Force periodic re-renders to update the slider position as time goes by.
+ this.timer = setInterval(() => { this.forceUpdate(); }, TIMELINE_SLIDER_UPDATE_INTERVAL);
+ }
+
+ componentWillUnmount() {
+ clearInterval(this.timer);
+ this.updateTimestamp();
+ }
+
+ 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.startWebsocketTransitionLoader();
+ this.debouncedUpdateTimestamp(millisecondsInPast);
+
+ this.debouncedTrackSliderChange();
+ }
+
+ handleRangeOptionClick(rangeOption) {
+ this.setState({ rangeOptionSelected: rangeOption });
+
+ const rangeMilliseconds = this.getRangeMilliseconds(rangeOption);
+ if (this.state.millisecondsInPast > rangeMilliseconds) {
+ this.setState({ millisecondsInPast: rangeMilliseconds });
+ this.updateTimestamp(rangeMilliseconds);
+ this.props.startWebsocketTransitionLoader();
+ }
+
+ trackMixpanelEvent('scope.time.range.select', {
+ layout: this.props.topologyViewMode,
+ topologyId: this.props.currentTopology.get('id'),
+ parentTopologyId: this.props.currentTopology.get('parentId'),
+ label: rangeOption.label,
+ });
+ }
+
+ handleJumpToNowClick() {
+ this.setState({
+ showSliderPanel: false,
+ millisecondsInPast: 0,
+ rangeOptionSelected: sliderRanges.last1Hour,
+ });
+ this.updateTimestamp();
+ this.props.startWebsocketTransitionLoader();
+
+ 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.websocketQueryInPast(millisecondsInPast);
+ this.props.clickResumeUpdate();
+ }
+
+ getRangeMilliseconds(rangeOption = this.state.rangeOptionSelected) {
+ return moment().diff(rangeOption.getStart());
+ }
+
+ trackSliderChange() {
+ trackMixpanelEvent('scope.time.slider.change', {
+ layout: this.props.topologyViewMode,
+ topologyId: this.props.currentTopology.get('id'),
+ parentTopologyId: this.props.currentTopology.get('parentId'),
+ });
+ }
+
+ renderRangeOption(rangeOption) {
+ const handleClick = () => { this.handleRangeOptionClick(rangeOption); };
+ const selected = (this.state.rangeOptionSelected.label === rangeOption.label);
+ const className = classNames('option', { selected });
+
+ return (
+
+ {rangeOption.label}
+
+ );
+ }
+
+ renderJumpToNowButton() {
+ return (
+
+
+
+ );
+ }
+
+ renderTimeSlider() {
+ const { millisecondsInPast } = this.state;
+ const rangeMilliseconds = this.getRangeMilliseconds();
+
+ return (
+
+ );
+ }
+
+ render() {
+ const { websocketTransitioning, hasTimeTravel } = this.props;
+ const { showSliderPanel, millisecondsInPast, rangeOptionSelected } = this.state;
+ const lowerCaseLabel = rangeOptionSelected.label.toLowerCase();
+ const isCurrent = (millisecondsInPast === 0);
+
+ // Don't render the time travel control if it's not explicitly enabled for this instance.
+ if (!hasTimeTravel) return null;
+
+ return (
+
+ {showSliderPanel &&
+
+
+ {this.renderRangeOption(sliderRanges.last15Minutes)}
+ {this.renderRangeOption(sliderRanges.last1Hour)}
+ {this.renderRangeOption(sliderRanges.last6Hours)}
+ {this.renderRangeOption(sliderRanges.last24Hours)}
+
+
+ {this.renderRangeOption(sliderRanges.last7Days)}
+ {this.renderRangeOption(sliderRanges.last30Days)}
+ {this.renderRangeOption(sliderRanges.last90Days)}
+ {this.renderRangeOption(sliderRanges.last1Year)}
+
+
+ {this.renderRangeOption(sliderRanges.todaySoFar)}
+ {this.renderRangeOption(sliderRanges.thisWeekSoFar)}
+ {this.renderRangeOption(sliderRanges.thisMonthSoFar)}
+ {this.renderRangeOption(sliderRanges.thisYearSoFar)}
+
+
+
Move the slider to explore {lowerCaseLabel}
+ {this.renderTimeSlider()}
+
}
+
+ {websocketTransitioning &&
+
+
}
+
+ {!isCurrent && this.renderJumpToNowButton()}
+
+
+
+ );
+ }
+}
+
+function mapStateToProps({ scope, root }, { params }) {
+ const cloudInstance = root.instances[params.orgId] || {};
+ const featureFlags = cloudInstance.featureFlags || [];
+ return {
+ hasTimeTravel: featureFlags.includes('timeline-control'),
+ websocketTransitioning: scope.get('websocketTransitioning'),
+ topologyViewMode: scope.get('topologyViewMode'),
+ currentTopology: scope.get('currentTopology'),
+ };
+}
+
+export default connect(
+ mapStateToProps,
+ {
+ websocketQueryInPast,
+ startWebsocketTransitionLoader,
+ clickResumeUpdate,
+ }
+)(TimeTravel);
diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js
index c84a8a6452..d610ad8948 100644
--- a/client/app/scripts/constants/action-types.js
+++ b/client/app/scripts/constants/action-types.js
@@ -3,9 +3,12 @@ import { zipObject } from 'lodash';
const ACTION_TYPES = [
'ADD_QUERY_FILTER',
'BLUR_SEARCH',
+ 'BUFFER_NODES_DELTA',
'CACHE_ZOOM_STATE',
+ 'CHANGE_INSTANCE',
'CHANGE_TOPOLOGY_OPTION',
'CLEAR_CONTROL_ERROR',
+ 'CLEAR_NODES_DELTA_BUFFER',
'CLICK_BACKGROUND',
'CLICK_CLOSE_DETAILS',
'CLICK_CLOSE_TERMINAL',
@@ -18,54 +21,57 @@ const ACTION_TYPES = [
'CLICK_TERMINAL',
'CLICK_TOPOLOGY',
'CLOSE_WEBSOCKET',
+ 'CONSOLIDATE_NODES_DELTA_BUFFER',
'DEBUG_TOOLBAR_INTERFERING',
'DESELECT_NODE',
- 'DO_CONTROL',
'DO_CONTROL_ERROR',
'DO_CONTROL_SUCCESS',
+ 'DO_CONTROL',
'DO_SEARCH',
'ENTER_EDGE',
'ENTER_NODE',
'FOCUS_SEARCH',
'HIDE_HELP',
'HOVER_METRIC',
- 'UNHOVER_METRIC',
'LEAVE_EDGE',
'LEAVE_NODE',
+ 'OPEN_WEBSOCKET',
'PIN_METRIC',
+ 'PIN_NETWORK',
'PIN_SEARCH',
- 'UNPIN_METRIC',
- 'UNPIN_SEARCH',
- 'OPEN_WEBSOCKET',
+ 'POP_NODES_DELTA_BUFFER',
+ 'RECEIVE_API_DETAILS',
'RECEIVE_CONTROL_NODE_REMOVED',
- 'RECEIVE_CONTROL_PIPE',
'RECEIVE_CONTROL_PIPE_STATUS',
+ 'RECEIVE_CONTROL_PIPE',
+ 'RECEIVE_ERROR',
'RECEIVE_NODE_DETAILS',
- 'RECEIVE_NODES',
'RECEIVE_NODES_DELTA',
'RECEIVE_NODES_FOR_TOPOLOGY',
+ 'RECEIVE_NODES',
'RECEIVE_NOT_FOUND',
+ 'RECEIVE_SERVICE_IMAGES',
'RECEIVE_TOPOLOGIES',
- 'RECEIVE_API_DETAILS',
- 'RECEIVE_ERROR',
+ 'REQUEST_SERVICE_IMAGES',
'RESET_LOCAL_VIEW_STATE',
'ROUTE_TOPOLOGY',
- 'SHOW_HELP',
- 'SET_VIEWPORT_DIMENSIONS',
- 'SET_EXPORTING_GRAPH',
'SELECT_NETWORK',
- 'TOGGLE_TROUBLESHOOTING_MENU',
- 'PIN_NETWORK',
- 'UNPIN_NETWORK',
- 'SHOW_NETWORKS',
+ 'SET_EXPORTING_GRAPH',
'SET_RECEIVED_NODES_DELTA',
- 'SORT_ORDER_CHANGED',
'SET_VIEW_MODE',
- 'CHANGE_INSTANCE',
- 'TOGGLE_CONTRAST_MODE',
+ 'SET_VIEWPORT_DIMENSIONS',
+ 'SHOW_HELP',
+ 'SHOW_NETWORKS',
'SHUTDOWN',
- 'REQUEST_SERVICE_IMAGES',
- 'RECEIVE_SERVICE_IMAGES'
+ 'SORT_ORDER_CHANGED',
+ 'START_WEBSOCKET_TRANSITION_LOADER',
+ 'TOGGLE_CONTRAST_MODE',
+ 'TOGGLE_TROUBLESHOOTING_MENU',
+ 'UNHOVER_METRIC',
+ 'UNPIN_METRIC',
+ 'UNPIN_NETWORK',
+ 'UNPIN_SEARCH',
+ 'WEBSOCKET_QUERY_MILLISECONDS_IN_PAST',
];
export default zipObject(ACTION_TYPES, ACTION_TYPES);
diff --git a/client/app/scripts/constants/limits.js b/client/app/scripts/constants/limits.js
index afb2c79382..56e80aa409 100644
--- a/client/app/scripts/constants/limits.js
+++ b/client/app/scripts/constants/limits.js
@@ -1,2 +1,3 @@
export const NODE_DETAILS_DATA_ROWS_DEFAULT_LIMIT = 5;
+export const NODES_DELTA_BUFFER_SIZE_LIMIT = 100;
diff --git a/client/app/scripts/constants/timer.js b/client/app/scripts/constants/timer.js
index c828bfda1e..fdb9d2e069 100644
--- a/client/app/scripts/constants/timer.js
+++ b/client/app/scripts/constants/timer.js
@@ -1,7 +1,14 @@
/* Intervals in ms */
-export const API_INTERVAL = 30000;
-export const TOPOLOGY_INTERVAL = 5000;
+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;
export const TABLE_ROW_FOCUS_DEBOUNCE_INTERVAL = 10;
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;
diff --git a/client/app/scripts/reducers/__tests__/root-test.js b/client/app/scripts/reducers/__tests__/root-test.js
index cfd2107ab4..a70df2df39 100644
--- a/client/app/scripts/reducers/__tests__/root-test.js
+++ b/client/app/scripts/reducers/__tests__/root-test.js
@@ -2,9 +2,9 @@ import { is, fromJS } from 'immutable';
import expect from 'expect';
import { TABLE_VIEW_MODE } from '../../constants/naming';
-// Root reducer test suite using Jasmine matchers
import { constructEdgeId } from '../../utils/layouter-utils';
+// Root reducer test suite using Jasmine matchers
describe('RootReducer', () => {
const ActionTypes = require('../../constants/action-types').default;
const reducer = require('../root').default;
@@ -512,8 +512,6 @@ describe('RootReducer', () => {
nextState = reducer(nextState, OpenWebsocketAction);
expect(nextState.get('websocketClosed')).toBeFalsy();
- // opened socket clears nodes
- expect(nextState.get('nodes').toJS()).toEqual({});
});
// adjacency test
@@ -538,12 +536,19 @@ describe('RootReducer', () => {
let nextState = initialState;
nextState = reducer(nextState, ReceiveTopologiesAction);
nextState = reducer(nextState, ClickTopologyAction);
+ expect(isTopologyEmpty(nextState)).toBeTruthy();
+
+ nextState = reducer(nextState, ReceiveNodesDeltaAction);
expect(isTopologyEmpty(nextState)).toBeFalsy();
nextState = reducer(nextState, ClickTopology2Action);
+ nextState = reducer(nextState, ReceiveNodesDeltaAction);
expect(isTopologyEmpty(nextState)).toBeTruthy();
nextState = reducer(nextState, ClickTopologyAction);
+ expect(isTopologyEmpty(nextState)).toBeTruthy();
+
+ nextState = reducer(nextState, ReceiveNodesDeltaAction);
expect(isTopologyEmpty(nextState)).toBeFalsy();
});
diff --git a/client/app/scripts/reducers/root.js b/client/app/scripts/reducers/root.js
index 3f731a8287..e5920517c6 100644
--- a/client/app/scripts/reducers/root.js
+++ b/client/app/scripts/reducers/root.js
@@ -1,8 +1,15 @@
/* eslint-disable import/no-webpack-loader-syntax, import/no-unresolved */
import debug from 'debug';
+import moment from 'moment';
import { size, each, includes, isEqual } from 'lodash';
-import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap,
- OrderedMap as makeOrderedMap, Set as makeSet } from 'immutable';
+import {
+ fromJS,
+ is as isDeepEqual,
+ List as makeList,
+ Map as makeMap,
+ OrderedMap as makeOrderedMap,
+ Set as makeSet,
+} from 'immutable';
import ActionTypes from '../constants/action-types';
import {
@@ -15,6 +22,7 @@ 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,
@@ -56,6 +64,7 @@ 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
@@ -78,11 +87,13 @@ export const initialState = makeMap({
topologyOptions: makeOrderedMap(), // topologyId -> options
topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl
topologyViewMode: GRAPH_VIEW_MODE,
- updatePausedAt: null, // Date
+ updatePausedAt: null,
version: '...',
versionUpdate: null,
viewport: makeMap(),
websocketClosed: false,
+ websocketTransitioning: false,
+ websocketQueryMillisecondsInPast: 0,
zoomCache: makeMap(),
serviceImages: makeMap()
});
@@ -285,7 +296,8 @@ export function rootReducer(state = initialState, action) {
}
case ActionTypes.CLICK_PAUSE_UPDATE: {
- return state.set('updatePausedAt', new Date());
+ const millisecondsInPast = state.get('websocketQueryMillisecondsInPast');
+ return state.set('updatePausedAt', moment().utc().subtract(millisecondsInPast));
}
case ActionTypes.CLICK_RELATIVE: {
@@ -331,7 +343,8 @@ export function rootReducer(state = initialState, action) {
state = resumeUpdate(state);
state = closeAllNodeDetails(state);
- if (action.topologyId !== state.get('currentTopologyId')) {
+ const currentTopologyId = state.get('currentTopologyId');
+ if (action.topologyId !== currentTopologyId) {
state = setTopology(state, action.topologyId);
state = clearNodes(state);
}
@@ -339,11 +352,47 @@ export function rootReducer(state = initialState, action) {
return state;
}
+ //
+ // websockets
+ //
+
+ case ActionTypes.OPEN_WEBSOCKET: {
+ return state.set('websocketClosed', false);
+ }
+
+ case ActionTypes.START_WEBSOCKET_TRANSITION_LOADER: {
+ return state.set('websocketTransitioning', true);
+ }
+
+ case ActionTypes.WEBSOCKET_QUERY_MILLISECONDS_IN_PAST: {
+ return state.set('websocketQueryMillisecondsInPast', action.millisecondsInPast);
+ }
+
case ActionTypes.CLOSE_WEBSOCKET: {
- if (!state.get('websocketClosed')) {
- state = state.set('websocketClosed', true);
- }
- return state;
+ 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));
}
//
@@ -479,13 +528,6 @@ export function rootReducer(state = initialState, action) {
return state;
}
- case ActionTypes.OPEN_WEBSOCKET: {
- // flush nodes cache after re-connect
- state = state.update('nodes', nodes => nodes.clear());
- state = state.set('websocketClosed', false);
- return state;
- }
-
case ActionTypes.DO_CONTROL_ERROR: {
return state.setIn(['controlStatus', action.nodeId], makeMap({
pending: false,
@@ -567,18 +609,22 @@ export function rootReducer(state = initialState, action) {
}
case ActionTypes.RECEIVE_NODES_DELTA: {
- const emptyMessage = !action.delta.add && !action.delta.remove
- && !action.delta.update;
-
- if (!emptyMessage) {
- log('RECEIVE_NODES_DELTA',
- 'remove', size(action.delta.remove),
- 'update', size(action.delta.update),
- 'add', size(action.delta.add));
- }
+ 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('websocketTransitioning')) {
+ state = state.set('websocketTransitioning', false);
+ state = clearNodes(state);
+ }
+
// nodes that no longer exist
each(action.delta.remove, (nodeId) => {
// in case node disappears before mouseleave event
diff --git a/client/app/scripts/selectors/resource-view/layout.js b/client/app/scripts/selectors/resource-view/layout.js
index 2f002e9730..615c2944ea 100644
--- a/client/app/scripts/selectors/resource-view/layout.js
+++ b/client/app/scripts/selectors/resource-view/layout.js
@@ -42,11 +42,11 @@ export const layerVerticalPositionByTopologyIdSelector = createSelector(
],
(topologiesIds) => {
let yPositions = makeMap();
- let currentY = RESOURCES_LAYER_PADDING;
+ let yCumulative = RESOURCES_LAYER_PADDING;
topologiesIds.forEach((topologyId) => {
- currentY -= RESOURCES_LAYER_HEIGHT + RESOURCES_LAYER_PADDING;
- yPositions = yPositions.set(topologyId, currentY);
+ yCumulative -= RESOURCES_LAYER_HEIGHT + RESOURCES_LAYER_PADDING;
+ yPositions = yPositions.set(topologyId, yCumulative);
});
return yPositions;
diff --git a/client/app/scripts/selectors/time-travel.js b/client/app/scripts/selectors/time-travel.js
new file mode 100644
index 0000000000..ae84cb43d8
--- /dev/null
+++ b/client/app/scripts/selectors/time-travel.js
@@ -0,0 +1,16 @@
+import { createSelector } from 'reselect';
+
+
+export const isPausedSelector = createSelector(
+ [
+ state => state.get('updatePausedAt')
+ ],
+ updatePausedAt => updatePausedAt !== null
+);
+
+export const isWebsocketQueryingCurrentSelector = createSelector(
+ [
+ state => state.get('websocketQueryMillisecondsInPast')
+ ],
+ websocketQueryMillisecondsInPast => websocketQueryMillisecondsInPast === 0
+);
diff --git a/client/app/scripts/utils/__tests__/web-api-utils-test.js b/client/app/scripts/utils/__tests__/web-api-utils-test.js
index f98fada427..fc467577bd 100644
--- a/client/app/scripts/utils/__tests__/web-api-utils-test.js
+++ b/client/app/scripts/utils/__tests__/web-api-utils-test.js
@@ -1,6 +1,6 @@
import {OrderedMap as makeOrderedMap} from 'immutable';
-import { buildOptionsQuery, basePath, getApiPath, getWebsocketUrl } from '../web-api-utils';
+import { buildUrlQuery, basePath, getApiPath, getWebsocketUrl } from '../web-api-utils';
describe('WebApiUtils', () => {
describe('basePath', () => {
@@ -21,13 +21,13 @@ describe('WebApiUtils', () => {
});
});
- describe('buildOptionsQuery', () => {
+ describe('buildUrlQuery', () => {
it('should handle empty options', () => {
- expect(buildOptionsQuery(makeOrderedMap({}))).toBe('');
+ expect(buildUrlQuery(makeOrderedMap({}))).toBe('');
});
it('should combine multiple options', () => {
- expect(buildOptionsQuery(makeOrderedMap([
+ expect(buildUrlQuery(makeOrderedMap([
['foo', 2],
['bar', 4]
]))).toBe('foo=2&bar=4');
diff --git a/client/app/scripts/utils/nodes-delta-utils.js b/client/app/scripts/utils/nodes-delta-utils.js
new file mode 100644
index 0000000000..0c1f01983c
--- /dev/null
+++ b/client/app/scripts/utils/nodes-delta-utils.js
@@ -0,0 +1,60 @@
+import debug from 'debug';
+import { union, size, map, find, reject, each } from 'lodash';
+
+const log = debug('scope:nodes-delta-utils');
+
+
+// TODO: It would be nice to have a unit test for this function.
+export function consolidateNodesDeltas(first, second) {
+ let toAdd = union(first.add, second.add);
+ let toUpdate = union(first.update, second.update);
+ let toRemove = union(first.remove, second.remove);
+ log('Consolidating delta buffer',
+ 'add', size(toAdd),
+ 'update', size(toUpdate),
+ 'remove', size(toRemove));
+
+ // check if an added node in first was updated in second -> add second update
+ toAdd = map(toAdd, (node) => {
+ const updateNode = find(second.update, {id: node.id});
+ if (updateNode) {
+ toUpdate = reject(toUpdate, {id: node.id});
+ return updateNode;
+ }
+ return node;
+ });
+
+ // check if an updated node in first was updated in second -> updated second update
+ // no action needed, successive updates are fine
+
+ // check if an added node in first was removed in second -> dont add, dont remove
+ each(first.add, (node) => {
+ const removedNode = find(second.remove, {id: node.id});
+ if (removedNode) {
+ toAdd = reject(toAdd, {id: node.id});
+ toRemove = reject(toRemove, {id: node.id});
+ }
+ });
+
+ // check if an updated node in first was removed in second -> remove
+ each(first.update, (node) => {
+ const removedNode = find(second.remove, {id: node.id});
+ if (removedNode) {
+ toUpdate = reject(toUpdate, {id: node.id});
+ }
+ });
+
+ // check if an removed node in first was added in second -> update
+ // remove -> add is fine for the store
+
+ log('Consolidated delta buffer',
+ 'add', size(toAdd),
+ 'update', size(toUpdate),
+ 'remove', size(toRemove));
+
+ return {
+ add: toAdd.length > 0 ? toAdd : null,
+ update: toUpdate.length > 0 ? toUpdate : null,
+ remove: toRemove.length > 0 ? toRemove : null
+ };
+}
diff --git a/client/app/scripts/utils/topology-utils.js b/client/app/scripts/utils/topology-utils.js
index 82cf1f65c8..d67c3d5302 100644
--- a/client/app/scripts/utils/topology-utils.js
+++ b/client/app/scripts/utils/topology-utils.js
@@ -1,6 +1,7 @@
import { endsWith } from 'lodash';
import { Set as makeSet, List as makeList } from 'immutable';
+import { isWebsocketQueryingCurrentSelector } from '../selectors/time-travel';
import { isResourceViewModeSelector } from '../selectors/topology';
import { pinnedMetricSelector } from '../selectors/node-metric';
@@ -133,15 +134,23 @@ export function getCurrentTopologyOptions(state) {
return state.getIn(['currentTopology', 'options']);
}
-export function isTopologyEmpty(state) {
- // Consider a topology in the resource view empty if it has no pinned metric.
- const resourceViewEmpty = isResourceViewModeSelector(state) && !pinnedMetricSelector(state);
- // Otherwise (in graph and table view), we only look at the node count.
+export function isTopologyNodeCountZero(state) {
const nodeCount = state.getIn(['currentTopology', 'stats', 'node_count'], 0);
- const nodesEmpty = nodeCount === 0 && state.get('nodes').size === 0;
- return resourceViewEmpty || nodesEmpty;
+ return nodeCount === 0 && isWebsocketQueryingCurrentSelector(state);
+}
+
+export function isNodesDisplayEmpty(state) {
+ // Consider a topology in the resource view empty if it has no pinned metric.
+ if (isResourceViewModeSelector(state)) {
+ return !pinnedMetricSelector(state);
+ }
+ // Otherwise (in graph and table view), we only look at the nodes content.
+ return state.get('nodes').isEmpty();
}
+export function isTopologyEmpty(state) {
+ return isTopologyNodeCountZero(state) || isNodesDisplayEmpty(state);
+}
export function getAdjacentNodes(state, originNodeId) {
let adjacentNodes = makeSet();
diff --git a/client/app/scripts/utils/update-buffer-utils.js b/client/app/scripts/utils/update-buffer-utils.js
deleted file mode 100644
index cd65da5516..0000000000
--- a/client/app/scripts/utils/update-buffer-utils.js
+++ /dev/null
@@ -1,114 +0,0 @@
-import debug from 'debug';
-import Immutable from 'immutable';
-import { union, size, map, find, reject, each } from 'lodash';
-
-import { receiveNodesDelta } from '../actions/app-actions';
-
-const log = debug('scope:update-buffer-utils');
-const makeList = Immutable.List;
-const feedInterval = 1000;
-const bufferLength = 100;
-
-let deltaBuffer = makeList();
-let updateTimer = null;
-
-function isPaused(getState) {
- return getState().get('updatePausedAt') !== null;
-}
-
-export function resetUpdateBuffer() {
- clearTimeout(updateTimer);
- deltaBuffer = deltaBuffer.clear();
-}
-
-function maybeUpdate(getState) {
- if (isPaused(getState)) {
- clearTimeout(updateTimer);
- resetUpdateBuffer();
- } else {
- if (deltaBuffer.size > 0) {
- const delta = deltaBuffer.first();
- deltaBuffer = deltaBuffer.shift();
- receiveNodesDelta(delta);
- }
- if (deltaBuffer.size > 0) {
- updateTimer = setTimeout(() => maybeUpdate(getState), feedInterval);
- }
- }
-}
-
-// consolidate first buffer entry with second
-function consolidateBuffer() {
- const first = deltaBuffer.first();
- deltaBuffer = deltaBuffer.shift();
- const second = deltaBuffer.first();
- 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
-
- // update buffer
- log('Consolidated delta buffer', 'add', size(toAdd), 'update',
- size(toUpdate), 'remove', size(toRemove));
- deltaBuffer.set(0, {
- add: toAdd.length > 0 ? toAdd : null,
- update: toUpdate.length > 0 ? toUpdate : null,
- remove: toRemove.length > 0 ? toRemove : null
- });
-}
-
-export function bufferDeltaUpdate(delta) {
- if (delta.add === null && delta.update === null && delta.remove === null) {
- log('Discarding empty nodes delta');
- return;
- }
-
- if (deltaBuffer.size >= bufferLength) {
- consolidateBuffer();
- }
-
- deltaBuffer = deltaBuffer.push(delta);
- log('Buffering node delta, new size', deltaBuffer.size);
-}
-
-export function getUpdateBufferSize() {
- return deltaBuffer.size;
-}
-
-export function resumeUpdate(getState) {
- maybeUpdate(getState);
-}
diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js
index 41e9fa7e29..be177efac1 100644
--- a/client/app/scripts/utils/web-api-utils.js
+++ b/client/app/scripts/utils/web-api-utils.js
@@ -1,7 +1,8 @@
import debug from 'debug';
+import moment from 'moment';
import reqwest from 'reqwest';
-import defaults from 'lodash/defaults';
-import { Map as makeMap, List } from 'immutable';
+import { defaults } from 'lodash';
+import { fromJS, Map as makeMap, List } from 'immutable';
import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveError,
receiveApiDetails, receiveNodesDelta, receiveNodeDetails, receiveControlError,
@@ -9,8 +10,11 @@ import { blurSearch, clearControlError, closeWebsocket, openWebsocket, receiveEr
receiveControlSuccess, receiveTopologies, receiveNotFound,
receiveNodesForTopology } from '../actions/app-actions';
+import { getCurrentTopologyUrl } from '../utils/topology-utils';
import { layersTopologyIdsSelector } from '../selectors/resource-view/layout';
-import { API_INTERVAL, TOPOLOGY_INTERVAL } from '../constants/timer';
+import { activeTopologyOptionsSelector } from '../selectors/topology';
+import { isWebsocketQueryingCurrentSelector } from '../selectors/time-travel';
+import { API_REFRESH_INTERVAL, TOPOLOGY_REFRESH_INTERVAL } from '../constants/timer';
const log = debug('scope:web-api-utils');
@@ -34,25 +38,25 @@ const csrfToken = (() => {
let socket;
let reconnectTimer = 0;
-let currentUrl = null;
-let currentOptions = null;
let topologyTimer = 0;
let apiDetailsTimer = 0;
let controlErrorTimer = 0;
-let createWebsocketAt = 0;
-let firstMessageOnWebsocketAt = 0;
+let currentUrl = null;
+let createWebsocketAt = null;
+let firstMessageOnWebsocketAt = null;
let continuePolling = true;
-export function buildOptionsQuery(options) {
- if (options) {
- return options.map((value, param) => {
- if (List.isList(value)) {
- value = value.join(',');
- }
- return `${param}=${value}`;
- }).join('&');
- }
- return '';
+export function buildUrlQuery(params) {
+ if (!params) return '';
+
+ // Ignore the entries with values `null` or `undefined`.
+ return params.map((value, param) => {
+ if (value === undefined || value === null) return null;
+ if (List.isList(value)) {
+ value = value.join(',');
+ }
+ return `${param}=${value}`;
+ }).filter(s => s).join('&');
}
export function basePath(urlPath) {
@@ -93,7 +97,16 @@ export function getWebsocketUrl(host = window.location.host, pathname = window.l
return `${wsProto}://${host}${process.env.SCOPE_API_PREFIX || ''}${basePath(pathname)}`;
}
-function createWebsocket(topologyUrl, optionsQuery, dispatch) {
+function buildWebsocketUrl(topologyUrl, topologyOptions = makeMap(), queryTimestamp) {
+ const query = buildUrlQuery(fromJS({
+ t: updateFrequency,
+ timestamp: queryTimestamp,
+ ...topologyOptions.toJS(),
+ }));
+ return `${getWebsocketUrl()}${topologyUrl}/ws?${query}`;
+}
+
+function createWebsocket(websocketUrl, dispatch) {
if (socket) {
socket.onclose = null;
socket.onerror = null;
@@ -104,30 +117,31 @@ function createWebsocket(topologyUrl, optionsQuery, dispatch) {
// profiling
createWebsocketAt = new Date();
- firstMessageOnWebsocketAt = 0;
+ firstMessageOnWebsocketAt = null;
- socket = new WebSocket(`${getWebsocketUrl()}${topologyUrl}/ws?t=${updateFrequency}&${optionsQuery}`);
+ socket = new WebSocket(websocketUrl);
socket.onopen = () => {
+ log(`Opening websocket to ${websocketUrl}`);
dispatch(openWebsocket());
};
socket.onclose = () => {
clearTimeout(reconnectTimer);
- log(`Closing websocket to ${topologyUrl}`, socket.readyState);
+ log(`Closing websocket to ${websocketUrl}`, socket.readyState);
socket = null;
dispatch(closeWebsocket());
if (continuePolling) {
reconnectTimer = setTimeout(() => {
- createWebsocket(topologyUrl, optionsQuery, dispatch);
+ createWebsocket(websocketUrl, dispatch);
}, reconnectTimerInterval);
}
};
socket.onerror = () => {
- log(`Error in websocket to ${topologyUrl}`);
- dispatch(receiveError(currentUrl));
+ log(`Error in websocket to ${websocketUrl}`);
+ dispatch(receiveError(websocketUrl));
};
socket.onmessage = (event) => {
@@ -170,7 +184,7 @@ function getNodesForTopologies(getState, dispatch, topologyIds, topologyOptions
getState().get('topologyUrlsById')
.filter((_, topologyId) => topologyIds.contains(topologyId))
.reduce((sequence, topologyUrl, topologyId) => sequence.then(() => {
- const optionsQuery = buildOptionsQuery(topologyOptions.get(topologyId));
+ const optionsQuery = buildUrlQuery(topologyOptions.get(topologyId));
return doRequest({ url: `${getApiPath()}${topologyUrl}?${optionsQuery}` });
})
.then(json => dispatch(receiveNodesForTopology(json.nodes, topologyId))),
@@ -200,7 +214,7 @@ export function getTopologies(options, dispatch, initialPoll) {
// Used to resume polling when navigating between pages in Weave Cloud.
continuePolling = initialPoll === true ? true : continuePolling;
clearTimeout(topologyTimer);
- const optionsQuery = buildOptionsQuery(options);
+ const optionsQuery = buildUrlQuery(options);
const url = `${getApiPath()}/api/topology?${optionsQuery}`;
doRequest({
url,
@@ -209,7 +223,7 @@ export function getTopologies(options, dispatch, initialPoll) {
dispatch(receiveTopologies(res));
topologyTimer = setTimeout(() => {
getTopologies(options, dispatch);
- }, TOPOLOGY_INTERVAL);
+ }, TOPOLOGY_REFRESH_INTERVAL);
}
},
error: (req) => {
@@ -219,26 +233,32 @@ export function getTopologies(options, dispatch, initialPoll) {
if (continuePolling) {
topologyTimer = setTimeout(() => {
getTopologies(options, dispatch);
- }, TOPOLOGY_INTERVAL);
+ }, TOPOLOGY_REFRESH_INTERVAL);
}
}
});
}
-// TODO: topologyUrl and options are always used for the current topology so they as arguments
-// can be replaced by the `state` and then retrieved here internally from selectors.
-export function getNodesDelta(topologyUrl, options, dispatch) {
- const optionsQuery = buildOptionsQuery(options);
+function getWebsocketQueryTimestamp(state) {
+ // The timestamp query parameter will be used only if it's in the past.
+ if (isWebsocketQueryingCurrentSelector(state)) return null;
+
+ const millisecondsInPast = state.get('websocketQueryMillisecondsInPast');
+ return moment().utc().subtract(millisecondsInPast).toISOString();
+}
+
+export function updateWebsocketChannel(state, dispatch) {
+ const topologyUrl = getCurrentTopologyUrl(state);
+ const topologyOptions = activeTopologyOptionsSelector(state);
+ const queryTimestamp = getWebsocketQueryTimestamp(state);
+ const websocketUrl = buildWebsocketUrl(topologyUrl, topologyOptions, queryTimestamp);
// Only recreate websocket if url changed or if forced (weave cloud instance reload);
- // Check for truthy options and that options have changed.
- const isNewOptions = currentOptions && currentOptions !== optionsQuery;
- const isNewUrl = topologyUrl !== currentUrl || isNewOptions;
+ 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) || (topologyUrl && isNewUrl)) {
- createWebsocket(topologyUrl, optionsQuery, dispatch);
- currentUrl = topologyUrl;
- currentOptions = optionsQuery;
+ if (topologyUrl && (!socket || isNewUrl)) {
+ createWebsocket(websocketUrl, dispatch);
+ currentUrl = websocketUrl;
}
}
@@ -250,7 +270,7 @@ export function getNodeDetails(topologyUrlsById, currentTopologyId, options, nod
let urlComponents = [getApiPath(), topologyUrl, '/', encodeURIComponent(obj.id)];
if (currentTopologyId === obj.topologyId) {
// Only forward filters for nodes in the current topology
- const optionsQuery = buildOptionsQuery(options);
+ const optionsQuery = buildUrlQuery(options);
urlComponents = urlComponents.concat(['?', optionsQuery]);
}
const url = urlComponents.join('');
@@ -288,7 +308,7 @@ export function getApiDetails(dispatch) {
if (continuePolling) {
apiDetailsTimer = setTimeout(() => {
getApiDetails(dispatch);
- }, API_INTERVAL);
+ }, API_REFRESH_INTERVAL);
}
},
error: (req) => {
@@ -297,7 +317,7 @@ export function getApiDetails(dispatch) {
if (continuePolling) {
apiDetailsTimer = setTimeout(() => {
getApiDetails(dispatch);
- }, API_INTERVAL / 2);
+ }, API_REFRESH_INTERVAL / 2);
}
}
});
@@ -407,6 +427,6 @@ export function teardownWebsockets() {
socket.onopen = null;
socket.close();
socket = null;
- currentOptions = null;
+ currentUrl = null;
}
}
diff --git a/client/app/styles/_base.scss b/client/app/styles/_base.scss
index 7588841df2..3f08b66cf6 100644
--- a/client/app/styles/_base.scss
+++ b/client/app/styles/_base.scss
@@ -35,6 +35,10 @@
transition: opacity .5s $base-ease;
}
+.blinkable {
+ animation: blinking 1.5s infinite $base-ease;
+}
+
.hang-around {
transition-delay: .5s;
}
@@ -48,13 +52,40 @@
}
.overlay-wrapper {
+ align-items: center;
background-color: fade-out($background-average-color, 0.1);
border-radius: 4px;
color: $text-tertiary-color;
display: flex;
font-size: 0.7rem;
+ justify-content: center;
padding: 5px;
position: absolute;
+ bottom: 11px;
+
+ a {
+ @extend .btn-opacity;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ color: $text-secondary-color;
+ cursor: pointer;
+ padding: 1px 3px;
+
+ .fa {
+ font-size: 150%;
+ position: relative;
+ top: 2px;
+ }
+
+ &:hover, &.selected {
+ border: 1px solid $text-tertiary-color;
+ }
+
+ &.active {
+ & > * { @extend .blinkable; }
+ border: 1px solid $text-tertiary-color;
+ }
+ }
}
.btn-opacity {
@@ -137,17 +168,18 @@
}
}
+
+.rc-slider {
+ .rc-slider-step { cursor: pointer; }
+ .rc-slider-track { background-color: $text-tertiary-color; }
+ .rc-slider-rail { background-color: $border-light-color; }
+ .rc-slider-handle { border-color: $text-tertiary-color; }
+}
+
.footer {
@extend .overlay-wrapper;
- bottom: 11px;
right: 43px;
- a {
- @extend .btn-opacity;
- color: $text-secondary-color;
- cursor: pointer;
- }
-
&-status {
margin-right: 1em;
}
@@ -162,41 +194,85 @@
text-transform: uppercase;
}
+ &-tools {
+ display: flex;
+ }
+
&-icon {
margin-left: 0.5em;
- padding: 4px 3px;
- color: $text-color;
- position: relative;
- top: -1px;
- border: 1px solid transparent;
- border-radius: 4px;
+ }
- &:hover {
- border: 1px solid $text-tertiary-color;
+ .tooltip {
+ // above everything
+ z-index: 20000;
+ }
+}
+
+.nodes-wrapper {
+ @extend .hideable;
+
+ &.blurred { opacity: 0.2; }
+}
+
+.time-travel {
+ @extend .overlay-wrapper;
+ display: block;
+ right: 530px;
+
+ &-status {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+
+ .time-travel-jump-loader {
+ font-size: 1rem;
}
- .fa {
- font-size: 150%;
- position: relative;
- top: 2px;
+ .time-travel-timestamp-info, .pause-text {
+ font-size: 115%;
+ margin-right: 5px;
}
- &-active {
- border: 1px solid $text-tertiary-color;
- animation: blinking 1.5s infinite $base-ease;
+ .button { margin-left: 0.5em; }
+
+ .time-travel-timestamp:not(.current) {
+ & > * { @extend .blinkable; }
+ font-weight: bold;
}
}
- &-icon &-label {
- margin-right: 0.5em;
- }
+ &-slider {
+ width: 355px;
- .tooltip {
- // above everything
- z-index: 20000;
+ .slider-tip {
+ display: inline-block;
+ font-size: 0.8125rem;
+ font-style: italic;
+ padding: 5px 10px;
+ }
+
+ .options {
+ display: flex;
+ padding: 2px 0 10px;
+
+ .column {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ padding: 0 7px;
+
+ a { padding: 0 3px; }
+ }
+ }
+
+ .rc-slider {
+ margin: 0 10px 8px;
+ width: auto;
+ }
}
}
+
.topologies {
margin: 8px 4px;
display: flex;
@@ -279,10 +355,12 @@
opacity: 0.25;
font-size: 320px;
}
+
+ li { padding-top: 5px; }
}
&-loading &-error-icon-container {
- animation: blinking 2.0s infinite $base-ease;
+ @extend .blinkable;
}
&-loading {
@@ -695,8 +773,8 @@
color: $white;
&-icon {
+ @extend .blinkable;
margin-right: 0.5em;
- animation: blinking 2.0s infinite $base-ease;
}
}
}
@@ -1409,9 +1487,9 @@
.sidebar {
position: fixed;
- bottom: 12px;
- left: 12px;
- padding: 4px;
+ bottom: 11px;
+ left: 11px;
+ padding: 5px;
font-size: .7rem;
border-radius: 8px;
border: 1px solid transparent;
@@ -1760,27 +1838,16 @@
.zoom-control {
@extend .overlay-wrapper;
- align-items: center;
flex-direction: column;
- padding: 10px 10px 5px;
+ padding: 5px 7px 0;
bottom: 50px;
right: 40px;
- .zoom-in, .zoom-out {
- @extend .btn-opacity;
- color: $text-secondary-color;
- cursor: pointer;
- font-size: 150%;
- }
+ a:hover { border-color: transparent; }
.rc-slider {
- margin: 10px 0;
+ margin: 5px 0;
height: 60px;
-
- .rc-slider-step { cursor: pointer; }
- .rc-slider-track { background-color: $text-tertiary-color; }
- .rc-slider-rail { background-color: $border-light-color; }
- .rc-slider-handle { border-color: $text-tertiary-color; }
}
}