diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 31fde7260..c0cbbfa98 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -4,6 +4,8 @@ import AppDispatcher from '../dispatcher/app-dispatcher'; import ActionTypes from '../constants/action-types'; import { saveGraph } from '../utils/file-utils'; import { updateRoute } from '../utils/router-utils'; +import { bufferDeltaUpdate, resumeUpdate, + resetUpdateBuffer } from '../utils/update-buffer-utils'; import { doControlRequest, getNodesDelta, getNodeDetails, getTopologies, deletePipe } from '../utils/web-api-utils'; import AppStore from '../stores/app-store'; @@ -19,6 +21,7 @@ export function changeTopologyOption(option, value, topologyId) { }); updateRoute(); // update all request workers with new options + resetUpdateBuffer(); getTopologies( AppStore.getActiveTopologyOptions() ); @@ -82,6 +85,12 @@ export function clickNode(nodeId, label, origin) { ); } +export function clickPauseUpdate() { + AppDispatcher.dispatch({ + type: ActionTypes.CLICK_PAUSE_UPDATE + }); +} + export function clickRelative(nodeId, topologyId, label, origin) { AppDispatcher.dispatch({ type: ActionTypes.CLICK_RELATIVE, @@ -97,6 +106,13 @@ export function clickRelative(nodeId, topologyId, label, origin) { ); } +export function clickResumeUpdate() { + AppDispatcher.dispatch({ + type: ActionTypes.CLICK_RESUME_UPDATE + }); + resumeUpdate(); +} + export function clickShowTopologyForNode(topologyId, nodeId) { AppDispatcher.dispatch({ type: ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE, @@ -104,6 +120,7 @@ export function clickShowTopologyForNode(topologyId, nodeId) { nodeId }); updateRoute(); + resetUpdateBuffer(); getNodesDelta( AppStore.getCurrentTopologyUrl(), AppStore.getActiveTopologyOptions() @@ -116,6 +133,7 @@ export function clickTopology(topologyId) { topologyId: topologyId }); updateRoute(); + resetUpdateBuffer(); getNodesDelta( AppStore.getCurrentTopologyUrl(), AppStore.getActiveTopologyOptions() @@ -215,10 +233,14 @@ export function receiveNodeDetails(details) { } export function receiveNodesDelta(delta) { - AppDispatcher.dispatch({ - type: ActionTypes.RECEIVE_NODES_DELTA, - delta: delta - }); + if (AppStore.isUpdatePaused()) { + bufferDeltaUpdate(delta); + } else { + AppDispatcher.dispatch({ + type: ActionTypes.RECEIVE_NODES_DELTA, + delta: delta + }); + } } export function receiveTopologies(topologies) { diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 702fcefcd..c5bace2ee 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -2,13 +2,13 @@ import React from 'react'; import Logo from './logo'; import AppStore from '../stores/app-store'; +import Footer from './footer.js'; import Sidebar from './sidebar.js'; import Status from './status.js'; import Topologies from './topologies.js'; import TopologyOptions from './topology-options.js'; -import { contrastModeUrl, isContrastMode } from '../utils/contrast-utils'; -import { getApiDetails, getTopologies, basePathSlash } from '../utils/web-api-utils'; -import { clickDownloadGraph, clickForceRelayout, hitEsc } from '../actions/app-actions'; +import { getApiDetails, getTopologies } from '../utils/web-api-utils'; +import { hitEsc } from '../actions/app-actions'; import Details from './details'; import Nodes from './nodes'; import EmbeddedTerminal from './embedded-terminal'; @@ -35,6 +35,8 @@ function getStateFromStores() { selectedNodeId: AppStore.getSelectedNodeId(), topologies: AppStore.getTopologies(), topologiesLoaded: AppStore.isTopologiesLoaded(), + updatePaused: AppStore.isUpdatePaused(), + updatePausedAt: AppStore.getUpdatePausedAt(), version: AppStore.getVersion(), websocketClosed: AppStore.isWebsocketClosed() }; @@ -72,17 +74,12 @@ export default class App extends React.Component { } render() { - const showingDetails = this.state.nodeDetails.size > 0; - const showingTerminal = this.state.controlPipe; + const {nodeDetails, controlPipe } = this.state; + const showingDetails = nodeDetails.size > 0; + const showingTerminal = controlPipe; // width of details panel blocking a view const detailsWidth = showingDetails ? 450 : 0; const topMargin = 100; - const contrastMode = isContrastMode(); - // link url to switch contrast with current UI state - const otherContrastModeUrl = contrastMode ? basePathSlash(window.location.pathname) : contrastModeUrl; - const otherContrastModeTitle = contrastMode ? 'Switch to normal contrast' : 'Switch to high contrast'; - const forceRelayoutClassName = 'footer-label footer-label-icon'; - const forceRelayoutTitle = 'Force re-layout (might reduce edge crossings, but may shift nodes around)'; return (
@@ -120,25 +117,7 @@ export default class App extends React.Component { activeOptions={this.state.activeTopologyOptions} /> -
- Version - {this.state.version} - on - {this.state.hostname} -   - - - - - - - - - - - - -
+
); } diff --git a/client/app/scripts/components/footer.js b/client/app/scripts/components/footer.js new file mode 100644 index 000000000..255871307 --- /dev/null +++ b/client/app/scripts/components/footer.js @@ -0,0 +1,67 @@ +import React from 'react'; +import moment from 'moment'; + +import { getUpdateBufferSize } from '../utils/update-buffer-utils'; +import { contrastModeUrl, isContrastMode } from '../utils/contrast-utils'; +import { clickDownloadGraph, clickForceRelayout, clickPauseUpdate, + clickResumeUpdate } from '../actions/app-actions'; +import { basePathSlash } from '../utils/web-api-utils'; + +export default (props) => { + const { hostname, updatePaused, updatePausedAt, version } = props; + const contrastMode = isContrastMode(); + + // link url to switch contrast with current UI state + const otherContrastModeUrl = contrastMode ? basePathSlash(window.location.pathname) : contrastModeUrl; + const otherContrastModeTitle = contrastMode ? 'Switch to normal contrast' : 'Switch to high contrast'; + const forceRelayoutTitle = 'Force re-layout (might reduce edge crossings, but may shift nodes around)'; + + // pause button + const isPaused = updatePaused; + const updateCount = getUpdateBufferSize(); + const hasUpdates = updateCount > 0; + const pausedAgo = moment(updatePausedAt).fromNow(); + const pauseTitle = isPaused ? `Paused ${pausedAgo}` : 'Pause updates (freezes the nodes in their current layout)'; + const pauseAction = isPaused ? clickResumeUpdate : clickPauseUpdate; + const pauseClassName = isPaused ? 'footer-icon footer-icon-active' : 'footer-icon'; + let pauseLabel = ''; + if (hasUpdates && isPaused) { + pauseLabel = `Paused +${updateCount}`; + } else if (hasUpdates && !isPaused) { + pauseLabel = `Resuming +${updateCount}`; + } else if (!hasUpdates && isPaused) { + pauseLabel = 'Paused'; + } + + return ( +
+ +
+ Version + {version} + on + {hostname} +
+ +
+ + {pauseLabel !== '' && {pauseLabel}} + + + + + + + + + + + + + + +
+ +
+ ); +}; diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index 3e62b7bc2..047dc8274 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -8,7 +8,9 @@ const ACTION_TYPES = [ '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', diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js index 1b6d8056e..5b17145c1 100644 --- a/client/app/scripts/stores/app-store.js +++ b/client/app/scripts/stores/app-store.js @@ -69,6 +69,7 @@ let topologiesLoaded = false; let topologyUrlsById = makeOrderedMap(); // topologyId -> topologyUrl let routeSet = false; let controlPipes = makeOrderedMap(); // pipeId -> controlPipe +let updatePausedAt = null; // Date let websocketClosed = true; // adds ID field to topology (based on last part of URL path) and save urls in @@ -129,6 +130,10 @@ function closeAllNodeDetails() { } } +function resumeUpdate() { + updatePausedAt = null; +} + // Store API export class AppStore extends Store { @@ -263,6 +268,10 @@ export class AppStore extends Store { return topologyUrlsById; } + getUpdatePausedAt() { + return updatePausedAt; + } + getVersion() { return version; } @@ -283,6 +292,10 @@ export class AppStore extends Store { return currentTopology && currentTopology.stats && currentTopology.stats.node_count === 0 && nodes.size === 0; } + isUpdatePaused() { + return updatePausedAt !== null; + } + isWebsocketClosed() { return websocketClosed; } @@ -294,6 +307,7 @@ export class AppStore extends Store { switch (payload.type) { case ActionTypes.CHANGE_TOPOLOGY_OPTION: + resumeUpdate(); if (topologyOptions.getIn([payload.topologyId, payload.option]) !== payload.value) { nodes = nodes.clear(); @@ -357,6 +371,11 @@ export class AppStore extends Store { this.__emitChange(); break; + case ActionTypes.CLICK_PAUSE_UPDATE: + updatePausedAt = new Date; + this.__emitChange(); + break; + case ActionTypes.CLICK_RELATIVE: if (nodeDetails.has(payload.nodeId)) { // bring to front @@ -377,7 +396,13 @@ export class AppStore extends Store { this.__emitChange(); break; + case ActionTypes.CLICK_RESUME_UPDATE: + resumeUpdate(); + this.__emitChange(); + break; + case ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE: + resumeUpdate(); nodeDetails = nodeDetails.filter((v, k) => k === payload.nodeId); controlPipes = controlPipes.clear(); selectedNodeId = payload.nodeId; @@ -389,6 +414,7 @@ export class AppStore extends Store { break; case ActionTypes.CLICK_TOPOLOGY: + resumeUpdate(); closeAllNodeDetails(); if (payload.topologyId !== currentTopologyId) { setTopology(payload.topologyId); @@ -482,6 +508,7 @@ export class AppStore extends Store { case ActionTypes.RECEIVE_NODE_DETAILS: errorUrl = null; + // disregard if node is not selected anymore if (nodeDetails.has(payload.details.id)) { nodeDetails = nodeDetails.update(payload.details.id, obj => { diff --git a/client/app/scripts/utils/string-utils.js b/client/app/scripts/utils/string-utils.js index 1364c67fc..e4f3b9bdb 100644 --- a/client/app/scripts/utils/string-utils.js +++ b/client/app/scripts/utils/string-utils.js @@ -42,3 +42,5 @@ export function formatMetric(value, opts) { const formatter = opts && formatters[opts.format] ? opts.format : 'number'; return formatters[formatter](value); } + +export const formatDate = d3.time.format.iso; diff --git a/client/app/scripts/utils/update-buffer-utils.js b/client/app/scripts/utils/update-buffer-utils.js new file mode 100644 index 000000000..fd96e2dfb --- /dev/null +++ b/client/app/scripts/utils/update-buffer-utils.js @@ -0,0 +1,113 @@ +import _ from 'lodash'; +import debug from 'debug'; +import Immutable from 'immutable'; + +import { receiveNodesDelta } from '../actions/app-actions'; +import AppStore from '../stores/app-store'; + +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() { + return AppStore.isUpdatePaused(); +} + +export function resetUpdateBuffer() { + clearTimeout(updateTimer); + deltaBuffer = deltaBuffer.clear(); +} + +function maybeUpdate() { + if (isPaused()) { + clearTimeout(updateTimer); + resetUpdateBuffer(); + } else { + if (deltaBuffer.size > 0) { + const delta = deltaBuffer.first(); + deltaBuffer = deltaBuffer.shift(); + receiveNodesDelta(delta); + } + if (deltaBuffer.size > 0) { + updateTimer = setTimeout(maybeUpdate, 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() { + maybeUpdate(); +} diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 7bd57a9b7..50fbfef11 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -179,6 +179,7 @@ h2 { z-index: 20; color: @text-tertiary-color; font-size: 0.7rem; + display: flex; a { color: @text-secondary-color; @@ -186,28 +187,45 @@ h2 { text-decoration: none; font-weight: bold; font-size: 90%; + cursor: pointer; + } + + &-status { + margin-right: 1em; } &-label { text-transform: uppercase; margin: 0 0.25em; + } - &-icon { - margin-left: 0.5em; - padding: 4px 3px; - color: @text-color; - position: relative; - border: 1px solid transparent; - border-radius: 10%; - &:hover { - border: 1px solid @text-tertiary-color; - } - span { - font-size: 150%; - position: relative; - top: 2px; - } + &-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; } + + .fa { + font-size: 150%; + position: relative; + top: 2px; + } + + &-active { + border: 1px solid @text-tertiary-color; + animation: blinking 1.5s infinite ease-in-out; + } + } + + &-icon &-label { + margin-right: 0.5em; } } diff --git a/client/package.json b/client/package.json index 824502f96..9e2e2bf66 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "immutable": "~3.7.4", "lodash": "~3.10.1", "materialize-css": "0.97.2", + "moment": "2.12.0", "page": "1.6.4", "react": "0.14.3", "react-addons-pure-render-mixin": "0.14.3",