Terminal UI for pipes

- term.js
- Add eslintignore
- Fix color and es2015 after rebase
- Fix JS test, probably deleted during conflict resolution
- Moves terminal close button to top-right of window
- Consitent w/ details window.
- Changes padding of details window close button so both close buttons
  are horizonally aligned.
- Terminal resizes w/ browser window.
- No longer can drag window around.
- Add tiny big of padding between term and node-details.
- Playing w/ terminal placement.  This one's more drawer-like.
- Send DELETE when we close a terminal window.
- Dont lint or bable JS vendor files
- Ignore ctags 'tags' files.
- Adds popping out terminal out into a new browser window.
- Simplify code as now we've just a single terminal window.
- ESC is back to close the terminal, then the details panel.
- Fixes bug w/ slow response to closing the details panel.
- Moving away from "drawer-style" for terminal size and position to a
  simple window.
- Just gotta handle the case for refreshing a popped out terminal.
- Stop terminal text being auto-deselected.
- window resizes will still deselect.
- Adds state.connected to react.scu check.
- Don't delete pipe when browser closes
- To allow for nicer refresh flows
- scope-app will time out pipe after a while.
- Keep terminal-open/closed state in the url.
- shouldComponentUpdate fix to prevent deselection of text has been
  rolled back so gotta come up w/ another way to handle that...
- Fixes terminal text-selection again.
- Make pipes work for non-raw terminals too.
- Move document.title updating somewhere more sensible.
- Pass rawTty prop along to all terminals.
- Don't render react root into doc.body
- Reconnect the websocket if we lose it.
- First, slightly rough, attempt at displaying if pipe has been deleted
- Refactor controlPipe structure in the AppStore/hash.
- Merge controlPipeId, controlPipeRaw, controlPipeStatus into a single object.
- Adds a status bar to the terminal window.
- Error handling in popout working again.
- Don't show terminal cursor when not connected.
- Simplify controlPipe status and error handling.
- Don't keep the status in the hash.
- Use special new action receiveControlPipeFromParams rather than
  adding lots of branching to receiveControlPipe.
- You can reload a terminal but it doesn't exist in history stack.
- Pull out terminal into its own entry point!
- Fixes prod webpack build
- Fixes terminal-app websocket path when running on prod.
- Fixes old terminals appearing when closing a terminal.
- History hacking wasn't working, this is a little simpler.
This commit is contained in:
Simon Howe
2015-11-16 19:37:49 +01:00
committed by Tom Wilkie
parent b77cd3f300
commit 3b2af2ae6c
26 changed files with 6773 additions and 53 deletions

3
.gitignore vendored
View File

@@ -30,6 +30,9 @@ coverage.html
# Emacs backup files
*~
# ctags files
tags
# Project specific
.*.uptodate
scope.tar

View File

@@ -0,0 +1 @@
app/scripts/vendor/term.js

View File

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

View File

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

View File

@@ -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 (
<div>
<div className="app">
{showingDetails && <Details nodes={this.state.nodes}
controlError={this.state.controlError}
controlPending={this.state.controlPending}
nodeId={this.state.selectedNodeId}
details={this.state.nodeDetails} /> }
details={this.state.nodeDetails} />}
{showingTerminal && <EmbeddedTerminal
pipe={this.state.controlPipe}
nodeId={this.state.selectedNodeId}
nodes={this.state.nodes} />}
<div className="header">
<Logo />

View File

@@ -17,9 +17,7 @@ export default class Details extends React.Component {
render() {
return (
<div id="details">
<div style={{height: '100%', paddingBottom: 8, borderRadius: 2,
backgroundColor: '#fff',
boxShadow: '0 10px 30px rgba(0, 0, 0, 0.19), 0 6px 10px rgba(0, 0, 0, 0.23)'}}>
<div className="details-wrapper">
<div className="details-tools-wrapper">
<div className="details-tools">
<span className="fa fa-close" onClick={this.handleClickClose} />

View File

@@ -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 (
<div className="terminal-embedded">
<Terminal key={pipe.id} pipe={pipe} titleBarColor={titleBarColor}
statusBarColor={statusBarColor} title={title} />
</div>
);
}

View File

@@ -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 (
<div className="terminal-app" style={style}>
{this.state.controlPipe && <Terminal
key={this.state.controlPipe.id}
pipe={this.state.controlPipe}
titleBarColor={this.state.titleBarColor}
statusBarColor={this.state.statusBarColor}
title={this.state.title}
embedded={false} />}
</div>
);
}
}

View File

@@ -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 (&times;) 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 (
<div className="terminal-header" style={style}>
<div className="terminal-header-tools">
<span className="terminal-header-tools-icon fa fa-external-link"
onClick={this.handlePopoutTerminal} />
<span className="terminal-header-tools-icon fa fa-close"
onClick={this.handleCloseClick} />
</div>
<span className="terminal-header-title">{this.getTitle()}</span>
</div>
);
}
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 (
<div className="terminal-status-bar hideable" style={style}>
<h1>{title}</h1>
<p>{message}</p>
</div>
);
}
render() {
const innerStyle = {
opacity: this.state.connected ? 1 : 0.8
};
const innerClassName = classNames('terminal-inner hideable', {
'terminal-inactive': !this.state.connected
});
return (
<div className="terminal-wrapper">
{this.isEmbedded() && this.getTerminalHeader()}
<div ref={(ref) => this.innerFlex = ref}
className={innerClassName} style={innerStyle} >
<div ref={(ref) => this.inner = ref} />
</div>
{this.getTerminalStatusBar()}
</div>
);
}
}

View File

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

View File

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

View File

@@ -6,6 +6,4 @@ import ReactDOM from 'react-dom';
import App from './components/app.js';
ReactDOM.render(
<App/>,
document.getElementById('app'));
ReactDOM.render(<App/>, document.getElementById('app'));

View File

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

View File

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

View File

@@ -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(<TerminalApp/>, document.getElementById('app'));

View File

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

View File

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

View File

@@ -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() {

View File

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

5973
client/app/scripts/vendor/term.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,9 @@
@weave-orange: rgb(255,75,25);
@weave-charcoal-blue: rgb(50,50,75); // #32324B
@base-font: "Roboto", sans-serif;
@mono-font: "Menlo", "DejaVu Sans Mono", "Liberation Mono", monospace;
@primary-color: @weave-charcoal-blue;
@background-color: lighten(@primary-color, 66%);
@background-secondary-color: lighten(@background-color, 8%);
@@ -29,6 +32,9 @@
@text-darker-color: @primary-color;
@white: @background-secondary-color;
@details-window-width: 420px;
@details-window-padding-left: 36px;
/* add this class to truncate text with ellipsis, container needs width */
.truncate {
white-space: nowrap;
@@ -53,6 +59,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);
@@ -77,7 +91,7 @@ body {
background: linear-gradient(30deg, @background-color 0%, @background-secondary-color 100%);
color: @text-color;
line-height: 150%;
font-family: "Roboto", sans-serif;
font-family: @base-font;
font-size: 13px;
margin: 0;
}
@@ -99,7 +113,13 @@ h2 {
font-weight: 400;
}
#app {
.app {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
overflow: auto;
}
.header {
@@ -222,7 +242,7 @@ h2 {
}
text {
font-family: Roboto;
font-family: @base-font;
fill: @text-secondary-color;
&.node-label {
@@ -313,13 +333,13 @@ h2 {
}
#details {
position: absolute;
position: fixed;
z-index: 1024;
display: block;
right: 36px;
right: @details-window-padding-left;
top: 24px;
bottom: 48px;
width: 420px;
width: @details-window-width;
.details-tools-wrapper {
position: relative;
@@ -327,8 +347,8 @@ h2 {
.details-tools {
position: absolute;
top: 16px;
right: 24px;
top: 8px;
right: 8px;
span {
.palable;
@@ -347,6 +367,14 @@ h2 {
}
}
}
.details-wrapper {
height: 100%;
padding-bottom: 8px;
border-radius: 2px;
background-color: #fff;
.shadow-2;
}
}
.node-details {
@@ -508,9 +536,112 @@ h2 {
}
}
}
}
.terminal {
&-app {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
flex-flow: column;
}
&-embedded {
z-index: 512;
position: fixed;
display: flex;
flex-flow: column;
top: 24px;
bottom: 48px;
left: 36px;
right: (@details-window-width + @details-window-padding-left + 4px);
}
&-wrapper {
display: flex;
flex-flow: column;
flex: 1;
border: 0px solid #000000;
border-radius: 4px;
color: #f0f0f0;
font-family: @mono-font;
.shadow-2;
}
&-header {
.truncate;
text-align: center;
color: @white;
padding: 8px 24px;
background-color: @text-color;
position: relative;
&-title {
cursor: default;
}
&-tools {
position: absolute;
right: 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 {
flex: 1;
background-color: rgba(0, 0, 0, 0.93);
padding: 8px;
.terminal {
background-color: transparent !important;
}
}
&-status-bar {
font-family: @base-font;
position: absolute;
bottom: 16px;
right: 16px;
width: 50%;
height: 130px;
padding: 16px;
opacity: 0.8;
h1 {
margin: 8px 0;
}
p {
color: @white;
}
}
&-cursor {
color: #000;
background: #f0f0f0;
}
}
.terminal-inactive .terminal-cursor {
visibility: hidden;
}
.sidebar {

View File

@@ -0,0 +1,19 @@
<!doctype html>
<html class="no-js">
<head>
<meta charset="utf-8">
<title>Weave Scope Terminal</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<!--[if lt IE 10]>
<p class="browsehappy">You are using an <strong>outdated</strong> browser. Please <a href="http://browsehappy.com/">upgrade your browser</a> to improve your experience.</p>
<![endif]-->
<div class="wrap">
<div id="app"></div>
</div>
<script src="terminal-app.js"></script>
</body>
</html>

View File

@@ -6,6 +6,7 @@
"license": "Apache-2.0",
"private": true,
"dependencies": {
"classnames": "^2.2.1",
"d3": "~3.5.5",
"dagre": "0.7.4",
"debug": "~2.2.0",

View File

@@ -10,6 +10,7 @@ var app = express();
*
* Express routes for:
* - app.js
* - app-terminal.js
* - index.html
*
* Proxy requests to:
@@ -18,11 +19,12 @@ var app = express();
************************************************************/
// Serve application file depending on environment
app.get('/app.js', function(req, res) {
app.get(/(app|terminal-app).js/, function(req, res) {
var filename = req.originalUrl;
if (process.env.NODE_ENV === 'production') {
res.sendFile(__dirname + '/build/app.js');
res.sendFile(__dirname + '/build' + filename);
} else {
res.redirect('//localhost:4041/build/app.js');
res.redirect('//localhost:4041/build' + filename);
}
});

View File

@@ -25,17 +25,24 @@ module.exports = {
devtool: 'cheap-module-source-map',
// Set entry point include necessary files for hot load
entry: [
'webpack-dev-server/client?http://localhost:4041',
'webpack/hot/only-dev-server',
'./app/scripts/main'
],
entry: {
'app': [
'./app/scripts/main',
'webpack-dev-server/client?http://localhost:4041',
'webpack/hot/only-dev-server'
],
'terminal-app': [
'./app/scripts/terminal-main',
'webpack-dev-server/client?http://localhost:4041',
'webpack/hot/only-dev-server'
]
},
// This will not actually create a app.js file in ./build. It is used
// by the dev server for dynamic hot loading.
output: {
path: path.join(__dirname, 'build/'),
filename: 'app.js',
filename: '[name].js',
publicPath: 'http://localhost:4041/build/'
},
@@ -51,7 +58,7 @@ module.exports = {
preLoaders: [
{
test: /\.js$/,
exclude: /node_modules/,
exclude: /node_modules|vendor/,
loader: 'eslint-loader'
}
],
@@ -74,7 +81,7 @@ module.exports = {
},
{
test: /\.jsx?$/,
exclude: /node_modules/,
exclude: /node_modules|vendor/,
loaders: ['react-hot', 'babel']
}
]

View File

@@ -15,18 +15,21 @@ module.exports = {
// fail on first error when building release
bail: true,
entry: './app/scripts/main',
entry: {
app: './app/scripts/main',
'terminal-app': './app/scripts/terminal-main'
},
output: {
path: path.join(__dirname, 'build/'),
filename: 'app.js'
filename: '[name].js'
},
module: {
preLoaders: [
{
test: /\.js$/,
exclude: /node_modules/,
exclude: /node_modules|vendor/,
loader: 'eslint-loader'
}
],
@@ -43,7 +46,7 @@ module.exports = {
test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
loader: 'file-loader'
},
{ test: /\.jsx?$/, exclude: /node_modules/, loader: 'babel' }
{ test: /\.jsx?$/, exclude: /node_modules|vendor/, loader: 'babel' }
]
},