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