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: [