From d7507cf67c056a9660aacf9b24e47c70eafd4031 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Thu, 3 Mar 2016 14:11:03 +0100 Subject: [PATCH] Pause Button * pauses topology and details panel updates * buffers requests * un-pause plays back the buffered requests * automatically un-pauses when topology is changed, or other node is clicked (then discards buffer) Fixes #1025 --- client/app/scripts/actions/app-actions.js | 30 ++++- client/app/scripts/components/app.js | 27 ++++- client/app/scripts/constants/action-types.js | 2 + client/app/scripts/stores/app-store.js | 27 +++++ client/app/scripts/utils/string-utils.js | 2 + .../app/scripts/utils/update-buffer-utils.js | 109 ++++++++++++++++++ client/app/styles/main.less | 5 + 7 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 client/app/scripts/utils/update-buffer-utils.js 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..456a37aa1 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -6,13 +6,15 @@ import Sidebar from './sidebar.js'; import Status from './status.js'; import Topologies from './topologies.js'; import TopologyOptions from './topology-options.js'; +import { getUpdateBufferSize } from '../utils/update-buffer-utils'; 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 { clickDownloadGraph, clickForceRelayout, clickPauseUpdate, clickResumeUpdate, hitEsc } from '../actions/app-actions'; import Details from './details'; import Nodes from './nodes'; import EmbeddedTerminal from './embedded-terminal'; import { getRouter } from '../utils/router-utils'; +import { formatDate } from '../utils/string-utils'; import { showingDebugToolbar, DebugToolbar } from './debug-toolbar.js'; const ESC_KEY_CODE = 27; @@ -35,6 +37,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,8 +76,9 @@ export default class App extends React.Component { } render() { - const showingDetails = this.state.nodeDetails.size > 0; - const showingTerminal = this.state.controlPipe; + const {nodeDetails, updatePaused, updatePausedAt, 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; @@ -83,6 +88,13 @@ export default class App extends React.Component { 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)'; + const isPaused = updatePaused; + const pauseClassName = isPaused ? 'footer-label footer-label-icon footer-label-active' : 'footer-label footer-label-icon'; + const updateCount = getUpdateBufferSize(); + const hasUpdates = updateCount > 0; + const pauseTitle = isPaused ? `Paused on ${formatDate(updatePausedAt)}` : 'Pause updates'; + const pauseAction = isPaused ? clickResumeUpdate : clickPauseUpdate; + const pauseWrapperClassName = isPaused ? 'footer-active' : ''; return (
@@ -126,6 +138,15 @@ export default class App extends React.Component { on {this.state.hostname}   +   + + {!hasUpdates && isPaused && Paused} + {hasUpdates && isPaused && Paused ({updateCount} updates waiting)} + {hasUpdates && !isPaused && Resuming ({updateCount} updates remaining)} + + + + 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..c40e036b1 --- /dev/null +++ b/client/app/scripts/utils/update-buffer-utils.js @@ -0,0 +1,109 @@ +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); + } + } +} + +export function bufferDeltaUpdate(delta) { + if (delta.add === null && delta.update === null && delta.remove === null) { + log('Discarding empty nodes delta'); + return; + } + + if (deltaBuffer.size >= bufferLength) { + // consolidate first buffer entry with second + 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 + }); + } + + 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..50a3e011f 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -188,6 +188,10 @@ h2 { font-size: 90%; } + &-active { + animation: blinking 1.5s infinite ease-in-out; + } + &-label { text-transform: uppercase; margin: 0 0.25em; @@ -197,6 +201,7 @@ h2 { padding: 4px 3px; color: @text-color; position: relative; + top: -1px; border: 1px solid transparent; border-radius: 10%; &:hover {