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-03 10:30:00 +00:00
committed by Tom Wilkie
parent 8f957c4f13
commit abcb94b1f1
20 changed files with 267 additions and 69 deletions

View File

@@ -26,6 +26,9 @@
"templateStrings": true,
"jsx": true
},
"globals": {
__WS_URL__: false
},
"rules": {
/**
* Strict mode

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
});
},

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);
},

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

View File

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

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} /> }

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

View File

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

View File

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

View File

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

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}

View File

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

View File

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

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');

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

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,

View File

@@ -35,6 +35,11 @@
text-overflow: ellipsis;
}
.palable {
transition: opacity .2s ease-in-out;
transition: border-color .2s ease-in-out;
}
.hideable {
transition: opacity .5s ease-in-out;
}
@@ -313,24 +318,6 @@ h2 {
top: 24px;
bottom: 48px;
width: 420px;
.details-tools-wrapper {
position: relative;
}
.details-tools {
position: absolute;
top: 16px;
right: 24px;
color: @white;
span {
cursor: pointer;
&:hover {
color: white;
}
}
}
}
.node-details {
@@ -342,26 +329,79 @@ h2 {
padding: 24px 36px 24px 36px;
&-row {
display: flex;
}
&-label {
color: white;
margin: 0;
width: 348px;
padding-top: 0;
&-minor {
width: 348px;
flex: 1;
font-size: 120%;
color: @white;
}
}
.details-tools {
position: absolute;
top: 16px;
right: 24px;
}
&-notavailable {
background-color: @background-dark-color;
}
}
&-controls {
white-space: nowrap;
text-align: right;
margin: -8px -8px 0 0;
.node-control-button {
.palable;
padding: 6px;
margin-left: 2px;
font-size: 110%;
color: @white;
cursor: pointer;
opacity: 0.7;
border: 1px solid rgba(255, 255, 255, 0);
border-radius: 10%;
&:hover {
opacity: 1;
border-color: rgba(255, 255, 255, 0.6);
}
&-pending, &-pending:hover {
opacity: 0.2;
border-color: rgba(255, 255, 255, 0);
cursor: not-allowed;
}
}
&-error {
.truncate;
float: left;
width: 66%;
padding-top: 6px;
text-align: left;
color: @white;
&-icon {
margin-right: 0.5em;
animation: blinking 2.0s infinite ease-in-out;
}
}
}
&-content {
position: absolute;
top: 115px;
top: 128px;
bottom: 0;
padding: 0 36px 0 36px;
overflow-y: scroll;
@@ -449,6 +489,14 @@ h2 {
}
}
@keyframes blinking {
0%, 100% {
opacity: 1.0;
} 50% {
opacity: 0.5;
}
}
@keyframes status-loading {
0%, 100% {
background-color: darken(@background-color, 4%);

View File

@@ -28,9 +28,11 @@ app.get('/app.js', function(req, res) {
// Proxy to backend
var BACKEND_HOST = process.env.BACKEND_HOST || 'localhost:4040';
// HACK need express-http-proxy, because proxy-middleware does
// not proxy to /api itself
app.use(httpProxy('localhost:4040', {
app.use(httpProxy(BACKEND_HOST, {
filter: function(req) {
return url.parse(req.url).path === '/api';
},
@@ -39,7 +41,7 @@ app.use(httpProxy('localhost:4040', {
}
}));
app.use('/api', proxy('http://localhost:4040/api/'));
app.use('/api', proxy('http://' + BACKEND_HOST + '/api/'));
// Serve index page

View File

@@ -12,6 +12,13 @@ var path = require('path');
*
* For more information, see: http://webpack.github.io/docs/configuration.html
*/
// Inject websocket url to dev backend
var BACKEND_HOST = process.env.BACKEND_HOST || 'localhost:4040';
var GLOBALS = {
__WS_URL__: JSON.stringify('ws://' + BACKEND_HOST)
};
module.exports = {
// Efficiently evaluate modules with source maps
@@ -21,7 +28,7 @@ module.exports = {
entry: [
'webpack-dev-server/client?http://localhost:4041',
'webpack/hot/only-dev-server',
'./app/scripts/local'
'./app/scripts/main'
],
// This will not actually create a app.js file in ./build. It is used
@@ -34,6 +41,7 @@ module.exports = {
// Necessary plugins for hot load
plugins: [
new webpack.DefinePlugin(GLOBALS),
new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin()
],

View File

@@ -2,6 +2,10 @@ var webpack = require('webpack');
var autoprefixer = require('autoprefixer-core');
var path = require('path');
var GLOBALS = {
__WS_URL__: 'false'
};
/**
* This is the Webpack configuration file for production.
*/
@@ -56,9 +60,12 @@ module.exports = {
extensions: ['', '.js', '.jsx']
},
plugins: [new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
}
})]
plugins: [
new webpack.DefinePlugin(GLOBALS),
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
}
})
]
};