diff --git a/client/app/scripts/components/embedded-terminal.js b/client/app/scripts/components/embedded-terminal.js
new file mode 100644
index 000000000..edac51117
--- /dev/null
+++ b/client/app/scripts/components/embedded-terminal.js
@@ -0,0 +1,20 @@
+import React from 'react';
+
+import { getNodeColor, getNodeColorDark } from '../utils/color-utils';
+import Terminal from './terminal';
+
+export default function EmeddedTerminal({pipe, nodeId, nodes}) {
+ const node = nodes.get(nodeId && nodeId.split(';').pop());
+ const titleBarColor = node && getNodeColorDark(node.get('rank'), node.get('label_major'));
+ const statusBarColor = node && getNodeColor(node.get('rank'), node.get('label_major'));
+ const title = node && node.get('label_major');
+
+ // React unmount/remounts when key changes, this is important for cleaning up
+ // the term.js and creating a new one for the new pipe.
+ return (
+
+ );
+}
diff --git a/client/app/scripts/components/terminal-app.js b/client/app/scripts/components/terminal-app.js
new file mode 100644
index 000000000..aa1aee6c2
--- /dev/null
+++ b/client/app/scripts/components/terminal-app.js
@@ -0,0 +1,57 @@
+import React from 'react';
+
+import AppStore from '../stores/app-store';
+import Terminal from './terminal';
+import { receiveControlPipeFromParams } from '../actions/app-actions';
+
+export function shouldLaunchTerminal() {
+ return window.location.hash.indexOf('#!/terminal/') === 0;
+}
+
+function getStateFromStores() {
+ return {
+ controlPipe: AppStore.getControlPipe()
+ };
+}
+
+export class TerminalApp extends React.Component {
+
+ constructor(props, context) {
+ super(props, context);
+ this.onChange = this.onChange.bind(this);
+
+ const paramString = window.location.hash.split('/').pop();
+ const params = JSON.parse(paramString);
+ receiveControlPipeFromParams(params.pipe.id, null, params.pipe.raw, false);
+
+ this.state = {
+ titleBarColor: params.titleBarColor,
+ statusBarColor: params.statusBarColor,
+ controlPipe: AppStore.getControlPipe()
+ };
+ }
+
+ componentDidMount() {
+ AppStore.addListener(this.onChange);
+ }
+
+ onChange() {
+ this.setState(getStateFromStores());
+ }
+
+ render() {
+ const style = {borderTop: `4px solid ${this.state.titleBarColor}`};
+
+ return (
+
+ );
+ }
+}
diff --git a/client/app/scripts/components/terminal.js b/client/app/scripts/components/terminal.js
new file mode 100644
index 000000000..691891c4a
--- /dev/null
+++ b/client/app/scripts/components/terminal.js
@@ -0,0 +1,283 @@
+import debug from 'debug';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import classNames from 'classnames';
+
+import { clickCloseTerminal } from '../actions/app-actions';
+import { getNeutralColor } from '../utils/color-utils';
+import { setDocumentTitle } from '../utils/title-utils';
+import { getPipeStatus, basePath } from '../utils/web-api-utils';
+import Term from '../vendor/term.js';
+
+const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
+const wsUrl = __WS_URL__ || wsProto + '://' + location.host + basePath(location.pathname);
+const log = debug('scope:terminal');
+
+const DEFAULT_COLS = 80;
+const DEFAULT_ROWS = 24;
+const MIN_COLS = 40;
+const MIN_ROWS = 4;
+// Unicode points can be used in html and document.title
+// html shorthand codes (×) don't work in document.title.
+const TIMES = '\u00D7';
+const MDASH = '\u2014';
+
+const reconnectTimerInterval = 2000;
+let reconnectTimer = null;
+
+function ab2str(buf) {
+ return String.fromCharCode.apply(null, new Uint8Array(buf));
+}
+
+function terminalCellSize(wrapperNode, rows, cols) {
+ //
+ // wrapperNode should be an unsized block. E.g. An element that 'clings' to
+ // its inner terminal so we get a nice size reading of the inner terminal.
+ //
+ const width = wrapperNode.clientWidth;
+ const height = wrapperNode.clientHeight;
+ const pixelPerRow = height / rows;
+ const pixelPerCol = width / cols;
+ return {pixelPerCol, pixelPerRow};
+}
+
+function openNewWindow(url) {
+ const screenLeft = window.screenX || window.screenLeft;
+ const screenTop = window.screenY || window.screenTop;
+ const popoutWindowToolbarHeight = 51;
+ const windowOptions = {
+ width: window.innerWidth - 420 - 36 - 36 - 4,
+ height: window.innerHeight - 24 - 48 - popoutWindowToolbarHeight,
+ left: screenLeft + 36,
+ top: screenTop + (window.outerHeight - window.innerHeight) + 24,
+ location: 'no',
+ };
+
+ const windowOptionsString = Object.keys(windowOptions)
+ .map((k) => k + '=' + windowOptions[k])
+ .join(',');
+
+ window.open(url, '', windowOptionsString);
+}
+
+export default class Terminal extends React.Component {
+ constructor(props, context) {
+ super(props, context);
+ this.state = {
+ connected: false,
+ rows: DEFAULT_ROWS,
+ cols: DEFAULT_COLS,
+ pixelPerCol: 0,
+ pixelPerRow: 0
+ };
+ this.handleCloseClick = this.handleCloseClick.bind(this);
+ this.handlePopoutTerminal = this.handlePopoutTerminal.bind(this);
+ this.handleResize = this.handleResize.bind(this);
+ }
+
+ createWebsocket(term) {
+ const socket = new WebSocket(wsUrl + '/api/pipe/' + this.getPipeId());
+ socket.binaryType = 'arraybuffer';
+
+ getPipeStatus(this.getPipeId());
+
+ socket.onopen = () => {
+ clearTimeout(reconnectTimer);
+ log('socket open to', wsUrl);
+ this.setState({connected: true});
+ };
+
+ socket.onclose = () => {
+ log('socket closed');
+ this.setState({connected: false});
+ this.socket = null;
+ if (this.term && this.props.pipe.status !== 'PIPE_DELETED') {
+ reconnectTimer = setTimeout(
+ this.createWebsocket.bind(this, term), reconnectTimerInterval);
+ }
+ };
+
+ socket.onerror = (err) => {
+ log('socket error', err);
+ };
+
+ socket.onmessage = (event) => {
+ log('pipe data', event.data.size);
+ const input = ab2str(event.data);
+ term.write(input);
+ };
+
+ this.socket = socket;
+ }
+
+ componentDidMount() {
+ const component = this;
+
+ this.term = new Term({
+ cols: this.state.cols,
+ rows: this.state.rows,
+ screenKeys: true,
+ convertEol: !this.props.raw
+ });
+
+ const innerNode = ReactDOM.findDOMNode(component.inner);
+ this.term.open(innerNode);
+ this.term.on('data', (data) => {
+ if (this.socket) {
+ this.socket.send(data);
+ }
+ });
+
+ this.createWebsocket(this.term);
+
+ const {pixelPerCol, pixelPerRow} = terminalCellSize(
+ innerNode, this.state.rows, this.state.cols);
+
+ window.addEventListener('resize', this.handleResize);
+
+ setTimeout(() => {
+ this.setState({
+ pixelPerCol: pixelPerCol,
+ pixelPerRow: pixelPerRow
+ });
+ this.handleResize();
+ }, 10);
+ }
+
+ componentWillUnmount() {
+ log('cwu terminal');
+ window.removeEventListener('resize', this.handleResize);
+ if (this.term) {
+ log('destroy terminal');
+ this.term.destroy();
+ this.term = null;
+ }
+ if (this.socket) {
+ log('close socket');
+ this.socket.close();
+ this.socket = null;
+ }
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ log('cdu terminal');
+ const sizeChanged = (
+ prevState.cols !== this.state.cols ||
+ prevState.rows !== this.state.rows
+ );
+ if (sizeChanged) {
+ this.term.resize(this.state.cols, this.state.rows);
+ }
+ if (!this.props.embedded) {
+ setDocumentTitle(this.getTitle());
+ }
+ }
+
+ handleCloseClick(ev) {
+ ev.preventDefault();
+ clickCloseTerminal(this.getPipeId(), true);
+ }
+
+ handlePopoutTerminal(ev) {
+ ev.preventDefault();
+ const paramString = JSON.stringify(this.props);
+ clickCloseTerminal(this.getPipeId());
+ openNewWindow(`/terminal.html#!/state/${paramString}`);
+ }
+
+ handleResize() {
+ const innerNode = ReactDOM.findDOMNode(this.innerFlex);
+ const width = innerNode.clientWidth - (2 * 8);
+ const height = innerNode.clientHeight - (2 * 8);
+ 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({cols, rows});
+ }
+
+ isEmbedded() {
+ return (this.props.embedded !== false);
+ }
+
+ getPipeId() {
+ return this.props.pipe.id;
+ }
+
+ getTitle() {
+ const nodeName = this.props.title || 'n/a';
+ return `Terminal ${nodeName} ${MDASH}
+ ${this.state.cols}${TIMES}${this.state.rows}`;
+ }
+
+ getTerminalHeader() {
+ const style = {
+ backgroundColor: this.props.titleBarColor || getNeutralColor()
+ };
+ return (
+
+ );
+ }
+
+ getStatus() {
+ if (this.props.pipe.status === 'PIPE_DELETED') {
+ return {
+ title: 'Pipe Deleted.',
+ message: 'The control pipe for the connection to this container has ' +
+ 'been deleted. Try performing the attach or exec again.'
+ };
+ }
+
+ if (!this.state.connected) {
+ return {
+ title: 'Connecting...',
+ message: 'Establishing a connection to the container.'
+ };
+ }
+
+ return {
+ title: 'Connected',
+ message: 'Lets do this!'
+ };
+ }
+
+ getTerminalStatusBar() {
+ const {title, message} = this.getStatus();
+ const style = {
+ backgroundColor: this.props.statusBarColor || getNeutralColor(),
+ opacity: this.state.connected ? 0 : 0.9
+ };
+ return (
+
+ );
+ }
+
+ render() {
+ const innerStyle = {
+ opacity: this.state.connected ? 1 : 0.8
+ };
+ const innerClassName = classNames('terminal-inner hideable', {
+ 'terminal-inactive': !this.state.connected
+ });
+
+ return (
+
+ {this.isEmbedded() && this.getTerminalHeader()}
+
this.innerFlex = ref}
+ className={innerClassName} style={innerStyle} >
+
this.inner = ref} />
+
+ {this.getTerminalStatusBar()}
+
+ );
+ }
+}
diff --git a/client/app/scripts/constants/action-types.js b/client/app/scripts/constants/action-types.js
index 2a05b5b78..a4019dea4 100644
--- a/client/app/scripts/constants/action-types.js
+++ b/client/app/scripts/constants/action-types.js
@@ -4,18 +4,22 @@ const ACTION_TYPES = [
'CHANGE_TOPOLOGY_OPTION',
'CLEAR_CONTROL_ERROR',
'CLICK_CLOSE_DETAILS',
+ 'CLICK_CLOSE_TERMINAL',
'CLICK_NODE',
+ 'CLICK_TERMINAL',
'CLICK_TOPOLOGY',
'CLOSE_WEBSOCKET',
+ 'DESELECT_NODE',
'DO_CONTROL',
'DO_CONTROL_ERROR',
'DO_CONTROL_SUCCESS',
'ENTER_EDGE',
'ENTER_NODE',
- 'HIT_ESC_KEY',
'LEAVE_EDGE',
'LEAVE_NODE',
'OPEN_WEBSOCKET',
+ 'RECEIVE_CONTROL_PIPE',
+ 'RECEIVE_CONTROL_PIPE_STATUS',
'RECEIVE_NODE_DETAILS',
'RECEIVE_NODES',
'RECEIVE_NODES_DELTA',
diff --git a/client/app/scripts/dispatcher/app-dispatcher.js b/client/app/scripts/dispatcher/app-dispatcher.js
index 5ad898cda..8869e1c14 100644
--- a/client/app/scripts/dispatcher/app-dispatcher.js
+++ b/client/app/scripts/dispatcher/app-dispatcher.js
@@ -1,11 +1,14 @@
import { Dispatcher } from 'flux';
import _ from 'lodash';
+import debug from 'debug';
+const log = debug('scope:dispatcher');
const instance = new Dispatcher();
instance.dispatch = _.wrap(Dispatcher.prototype.dispatch, function(func) {
const args = Array.prototype.slice.call(arguments, 1);
- // console.log(args[0]);
+ const type = args[0] && args[0].type;
+ log(type, args[0]);
func.apply(this, args);
});
diff --git a/client/app/scripts/main.js b/client/app/scripts/main.js
index 4035f612f..cac95b50f 100644
--- a/client/app/scripts/main.js
+++ b/client/app/scripts/main.js
@@ -6,6 +6,4 @@ import ReactDOM from 'react-dom';
import App from './components/app.js';
-ReactDOM.render(
-
,
- document.getElementById('app'));
+ReactDOM.render(
, document.getElementById('app'));
diff --git a/client/app/scripts/stores/__tests__/app-store-test.js b/client/app/scripts/stores/__tests__/app-store-test.js
index 7ca500d8d..760426664 100644
--- a/client/app/scripts/stores/__tests__/app-store-test.js
+++ b/client/app/scripts/stores/__tests__/app-store-test.js
@@ -70,8 +70,8 @@ describe('AppStore', function() {
type: ActionTypes.CLOSE_WEBSOCKET
};
- const HitEscAction = {
- type: ActionTypes.HIT_ESC_KEY
+ const deSelectNode = {
+ type: ActionTypes.DESELECT_NODE
};
const OpenWebsocketAction = {
@@ -251,7 +251,7 @@ describe('AppStore', function() {
expect(AppStore.getSelectedNodeId()).toBe('n1');
expect(AppStore.getNodes().toJS()).toEqual(NODE_SET);
- registeredCallback(HitEscAction);
+ registeredCallback(deSelectNode);
expect(AppStore.getSelectedNodeId()).toBe(null);
expect(AppStore.getNodes().toJS()).toEqual(NODE_SET);
});
@@ -318,7 +318,7 @@ describe('AppStore', function() {
expect(AppStore.getAdjacentNodes('n1').has('n1')).toBeTruthy();
expect(AppStore.getAdjacentNodes('n1').has('n2')).toBeTruthy();
- registeredCallback(HitEscAction);
+ registeredCallback(deSelectNode);
expect(AppStore.getAdjacentNodes().size).toEqual(0);
});
diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js
index d01d158b3..db0f837e7 100644
--- a/client/app/scripts/stores/app-store.js
+++ b/client/app/scripts/stores/app-store.js
@@ -11,6 +11,8 @@ const makeOrderedMap = Immutable.OrderedMap;
const makeSet = Immutable.Set;
const log = debug('scope:app-store');
+const error = debug('scope:error');
+
// Helpers
function findCurrentTopology(subTree, topologyId) {
@@ -44,7 +46,7 @@ function makeNode(node) {
// Initial values
-let topologyOptions = makeOrderedMap();
+let topologyOptions = makeOrderedMap(); // topologyId -> options
let adjacentNodes = makeSet();
let controlError = null;
let controlPending = false;
@@ -55,12 +57,13 @@ let hostname = '...';
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 websocketClosed = true;
function processTopologies(topologyList) {
@@ -102,6 +105,7 @@ function setDefaultTopologyOptions(topologyList) {
function deSelectNode() {
selectedNodeId = null;
nodeDetails = null;
+ controlPipe = null;
}
// Store API
@@ -113,6 +117,7 @@ export class AppStore extends Store {
return {
topologyId: currentTopologyId,
selectedNodeId: this.getSelectedNodeId(),
+ controlPipe: this.getControlPipe(),
topologyOptions: topologyOptions.toJS() // all options
};
}
@@ -142,6 +147,10 @@ export class AppStore extends Store {
return controlError;
}
+ getControlPipe() {
+ return controlPipe;
+ }
+
getCurrentTopology() {
if (!currentTopology) {
currentTopology = setTopology(currentTopologyId);
@@ -244,8 +253,11 @@ export class AppStore extends Store {
}
__onDispatch(payload) {
- switch (payload.type) {
+ if (!payload.type) {
+ error('Payload missing a type!', payload);
+ }
+ switch (payload.type) {
case ActionTypes.CHANGE_TOPOLOGY_OPTION:
if (topologyOptions.getIn([payload.topologyId, payload.option])
!== payload.value) {
@@ -268,6 +280,11 @@ export class AppStore extends Store {
this.__emitChange();
break;
+ case ActionTypes.CLICK_CLOSE_TERMINAL:
+ controlPipe = null;
+ this.__emitChange();
+ break;
+
case ActionTypes.CLICK_NODE:
deSelectNode();
if (payload.nodeId !== selectedNodeId) {
@@ -307,7 +324,7 @@ export class AppStore extends Store {
this.__emitChange();
break;
- case ActionTypes.HIT_ESC_KEY:
+ case ActionTypes.DESELECT_NODE:
deSelectNode();
this.__emitChange();
break;
@@ -342,6 +359,21 @@ export class AppStore extends Store {
this.__emitChange();
break;
+ case ActionTypes.RECEIVE_CONTROL_PIPE:
+ controlPipe = {
+ id: payload.pipeId,
+ raw: payload.rawTty
+ };
+ this.__emitChange();
+ break;
+
+ case ActionTypes.RECEIVE_CONTROL_PIPE_STATUS:
+ if (controlPipe) {
+ controlPipe.status = payload.status;
+ this.__emitChange();
+ }
+ break;
+
case ActionTypes.RECEIVE_ERROR:
errorUrl = payload.errorUrl;
this.__emitChange();
@@ -421,6 +453,7 @@ export class AppStore extends Store {
setTopology(payload.state.topologyId);
setDefaultTopologyOptions(topologies);
selectedNodeId = payload.state.selectedNodeId;
+ controlPipe = payload.state.controlPipe;
topologyOptions = Immutable.fromJS(payload.state.topologyOptions)
|| topologyOptions;
this.__emitChange();
diff --git a/client/app/scripts/terminal-main.js b/client/app/scripts/terminal-main.js
new file mode 100644
index 000000000..8a2715f7d
--- /dev/null
+++ b/client/app/scripts/terminal-main.js
@@ -0,0 +1,9 @@
+require('font-awesome-webpack');
+require('../styles/main.less');
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import { TerminalApp } from './components/terminal-app.js';
+
+ReactDOM.render(
, document.getElementById('app'));
diff --git a/client/app/scripts/utils/__tests__/web-api-utils-test.js b/client/app/scripts/utils/__tests__/web-api-utils-test.js
new file mode 100644
index 000000000..e6407d0cf
--- /dev/null
+++ b/client/app/scripts/utils/__tests__/web-api-utils-test.js
@@ -0,0 +1,25 @@
+jest.dontMock('../web-api-utils');
+
+describe('WebApiUtils', function() {
+ const WebApiUtils = require('../web-api-utils');
+
+ describe('basePath', function() {
+ const basePath = WebApiUtils.basePath;
+
+ it('should handle /scope/terminal.html', function() {
+ expect(basePath("/scope/terminal.html")).toBe("/scope");
+ });
+
+ it('should handle /scope/', function() {
+ expect(basePath("/scope/")).toBe("/scope");
+ });
+
+ it('should handle /scope', function() {
+ expect(basePath("/scope")).toBe("/scope");
+ });
+
+ it('should handle /', function() {
+ expect(basePath("/")).toBe("");
+ });
+ });
+});
diff --git a/client/app/scripts/utils/color-utils.js b/client/app/scripts/utils/color-utils.js
index 539ec578f..4e8c45d0b 100644
--- a/client/app/scripts/utils/color-utils.js
+++ b/client/app/scripts/utils/color-utils.js
@@ -40,8 +40,12 @@ function colors(text, secondText) {
return color;
}
+export function getNeutralColor() {
+ return PSEUDO_COLOR;
+}
+
export function getNodeColor(text, secondText) {
- return colors(text, secondText);
+ return colors(text, secondText).toString();
}
export function getNodeColorDark(text, secondText) {
diff --git a/client/app/scripts/utils/router-utils.js b/client/app/scripts/utils/router-utils.js
index ad020fed9..9c0896876 100644
--- a/client/app/scripts/utils/router-utils.js
+++ b/client/app/scripts/utils/router-utils.js
@@ -3,12 +3,31 @@ import page from 'page';
import { route } from '../actions/app-actions';
import AppStore from '../stores/app-store';
+//
+// TODO: move this logic somewhere else.
+//
+function shouldReplaceState(prevState, nextState) {
+ return (
+ // Opening a new terminal while an existing one is open.
+ (prevState.controlPipe && nextState.controlPipe) ||
+ // Closing a terminal.
+ (prevState.controlPipe && !nextState.controlPipe)
+ );
+}
+
export function updateRoute() {
const state = AppStore.getAppState();
const stateUrl = JSON.stringify(state);
const dispatch = false;
+ const urlStateString = window.location.hash.replace('#!/state/', '') || '{}';
+ const prevState = JSON.parse(urlStateString);
- page.show('/state/' + stateUrl, state, dispatch);
+ if (shouldReplaceState(prevState, state)) {
+ // Replace the top of the history rather than pushing on a new item.
+ page.replace('/state/' + stateUrl, state, dispatch);
+ } else {
+ page.show('/state/' + stateUrl, state, dispatch);
+ }
}
page('/', function() {
diff --git a/client/app/scripts/utils/web-api-utils.js b/client/app/scripts/utils/web-api-utils.js
index 6677f8fe2..18b60a664 100644
--- a/client/app/scripts/utils/web-api-utils.js
+++ b/client/app/scripts/utils/web-api-utils.js
@@ -3,7 +3,8 @@ import reqwest from 'reqwest';
import { clearControlError, closeWebsocket, openWebsocket, receiveError,
receiveApiDetails, receiveNodesDelta, receiveNodeDetails, receiveControlError,
- receiveControlSuccess, receiveTopologies } from '../actions/app-actions';
+ receiveControlPipe, receiveControlPipeStatus, receiveControlSuccess,
+ receiveTopologies } from '../actions/app-actions';
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = __WS_URL__ || wsProto + '://' + location.host + location.pathname.replace(/\/$/, '');
@@ -31,6 +32,21 @@ function buildOptionsQuery(options) {
return '';
}
+export function basePath(urlPath) {
+ //
+ // "/scope/terminal.html" -> "/scope"
+ // "/scope/" -> "/scope"
+ // "/scope" -> "/scope"
+ // "/" -> ""
+ //
+ const parts = urlPath.split('/');
+ // if the last item has a "." in it, e.g. foo.html...
+ if (parts[parts.length - 1].indexOf('.') !== -1) {
+ return parts.slice(0, -1).join('/');
+ }
+ return parts.join('/').replace(/\/$/, '');
+}
+
function createWebsocket(topologyUrl, optionsQuery) {
if (socket) {
socket.onclose = null;
@@ -146,8 +162,11 @@ export function doControl(probeId, nodeId, control) {
reqwest({
method: 'POST',
url: url,
- success: function() {
+ success: function(res) {
receiveControlSuccess();
+ if (res && res.pipe) {
+ receiveControlPipe(res.pipe, nodeId, res.raw_tty, true);
+ }
},
error: function(err) {
receiveControlError(err.response);
@@ -157,3 +176,42 @@ export function doControl(probeId, nodeId, control) {
}
});
}
+
+export function deletePipe(pipeId) {
+ const url = `api/pipe/${encodeURIComponent(pipeId)}`;
+ reqwest({
+ method: 'DELETE',
+ url: url,
+ success: function() {
+ log('Closed the pipe!');
+ },
+ error: function(err) {
+ log('Error closing pipe:' + err);
+ receiveError(url);
+ }
+ });
+}
+
+export function getPipeStatus(pipeId) {
+ const url = `/api/pipe/${encodeURIComponent(pipeId)}`;
+ reqwest({
+ method: 'GET',
+ url: url,
+ success: function(res) {
+ log('ERROR: expected responses: [400, 404]. Got:', res);
+ },
+ error: function(err) {
+ const status = {
+ 400: 'PIPE_ALIVE',
+ 404: 'PIPE_DELETED'
+ }[err.status];
+
+ if (!status) {
+ log('Unexpected pipe status:', err.status);
+ return;
+ }
+
+ receiveControlPipeStatus(pipeId, status);
+ }
+ });
+}
diff --git a/client/app/scripts/vendor/term.js b/client/app/scripts/vendor/term.js
new file mode 100644
index 000000000..35a90d2d5
--- /dev/null
+++ b/client/app/scripts/vendor/term.js
@@ -0,0 +1,5973 @@
+/**
+ * term.js - an xterm emulator
+ * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
+ * https://github.com/chjj/term.js
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * Originally forked from (with the author's permission):
+ * Fabrice Bellard's javascript vt100 for jslinux:
+ * http://bellard.org/jslinux/
+ * Copyright (c) 2011 Fabrice Bellard
+ * The original design remains. The terminal itself
+ * has been extended to include xterm CSI codes, among
+ * other features.
+ */
+
+;(function() {
+
+/**
+ * Terminal Emulation References:
+ * http://vt100.net/
+ * http://invisible-island.net/xterm/ctlseqs/ctlseqs.txt
+ * http://invisible-island.net/xterm/ctlseqs/ctlseqs.html
+ * http://invisible-island.net/vttest/
+ * http://www.inwap.com/pdp10/ansicode.txt
+ * http://linux.die.net/man/4/console_codes
+ * http://linux.die.net/man/7/urxvt
+ */
+
+'use strict';
+
+/**
+ * Shared
+ */
+
+var window = this
+ , document = this.document;
+
+/**
+ * EventEmitter
+ */
+
+function EventEmitter() {
+ this._events = this._events || {};
+}
+
+EventEmitter.prototype.addListener = function(type, listener) {
+ this._events[type] = this._events[type] || [];
+ this._events[type].push(listener);
+};
+
+EventEmitter.prototype.on = EventEmitter.prototype.addListener;
+
+EventEmitter.prototype.removeListener = function(type, listener) {
+ if (!this._events[type]) return;
+
+ var obj = this._events[type]
+ , i = obj.length;
+
+ while (i--) {
+ if (obj[i] === listener || obj[i].listener === listener) {
+ obj.splice(i, 1);
+ return;
+ }
+ }
+};
+
+EventEmitter.prototype.off = EventEmitter.prototype.removeListener;
+
+EventEmitter.prototype.removeAllListeners = function(type) {
+ if (this._events[type]) delete this._events[type];
+};
+
+EventEmitter.prototype.once = function(type, listener) {
+ function on() {
+ var args = Array.prototype.slice.call(arguments);
+ this.removeListener(type, on);
+ return listener.apply(this, args);
+ }
+ on.listener = listener;
+ return this.on(type, on);
+};
+
+EventEmitter.prototype.emit = function(type) {
+ if (!this._events[type]) return;
+
+ var args = Array.prototype.slice.call(arguments, 1)
+ , obj = this._events[type]
+ , l = obj.length
+ , i = 0;
+
+ for (; i < l; i++) {
+ obj[i].apply(this, args);
+ }
+};
+
+EventEmitter.prototype.listeners = function(type) {
+ return this._events[type] = this._events[type] || [];
+};
+
+/**
+ * Stream
+ */
+
+function Stream() {
+ EventEmitter.call(this);
+}
+
+inherits(Stream, EventEmitter);
+
+Stream.prototype.pipe = function(dest, options) {
+ var src = this
+ , ondata
+ , onerror
+ , onend;
+
+ function unbind() {
+ src.removeListener('data', ondata);
+ src.removeListener('error', onerror);
+ src.removeListener('end', onend);
+ dest.removeListener('error', onerror);
+ dest.removeListener('close', unbind);
+ }
+
+ src.on('data', ondata = function(data) {
+ dest.write(data);
+ });
+
+ src.on('error', onerror = function(err) {
+ unbind();
+ if (!this.listeners('error').length) {
+ throw err;
+ }
+ });
+
+ src.on('end', onend = function() {
+ dest.end();
+ unbind();
+ });
+
+ dest.on('error', onerror);
+ dest.on('close', unbind);
+
+ dest.emit('pipe', src);
+
+ return dest;
+};
+
+/**
+ * States
+ */
+
+var normal = 0
+ , escaped = 1
+ , csi = 2
+ , osc = 3
+ , charset = 4
+ , dcs = 5
+ , ignore = 6
+ , UDK = { type: 'udk' };
+
+/**
+ * Terminal
+ */
+
+function Terminal(options) {
+ var self = this;
+
+ if (!(this instanceof Terminal)) {
+ return new Terminal(arguments[0], arguments[1], arguments[2]);
+ }
+
+ Stream.call(this);
+
+ if (typeof options === 'number') {
+ options = {
+ cols: arguments[0],
+ rows: arguments[1],
+ handler: arguments[2]
+ };
+ }
+
+ options = options || {};
+
+ each(keys(Terminal.defaults), function(key) {
+ if (options[key] == null) {
+ options[key] = Terminal.options[key];
+ // Legacy:
+ if (Terminal[key] !== Terminal.defaults[key]) {
+ options[key] = Terminal[key];
+ }
+ }
+ self[key] = options[key];
+ });
+
+ if (options.colors.length === 8) {
+ options.colors = options.colors.concat(Terminal._colors.slice(8));
+ } else if (options.colors.length === 16) {
+ options.colors = options.colors.concat(Terminal._colors.slice(16));
+ } else if (options.colors.length === 10) {
+ options.colors = options.colors.slice(0, -2).concat(
+ Terminal._colors.slice(8, -2), options.colors.slice(-2));
+ } else if (options.colors.length === 18) {
+ options.colors = options.colors.slice(0, -2).concat(
+ Terminal._colors.slice(16, -2), options.colors.slice(-2));
+ }
+ this.colors = options.colors;
+
+ this.options = options;
+
+ // this.context = options.context || window;
+ // this.document = options.document || document;
+ this.parent = options.body || options.parent
+ || (document ? document.getElementsByTagName('body')[0] : null);
+
+ this.cols = options.cols || options.geometry[0];
+ this.rows = options.rows || options.geometry[1];
+
+ // Act as though we are a node TTY stream:
+ this.setRawMode;
+ this.isTTY = true;
+ this.isRaw = true;
+ this.columns = this.cols;
+ this.rows = this.rows;
+
+ if (options.handler) {
+ this.on('data', options.handler);
+ }
+
+ this.ybase = 0;
+ this.ydisp = 0;
+ this.x = 0;
+ this.y = 0;
+ this.cursorState = 0;
+ this.cursorHidden = false;
+ this.convertEol;
+ this.state = 0;
+ this.queue = '';
+ this.scrollTop = 0;
+ this.scrollBottom = this.rows - 1;
+
+ // modes
+ this.applicationKeypad = false;
+ this.applicationCursor = false;
+ this.originMode = false;
+ this.insertMode = false;
+ this.wraparoundMode = false;
+ this.normal = null;
+
+ // select modes
+ this.prefixMode = false;
+ this.selectMode = false;
+ this.visualMode = false;
+ this.searchMode = false;
+ this.searchDown;
+ this.entry = '';
+ this.entryPrefix = 'Search: ';
+ this._real;
+ this._selected;
+ this._textarea;
+
+ // charset
+ this.charset = null;
+ this.gcharset = null;
+ this.glevel = 0;
+ this.charsets = [null];
+
+ // mouse properties
+ this.decLocator;
+ this.x10Mouse;
+ this.vt200Mouse;
+ this.vt300Mouse;
+ this.normalMouse;
+ this.mouseEvents;
+ this.sendFocus;
+ this.utfMouse;
+ this.sgrMouse;
+ this.urxvtMouse;
+
+ // misc
+ this.element;
+ this.children;
+ this.refreshStart;
+ this.refreshEnd;
+ this.savedX;
+ this.savedY;
+ this.savedCols;
+
+ // stream
+ this.readable = true;
+ this.writable = true;
+
+ this.defAttr = (0 << 18) | (257 << 9) | (256 << 0);
+ this.curAttr = this.defAttr;
+
+ this.params = [];
+ this.currentParam = 0;
+ this.prefix = '';
+ this.postfix = '';
+
+ this.lines = [];
+ var i = this.rows;
+ while (i--) {
+ this.lines.push(this.blankLine());
+ }
+
+ this.tabs;
+ this.setupStops();
+}
+
+inherits(Terminal, Stream);
+
+/**
+ * Colors
+ */
+
+// Colors 0-15
+Terminal.tangoColors = [
+ // dark:
+ '#2e3436',
+ '#cc0000',
+ '#4e9a06',
+ '#c4a000',
+ '#3465a4',
+ '#75507b',
+ '#06989a',
+ '#d3d7cf',
+ // bright:
+ '#555753',
+ '#ef2929',
+ '#8ae234',
+ '#fce94f',
+ '#729fcf',
+ '#ad7fa8',
+ '#34e2e2',
+ '#eeeeec'
+];
+
+Terminal.xtermColors = [
+ // dark:
+ '#000000', // black
+ '#cd0000', // red3
+ '#00cd00', // green3
+ '#cdcd00', // yellow3
+ '#0000ee', // blue2
+ '#cd00cd', // magenta3
+ '#00cdcd', // cyan3
+ '#e5e5e5', // gray90
+ // bright:
+ '#7f7f7f', // gray50
+ '#ff0000', // red
+ '#00ff00', // green
+ '#ffff00', // yellow
+ '#5c5cff', // rgb:5c/5c/ff
+ '#ff00ff', // magenta
+ '#00ffff', // cyan
+ '#ffffff' // white
+];
+
+// Colors 0-15 + 16-255
+// Much thanks to TooTallNate for writing this.
+Terminal.colors = (function() {
+ var colors = Terminal.tangoColors.slice()
+ , r = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff]
+ , i;
+
+ // 16-231
+ i = 0;
+ for (; i < 216; i++) {
+ out(r[(i / 36) % 6 | 0], r[(i / 6) % 6 | 0], r[i % 6]);
+ }
+
+ // 232-255 (grey)
+ i = 0;
+ for (; i < 24; i++) {
+ r = 8 + i * 10;
+ out(r, r, r);
+ }
+
+ function out(r, g, b) {
+ colors.push('#' + hex(r) + hex(g) + hex(b));
+ }
+
+ function hex(c) {
+ c = c.toString(16);
+ return c.length < 2 ? '0' + c : c;
+ }
+
+ return colors;
+})();
+
+// Default BG/FG
+Terminal.colors[256] = '#000000';
+Terminal.colors[257] = '#f0f0f0';
+
+Terminal._colors = Terminal.colors.slice();
+
+Terminal.vcolors = (function() {
+ var out = []
+ , colors = Terminal.colors
+ , i = 0
+ , color;
+
+ for (; i < 256; i++) {
+ color = parseInt(colors[i].substring(1), 16);
+ out.push([
+ (color >> 16) & 0xff,
+ (color >> 8) & 0xff,
+ color & 0xff
+ ]);
+ }
+
+ return out;
+})();
+
+/**
+ * Options
+ */
+
+Terminal.defaults = {
+ colors: Terminal.colors,
+ convertEol: false,
+ termName: 'xterm',
+ geometry: [80, 24],
+ cursorBlink: true,
+ visualBell: false,
+ popOnBell: false,
+ scrollback: 1000,
+ screenKeys: false,
+ debug: false,
+ useStyle: false
+ // programFeatures: false,
+ // focusKeys: false,
+};
+
+Terminal.options = {};
+
+each(keys(Terminal.defaults), function(key) {
+ Terminal[key] = Terminal.defaults[key];
+ Terminal.options[key] = Terminal.defaults[key];
+});
+
+/**
+ * Focused Terminal
+ */
+
+Terminal.focus = null;
+
+Terminal.prototype.focus = function() {
+ if (Terminal.focus === this) return;
+
+ if (Terminal.focus) {
+ Terminal.focus.blur();
+ }
+
+ if (this.sendFocus) this.send('\x1b[I');
+ this.showCursor();
+
+ // try {
+ // this.element.focus();
+ // } catch (e) {
+ // ;
+ // }
+
+ // this.emit('focus');
+
+ Terminal.focus = this;
+};
+
+Terminal.prototype.blur = function() {
+ if (Terminal.focus !== this) return;
+
+ this.cursorState = 0;
+ this.refresh(this.y, this.y);
+ if (this.sendFocus) this.send('\x1b[O');
+
+ // try {
+ // this.element.blur();
+ // } catch (e) {
+ // ;
+ // }
+
+ // this.emit('blur');
+
+ Terminal.focus = null;
+};
+
+/**
+ * Initialize global behavior
+ */
+
+Terminal.prototype.initGlobal = function() {
+ var document = this.document;
+
+ Terminal._boundDocs = Terminal._boundDocs || [];
+ if (~indexOf(Terminal._boundDocs, document)) {
+ return;
+ }
+ Terminal._boundDocs.push(document);
+
+ Terminal.bindPaste(document);
+
+ Terminal.bindKeys(document);
+
+ Terminal.bindCopy(document);
+
+ if (this.isMobile) {
+ this.fixMobile(document);
+ }
+
+ if (this.useStyle) {
+ Terminal.insertStyle(document, this.colors[256], this.colors[257]);
+ }
+};
+
+/**
+ * Bind to paste event
+ */
+
+Terminal.bindPaste = function(document) {
+ // This seems to work well for ctrl-V and middle-click,
+ // even without the contentEditable workaround.
+ var window = document.defaultView;
+ on(window, 'paste', function(ev) {
+ var term = Terminal.focus;
+ if (!term) return;
+ if (ev.clipboardData) {
+ term.send(ev.clipboardData.getData('text/plain'));
+ } else if (term.context.clipboardData) {
+ term.send(term.context.clipboardData.getData('Text'));
+ }
+ // Not necessary. Do it anyway for good measure.
+ term.element.contentEditable = 'inherit';
+ return cancel(ev);
+ });
+};
+
+/**
+ * Global Events for key handling
+ */
+
+Terminal.bindKeys = function(document) {
+ // We should only need to check `target === body` below,
+ // but we can check everything for good measure.
+ on(document, 'keydown', function(ev) {
+ if (!Terminal.focus) return;
+ var target = ev.target || ev.srcElement;
+ if (!target) return;
+ if (target === Terminal.focus.element
+ || target === Terminal.focus.context
+ || target === Terminal.focus.document
+ || target === Terminal.focus.body
+ || target === Terminal._textarea
+ || target === Terminal.focus.parent) {
+ return Terminal.focus.keyDown(ev);
+ }
+ }, true);
+
+ on(document, 'keypress', function(ev) {
+ if (!Terminal.focus) return;
+ var target = ev.target || ev.srcElement;
+ if (!target) return;
+ if (target === Terminal.focus.element
+ || target === Terminal.focus.context
+ || target === Terminal.focus.document
+ || target === Terminal.focus.body
+ || target === Terminal._textarea
+ || target === Terminal.focus.parent) {
+ return Terminal.focus.keyPress(ev);
+ }
+ }, true);
+
+ // If we click somewhere other than a
+ // terminal, unfocus the terminal.
+ on(document, 'mousedown', function(ev) {
+ if (!Terminal.focus) return;
+
+ var el = ev.target || ev.srcElement;
+ if (!el) return;
+
+ do {
+ if (el === Terminal.focus.element) return;
+ } while (el = el.parentNode);
+
+ Terminal.focus.blur();
+ });
+};
+
+/**
+ * Copy Selection w/ Ctrl-C (Select Mode)
+ */
+
+Terminal.bindCopy = function(document) {
+ var window = document.defaultView;
+
+ // if (!('onbeforecopy' in document)) {
+ // // Copies to *only* the clipboard.
+ // on(window, 'copy', function fn(ev) {
+ // var term = Terminal.focus;
+ // if (!term) return;
+ // if (!term._selected) return;
+ // var text = term.grabText(
+ // term._selected.x1, term._selected.x2,
+ // term._selected.y1, term._selected.y2);
+ // term.emit('copy', text);
+ // ev.clipboardData.setData('text/plain', text);
+ // });
+ // return;
+ // }
+
+ // Copies to primary selection *and* clipboard.
+ // NOTE: This may work better on capture phase,
+ // or using the `beforecopy` event.
+ on(window, 'copy', function(ev) {
+ var term = Terminal.focus;
+ if (!term) return;
+ if (!term._selected) return;
+ var textarea = term.getCopyTextarea();
+ var text = term.grabText(
+ term._selected.x1, term._selected.x2,
+ term._selected.y1, term._selected.y2);
+ term.emit('copy', text);
+ textarea.focus();
+ textarea.textContent = text;
+ textarea.value = text;
+ textarea.setSelectionRange(0, text.length);
+ setTimeout(function() {
+ term.element.focus();
+ term.focus();
+ }, 1);
+ });
+};
+
+/**
+ * Fix Mobile
+ */
+
+Terminal.prototype.fixMobile = function(document) {
+ var self = this;
+
+ var textarea = document.createElement('textarea');
+ textarea.style.position = 'absolute';
+ textarea.style.left = '-32000px';
+ textarea.style.top = '-32000px';
+ textarea.style.width = '0px';
+ textarea.style.height = '0px';
+ textarea.style.opacity = '0';
+ textarea.style.backgroundColor = 'transparent';
+ textarea.style.borderStyle = 'none';
+ textarea.style.outlineStyle = 'none';
+ textarea.autocapitalize = 'none';
+ textarea.autocorrect = 'off';
+
+ document.getElementsByTagName('body')[0].appendChild(textarea);
+
+ Terminal._textarea = textarea;
+
+ setTimeout(function() {
+ textarea.focus();
+ }, 1000);
+
+ if (this.isAndroid) {
+ on(textarea, 'change', function() {
+ var value = textarea.textContent || textarea.value;
+ textarea.value = '';
+ textarea.textContent = '';
+ self.send(value + '\r');
+ });
+ }
+};
+
+/**
+ * Insert a default style
+ */
+
+Terminal.insertStyle = function(document, bg, fg) {
+ var style = document.getElementById('term-style');
+ if (style) return;
+
+ var head = document.getElementsByTagName('head')[0];
+ if (!head) return;
+
+ var style = document.createElement('style');
+ style.id = 'term-style';
+
+ // textContent doesn't work well with IE for