diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 096aee344c..1bbc147ca5 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -35,6 +35,13 @@ module.exports = { RouterUtils.updateRoute(); }, + clickCloseTerminal: function(pipeId) { + AppDispatcher.dispatch({ + type: ActionTypes.CLICK_CLOSE_TERMINAL, + pipeId: pipeId + }); + }, + clickNode: function(nodeId) { AppDispatcher.dispatch({ type: ActionTypes.CLICK_NODE, @@ -48,6 +55,13 @@ module.exports = { ); }, + clickTerminal: function(pipeId) { + AppDispatcher.dispatch({ + type: ActionTypes.CLICK_TERMINAL, + pipeId: pipeId + }); + }, + clickTopology: function(topologyId) { AppDispatcher.dispatch({ type: ActionTypes.CLICK_TOPOLOGY, @@ -79,6 +93,12 @@ module.exports = { }, doControl: function(probeId, nodeId, control) { + // TODO remove this fake dispatch + // AppDispatcher.dispatch({ + // type: ActionTypes.RECEIVE_CONTROL_PIPE, + // nodeId: nodeId, + // pipeId: nodeId + control + // }); AppDispatcher.dispatch({ type: ActionTypes.DO_CONTROL }); @@ -131,9 +151,10 @@ module.exports = { }); }, - receiveControlPipe: function(pipeId) { + receiveControlPipe: function(pipeId, nodeId) { AppDispatcher.dispatch({ type: ActionTypes.RECEIVE_CONTROL_PIPE, + nodeId: nodeId, pipeId: pipeId }); }, diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index 23a6c11779..2c7e1ad75f 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -20,7 +20,7 @@ function getStateFromStores() { activeTopologyOptions: AppStore.getActiveTopologyOptions(), controlError: AppStore.getControlError(), controlPending: AppStore.isControlPending(), - controlPipe: AppStore.getControlPipe(), + controlPipes: AppStore.getControlPipes(), currentTopology: AppStore.getCurrentTopology(), currentTopologyId: AppStore.getCurrentTopologyId(), currentTopologyOptions: AppStore.getCurrentTopologyOptions(), @@ -74,14 +74,19 @@ const App = React.createClass({ const topMargin = 100; return ( -
+
{showingDetails &&
} - {this.state.controlPipe && } + {this.state.controlPipes.toKeyedSeq().map((nodeId, pipeId) => { + return ( + + ); + })}
diff --git a/client/app/scripts/components/details.js b/client/app/scripts/components/details.js index 9bd1b9c8cc..3fcd5d228e 100644 --- a/client/app/scripts/components/details.js +++ b/client/app/scripts/components/details.js @@ -7,9 +7,7 @@ const Details = React.createClass({ render: function() { return (
-
+
diff --git a/client/app/scripts/components/terminal.js b/client/app/scripts/components/terminal.js index 18529a1ceb..536bdd1878 100644 --- a/client/app/scripts/components/terminal.js +++ b/client/app/scripts/components/terminal.js @@ -1,34 +1,64 @@ const React = require('react'); +const ReactDOM = require('react-dom'); +const PureRenderMixin = require('react-addons-pure-render-mixin'); +const Draggable = require('react-draggable'); +const DraggableCore = Draggable.DraggableCore; +const AppActions = require('../actions/app-actions'); +const NodeColorMixin = require('../mixins/node-color-mixin'); const Term = require('../vendor/term.js'); const wsProto = location.protocol === 'https:' ? 'wss' : 'ws'; const wsUrl = __WS_URL__ || wsProto + '://' + location.host + location.pathname.replace(/\/$/, ''); +const DEFAULT_COLS = 80; +const DEFAULT_ROWS = 24; +const MIN_COLS = 40; +const MIN_ROWS = 4; + function ab2str(buf) { return String.fromCharCode.apply(null, new Uint8Array(buf)); } const Terminal = React.createClass({ + mixins: [ + NodeColorMixin, + PureRenderMixin + ], + + getInitialState: function() { + return { + conected: false, + rows: DEFAULT_ROWS, + cols: DEFAULT_COLS, + pixelPerCol: 0, + pixelPerRow: 0, + height: 0, + width: 0 + }; + }, + componentDidMount: function() { - let socket = new WebSocket(wsUrl + '/api/pipe/' + this.props.controlPipe); + let socket = new WebSocket(wsUrl + '/api/pipe/' + this.props.pipeId); socket.binaryType = 'arraybuffer'; const component = this; const term = new Term({ - cols: 80, - rows: 24, + cols: this.state.cols, + rows: this.state.rows, screenKeys: true }); + const innerNode = ReactDOM.findDOMNode(component.inner); + term.open(innerNode); + term.on('data', function(data) { socket.send(data); }); socket.onopen = function() { - term.open(component.inner.getDOMNode()); - term.write('\x1b[31mWelcome to term.js!\x1b[m\r\n'); + console.log('socket open'); }; socket.onclose = function() { @@ -37,29 +67,119 @@ const Terminal = React.createClass({ socket = null; }; - socket.onerror = function() { - console.error('socket error'); + socket.onerror = function(err) { + console.error('socket error', err); }; socket.onmessage = function(event) { console.log('pipe data', event.data.size); - term.write(ab2str(event.data)); + const input = ab2str(event.data); + term.write(input); }; + + this.term = term; + this.socket = socket; + + setTimeout(() => { + this.setState({connected: true}); + }, 10); + }, + + componentWillUnmount: function() { + console.log('cwu terminal'); + if (this.socket) { + console.log('destroy terminal'); + this.socket.close(); + } + this.term = null; + this.socket = null; + }, + + componentDidUpdate: function() { + console.log('cdu terminal'); + this.term.resize(this.state.cols, this.state.rows); + }, + + handleCloseClick: function(ev) { + ev.preventDefault(); + AppActions.clickCloseTerminal(this.props.pipeId); + }, + + getNode: function() { + // FIXME use consistent node ID that does not include prefix 'hostname;' + const nodeId = this.props.nodeId && this.props.nodeId.split(';').pop(); + return this.props.nodes.get(nodeId); + }, + + handleResizeStart: function() { + const wrapperNode = ReactDOM.findDOMNode(this); + const width = wrapperNode.clientWidth; + const height = wrapperNode.clientHeight; + const pixelPerRow = height / this.state.rows; + const pixelPerCol = width / this.state.cols; + this.setState({width, height, pixelPerCol, pixelPerRow}); + }, + + handleResize: function(e, {position}) { + const width = this.state.width + position.deltaX; + const height = this.state.height + position.deltaY; + // calculate cols and rows based on new dimensions + const cols = Math.max(MIN_COLS, Math.floor(width / this.state.pixelPerCol)); + const rows = Math.max(MIN_ROWS, Math.floor(height / this.state.pixelPerRow)); + this.setState({width, height, cols, rows}); + }, + + handleResizeStop: function() { + const width = this.state.cols * this.state.pixelPerCol; + const height = this.state.rows * this.state.pixelPerRow; + this.setState({width, height}); + }, + + handleClick: function() { + AppActions.clickTerminal(this.props.pipeId); }, render: function() { + const node = this.getNode(); + + if (!this.props.pipeId) { + return
; // TODO this.renderLoading(); + } + + const nodeColor = node ? this.getNodeColorDark(node.get('label_major')) : this.getNeutralColor(); + const title = node ? node.get('label_major') : 'n/a'; + const styles = { + header: { + backgroundColor: node ? nodeColor : 'red' + }, + wrapper: { + opacity: node && this.state.connected ? 1 : 0.6, + left: 16 + } + }; + return ( -
-
this.inner = ref}> - Pipe: {this.props.controlPipe} + +
+
+
+ +
+ + Terminal {title} — {this.state.cols}×{this.state.rows} + +
+
this.inner = ref} /> + +
+ +
+
-
+
); } diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js index 0e3b2c051e..25422119fd 100644 --- a/client/app/scripts/constants/action-types.js +++ b/client/app/scripts/constants/action-types.js @@ -4,6 +4,8 @@ module.exports = keymirror({ CHANGE_TOPOLOGY_OPTION: null, CLEAR_CONTROL_ERROR: null, CLICK_CLOSE_DETAILS: null, + CLICK_CLOSE_TERMINAL: null, + CLICK_TERMINAL: null, CLICK_NODE: null, CLICK_TOPOLOGY: null, CLOSE_WEBSOCKET: null, diff --git a/client/app/scripts/main.js b/client/app/scripts/main.js index d2ab52ec80..c499c67422 100644 --- a/client/app/scripts/main.js +++ b/client/app/scripts/main.js @@ -8,4 +8,4 @@ const App = require('./components/app.js'); ReactDOM.render( , - document.getElementById('app')); + document.body); diff --git a/client/app/scripts/mixins/node-color-mixin.js b/client/app/scripts/mixins/node-color-mixin.js index 845d832b47..ee26fefb6c 100644 --- a/client/app/scripts/mixins/node-color-mixin.js +++ b/client/app/scripts/mixins/node-color-mixin.js @@ -8,6 +8,10 @@ colors(internetLabel); const NodeColorMixin = { + getNeutralColor: function() { + return '#b1b1cb'; + }, + getNodeColor: function(text) { return colors(text); }, diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js index b217c729ab..0d34eff209 100644 --- a/client/app/scripts/stores/app-store.js +++ b/client/app/scripts/stores/app-store.js @@ -43,7 +43,7 @@ function makeNode(node) { // Initial values -let topologyOptions = makeOrderedMap(); +let topologyOptions = makeOrderedMap(); // topologyId -> options let adjacentNodes = makeSet(); let controlError = null; let controlPending = false; @@ -53,13 +53,13 @@ let errorUrl = null; let version = ''; let mouseOverEdgeId = null; let mouseOverNodeId = null; -let nodes = makeOrderedMap(); +let nodes = makeOrderedMap(); // nodeId -> node let nodeDetails = null; let selectedNodeId = null; let topologies = []; let topologiesLoaded = false; let routeSet = false; -let controlPipe = null; +let controlPipes = makeOrderedMap(); // pipeId -> nodeId let websocketClosed = true; function processTopologies(topologyList) { @@ -138,8 +138,8 @@ const AppStore = Object.assign({}, EventEmitter.prototype, { return controlError; }, - getControlPipe: function() { - return controlPipe; + getControlPipes: function() { + return controlPipes; }, getCurrentTopology: function() { @@ -268,6 +268,11 @@ AppStore.registeredCallback = function(payload) { AppStore.emit(AppStore.CHANGE_EVENT); break; + case ActionTypes.CLICK_CLOSE_TERMINAL: + controlPipes = controlPipes.delete(payload.pipeId); + AppStore.emit(AppStore.CHANGE_EVENT); + break; + case ActionTypes.CLICK_NODE: if (payload.nodeId === selectedNodeId) { // clicking same node twice unsets the selection @@ -278,6 +283,16 @@ AppStore.registeredCallback = function(payload) { AppStore.emit(AppStore.CHANGE_EVENT); break; + case ActionTypes.CLICK_TERMINAL: + // removing and adding in separate steps to assure order (helps with vertical stacking) + const controlPipe = controlPipes.get(payload.pipeId); + if (controlPipe) { + controlPipes = controlPipes.delete(payload.pipeId); + controlPipes = controlPipes.set(payload.pipeId, controlPipe); + AppStore.emit(AppStore.CHANGE_EVENT); + } + break; + case ActionTypes.CLICK_TOPOLOGY: selectedNodeId = null; if (payload.topologyId !== currentTopologyId) { @@ -309,9 +324,12 @@ AppStore.registeredCallback = function(payload) { break; case ActionTypes.HIT_ESC_KEY: - nodeDetails = null; - selectedNodeId = null; - AppStore.emit(AppStore.CHANGE_EVENT); + // disable ESC when terminals are open + if (!controlPipes.size) { + nodeDetails = null; + selectedNodeId = null; + AppStore.emit(AppStore.CHANGE_EVENT); + } break; case ActionTypes.LEAVE_EDGE: @@ -345,7 +363,11 @@ AppStore.registeredCallback = function(payload) { break; case ActionTypes.RECEIVE_CONTROL_PIPE: - controlPipe = payload.pipeId; + if (controlPipes.has(payload.pipeId)) { + controlPipes = controlPipes.delete(payload.pipeId); // same click unsets + } else { + controlPipes = controlPipes.set(payload.pipeId, payload.nodeId); + } AppStore.emit(AppStore.CHANGE_EVENT); break; diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js index 2040a21ea1..747bce887f 100644 --- a/client/app/scripts/utils/web-api-utils.js +++ b/client/app/scripts/utils/web-api-utils.js @@ -146,7 +146,7 @@ function doControl(probeId, nodeId, control) { success: function(res) { AppActions.receiveControlSuccess(); if (res && res.pipe) { - AppActions.receiveControlPipe(res.pipe); + AppActions.receiveControlPipe(res.pipe, nodeId); } }, error: function(err) { diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 668a6a0ed7..4470c9e358 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -48,6 +48,14 @@ opacity: 0; } +.shadow-2 { + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.16), 0 3px 10px rgba(0, 0, 0, 0.23); +} + +.shadow-3 { + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.19), 0 6px 10px rgba(0, 0, 0, 0.23); +} + * { box-sizing: border-box; -webkit-tap-highlight-color: rgba(0, 0, 0, 0); @@ -94,7 +102,13 @@ h2 { font-weight: 400; } -#app { +.app { + position: fixed; + top: 0; + left: 0; + bottom: 0; + right: 0; + overflow: auto; } .header { @@ -309,13 +323,21 @@ h2 { } #details { - position: absolute; + position: fixed; z-index: 1024; display: block; right: 36px; top: 24px; bottom: 48px; width: 420px; + + .details-wrapper { + height: 100%; + padding-bottom: 8px; + border-radius: 2px; + background-color: #fff; + .shadow-2; + } } .node-details { @@ -466,19 +488,82 @@ h2 { } } + } +} +.terminal { + &-wrapper { + position: absolute; + z-index: 2048; + display: block; + top: 128px; + border: 0px solid #000000; + border-radius: 4px; + font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", monospace; + color: #f0f0f0; + .shadow-3; } -} + &-header { + .truncate; + text-align: center; + color: @white; + padding: 8px 24px; + background-color: @text-color; + position: relative; + cursor: move; -#terminal { - position: absolute; - z-index: 2048; - display: block; - left: 36px; - top: 24px; - // bottom: 48px; - width: 50%; + &-title { + cursor: default; + } + + &-tools { + position: absolute; + left: 8px; + + &-icon { + .palable; + padding: 4px; + 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); + } + } + } + } + + &-inner { + background-color: rgba(0, 0, 0, 0.93); + padding: 8px; + + .terminal { + background-color: transparent !important; + } + } + + &-footer { + position: relative; + } + + &-resize-handle { + .palable; + position: absolute; + bottom: -4px; + right: -2px; + padding: 4px; + cursor: se-resize; + touch-action: none; + transform: rotate(-45deg); + opacity: 0.5; + &:hover { + opacity: 1; + } + } } .terminal-cursor { diff --git a/client/package.json b/client/package.json index 8a87f04687..f87886a95a 100644 --- a/client/package.json +++ b/client/package.json @@ -22,6 +22,7 @@ "react-addons-transition-group": "0.14.2", "react-addons-update": "0.14.2", "react-dom": "0.14.2", + "react-draggable": "1.1.3", "react-motion": "0.3.1", "reqwest": "~2.0.5", "timely": "0.1.0" diff --git a/client/webpack.local.config.js b/client/webpack.local.config.js index ae14b65a9a..fa706577bf 100644 --- a/client/webpack.local.config.js +++ b/client/webpack.local.config.js @@ -22,7 +22,7 @@ var GLOBALS = { module.exports = { // Efficiently evaluate modules with source maps - devtool: 'eval', + devtool: 'cheap-module-source-map', // Set entry point include necessary files for hot load entry: [