mirror of
https://github.com/weaveworks/scope.git
synced 2026-08-18 03:46:45 +00:00
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
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<div className="app">
|
||||
@@ -126,6 +138,15 @@ export default class App extends React.Component {
|
||||
<span className="footer-label">on</span>
|
||||
{this.state.hostname}
|
||||
|
||||
|
||||
<span className={pauseWrapperClassName}>
|
||||
{!hasUpdates && isPaused && <span className="footer-label">Paused</span>}
|
||||
{hasUpdates && isPaused && <span className="footer-label">Paused ({updateCount} updates waiting)</span>}
|
||||
{hasUpdates && !isPaused && <span className="footer-label">Resuming ({updateCount} updates remaining)</span>}
|
||||
<a className={pauseClassName} onClick={pauseAction} title={pauseTitle}>
|
||||
<span className="fa fa-pause" />
|
||||
</a>
|
||||
</span>
|
||||
<a className={forceRelayoutClassName} onClick={clickForceRelayout} title={forceRelayoutTitle}>
|
||||
<span className="fa fa-refresh" />
|
||||
</a>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user