diff --git a/.travis.yml b/.travis.yml index d576b58f617..c644063dbb1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,6 +10,9 @@ env: - TRIGGER_REPO_BRANCH: "master" notifications: email: false +before_install: +- yarn install +- cd packages/console ; yarn install ; cd - script: - yarn test - yarn coveralls diff --git a/packages/console/README.md b/packages/console/README.md index 918e7870c6b..5b4d042401a 100644 --- a/packages/console/README.md +++ b/packages/console/README.md @@ -19,9 +19,19 @@ npm install @patternfly-react/console --save ### Usage ```javascript -import { VncConsole, SerialConsole } from '@patternfly-react/console +import { VncConsole, SerialConsole } from '@patternfly-react/console' ``` +#### Styling: +Example with LESS: +``` +@import "~bootstrap/less/variables"; +@import "~patternfly/dist/less/variables"; +@import "~patternfly-react/dist/less/patternfly-react.less"; +@import "~xterm/dist/xterm.css"; +@import "~@patternfly-react/console/dist/less/console.less"; +``` + ### Building ``` diff --git a/packages/console/less/console.less b/packages/console/less/console.less index 8270dcd6fd4..d64bf1d8d8c 100644 --- a/packages/console/less/console.less +++ b/packages/console/less/console.less @@ -1,6 +1,6 @@ /** -+ Styling shared by both VncConsole and SerialConsole. -+*/ + Styling shared by both VncConsole and SerialConsole. +*/ @import 'serial-console'; @import 'vnc-console'; diff --git a/packages/console/src/SerialConsole/SerialConsole.js b/packages/console/src/SerialConsole/SerialConsole.js index 446c721be8b..05b5f5d1075 100644 --- a/packages/console/src/SerialConsole/SerialConsole.js +++ b/packages/console/src/SerialConsole/SerialConsole.js @@ -1,11 +1,176 @@ import React from 'react'; +import PropTypes from 'prop-types'; -const propTypes = {}; -const defaultProps = {}; +import { EmptyState } from 'patternfly-react'; +import { Button } from 'patternfly-react'; +import { noop } from 'patternfly-react'; +import { CONNECTED, DISCONNECTED, LOADING } from './constants'; -const SerialConsole = () =>
Serial Console
; +import XTerm from './XTerm'; +import SerialConsoleActions from './SerialConsoleActions'; -SerialConsole.propTypes = propTypes; -SerialConsole.defaultProps = defaultProps; +class SerialConsole extends React.Component { + componentDidMount() { + this.props.onConnect(); + } + + componentWillUnmount() { + this.props.onDisconnect(); + } + + onResetClick = (event) => { + if (event.button !== 0) return; + + this.props.onDisconnect(); + this.props.onConnect(); + event.target.blur(); + this.focusTerminal(); + }; + + onDisconnectClick = (event) => { + if (event.button !== 0) return; + + this.props.onDisconnect(); + event.target.blur(); + this.focusTerminal(); + }; + + /** + * Backend sent data. + */ + onDataReceived(data) { + if (this.childTerminal && this.props.status === CONNECTED) { + this.childTerminal.onDataReceived(data); + } + } + + /** + * Backend closed connection. + */ + onConnectionClosed(reason) { + if (this.childTerminal) { + this.childTerminal.onConnectionClosed(reason); + } + } + + focusTerminal = () => { + this.childTerminal && this.childTerminal.focus(); + }; + + render() { + const { id, status, topClassName } = this.props; + const idPrefix = `${id || 'id'}-serialconsole`; + + let terminal; + let isDisconnectEnabled = false; + switch (status) { + case CONNECTED: + terminal = ( + { + this.childTerminal = c; + }} + cols={this.props.cols} + rows={this.props.rows} + onConnect={this.props.onConnect} + onDisconnect={this.props.onDisconnect} + onData={this.props.onData} + onTitleChanged={this.props.onTitleChanged} + onResize={this.props.onResize} + /> + ); + isDisconnectEnabled = true; + break; + case DISCONNECTED: + terminal = ( + + + {this.props.textDisconnectedTitle} + + {this.props.textDisconnected} + + + + + ); + break; + case LOADING: + default: + terminal = {this.props.textLoading}; + break; + } + + return ( +
+ +
{terminal}
+
+ ); + } +} + +SerialConsole.propTypes = { + /** Initiate connection to backend. In other words, the calling components manages connection state. */ + onConnect: PropTypes.func.isRequired, + /** Close connection to backend */ + onDisconnect: PropTypes.func.isRequired, + /** Terminal has been resized, backend shall be informed. (rows, cols) => {} */ + onResize: PropTypes.func, + /** Terminal produced data, like key-press */ + onData: PropTypes.func, + /** Terminal title has been changed. */ + onTitleChanged: PropTypes.func, + + /** Connection status, a value from [''connected', 'disconnected', 'loading']. Default is 'loading' for a not matching value. */ + status: PropTypes.string.isRequired, + id: PropTypes.string, + + /** Size of the terminal component */ + rows: PropTypes.number, + cols: PropTypes.number, + + /** Enable customization */ + topClassName: PropTypes.string, + + /** Localization */ + textDisconnect: PropTypes.string, + textDisconnectedTitle: PropTypes.string, + textDisconnected: PropTypes.string, + textLoading: PropTypes.string, + textReconnect: PropTypes.string, + textConnect: PropTypes.string +}; + +SerialConsole.defaultProps = { + topClassName: '', + + id: '', + rows: 25, + cols: 80, + + onTitleChanged: noop, + onData: noop, + onResize: noop, + + textDisconnectedTitle: 'Disconnected from serial console', + textDisconnected: 'Click Connect to open serial console.', + textLoading: 'Loading ...', + textConnect: 'Connect', + textDisconnect: undefined /** Default is set in SerialConsoleActions */, + textReconnect: undefined /** Default is set in SerialConsoleActions */ +}; export default SerialConsole; diff --git a/packages/console/src/SerialConsole/SerialConsole.stories.js b/packages/console/src/SerialConsole/SerialConsole.stories.js index 358443c9d8f..8e3882a5868 100644 --- a/packages/console/src/SerialConsole/SerialConsole.stories.js +++ b/packages/console/src/SerialConsole/SerialConsole.stories.js @@ -1,19 +1,148 @@ -/* eslint-disable import/no-extraneous-dependencies */ import React from 'react'; + import { storiesOf } from '@storybook/react'; -import { withInfo } from '@storybook/addon-info'; -import { inlineTemplate } from '../../../../storybook/decorators/storyTemplates'; -import SerialConsole from './SerialConsole'; - -const stories = storiesOf('@patternfly-react/console', module); - -stories.add( - 'SerialConsole', - withInfo()(() => { - const story = ; - return inlineTemplate({ - story, - title: 'SerialConsole' - }); +import { defaultTemplate } from '../../../../storybook/decorators/storyTemplates'; + +import { SerialConsole } from './index'; +import { CONNECTED, DISCONNECTED, LOADING } from './constants'; + +const stories = storiesOf('SerialConsole', module); +stories.addDecorator( + defaultTemplate({ + title: 'SerialConsole', + description: + 'This is an example of the SerialConsole component. For the purpose of this example, there is just a mock backend.' }) ); + +/* eslint no-console: ["warn", { allow: ["log"] }] */ +const { log } = console; // let's keep these trace messages for tutoring purposes + +const timeoutIds = []; +/** + * The SerialConsoleConnector component is consumer-specific and wraps the communication with backend. + * For the purpose of this storybook, the backend is just mimicked. + */ +class SerialConsoleConnector extends React.Component { + state = { status: LOADING, passKeys: false }; + + onBackendDisconnected = () => { + log('Backend has disconnected, pass the info to the UI component'); + if (this.childSerialconsole) { + this.childSerialconsole.onConnectionClosed( + 'Reason for disconnect provided by backend.' + ); + } + + this.setState({ + passKeys: false, + status: DISCONNECTED // will close the terminal window + }); + }; + + onConnect = () => { + log('SerialConsoleConnector.onConnect(), ', this.state); + this.setConnected(); + this.tellFairyTale(); + }; + + onData = data => { + log( + 'UI terminal component produced data, i.e. a key was pressed, pass it to backend. [', + data, + ']' + ); + + // Normally, the "data" shall be passed to the backend which might send them back via onData() call + // Since there is no backend, let;s pass them to UI component immediately. + if (this.state.passKeys) { + this.onDataFromBackend(data); + } + }; + + onDataFromBackend = data => { + log('Backend sent data, pass them to the UI component. [', data, ']'); + if (this.childSerialconsole) { + this.childSerialconsole.onDataReceived(data); + } + }; + + onDisconnect = () => { + this.setState({ + status: DISCONNECTED + }); + timeoutIds.forEach(id => clearTimeout(id)); + }; + + onResize = (rows, cols) => { + log( + 'UI has been resized, pass this info to backend. [', + rows, + ', ', + cols, + ']' + ); + }; + + setConnected = () => { + this.setState({ + status: CONNECTED, + passKeys: true + }); + }; + + tellFairyTale = () => { + let time = 1000; + timeoutIds.push( + setTimeout( + () => this.onDataFromBackend(' This is a mock terminal. '), + time + ) + ); + + time += 1000; + timeoutIds.push( + setTimeout( + () => this.onDataFromBackend(' Something is happening! '), + time + ) + ); + + time += 1000; + timeoutIds.push( + setTimeout( + () => this.onDataFromBackend(' Something is happening! '), + time + ) + ); + + time += 1000; + timeoutIds.push( + setTimeout( + () => this.onDataFromBackend(' Backend will be disconnected shortly. '), + time + ) + ); + + time += 5000; + timeoutIds.push(setTimeout(this.onBackendDisconnected, time)); + }; + + render() { + return ( + { + this.childSerialconsole = c; + }} + /> + ); + } +} + +stories.addWithInfo('SerialConsole', () => ); diff --git a/packages/console/src/SerialConsole/SerialConsole.test.js b/packages/console/src/SerialConsole/SerialConsole.test.js index 05211c3b63e..2969a1a6200 100644 --- a/packages/console/src/SerialConsole/SerialConsole.test.js +++ b/packages/console/src/SerialConsole/SerialConsole.test.js @@ -2,8 +2,9 @@ import React from 'react'; import { shallow } from 'enzyme'; import SerialConsole from './SerialConsole'; +import { noop } from 'patternfly-react'; test('placeholder render test', () => { - const view = shallow(); + const view = shallow(); expect(view).toMatchSnapshot(); }); diff --git a/packages/console/src/SerialConsole/SerialConsoleActions.js b/packages/console/src/SerialConsole/SerialConsoleActions.js new file mode 100644 index 00000000000..5d478760265 --- /dev/null +++ b/packages/console/src/SerialConsole/SerialConsoleActions.js @@ -0,0 +1,59 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { noop } from 'patternfly-react'; + +const SerialConsoleActions = ({ + idPrefix, + isDisconnectEnabled, + onDisconnect, + onReset, + textDisconnect, + textReconnect +}) => ( +
+ + + +
+); + +SerialConsoleActions.propTypes = { + idPrefix: PropTypes.string, + isDisconnectEnabled: PropTypes.bool, + onDisconnect: PropTypes.func, + onReset: PropTypes.func, + textDisconnect: PropTypes.string, + textReconnect: PropTypes.string +}; + +SerialConsoleActions.defaultProps = { + idPrefix: '', + isDisconnectEnabled: false, + onDisconnect: noop, + onReset: noop, + textDisconnect: 'Disconnect', + textReconnect: 'Reconnect' +}; + +export default SerialConsoleActions; diff --git a/packages/console/src/SerialConsole/XTerm.js b/packages/console/src/SerialConsole/XTerm.js new file mode 100644 index 00000000000..a45bedf9103 --- /dev/null +++ b/packages/console/src/SerialConsole/XTerm.js @@ -0,0 +1,159 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Terminal } from 'xterm'; + +import { noop } from 'patternfly-react'; + +/** + * Wraps terminal to a React Component. + * Based on cockpit-components-terminal.jsx from the Cockpit project (https://github.com/cockpit-project/cockpit) + */ +class XTerm extends React.Component { + state = { terminal: null, rows: null, cols: null }; + + componentWillMount() { + const term = new Terminal({ + cols: this.props.cols, + rows: this.props.rows, + screenKeys: true + }); + + if (this.props.onData) { + term.on('data', this.props.onData); + } + if (this.props.onTitleChanged) { + term.on('title', this.props.onTitleChanged); + } + + this.setState({ terminal: term }); + } + + componentDidMount() { + this.state.terminal.open(this.childTerminal); + + if (!this.props.rows) { + window.addEventListener('resize', this.onWindowResize); + this.onWindowResize(); + } + } + + componentWillUpdate(nextProps, nextState) { + if ( + nextState.cols !== this.state.cols || + nextState.rows !== this.state.rows + ) { + this.state.terminal.resize(nextState.cols, nextState.rows); + this.props.onResize(nextState.rows, nextState.cols); + } + } + + componentDidUpdate() { + this.state.terminal.reset(); + } + + componentWillUnmount() { + this.state.terminal.destroy(); + window.removeEventListener('resize', this.onWindowResize); + } + + // eslint-disable-next-line class-methods-use-this + onBeforeUnload = event => { + // Firefox requires this when the page is in an iframe + event.preventDefault(); + + // see "an almost cross-browser solution" at + // https://developer.mozilla.org/en-US/docs/Web/Events/beforeunload + event.returnValue = ''; + return ''; + }; + + /** + * Backend closed connection. + */ + onConnectionClosed(reason) { + const term = this.state.terminal; + if (term) { + term.write(`\x1b[31m${reason || 'disconnected'}\x1b[m\r\n`); + term.cursorHidden = true; + term.refresh(term.y, term.y); // start to end row + } + } + + /** + * Backend sent data. + */ + onDataReceived(data) { + if (this.state.terminal) { + this.state.terminal.write(data); + } + } + + onFocusIn = () => { + window.addEventListener('beforeunload', this.onBeforeUnload); + }; + + onFocusOut = () => { + window.removeEventListener('beforeunload', this.onBeforeUnload); + }; + + onWindowResize = () => { + const padding = 2 * 11; + const node = this.getDOMNode(); + const terminal = this.childTerminal.querySelector('.terminal'); + + const ch = document.createElement('span'); + ch.textContent = 'M'; + ch.style.position = 'absolute'; + terminal.appendChild(ch); + const rect = ch.getBoundingClientRect(); + terminal.removeChild(ch); + + const state = { + rows: Math.floor( + (node.parentElement.clientHeight - padding) / rect.height + ), + cols: Math.floor((node.parentElement.clientWidth - padding) / rect.width) + }; + this.setState(state); + }; + + focus = () => { + if (this.state.terminal) this.state.terminal.focus(); + }; + + render() { + // ensure react never reuses this div by keying it with the terminal widget + return ( +
{ + this.childTerminal = c; + }} + key={this.state.terminal} + className="console-pf" + onFocusIn={this.onFocusIn} + onFocusOut={this.onFocusOut} + /> + ); + } +} + +XTerm.propTypes = { + cols: PropTypes.number, + rows: PropTypes.number, + + onTitleChanged: PropTypes.func, // (title) => {} + onData: PropTypes.func, // Data to be sent from terminal to backend; (data) => {} + onResize: PropTypes.func // (rows, cols) => {} +}; + +XTerm.defaultProps = { + cols: 80, + rows: 25, + + onTitleChanged: noop, + onData: noop, + onResize: noop +}; + +export default XTerm; diff --git a/packages/console/src/SerialConsole/__snapshots__/SerialConsole.test.js.snap b/packages/console/src/SerialConsole/__snapshots__/SerialConsole.test.js.snap index 3a7497a07fe..ed0a01830fd 100644 --- a/packages/console/src/SerialConsole/__snapshots__/SerialConsole.test.js.snap +++ b/packages/console/src/SerialConsole/__snapshots__/SerialConsole.test.js.snap @@ -1,7 +1,24 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`placeholder render test 1`] = ` -
- Serial Console +
+ +
+ + Loading ... + +
`; diff --git a/packages/console/src/SerialConsole/constants.js b/packages/console/src/SerialConsole/constants.js new file mode 100644 index 00000000000..eebc5142c48 --- /dev/null +++ b/packages/console/src/SerialConsole/constants.js @@ -0,0 +1,3 @@ +export const CONNECTED = 'connected'; +export const DISCONNECTED = 'disconnected'; +export const LOADING = 'loading';