diff --git a/.gitignore b/.gitignore index f5ff4aab1..427d5ddaf 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ coverage.html # Emacs backup files *~ +# ctags files +tags + # Project specific .*.uptodate scope.tar diff --git a/client/.eslintignore b/client/.eslintignore index e69de29bb..7ce46b3a1 100644 --- a/client/.eslintignore +++ b/client/.eslintignore @@ -0,0 +1 @@ +app/scripts/vendor/term.js diff --git a/client/Dockerfile b/client/Dockerfile index 39589d5a8..22c5fde99 100644 --- a/client/Dockerfile +++ b/client/Dockerfile @@ -4,4 +4,4 @@ COPY package.json /home/weave/ ENV NPM_CONFIG_LOGLEVEL warn # Dont install optional developer tools RUN npm install --no-optional -COPY webpack.local.config.js webpack.production.config.js server.js .babelrc .eslintrc /home/weave/ +COPY webpack.local.config.js webpack.production.config.js server.js .babelrc .eslintrc .eslintignore /home/weave/ diff --git a/client/app/scripts/actions/app-actions.js b/client/app/scripts/actions/app-actions.js index 491f33a71..0278dd707 100644 --- a/client/app/scripts/actions/app-actions.js +++ b/client/app/scripts/actions/app-actions.js @@ -1,10 +1,14 @@ +import debug from 'debug'; + import AppDispatcher from '../dispatcher/app-dispatcher'; import ActionTypes from '../constants/action-types'; - import { updateRoute } from '../utils/router-utils'; -import { doControl as doControlRequest, getNodesDelta, getNodeDetails, getTopologies } from '../utils/web-api-utils'; +import { doControl as doControlRequest, getNodesDelta, getNodeDetails, + getTopologies, deletePipe } from '../utils/web-api-utils'; import AppStore from '../stores/app-store'; +const log = debug('scope:app-actions'); + export function changeTopologyOption(option, value, topologyId) { AppDispatcher.dispatch({ type: ActionTypes.CHANGE_TOPOLOGY_OPTION, @@ -34,6 +38,17 @@ export function clickCloseDetails() { updateRoute(); } +export function clickCloseTerminal(pipeId, closePipe) { + AppDispatcher.dispatch({ + type: ActionTypes.CLICK_CLOSE_TERMINAL, + pipeId: pipeId + }); + if (closePipe) { + deletePipe(pipeId); + } + updateRoute(); +} + export function clickNode(nodeId) { AppDispatcher.dispatch({ type: ActionTypes.CLICK_NODE, @@ -102,10 +117,18 @@ export function enterNode(nodeId) { } export function hitEsc() { - AppDispatcher.dispatch({ - type: ActionTypes.HIT_ESC_KEY - }); - updateRoute(); + // + // This simulates an "ESC-stack" + // 1st esc removes the controlPipe and closes the terminal. + // 2nd esc deselects the node and closes the details. + // + const controlPipe = AppStore.getControlPipe(); + if (controlPipe) { + clickCloseTerminal(controlPipe.id, true); + } else { + AppDispatcher.dispatch({type: ActionTypes.DESELECT_NODE}); + updateRoute(); + } } export function leaveEdge(edgeId) { @@ -172,6 +195,43 @@ export function receiveApiDetails(apiDetails) { }); } +export function receiveControlPipeFromParams(pipeId, rawTty) { + AppDispatcher.dispatch({ + type: ActionTypes.RECEIVE_CONTROL_PIPE, + pipeId: pipeId, + rawTty: rawTty + }); +} + +export function receiveControlPipe(pipeId, nodeId, rawTty) { + if (nodeId.split(';').pop() !== AppStore.getSelectedNodeId()) { + log('Node was deselected before we could set up control!'); + deletePipe(pipeId); + return; + } + + const controlPipe = AppStore.getControlPipe(); + if (controlPipe && controlPipe.id !== pipeId) { + deletePipe(controlPipe.id); + } + + AppDispatcher.dispatch({ + type: ActionTypes.RECEIVE_CONTROL_PIPE, + pipeId: pipeId, + rawTty: rawTty + }); + + updateRoute(); +} + +export function receiveControlPipeStatus(pipeId, status) { + AppDispatcher.dispatch({ + type: ActionTypes.RECEIVE_CONTROL_PIPE_STATUS, + pipeId: pipeId, + status: status + }); +} + export function receiveError(errorUrl) { AppDispatcher.dispatch({ errorUrl: errorUrl, diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index de024c07f..23652361b 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -10,6 +10,7 @@ import { getApiDetails, getTopologies } from '../utils/web-api-utils'; import { hitEsc } from '../actions/app-actions'; import Details from './details'; import Nodes from './nodes'; +import EmbeddedTerminal from './embedded-terminal'; import { getRouter } from '../utils/router-utils'; const ESC_KEY_CODE = 27; @@ -19,6 +20,7 @@ function getStateFromStores() { activeTopologyOptions: AppStore.getActiveTopologyOptions(), controlError: AppStore.getControlError(), controlPending: AppStore.isControlPending(), + controlPipe: AppStore.getControlPipe(), currentTopology: AppStore.getCurrentTopology(), currentTopologyId: AppStore.getCurrentTopologyId(), currentTopologyOptions: AppStore.getCurrentTopologyOptions(), @@ -33,11 +35,12 @@ function getStateFromStores() { topologiesLoaded: AppStore.isTopologiesLoaded(), version: AppStore.getVersion(), websocketClosed: AppStore.isWebsocketClosed() + }; } - export default class App extends React.Component { + constructor(props, context) { super(props, context); this.onChange = this.onChange.bind(this); @@ -69,18 +72,24 @@ export default class App extends React.Component { render() { const showingDetails = this.state.selectedNodeId; + const showingTerminal = this.state.controlPipe; const footer = `Version ${this.state.version} on ${this.state.hostname}`; // width of details panel blocking a view const detailsWidth = showingDetails ? 450 : 0; const topMargin = 100; return ( -
+
{showingDetails &&
} + details={this.state.nodeDetails} />} + + {showingTerminal && }
diff --git a/client/app/scripts/components/details.js b/client/app/scripts/components/details.js index 77637543e..f9cdaab60 100644 --- a/client/app/scripts/components/details.js +++ b/client/app/scripts/components/details.js @@ -17,9 +17,7 @@ export default class Details extends React.Component { render() { return (
-
+
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 ( +
+ {this.state.controlPipe && } +
+ ); + } +} 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 ( +
+
+ + +
+ {this.getTitle()} +
+ ); + } + + 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 ( +
+

{title}

+

{message}

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