UI for controls.

- Make backend address configurable via env variable
- `BACKEND_HOST=1.2.3.4:4040 npm start` points the frontend to the app on that server. Just for development
- Render control icons
  - removed close x for details panel
  - added control icons in its space
  - closing of panel still works by clicking on same node, or background
- Dont allow control while pending
- Render and auto-dismiss control error
- Make tests pass
This commit is contained in:
David Kaltschmidt
2015-11-06 17:44:28 +00:00
committed by Tom Wilkie
parent 8f957c4f13
commit abcb94b1f1
20 changed files with 267 additions and 69 deletions
+34 -4
View File
@@ -66,12 +66,29 @@ module.exports = {
});
},
clearControlError: function() {
AppDispatcher.dispatch({
type: ActionTypes.CLEAR_CONTROL_ERROR
});
},
closeWebsocket: function() {
AppDispatcher.dispatch({
type: ActionTypes.CLOSE_WEBSOCKET
});
},
doControl: function(probeId, nodeId, control) {
AppDispatcher.dispatch({
type: ActionTypes.DO_CONTROL
});
WebapiUtils.doControl(
probeId,
nodeId,
control,
);
},
enterEdge: function(edgeId) {
AppDispatcher.dispatch({
type: ActionTypes.ENTER_EDGE,
@@ -107,6 +124,19 @@ module.exports = {
});
},
receiveControlError: function(err) {
AppDispatcher.dispatch({
type: ActionTypes.DO_CONTROL_ERROR,
error: err
});
},
receiveControlSuccess: function() {
AppDispatcher.dispatch({
type: ActionTypes.DO_CONTROL_SUCCESS
});
},
receiveNodeDetails: function(details) {
AppDispatcher.dispatch({
type: ActionTypes.RECEIVE_NODE_DETAILS,
@@ -139,15 +169,15 @@ module.exports = {
receiveApiDetails: function(apiDetails) {
AppDispatcher.dispatch({
type: ActionTypes.RECEIVE_API_DETAILS,
version: apiDetails.version
type: ActionTypes.RECEIVE_API_DETAILS,
version: apiDetails.version
});
},
receiveError: function(errorUrl) {
AppDispatcher.dispatch({
errorUrl: errorUrl,
type: ActionTypes.RECEIVE_ERROR
errorUrl: errorUrl,
type: ActionTypes.RECEIVE_ERROR
});
},
+7 -2
View File
@@ -22,9 +22,9 @@ const Node = React.createClass({
const subLabelOffsetY = labelOffsetY + 17;
const isPseudo = !!this.props.pseudo;
const color = isPseudo ? '' : this.getNodeColor(this.props.rank);
const onClick = this.props.onClick;
const onMouseEnter = this.handleMouseEnter;
const onMouseLeave = this.handleMouseLeave;
const onMouseClick = this.handleMouseClick;
const classNames = ['node'];
const animConfig = [80, 20]; // stiffness, bounce
const label = this.ellipsis(props.label, 14, scale(4 * scaleFactor));
@@ -51,7 +51,7 @@ const Node = React.createClass({
const transform = `translate(${interpolated.x.val},${interpolated.y.val})`;
return (
<g className={classes} transform={transform} id={props.id}
onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
onClick={onMouseClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
{props.highlighted && <circle r={scale(0.7 * interpolated.f.val)} className="highlighted"></circle>}
<circle r={scale(0.5 * interpolated.f.val)} className="border" stroke={color}></circle>
<circle r={scale(0.45 * interpolated.f.val)} className="shadow"></circle>
@@ -79,6 +79,11 @@ const Node = React.createClass({
return truncatedText;
},
handleMouseClick: function(ev) {
ev.stopPropagation();
AppActions.clickNode(ev.currentTarget.id);
},
handleMouseEnter: function(ev) {
AppActions.enterNode(ev.currentTarget.id);
},
+2 -2
View File
@@ -218,7 +218,7 @@ const NodesChart = React.createClass({
<div className="nodes-chart">
{errorEmpty}
{errorMaxNodesExceeded}
<svg width="100%" height="100%" className={svgClassNames} onMouseUp={this.handleMouseUp}>
<svg width="100%" height="100%" className={svgClassNames} onClick={this.handleMouseClick}>
<Spring endValue={{val: translate, config: [80, 20]}}>
{function(interpolated) {
let interpolatedTranslate = wasShifted ? interpolated.val : panTranslate;
@@ -402,7 +402,7 @@ const NodesChart = React.createClass({
isZooming: false, // distinguish pan/zoom from click
handleMouseUp: function() {
handleMouseClick: function() {
if (!this.isZooming) {
AppActions.clickCloseDetails();
// allow shifts again
@@ -2,6 +2,8 @@ jest.dontMock('../node-details.js');
jest.dontMock('../../mixins/node-color-mixin');
jest.dontMock('../../utils/title-utils');
__WS_URL__ = false
describe('NodeDetails', () => {
let NodeDetails;
let nodes;
+4
View File
@@ -20,6 +20,8 @@ const ESC_KEY_CODE = 27;
function getStateFromStores() {
return {
activeTopologyOptions: AppStore.getActiveTopologyOptions(),
controlError: AppStore.getControlError(),
controlPending: AppStore.isControlPending(),
currentTopology: AppStore.getCurrentTopology(),
currentTopologyId: AppStore.getCurrentTopologyId(),
currentTopologyOptions: AppStore.getCurrentTopologyOptions(),
@@ -81,6 +83,8 @@ const App = React.createClass({
return (
<div>
{showingDetails && <Details nodes={this.state.nodes}
controlError={this.state.controlError}
controlPending={this.state.controlPending}
nodeId={this.state.selectedNodeId}
details={this.state.nodeDetails} /> }
+1 -13
View File
@@ -2,7 +2,6 @@ const React = require('react');
const mui = require('material-ui');
const Paper = mui.Paper;
const AppActions = require('../actions/app-actions');
const NodeDetails = require('./node-details');
const Details = React.createClass({
@@ -11,21 +10,10 @@ const Details = React.createClass({
return (
<div id="details">
<Paper zDepth={3} style={{height: '100%', paddingBottom: 8}}>
<div className="details-tools-wrapper">
<div className="details-tools">
<span className="fa fa-close" onClick={this.handleClickClose} />
</div>
</div>
<NodeDetails nodeId={this.props.nodeId} details={this.props.details}
nodes={this.props.nodes} />
<NodeDetails {...this.props} />
</Paper>
</div>
);
},
handleClickClose: function(ev) {
ev.preventDefault();
AppActions.clickCloseDetails();
}
});
@@ -0,0 +1,23 @@
const React = require('react');
const AppActions = require('../actions/app-actions');
const NodeControlButton = React.createClass({
render: function() {
let className = `node-control-button fa ${this.props.control.icon}`;
if (this.props.pending) {
className += ' node-control-button-pending';
}
return (
<span className={className} title={this.props.control.human} onClick={this.handleClick} />
);
},
handleClick: function(ev) {
ev.preventDefault();
AppActions.doControl(this.props.control.probeId, this.props.control.nodeId, this.props.control.id);
}
});
module.exports = NodeControlButton;
@@ -0,0 +1,25 @@
const React = require('react');
const NodeControlButton = require('./node-control-button');
const NodeDetailsControls = React.createClass({
render: function() {
return (
<div className="node-details-controls">
{this.props.error && <div className="node-details-controls-error" title={this.props.error}>
<span className="node-details-controls-error-icon fa fa-warning" />
<span className="node-details-controls-error-messages">{this.props.error}</span>
</div>}
{this.props.controls && this.props.controls.map(control => {
return (
<NodeControlButton control={control} pending={this.props.pending} />
);
})}
</div>
);
}
});
module.exports = NodeDetailsControls;
@@ -1,6 +1,7 @@
const _ = require('lodash');
const React = require('react');
const NodeDetailsControls = require('./node-details-controls');
const NodeDetailsTable = require('./node-details-table');
const NodeColorMixin = require('../mixins/node-color-mixin');
const TitleUtils = require('../utils/title-utils');
@@ -65,6 +66,8 @@ const NodeDetails = React.createClass({
return (
<div className="node-details">
<div className="node-details-header" style={style}>
<NodeDetailsControls controls={details.controls}
pending={this.props.controlPending} error={this.props.controlError} />
<h2 className="node-details-header-label truncate" title={details.label_major}>
{details.label_major}
</h2>
-6
View File
@@ -1,7 +1,6 @@
const React = require('react');
const NodesChart = require('../charts/nodes-chart');
const AppActions = require('../actions/app-actions');
const navbarHeight = 160;
const marginTop = 0;
@@ -23,10 +22,6 @@ const Nodes = React.createClass({
window.removeEventListener('resize', this.handleResize);
},
onNodeClick: function(ev) {
AppActions.clickNode(ev.currentTarget.id);
},
render: function() {
return (
<NodesChart
@@ -34,7 +29,6 @@ const Nodes = React.createClass({
highlightedNodeIds={this.props.highlightedNodeIds}
selectedNodeId={this.props.selectedNodeId}
nodes={this.props.nodes}
onNodeClick={this.onNodeClick}
width={this.state.width}
height={this.state.height}
topologyId={this.props.topologyId}
@@ -2,10 +2,14 @@ const keymirror = require('keymirror');
module.exports = keymirror({
CHANGE_TOPOLOGY_OPTION: null,
CLEAR_CONTROL_ERROR: null,
CLICK_CLOSE_DETAILS: null,
CLICK_NODE: null,
CLICK_TOPOLOGY: null,
CLOSE_WEBSOCKET: null,
DO_CONTROL: null,
DO_CONTROL_ERROR: null,
DO_CONTROL_SUCCESS: null,
ENTER_EDGE: null,
ENTER_NODE: null,
HIT_ESC_KEY: null,
@@ -5,7 +5,7 @@ const AppDispatcher = new flux.Dispatcher();
AppDispatcher.dispatch = _.wrap(flux.Dispatcher.prototype.dispatch, function(func) {
const args = Array.prototype.slice.call(arguments, 1);
// console.log(args[0]);
console.log(args[0]);
func.apply(this, args);
});
-8
View File
@@ -1,8 +0,0 @@
// This file is an entrypoint for development,
// see main.js for the real entrypoint
// Inject websocket url to dev backend
window.WS_PROTO = (location.protocol === 'https:' ? 'wss' : 'ws');
window.WS_URL = window.WS_PROTO + '://' + location.hostname + ':4040';
require('./main');
+39 -1
View File
@@ -46,6 +46,8 @@ function makeNode(node) {
let topologyOptions = makeOrderedMap();
let adjacentNodes = makeSet();
let controlError = null;
let controlPending = false;
let currentTopology = null;
let currentTopologyId = 'containers';
let errorUrl = null;
@@ -132,6 +134,10 @@ const AppStore = assign({}, EventEmitter.prototype, {
return adjacentNodes;
},
getControlError: function() {
return controlError;
},
getCurrentTopology: function() {
if (!currentTopology) {
currentTopology = setTopology(currentTopologyId);
@@ -209,6 +215,10 @@ const AppStore = assign({}, EventEmitter.prototype, {
return version;
},
isControlPending: function() {
return controlPending;
},
isRouteSet: function() {
return routeSet;
},
@@ -244,13 +254,23 @@ AppStore.registeredCallback = function(payload) {
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.CLEAR_CONTROL_ERROR:
controlError = null;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.CLICK_CLOSE_DETAILS:
selectedNodeId = null;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.CLICK_NODE:
selectedNodeId = payload.nodeId;
if (payload.nodeId === selectedNodeId) {
// clicking same node twice unsets the selection
selectedNodeId = null;
} else {
selectedNodeId = payload.nodeId;
}
AppStore.emit(AppStore.CHANGE_EVENT);
break;
@@ -268,6 +288,12 @@ AppStore.registeredCallback = function(payload) {
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.DO_CONTROL:
controlPending = true;
controlError = null;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.ENTER_EDGE:
mouseOverEdgeId = payload.edgeId;
AppStore.emit(AppStore.CHANGE_EVENT);
@@ -302,6 +328,18 @@ AppStore.registeredCallback = function(payload) {
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.DO_CONTROL_ERROR:
controlPending = false;
controlError = payload.error;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.DO_CONTROL_SUCCESS:
controlPending = false;
controlError = null;
AppStore.emit(AppStore.CHANGE_EVENT);
break;
case ActionTypes.RECEIVE_ERROR:
errorUrl = payload.errorUrl;
AppStore.emit(AppStore.CHANGE_EVENT);
+26 -4
View File
@@ -1,10 +1,11 @@
const debug = require('debug')('scope:web-api-utils');
const reqwest = require('reqwest');
const AppActions = require('../actions/app-actions');
const WS_PROTO = window.WS_PROTO || (location.protocol === 'https:' ? 'wss' : 'ws');
const WS_URL = window.WS_URL || WS_PROTO + '://' + location.host + location.pathname.replace(/\/$/, '');
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = __WS_URL__ || wsProto + '://' + location.host + location.pathname.replace(/\/$/, '');
const apiTimerInterval = 10000;
const reconnectTimerInterval = 5000;
@@ -17,7 +18,7 @@ let currentUrl = null;
let currentOptions = null;
let topologyTimer = 0;
let apiDetailsTimer = 0;
let controlErrorTimer = 0;
function buildOptionsQuery(options) {
if (options) {
@@ -35,7 +36,7 @@ function createWebsocket(topologyUrl, optionsQuery) {
socket.close();
}
socket = new WebSocket(WS_URL + topologyUrl
socket = new WebSocket(wsUrl + topologyUrl
+ '/ws?t=' + updateFrequency + '&' + optionsQuery);
socket.onopen = function() {
@@ -135,7 +136,28 @@ function getApiDetails() {
});
}
function doControl(probeId, nodeId, control) {
clearTimeout(controlErrorTimer);
const url = `api/control/${encodeURIComponent(probeId)}/`
+ `${encodeURIComponent(nodeId)}/${control}`;
reqwest({
method: 'POST',
url: url,
success: function() {
AppActions.receiveControlSuccess();
},
error: function(err) {
AppActions.receiveControlError(err.response);
controlErrorTimer = setTimeout(function() {
AppActions.clearControlError();
}, 10000);
}
});
}
module.exports = {
doControl: doControl,
getNodeDetails: getNodeDetails,
getTopologies: getTopologies,