custody-cli
Advanced tools
Comparing version 0.3.1 to 0.4.0
## Release History | ||
* 0.4.0 Add support for displaying maintail output #72 | ||
* 0.3.1 Build on local installation as well as on publishing (#71) | ||
@@ -4,0 +6,0 @@ |
@@ -8,2 +8,3 @@ 'use strict'; | ||
var path = require('path'); | ||
var path__default = _interopDefault(path); | ||
var promiseCallbacks = require('promise-callbacks'); | ||
@@ -13,2 +14,4 @@ var sanitizeFilename = _interopDefault(require('sanitize-filename')); | ||
var _ = _interopDefault(require('underscore')); | ||
var ini = _interopDefault(require('ini')); | ||
var supervisord = _interopDefault(require('supervisord')); | ||
var memoize = _interopDefault(require('memoize-one')); | ||
@@ -18,5 +21,5 @@ var PropTypes = _interopDefault(require('prop-types')); | ||
var React__default = _interopDefault(React); | ||
var tail = require('tail'); | ||
var fuzzy = _interopDefault(require('fuzzy')); | ||
var xor = _interopDefault(require('lodash.xor')); | ||
var tail = require('tail'); | ||
var notifier = _interopDefault(require('node-notifier')); | ||
@@ -26,3 +29,2 @@ var kexec = _interopDefault(require('kexec')); | ||
var EventEmitter = _interopDefault(require('events')); | ||
var supervisord = _interopDefault(require('supervisord')); | ||
var reactBlessed = require('react-blessed'); | ||
@@ -188,16 +190,2 @@ | ||
class Restart { | ||
constructor(process) { | ||
this.process = process; | ||
} | ||
get verb() { | ||
return 'restart'; | ||
} | ||
async toggle() { | ||
await this.process.restart(); | ||
} | ||
} | ||
/** | ||
@@ -303,77 +291,263 @@ * States of a process' lifecycle. | ||
function processIsHalted(process) { | ||
return _.contains([STATES.STOPPED, STATES.FATAL], process.effectiveState.state); | ||
} | ||
const exec = promiseCallbacks.promisify(require('child_process').exec); | ||
class ToggleStopStart { | ||
constructor(process) { | ||
this.process = process; | ||
/** | ||
* Determines whether the process has crashed due to a port conflict, and if so, returns the | ||
* port in question. | ||
* | ||
* @param {Process} process - The process to analyze. | ||
* | ||
* @return {Int|null} The port in conflict, if any, or `null` if none. | ||
*/ | ||
function detectPortConflict(process) { | ||
const { state, description } = process.effectiveState; | ||
if (state === STATES.FATAL) { | ||
const portString = _.last(description.match(/EADDRINUSE.+?(\d+)/)); | ||
if (portString) return parseInt(portString, 10); | ||
} | ||
return null; | ||
} | ||
get verb() { | ||
if (processIsHalted(this.process)) { | ||
return 'stop/{underline}start{/}'; | ||
} else { | ||
return '{underline}stop{/}/start'; | ||
} | ||
} | ||
/** | ||
* Clears a port conflict by killing the process listening to that port. | ||
* | ||
* @param {Int} port - The port to clear. | ||
* | ||
* @return {Promise<Error>} A promise that resolves to `undefined` if the port is successfully | ||
* cleared, otherwise rejects with the error. | ||
*/ | ||
async function clearPortConflict(port) { | ||
return exec(`kill -9 $(lsof -ti :${port})`); | ||
} | ||
async toggle() { | ||
if (processIsHalted(this.process)) { | ||
await this.process.start(); | ||
} else { | ||
await this.process.stop(); | ||
/** | ||
* Determines the working directory of the specified process. | ||
* | ||
* @param {Int} pid - A process ID. | ||
* | ||
* @return {Promise<String>} The working directory of the process. | ||
*/ | ||
async function getWorkingDirectory(pid) { | ||
// https://unix.stackexchange.com/a/94365 | ||
// https://unix.stackexchange.com/a/255806 | ||
return (await exec(`lsof -a -Fn -p ${pid} -d cwd | sed -n 3p | cut -c 2-`)).trim(); | ||
} | ||
const readFile$1 = promiseCallbacks.promisify.method(require('fs'), 'readFile'); | ||
/** | ||
* Creates a promisified client for the supervisord instance listening on the specified port. | ||
* | ||
* @param {Int} port - The port on which the supervisord instance is listening. | ||
* | ||
* @return {Promisified Supervisord} A promisified supervisord client. | ||
*/ | ||
function createClient(port) { | ||
// TODO(jeff): Log connection errors here; for some reason this crashes the process without even | ||
// an uncaught exception. | ||
return promiseCallbacks.promisify.all(supervisord.connect(`http://localhost:${port}`)); | ||
} | ||
/** | ||
* Determines the path of the main logfile in use by Supervisor. | ||
* | ||
* @param {Promisified Supervisord} supervisor - a promisified supervisord client. | ||
* | ||
* @return {String} The path of the main logfile. | ||
*/ | ||
async function getMainLogfile(supervisor) { | ||
// Supervisor does not expose an XML-RPC API for determining the path of the main logfile as of | ||
// API version 3.0: http://supervisord.org/api.html. To identify the main logfile, we must read it | ||
// from the configuration file. Supervisor _also_ lacks APIs for returning its full configuration | ||
// and/or the path of the configuration file, so we identify the configuration file by determining | ||
// `supervisord`'s working directory, then looking up the configuration file as described at | ||
// http://supervisord.org/configuration.html#configuration-file. | ||
const supervisorPid = await supervisor.getPID(); | ||
const supervisorCwd = await getWorkingDirectory(supervisorPid); | ||
// TODO(jeff): Look up configuration files at the other possible locations (see | ||
// http://supervisord.org/configuration.html#configuration-file). | ||
const supervisorConfigFile = path__default.join(supervisorCwd, 'supervisord.conf'); | ||
const supervisorConfig = ini.parse((await readFile$1(supervisorConfigFile, 'utf-8'))); | ||
return supervisorConfig.supervisord.logfile; | ||
} | ||
/** | ||
* Utilities for managing the command menu's data structure, a list of commands. | ||
* | ||
* Commands are added and removed in "sets" using the `addCommandSet` and `removeCommandSet` APIs. | ||
* The list of commands is available as `commands`. | ||
* | ||
* This data is intended to be provided to the command menu through React context, so that different | ||
* components can add/remove command sets as they appear. | ||
*/ | ||
/** | ||
* @typedef {Object} CommandDescription | ||
* | ||
* @property {String} verb - A description of what the command does, of the form | ||
* "<action> [<subject>]". Examples: 'restart' or "show/hide commands". | ||
* @property {AsyncFunction|Function} toggle - A function that, when invoked, will perform the | ||
* command. | ||
* @property {Boolean=false} isSeparator - `true` if this command should be rendered as a separator | ||
* in the command list rather than a command that performs an action. | ||
*/ | ||
/** | ||
* @typedef {[String, CommandDescription]} Command | ||
* | ||
* A command is an array containing two elements: | ||
* | ||
* 1. The full name of the key which will trigger the command. Examples: 's' or 'tab'. | ||
* 2. An object describing the command. | ||
*/ | ||
/** | ||
* @typedef {Array<Command>} CommandList | ||
*/ | ||
/** | ||
* @type {Command} | ||
* | ||
* A command that represents a separator in the command list. | ||
*/ | ||
const separatorCommand = ['', { isSeparator: true }]; | ||
let commands = []; | ||
const commandSetMap = new Map(); | ||
/** | ||
* Updates the list of commands to reflect the current command sets. | ||
* | ||
* @return {CommandList} The new list of commands. | ||
*/ | ||
function updateCommands() { | ||
commands = [...commandSetMap].reverse() // Most recently-added commands first. | ||
.reduce((commands, [, set], idx) => { | ||
commands.push(...set); | ||
if (idx < commandSetMap.size - 1) { | ||
commands.push(separatorCommand); | ||
} | ||
} | ||
return commands; | ||
}, []); | ||
return commands; | ||
} | ||
// Currently, show keyboard shortcuts every time that a user launches custody, then hide them for | ||
/** | ||
* @type {CommandList} | ||
* | ||
* The list of commands, a live binding kept up to date by `addCommandSet` and `removeCommandSet`. | ||
* You should use those APIs to manipulate the list rather than editing it directly. | ||
*/ | ||
var commands$1 = commands; | ||
/** | ||
* Adds a set of commands to the command list. | ||
* | ||
* Commands will be rendered most-recently-added first, by set; and then within the set, in the | ||
* order in which they appear when passed to this function. | ||
* | ||
* If a set was already registered under `name`, this function will replace the old set with the | ||
* new one. | ||
* | ||
* @param {String} name - A name by which to identify the set of commands and remove the commands | ||
* later, using `removeCommandSet`. | ||
* @param {CommandList} commands - The set of commands. | ||
* | ||
* @return {CommandList} The new list of commands. | ||
*/ | ||
function addCommandSet(name, commands) { | ||
commandSetMap.set(name, commands); | ||
return updateCommands(); | ||
} | ||
/** | ||
* Removes a set of commands from the command list. | ||
* | ||
* This function is a no-op if no set was registered for `name`. | ||
* | ||
* @param {String} name - The name of a set of commands to remove from the command list. | ||
* | ||
* @return {CommandList} The new list of commands. | ||
*/ | ||
function removeCommandSet(name) { | ||
commandSetMap.delete(name); | ||
return updateCommands(); | ||
} | ||
// Currently, show the command menu every time that a user launches custody, then hide them for | ||
// the lifetime of the process (unless the user shows them again). This compensates for the user | ||
// forgetting how to show the keyboard shortcuts. We could persist this using the `storage` APIs | ||
// forgetting how to show the command menu. We could persist this using the `storage` APIs | ||
// if we wanted, although loading would be a bit racy because it's async. | ||
let CONTROLS_ARE_HIDDEN = false; | ||
let COMMANDS_ARE_HIDDEN = false; | ||
const getControls = component => (process, controls) => { | ||
return new Map([['r', new Restart(process)], ['s', new ToggleStopStart(process)], ...controls, ['/', { | ||
verb: 'show/hide shortcuts', | ||
const getCommands = component => commands => { | ||
const allCommands = [...commands]; | ||
if (!_.isEmpty(allCommands)) { | ||
allCommands.push(separatorCommand); | ||
} | ||
allCommands.push(['/', { | ||
verb: 'show/hide commands', | ||
toggle() { | ||
CONTROLS_ARE_HIDDEN = !CONTROLS_ARE_HIDDEN; | ||
component.setState({ hidden: CONTROLS_ARE_HIDDEN }); | ||
COMMANDS_ARE_HIDDEN = !COMMANDS_ARE_HIDDEN; | ||
component.setState({ hidden: COMMANDS_ARE_HIDDEN }); | ||
} | ||
}]]); | ||
}]); | ||
return allCommands; | ||
}; | ||
class ProcessControls extends React.Component { | ||
class CommandMenu extends React.Component { | ||
constructor(props) { | ||
super(props); | ||
this.controlGetter = memoize(getControls(this)); | ||
// Store the commands both as a list and as a map (rather than just enumerating the map when | ||
// necessary) because the map will not let us enumerate separator commands in their true order, | ||
// as all separator commands share the same key. | ||
this.commandGetter = memoize(getCommands(this)); | ||
this.commandMapGetter = memoize(commands => new Map(commands)); | ||
this.state = { | ||
hidden: CONTROLS_ARE_HIDDEN | ||
hidden: COMMANDS_ARE_HIDDEN | ||
}; | ||
} | ||
onKeypress(ch) { | ||
const control = this.controls.get(ch); | ||
if (!control) return; | ||
componentDidUpdate() { | ||
// The menu should float in front of other components. If we do not reset the z-index when the | ||
// menu re-renders, it may be hidden behind another component. | ||
if (this.el) this.el.setFront(); | ||
} | ||
const process = this.props.process; | ||
willHandleKeypress(key) { | ||
return !!this.commandMap.get(key.full); | ||
} | ||
// `Promise.resolve` the result of `toggle` to support both synchronous and asynchronous actions. | ||
Promise.resolve(control.toggle()).catch(err => { | ||
// HACK(jeff): We don't know for sure that the control manipulates the process. | ||
// But it's a fair bet. | ||
screen.debug(`Could not ${control.verb} ${process.name}:`, err); | ||
onKeypress(key) { | ||
const command = this.commandMap.get(key.full); | ||
if (!command) return; | ||
// `Promise.resolve` the result of `toggle` to support both synchronous and asynchronous commands. | ||
Promise.resolve(command.toggle()).catch(err => { | ||
screen.debug(`Could not ${command.verb}:`, err); | ||
}); | ||
} | ||
get controls() { | ||
return this.controlGetter(this.props.process, this.props.controls); | ||
get commands() { | ||
return this.commandGetter(this.context.commands || []); | ||
} | ||
get commandMap() { | ||
return this.commandMapGetter(this.commands); | ||
} | ||
render() { | ||
if (this.state.hidden) return null; | ||
let boxContent = [...this.controls].map(([ch, { verb }]) => `'${ch}' to ${verb}`).join('\n'); | ||
let boxContent = [...this.commands].map(([ch, { verb, isSeparator }]) => { | ||
return isSeparator ? '' : `'${ch}' to ${verb}`; | ||
}).join('\n'); | ||
@@ -386,2 +560,3 @@ // For some reason the `tags` attribute doesn't work on the box. | ||
{ | ||
ref: el => this.el = el, | ||
border: { type: 'line' }, | ||
@@ -397,7 +572,212 @@ shrink: true // Shrink the border to fit the content. | ||
ProcessControls.propTypes = { | ||
// react-blessed does not support the new context API introduced with version 16.3 :( | ||
// https://github.com/Yomguithereal/react-blessed/issues/83 | ||
CommandMenu.contextTypes = { | ||
/** | ||
* @type {CommandList} | ||
* | ||
* See `/components/commandMenu/commands` for more information. | ||
*/ | ||
commands: PropTypes.array | ||
}; | ||
var _extends = Object.assign || function (target) { | ||
for (var i = 1; i < arguments.length; i++) { | ||
var source = arguments[i]; | ||
for (var key in source) { | ||
if (Object.prototype.hasOwnProperty.call(source, key)) { | ||
target[key] = source[key]; | ||
} | ||
} | ||
} | ||
return target; | ||
}; | ||
// It might be nice to render the entire log file. However this is probably (?) unnecessary and | ||
// (more to the point) for complex/active services like app, the log file is quite large and, if we | ||
// try to load it all into the `log` component, can take several seconds to render. | ||
// We can probably afford a larger scrollback after the logs have grown though: | ||
// https://github.com/mixmaxhq/custody/issues/49. | ||
const INITIAL_SCROLLBACK = 100 /* lines */; | ||
const SCROLLBACK = 1000 /* lines */; | ||
class FileLog extends React.Component { | ||
componentDidMount() { | ||
// See comment about `mouse` in `render`. | ||
enableMouse(false); | ||
this.startTailing(); | ||
} | ||
componentWillUnmount() { | ||
this.stopTailing(); | ||
enableMouse(true); | ||
} | ||
startTailing() { | ||
// Safety belts. | ||
if (this.tail) return; | ||
const { logfile } = this.props; | ||
function onTailError(err) { | ||
screen.debug(`Could not tail logfile ${logfile}:`, err); | ||
} | ||
// We need to check that the logfile exists before initializing `Tail` because we can't handle | ||
// such an error if we let `Tail` emit it: https://github.com/lucagrulla/node-tail/issues/66 | ||
try { | ||
fs.statSync(logfile); | ||
} catch (e) { | ||
onTailError(e); | ||
return; | ||
} | ||
// As documented on `INITIAL_SCROLLBACK`, we can't render the entire log file. However, the | ||
// 'tail' module lacks a `-n`-like option to get the last `INITIAL_SCROLLBACK` lines. So what we | ||
// do is load the entire file, but wait to render only the last `INITIAL_SCROLLBACK` lines, then | ||
// start streaming. | ||
this.tail = new tail.Tail(logfile, { fromBeginning: true }); | ||
let logs = []; | ||
let initialDataFlushed = false; | ||
this.tail.on('line', line => { | ||
if (initialDataFlushed) { | ||
this.log.add(line); | ||
} else { | ||
logs.push(line); | ||
if (logs.length > INITIAL_SCROLLBACK) logs.shift(); | ||
} | ||
}).on('historicalDataEnd', () => { | ||
logs.forEach(line => this.log.add(line)); | ||
logs = []; | ||
initialDataFlushed = true; | ||
}).on('error', onTailError); | ||
} | ||
stopTailing() { | ||
if (this.tail) { | ||
this.tail.unwatch(); | ||
this.tail = null; | ||
} | ||
} | ||
render() { | ||
return ( | ||
// Unfortunately react-blessed@0.2.1 doesn't support `React.createRef`. | ||
React__default.createElement('log', _extends({ | ||
ref: log => this.log = log | ||
}, this.props.layout, { | ||
// Enables 'keypress' events, for the use of `keys`. | ||
input: true | ||
// Pass `keys` to enable the use of the up and down arrow keys to navigate the keyboard. | ||
// Note that we do _not_ pass `mouse`, because having blessed scroll will a) break native | ||
// text selection https://github.com/chjj/blessed/issues/263 b) be too fast | ||
// https://github.com/mixmaxhq/custody/issues/37#issuecomment-390855414. Instead, we disable | ||
// blessed's mouse handling while this component is mounted to let the terminal take over | ||
// scrolling. | ||
, keys: true, | ||
focused: this.props.focused, | ||
scrollback: SCROLLBACK | ||
// TODO(jeff): Enable pinning to the bottom of the logs. Possible using the `scrollOnInput` | ||
// prop, just requires us to give the user a keyboard shortcut to do so. Not totally sure | ||
// this is necessary though since the component will follow the logs if the user is scrolled | ||
// to the bottom--sounds like enabling this would only jump the user to the end if they had | ||
// scrolled away. | ||
})) | ||
); | ||
} | ||
} | ||
FileLog.propTypes = { | ||
logfile: PropTypes.string.isRequired, | ||
focused: PropTypes.bool, | ||
layout: PropTypes.object | ||
}; | ||
FileLog.defaultProps = { | ||
focused: false | ||
}; | ||
class ProcessLog extends React.Component { | ||
constructor(props) { | ||
super(props); | ||
this.state = { | ||
logfileIsLoaded: false | ||
}; | ||
} | ||
componentDidMount() { | ||
this._isMounted = true; | ||
this.loadLogfile(); | ||
} | ||
componentWillUnmount() { | ||
this._isMounted = false; | ||
} | ||
loadLogfile() { | ||
const { name, logfile } = this.props.process; | ||
// Make sure that the process' logfile exists for the benefit of `FileLog`. | ||
try { | ||
fs.statSync(logfile); | ||
} catch (e) { | ||
if (e.code === 'ENOENT') { | ||
// This logfile does not exist (has disappeared?) for some reason: | ||
// https://github.com/mixmaxhq/custody/issues/6 Restart the process to fix. | ||
screen.debug(`${name}'s logfile is missing. Restarting process to fix…`); | ||
this.props.process.restart().then(() => { | ||
if (!this._isMounted) return; | ||
this.loadLogfile(); | ||
}).catch(err => { | ||
if (!this._isMounted) return; | ||
screen.debug(`Could not restart ${name}: ${err}`); | ||
}); | ||
} else { | ||
screen.debug(`Could not tail ${name}'s logfile ${logfile}:`, e); | ||
} | ||
return; | ||
} | ||
this.setState({ logfileIsLoaded: true }); | ||
} | ||
render() { | ||
if (!this.state.logfileIsLoaded) return null; | ||
return React__default.createElement(FileLog, { | ||
logfile: this.props.process.logfile, | ||
focused: this.props.focused, | ||
layout: this.props.layout | ||
}); | ||
} | ||
} | ||
ProcessLog.propTypes = { | ||
process: PropTypes.object.isRequired, | ||
controls: PropTypes.array | ||
focused: PropTypes.bool, | ||
layout: PropTypes.object | ||
}; | ||
ProcessLog.defaultProps = { | ||
focused: false | ||
}; | ||
class Restart { | ||
constructor(process) { | ||
this.process = process; | ||
} | ||
get verb() { | ||
return 'restart'; | ||
} | ||
async toggle() { | ||
await this.process.restart(); | ||
} | ||
} | ||
/** | ||
@@ -568,3 +948,4 @@ * Returns the last index in `string` at which `regex` matches. | ||
this.setState({ search: '' }); | ||
} else if (ch && !_.contains(['enter', 'return'], key.name)) { | ||
} else if (ch && this.props.shouldHandleKeypress(ch, key) && !_.contains(['enter', 'return'], key.name)) { | ||
this.setState(prevState => { | ||
@@ -636,5 +1017,10 @@ const newSearch = prevState.search + ch; | ||
processes: PropTypes.array.isRequired, | ||
onSelect: PropTypes.func | ||
onSelect: PropTypes.func, | ||
shouldHandleKeypress: PropTypes.func | ||
}; | ||
ProcessTable.defaultProps = { | ||
shouldHandleKeypress: () => true | ||
}; | ||
function ProcessSummary({ process }) { | ||
@@ -688,145 +1074,53 @@ const data = HEADERS.map(key => { | ||
var _extends = Object.assign || function (target) { | ||
for (var i = 1; i < arguments.length; i++) { | ||
var source = arguments[i]; | ||
function processIsHalted(process) { | ||
return _.contains([STATES.STOPPED, STATES.FATAL], process.effectiveState.state); | ||
} | ||
for (var key in source) { | ||
if (Object.prototype.hasOwnProperty.call(source, key)) { | ||
target[key] = source[key]; | ||
} | ||
} | ||
class ToggleStopStart { | ||
constructor(process) { | ||
this.process = process; | ||
} | ||
return target; | ||
}; | ||
// It might be nice to render the entire log file. However this is probably (?) unnecessary and | ||
// (more to the point) for complex/active services like app, the log file is quite large and, if we | ||
// try to load it all into the `log` component, can take several seconds to render. | ||
// We can probably afford a larger scrollback after the logs have grown though: | ||
// https://github.com/mixmaxhq/custody/issues/49. | ||
const INITIAL_SCROLLBACK = 100 /* lines */; | ||
const SCROLLBACK = 1000 /* lines */; | ||
class ProcessLog extends React.Component { | ||
componentDidMount() { | ||
this._isMounted = true; | ||
// See comment about `mouse` in `render`. | ||
enableMouse(false); | ||
this.startTailing(); | ||
get verb() { | ||
if (processIsHalted(this.process)) { | ||
return 'stop/{underline}start{/}'; | ||
} else { | ||
return '{underline}stop{/}/start'; | ||
} | ||
} | ||
componentWillUnmount() { | ||
this.stopTailing(); | ||
enableMouse(true); | ||
this._isMounted = false; | ||
async toggle() { | ||
if (processIsHalted(this.process)) { | ||
await this.process.start(); | ||
} else { | ||
await this.process.stop(); | ||
} | ||
} | ||
} | ||
startTailing() { | ||
// Safety belts. | ||
if (this.tail) return; | ||
const COMMAND_SET_NAME = 'process-details'; | ||
const { name, logfile } = this.props.process; | ||
function onTailError(err) { | ||
screen.debug(`Could not tail ${name}'s logfile ${logfile}:`, err); | ||
function getCommands$1(process) { | ||
return [['r', new Restart(process)], ['s', new ToggleStopStart(process)], ['Esc', { | ||
verb: 'go back', | ||
toggle() { | ||
// Nothing to do since we already handle escape in `onElementKeypress` below. | ||
} | ||
}]]; | ||
} | ||
// We need to check that the logfile exists before initializing `Tail` because we can't handle | ||
// such an error if we let `Tail` emit it: https://github.com/lucagrulla/node-tail/issues/66 | ||
try { | ||
fs.statSync(logfile); | ||
} catch (e) { | ||
if (e.code === 'ENOENT') { | ||
// This logfile does not exist (has disappeared?) for some reason: | ||
// https://github.com/mixmaxhq/custody/issues/6 Restart the process to fix. | ||
this.log.add(`${name}'s logfile is missing. Restarting process to fix…`); | ||
this.props.process.restart().then(() => { | ||
if (!this._isMounted) return; | ||
this.startTailing(); | ||
}).catch(err => { | ||
if (!this._isMounted) return; | ||
this.log.add(`Could not restart ${name}: ${err}`); | ||
}); | ||
} else { | ||
onTailError(e); | ||
} | ||
return; | ||
} | ||
class ProcessDetails extends React.Component { | ||
constructor(props) { | ||
super(props); | ||
// As documented on `INITIAL_SCROLLBACK`, we can't render the entire log file. However, the | ||
// 'tail' module lacks a `-n`-like option to get the last `INITIAL_SCROLLBACK` lines. So what we | ||
// do is load the entire file, but wait to render only the last `INITIAL_SCROLLBACK` lines, then | ||
// start streaming. | ||
this.tail = new tail.Tail(logfile, { fromBeginning: true }); | ||
let logs = []; | ||
let initialDataFlushed = false; | ||
this.tail.on('line', line => { | ||
if (initialDataFlushed) { | ||
this.log.add(line); | ||
} else { | ||
logs.push(line); | ||
if (logs.length > INITIAL_SCROLLBACK) logs.shift(); | ||
} | ||
}).on('historicalDataEnd', () => { | ||
logs.forEach(line => this.log.add(line)); | ||
logs = []; | ||
initialDataFlushed = true; | ||
}).on('error', onTailError); | ||
this.commandGetter = memoize(getCommands$1); | ||
} | ||
stopTailing() { | ||
if (this.tail) { | ||
this.tail.unwatch(); | ||
this.tail = null; | ||
} | ||
get commands() { | ||
return this.commandGetter(this.props.process); | ||
} | ||
render() { | ||
return ( | ||
// Unfortunately react-blessed@0.2.1 doesn't support `React.createRef`. | ||
React__default.createElement('log', _extends({ | ||
ref: log => this.log = log | ||
}, this.props.layout, { | ||
// Enables 'keypress' events, for the use of `keys`. | ||
input: true | ||
// Pass `keys` to enable the use of the up and down arrow keys to navigate the keyboard. | ||
// Note that we do _not_ pass `mouse`, because having blessed scroll will a) break native | ||
// text selection https://github.com/chjj/blessed/issues/263 b) be too fast | ||
// https://github.com/mixmaxhq/custody/issues/37#issuecomment-390855414. Instead, we disable | ||
// blessed's mouse handling while this component is mounted to let the terminal take over | ||
// scrolling. | ||
, keys: true, | ||
focused: this.props.focused, | ||
scrollback: SCROLLBACK | ||
// TODO(jeff): Enable pinning to the bottom of the logs. Possible using the `scrollOnInput` | ||
// prop, just requires us to give the user a keyboard shortcut to do so. Not totally sure | ||
// this is necessary though since the component will follow the logs if the user is scrolled | ||
// to the bottom--sounds like enabling this would only jump the user to the end if they had | ||
// scrolled away. | ||
})) | ||
); | ||
} | ||
} | ||
componentDidMount() { | ||
this.context.addCommandSet(COMMAND_SET_NAME, this.commands); | ||
ProcessLog.propTypes = { | ||
process: PropTypes.object.isRequired, | ||
focused: PropTypes.bool, | ||
layout: PropTypes.object | ||
}; | ||
ProcessLog.defaultProps = { | ||
focused: false | ||
}; | ||
const DEFAULT_CONTROLS = [['Esc', { | ||
verb: 'go back', | ||
toggle() { | ||
// Nothing to do since we already handle escape in `onElementKeypress` below. | ||
} | ||
}]]; | ||
class ProcessDetails extends React.Component { | ||
componentDidMount() { | ||
// The log has to be focused--not our root element--in order to enable keyboard navigation | ||
@@ -839,8 +1133,15 @@ // thereof. But then this means that we have to listen for the log's keypress events, using the | ||
componentDidUpdate(prevProps) { | ||
if (this.props.process !== prevProps.process) { | ||
this.context.addCommandSet(COMMAND_SET_NAME, this.commands); | ||
} | ||
} | ||
componentWillUnmount() { | ||
this.context.removeCommandSet(COMMAND_SET_NAME); | ||
} | ||
onElementKeypress(el, ch, key) { | ||
if (key.name === 'escape') { | ||
this.props.onClose(); | ||
} else { | ||
// Forward keypresses to the controls since we have to keep the log focused. | ||
this.controls.onKeypress(ch, key); | ||
} | ||
@@ -859,7 +1160,2 @@ } | ||
, layout: { top: 1 } | ||
}), | ||
React__default.createElement(ProcessControls, { | ||
ref: controls => this.controls = controls, | ||
process: this.props.process, | ||
controls: DEFAULT_CONTROLS | ||
}) | ||
@@ -875,4 +1171,9 @@ ); | ||
const exec = promiseCallbacks.promisify(require('child_process').exec); | ||
ProcessDetails.contextTypes = { | ||
addCommandSet: PropTypes.func.isRequired, | ||
removeCommandSet: PropTypes.func.isRequired | ||
}; | ||
const exec$1 = promiseCallbacks.promisify(require('child_process').exec); | ||
function processHasChangedState(prevProps) { | ||
@@ -891,2 +1192,20 @@ return process => { | ||
const COMMAND_SET_NAME$1 = 'console'; | ||
function getCommandSet(component) { | ||
let maintailDescription; | ||
if (component.state.tailingMainLogfile) { | ||
maintailDescription = 'switch to/{underline}from{/} maintail'; | ||
} else { | ||
maintailDescription = 'switch {underline}to{/}/from maintail'; | ||
} | ||
return [['tab', { | ||
verb: maintailDescription, | ||
toggle() { | ||
component.setState(({ tailingMainLogfile }) => ({ tailingMainLogfile: !tailingMainLogfile })); | ||
} | ||
}]]; | ||
} | ||
class Console extends React.Component { | ||
@@ -897,7 +1216,37 @@ constructor(props) { | ||
this.state = { | ||
selectedProcess: null | ||
tailingMainLogfile: false, | ||
selectedProcess: null, | ||
commands: commands$1 | ||
}; | ||
} | ||
/** | ||
* Permit child components to add/remove command sets, and re-render the command menu when they | ||
* do so. | ||
*/ | ||
getChildContext() { | ||
return { | ||
commands: this.state.commands, | ||
addCommandSet: this.addCommandSet.bind(this), | ||
removeCommandSet: this.removeCommandSet.bind(this) | ||
}; | ||
} | ||
addCommandSet(name, commands) { | ||
this.setState({ commands: addCommandSet(name, commands) }); | ||
} | ||
removeCommandSet(name) { | ||
this.setState({ commands: removeCommandSet(name) }); | ||
} | ||
componentDidMount() { | ||
this.addCommandSet(COMMAND_SET_NAME$1, getCommandSet(this)); | ||
// Our various children have to be focused--not our root element--in order to enable keyboard | ||
// navigation thereof. But then this means that we have to listen for the children's keypress | ||
// events, using the special bubbling syntax https://github.com/chjj/blessed#event-bubbling, and | ||
// have to do so manually (ugh): https://github.com/Yomguithereal/react-blessed/issues/61 | ||
this.el.on('element keypress', this.onElementKeypress.bind(this)); | ||
// Restore the last-selected process when we load, if we didn't cleanly shut down. | ||
@@ -930,2 +1279,17 @@ // Perhaps at some later point we will wish to always restore the last-selected process. | ||
componentWillUnmount() { | ||
this.removeCommandSet(COMMAND_SET_NAME$1); | ||
} | ||
// Whatever keys we handle here, should also be withheld from the process table in | ||
// `tableShouldHandleKeypress` below. | ||
onElementKeypress(el, ch, key) { | ||
// Forward keypresses to the command menu since we have to keep our various children focused. | ||
this.commandMenu.onKeypress(key); | ||
} | ||
tableShouldHandleKeypress(ch, key) { | ||
return !this.commandMenu.willHandleKeypress(key); | ||
} | ||
// I don't think that react-blessed@0.2.1 supports `getDerivedStateFromProps`, that wasn't being | ||
@@ -955,2 +1319,6 @@ // called. react-blessed doesn't support `UNSAFE_componentWillReceiveProps` either so hopefully | ||
} | ||
if (this.state.tailingMainLogfile !== prevState.tailingMainLogfile) { | ||
this.addCommandSet(COMMAND_SET_NAME$1, getCommandSet(this)); | ||
} | ||
} | ||
@@ -976,3 +1344,3 @@ | ||
// through `notifier.notify` to `terminal-notifier` but it doesn't work for some reason. :\ | ||
exec('open -a Terminal').catch(err => screen.debug('Could not activate Terminal:', err)); | ||
exec$1('open -a Terminal').catch(err => screen.debug('Could not activate Terminal:', err)); | ||
@@ -998,9 +1366,20 @@ // Show the logs for the (current version of) the process if it's still running (safety | ||
render() { | ||
return this.state.selectedProcess ? React__default.createElement(ProcessDetails, { | ||
process: this.state.selectedProcess, | ||
onClose: this.onDeselect.bind(this) | ||
}) : React__default.createElement(ProcessTable, { | ||
processes: this.props.processes, | ||
onSelect: this.onSelect.bind(this) | ||
}); | ||
return React__default.createElement( | ||
'box', | ||
{ ref: el => this.el = el }, | ||
this.state.tailingMainLogfile ? React__default.createElement(FileLog, { | ||
logfile: this.props.mainLogfile, | ||
focused: true | ||
}) : this.state.selectedProcess ? React__default.createElement(ProcessDetails, { | ||
process: this.state.selectedProcess, | ||
onClose: this.onDeselect.bind(this) | ||
}) : React__default.createElement(ProcessTable, { | ||
processes: this.props.processes, | ||
onSelect: this.onSelect.bind(this), | ||
shouldHandleKeypress: this.tableShouldHandleKeypress.bind(this) | ||
}), | ||
React__default.createElement(CommandMenu, { | ||
ref: menu => this.commandMenu = menu | ||
}) | ||
); | ||
} | ||
@@ -1010,2 +1389,3 @@ } | ||
Console.propTypes = { | ||
mainLogfile: PropTypes.string.isRequired, | ||
processes: PropTypes.array.isRequired, | ||
@@ -1019,2 +1399,8 @@ notifications: PropTypes.bool | ||
Console.childContextTypes = { | ||
commands: PropTypes.array, | ||
addCommandSet: PropTypes.func.isRequired, | ||
removeCommandSet: PropTypes.func.isRequired | ||
}; | ||
const OOM_CHECK_DELAY = 30 * 1000; | ||
@@ -1059,34 +1445,3 @@ | ||
const exec$1 = promiseCallbacks.promisify(require('child_process').exec); | ||
/** | ||
* Determines whether the process has crashed due to a port conflict, and if so, returns the | ||
* port in question. | ||
* | ||
* @param {Process} process - The process to analyze. | ||
* | ||
* @return {Int|null} The port in conflict, if any, or `null` if none. | ||
*/ | ||
function detectPortConflict(process) { | ||
const { state, description } = process.effectiveState; | ||
if (state === STATES.FATAL) { | ||
const portString = _.last(description.match(/EADDRINUSE.+?(\d+)/)); | ||
if (portString) return parseInt(portString, 10); | ||
} | ||
return null; | ||
} | ||
/** | ||
* Clears a port conflict by killing the process listening to that port. | ||
* | ||
* @param {Int} port - The port to clear. | ||
* | ||
* @return {Promise<Error>} A promise that resolves to `undefined` if the port is successfully | ||
* cleared, otherwise rejects with the error. | ||
*/ | ||
async function clearPortConflict(port) { | ||
return exec$1(`kill -9 $(lsof -ti :${port})`); | ||
} | ||
const { readFile: readFile$1, readdir, unlink } = promiseCallbacks.promisify.methods(require('fs'), ['readFile', 'readdir', 'unlink']); | ||
const { readFile: readFile$2, readdir, unlink } = promiseCallbacks.promisify.methods(require('fs'), ['readFile', 'readdir', 'unlink']); | ||
const mkdirp$1 = promiseCallbacks.promisify(require('mkdirp')); | ||
@@ -1142,3 +1497,3 @@ | ||
try { | ||
state = JSON.parse((await readFile$1(path$$1, 'utf8')).trim()); | ||
state = JSON.parse((await readFile$2(path$$1, 'utf8')).trim()); | ||
} catch (e) { | ||
@@ -1155,6 +1510,6 @@ // This update was the file being deleted. | ||
supervisor: { | ||
port, | ||
pollInterval = 1000, | ||
fixPortConflicts = true | ||
} | ||
client, | ||
pollInterval = 1000 | ||
}, | ||
fixPortConflicts = true | ||
}) { | ||
@@ -1168,5 +1523,3 @@ super(); | ||
// TODO(jeff): Log connection errors here; for some reason this crashes the process without even | ||
// an uncaught exception. | ||
this._supervisor = promiseCallbacks.promisify.all(supervisord.connect(`http://localhost:${port}`)); | ||
this._supervisor = client; | ||
@@ -1270,3 +1623,3 @@ this._probeMonitor = new ProbeMonitor().on('update', this._onProcessUpdate.bind(this)).on('error', err => screen.debug('Error monitoring process states:', err)); | ||
function start({ port, notifications }) { | ||
var start = (async function start({ port, notifications }) { | ||
let stopOOMCheck; | ||
@@ -1279,3 +1632,3 @@ | ||
return new Promise((resolve, reject) => { | ||
try { | ||
clearShutdown(); | ||
@@ -1285,32 +1638,37 @@ | ||
// TODO(jeff): Distinguish between fatal and non-fatal errors. | ||
const processMonitor = new ProcessMonitor({ supervisor: { port } }).on('error', reject); | ||
const supervisor = createClient(port); | ||
const mainLogfile = await getMainLogfile(supervisor); | ||
// Load all processes, then render, to avoid a flash as they load in (including probe states). | ||
processMonitor.start().then(() => { | ||
function renderApp() { | ||
reactBlessed.render(React__default.createElement(Console, { | ||
processes: processMonitor.processes, | ||
notifications: notifications | ||
}), screen); | ||
} | ||
renderApp(); | ||
processMonitor.on('update', renderApp); | ||
}).catch(reject); | ||
await new Promise((resolve, reject) => { | ||
// TODO(jeff): Distinguish between fatal and non-fatal errors. | ||
const processMonitor = new ProcessMonitor({ supervisor: { client: supervisor } }).on('error', reject); | ||
// Don't allow components to lock-out our control keys, Ctrl-C (exit) and F12 (debug log). | ||
screen.ignoreLocked = ['C-c', 'f12']; | ||
// Load all processes, then render, to avoid a flash as they load in (including probe states). | ||
processMonitor.start().then(() => { | ||
function renderApp() { | ||
reactBlessed.render(React__default.createElement(Console, { | ||
mainLogfile: mainLogfile, | ||
processes: processMonitor.processes, | ||
notifications: notifications | ||
}), screen); | ||
} | ||
renderApp(); | ||
processMonitor.on('update', renderApp); | ||
}).catch(reject); | ||
screen.key(['C-c'], () => { | ||
markCleanShutdown(); | ||
resolve(); | ||
// Don't allow components to lock-out our control keys, Ctrl-C (exit) and F12 (debug log). | ||
screen.ignoreLocked = ['C-c', 'f12']; | ||
screen.key(['C-c'], () => { | ||
markCleanShutdown(); | ||
resolve(); | ||
}); | ||
}); | ||
}).then(teardown).catch(err => { | ||
// Make sure to reset the terminal before returning the error, otherwise the client won't be | ||
// able to print the error to the logs. | ||
} finally { | ||
// Note that it's important that this resets the terminal before returning the error, otherwise | ||
// the client won't be able to print the error to the logs. | ||
teardown(); | ||
throw err; | ||
}); | ||
} | ||
} | ||
}); | ||
module.exports = start; |
{ | ||
"name": "custody-cli", | ||
"version": "0.3.1", | ||
"version": "0.4.0", | ||
"description": "A developer-oriented frontend for Supervisor", | ||
@@ -25,2 +25,3 @@ "bin": "bin/custody-cli.js", | ||
"fuzzy": "^0.1.3", | ||
"ini": "^1.3.5", | ||
"kexec": "^3.0.0", | ||
@@ -27,0 +28,0 @@ "lodash.xor": "^4.5.0", |
59597
1444
19
6
+ Addedini@^1.3.5