Merge pull request #1106 from weaveworks/1025-pause-button

Pause Button
This commit is contained in:
David
2016-03-07 17:42:11 +01:00
9 changed files with 280 additions and 49 deletions

View File

@@ -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) {

View File

@@ -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 (
<div className="app">
@@ -120,25 +117,7 @@ export default class App extends React.Component {
activeOptions={this.state.activeTopologyOptions} />
</Sidebar>
<div className="footer">
<span className="footer-label">Version</span>
{this.state.version}
<span className="footer-label">on</span>
{this.state.hostname}
&nbsp;
<a className={forceRelayoutClassName} onClick={clickForceRelayout} title={forceRelayoutTitle}>
<span className="fa fa-refresh" />
</a>
<a className="footer-label footer-label-icon" onClick={clickDownloadGraph} title="Save canvas as SVG">
<span className="fa fa-download" />
</a>
<a className="footer-label footer-label-icon" href={otherContrastModeUrl} title={otherContrastModeTitle}>
<span className="fa fa-adjust" />
</a>
<a className="footer-label footer-label-icon" href="https://gitreports.com/issue/weaveworks/scope" target="_blank" title="Report an issue">
<span className="fa fa-bug" />
</a>
</div>
<Footer {...this.state} />
</div>
);
}

View File

@@ -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 (
<div className="footer">
<div className="footer-status">
<span className="footer-label">Version</span>
{version}
<span className="footer-label">on</span>
{hostname}
</div>
<div className="footer-tools">
<a className={pauseClassName} onClick={pauseAction} title={pauseTitle}>
{pauseLabel !== '' && <span className="footer-label">{pauseLabel}</span>}
<span className="fa fa-pause" />
</a>
<a className="footer-icon" onClick={clickForceRelayout} title={forceRelayoutTitle}>
<span className="fa fa-refresh" />
</a>
<a className="footer-icon" onClick={clickDownloadGraph} title="Save canvas as SVG">
<span className="fa fa-download" />
</a>
<a className="footer-icon" href={otherContrastModeUrl} title={otherContrastModeTitle}>
<span className="fa fa-adjust" />
</a>
<a className="footer-icon" href="https://gitreports.com/issue/weaveworks/scope" target="_blank" title="Report an issue">
<span className="fa fa-bug" />
</a>
</div>
</div>
);
};

View File

@@ -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',

View File

@@ -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 => {

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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;
}
}

View File

@@ -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",