better session handlers behaviour, added serial auto-reconnection logic - #3099

This commit is contained in:
Eugene Pankov 2021-01-31 18:20:39 +01:00
parent 91c9e8affd
commit 0611afa8b5
8 changed files with 177 additions and 106 deletions

View File

@ -110,7 +110,7 @@ export default class AppModule { // eslint-disable-line @typescript-eslint/no-ex
}) })
} }
static forRoot (): ModuleWithProviders { static forRoot (): ModuleWithProviders<AppModule> {
return { return {
ngModule: AppModule, ngModule: AppModule,
providers: PROVIDERS, providers: PROVIDERS,

View File

@ -1,3 +1,4 @@
import stripAnsi from 'strip-ansi'
import { BaseSession } from 'terminus-terminal' import { BaseSession } from 'terminus-terminal'
import { SerialPort } from 'serialport' import { SerialPort } from 'serialport'
import { Logger } from 'terminus-core' import { Logger } from 'terminus-core'
@ -50,49 +51,8 @@ export class SerialSession extends BaseSession {
async start (): Promise<void> { async start (): Promise<void> {
this.open = true this.open = true
this.serial.on('data', data => { this.serial.on('readable', () => {
const dataString = data.toString() this.onData(this.serial.read())
this.emitOutput(data)
if (this.scripts) {
let found = false
for (const script of this.scripts) {
let match = false
let cmd = ''
if (script.isRegex) {
const re = new RegExp(script.expect, 'g')
if (dataString.match(re)) {
cmd = dataString.replace(re, script.send)
match = true
found = true
}
} else {
if (dataString.includes(script.expect)) {
cmd = script.send
match = true
found = true
}
}
if (match) {
this.logger.info('Executing script: "' + cmd + '"')
this.serial.write(cmd + '\n')
this.scripts = this.scripts.filter(x => x !== script)
} else {
if (script.optional) {
this.logger.debug('Skip optional script: ' + script.expect)
found = true
this.scripts = this.scripts.filter(x => x !== script)
} else {
break
}
}
}
if (found) {
this.executeUnconditionalScripts()
}
}
}) })
this.serial.on('end', () => { this.serial.on('end', () => {
@ -123,6 +83,11 @@ export class SerialSession extends BaseSession {
this.serial.close() this.serial.close()
} }
emitServiceMessage (msg: string): void {
this.serviceMessage.next(msg)
this.logger.info(stripAnsi(msg))
}
async getChildProcesses (): Promise<any[]> { async getChildProcesses (): Promise<any[]> {
return [] return []
} }
@ -139,6 +104,51 @@ export class SerialSession extends BaseSession {
return null return null
} }
private onData (data: Buffer) {
const dataString = data.toString()
this.emitOutput(data)
if (this.scripts) {
let found = false
for (const script of this.scripts) {
let match = false
let cmd = ''
if (script.isRegex) {
const re = new RegExp(script.expect, 'g')
if (dataString.match(re)) {
cmd = dataString.replace(re, script.send)
match = true
found = true
}
} else {
if (dataString.includes(script.expect)) {
cmd = script.send
match = true
found = true
}
}
if (match) {
this.logger.info('Executing script: "' + cmd + '"')
this.serial.write(cmd + '\n')
this.scripts = this.scripts.filter(x => x !== script)
} else {
if (script.optional) {
this.logger.debug('Skip optional script: ' + script.expect)
found = true
this.scripts = this.scripts.filter(x => x !== script)
} else {
break
}
}
}
if (found) {
this.executeUnconditionalScripts()
}
}
}
private executeUnconditionalScripts () { private executeUnconditionalScripts () {
if (this.scripts) { if (this.scripts) {
for (const script of this.scripts) { for (const script of this.scripts) {

View File

@ -1,16 +1,16 @@
.tab-toolbar .tab-toolbar
.btn.btn-outline-secondary.reveal-button .btn.btn-outline-secondary.reveal-button
i.fas.fa-ellipsis-h i.fas.fa-ellipsis-h
.toolbar(*ngIf='session', [class.show]='!session.open') .toolbar([class.show]='!session || !session.open')
i.fas.fa-circle.text-success.mr-2(*ngIf='session.open') i.fas.fa-circle.text-success.mr-2(*ngIf='session && session.open')
i.fas.fa-circle.text-danger.mr-2(*ngIf='!session.open') i.fas.fa-circle.text-danger.mr-2(*ngIf='!session || !session.open')
strong(*ngIf='session') {{session.connection.port}} ({{session.connection.baudrate}}) strong(*ngIf='session') {{session.connection.port}} ({{session.connection.baudrate}})
.mr-auto .mr-auto
button.btn.btn-secondary.mr-3((click)='changeBaudRate()', *ngIf='session.open') button.btn.btn-secondary.mr-3((click)='changeBaudRate()', *ngIf='session && session.open')
span Change baud rate span Change baud rate
button.btn.btn-info((click)='reconnect()', *ngIf='!session.open') button.btn.btn-info((click)='reconnect()', *ngIf='!session || !session.open')
i.fas.fa-reload i.fas.fa-reload
span Reconnect span Reconnect

View File

@ -17,8 +17,9 @@ import { Subscription } from 'rxjs'
}) })
export class SerialTabComponent extends BaseTerminalTabComponent { export class SerialTabComponent extends BaseTerminalTabComponent {
connection?: SerialConnection connection?: SerialConnection
session?: SerialSession session: SerialSession|null = null
serialPort: any serialPort: any
private serialService: SerialService
private homeEndSubscription: Subscription private homeEndSubscription: Subscription
// eslint-disable-next-line @typescript-eslint/no-useless-constructor // eslint-disable-next-line @typescript-eslint/no-useless-constructor
@ -26,6 +27,7 @@ export class SerialTabComponent extends BaseTerminalTabComponent {
injector: Injector, injector: Injector,
) { ) {
super(injector) super(injector)
this.serialService = injector.get(SerialService)
} }
ngOnInit () { ngOnInit () {
@ -62,12 +64,8 @@ export class SerialTabComponent extends BaseTerminalTabComponent {
return return
} }
this.session = this.injector.get(SerialService).createSession(this.connection) const session = this.serialService.createSession(this.connection)
this.session.serviceMessage$.subscribe(msg => { this.setSession(session)
this.write(`\r\n${colors.black.bgWhite(' serial ')} ${msg}\r\n`)
this.session?.resize(this.size.columns, this.size.rows)
})
this.attachSessionHandlers()
this.write(`Connecting to `) this.write(`Connecting to `)
const spinner = new Spinner({ const spinner = new Spinner({
@ -80,15 +78,32 @@ export class SerialTabComponent extends BaseTerminalTabComponent {
spinner.start() spinner.start()
try { try {
this.serialPort = await this.injector.get(SerialService).connectSession(this.session) this.serialPort = await this.serialService.connectSession(this.session!)
spinner.stop(true) spinner.stop(true)
session.emitServiceMessage('Port opened')
} catch (e) { } catch (e) {
spinner.stop(true) spinner.stop(true)
this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n') this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n')
return return
} }
await this.session.start() await this.session!.start()
this.session.resize(this.size.columns, this.size.rows) this.session!.resize(this.size.columns, this.size.rows)
}
protected attachSessionHandlers () {
this.attachSessionHandler(this.session!.serviceMessage$.subscribe(msg => {
this.write(`\r\n${colors.black.bgWhite(' Serial ')} ${msg}\r\n`)
this.session?.resize(this.size.columns, this.size.rows)
}))
this.attachSessionHandler(this.session!.destroyed$.subscribe(() => {
this.write('Press any key to reconnect\r\n')
this.input$.pipe(first()).subscribe(() => {
if (!this.session?.open) {
this.reconnect()
}
})
}))
super.attachSessionHandlers()
} }
async getRecoveryToken (): Promise<any> { async getRecoveryToken (): Promise<any> {
@ -99,8 +114,10 @@ export class SerialTabComponent extends BaseTerminalTabComponent {
} }
} }
reconnect () { async reconnect (): Promise<void> {
this.initializeSession() this.session?.destroy()
await this.initializeSession()
this.session?.releaseInitialDataBuffer()
} }
async changeBaudRate () { async changeBaudRate () {

View File

@ -30,10 +30,17 @@ export class SerialService {
} }
async connectSession (session: SerialSession): Promise<SerialPort> { async connectSession (session: SerialSession): Promise<SerialPort> {
const serial = new SerialPort(session.connection.port, { autoOpen: false, baudRate: session.connection.baudrate, const serial = new SerialPort(session.connection.port, {
dataBits: session.connection.databits, stopBits: session.connection.stopbits, parity: session.connection.parity, autoOpen: false,
rtscts: session.connection.rtscts, xon: session.connection.xon, xoff: session.connection.xoff, baudRate: session.connection.baudrate,
xany: session.connection.xany }) dataBits: session.connection.databits,
stopBits: session.connection.stopbits,
parity: session.connection.parity,
rtscts: session.connection.rtscts,
xon: session.connection.xon,
xoff: session.connection.xoff,
xany: session.connection.xany,
})
session.serial = serial session.serial = serial
let connected = false let connected = false
await new Promise(async (resolve, reject) => { await new Promise(async (resolve, reject) => {
@ -50,6 +57,10 @@ export class SerialService {
} }
}) })
}) })
serial.on('close', () => {
session.emitServiceMessage('Port closed')
session.destroy()
})
try { try {
serial.open() serial.open()

View File

@ -20,12 +20,11 @@ import { Subscription } from 'rxjs'
}) })
export class SSHTabComponent extends BaseTerminalTabComponent { export class SSHTabComponent extends BaseTerminalTabComponent {
connection?: SSHConnection connection?: SSHConnection
session?: SSHSession session: SSHSession|null = null
private sessionStack: SSHSession[] = [] private sessionStack: SSHSession[] = []
private homeEndSubscription: Subscription private homeEndSubscription: Subscription
private recentInputs = '' private recentInputs = ''
private reconnectOffered = false private reconnectOffered = false
private sessionHandlers: Subscription[] = []
constructor ( constructor (
injector: Injector, injector: Injector,
@ -85,8 +84,12 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
await this.setupOneSession(jumpSession) await this.setupOneSession(jumpSession)
this.sessionHandlers.push( this.attachSessionHandler(
jumpSession.destroyed$.subscribe(() => session.destroy()) jumpSession.destroyed$.subscribe(() => {
if (session.open) {
session.destroy()
}
})
) )
session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut( session.jumpStream = await new Promise((resolve, reject) => jumpSession.ssh.forwardOut(
@ -107,31 +110,11 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
this.sessionStack.push(session) this.sessionStack.push(session)
} }
this.sessionHandlers.push(session.serviceMessage$.subscribe(msg => { this.attachSessionHandler(session.serviceMessage$.subscribe(msg => {
this.write(`\r\n${colors.black.bgWhite(' SSH ')} ${msg}\r\n`) this.write(`\r\n${colors.black.bgWhite(' SSH ')} ${msg}\r\n`)
session.resize(this.size.columns, this.size.rows) session.resize(this.size.columns, this.size.rows)
})) }))
this.sessionHandlers.push(session.destroyed$.subscribe(() => {
if (
// Ctrl-D
this.recentInputs.charCodeAt(this.recentInputs.length - 1) === 4 ||
this.recentInputs.endsWith('exit\r')
) {
// User closed the session
this.destroy()
} else {
// Session was closed abruptly
this.write('\r\n' + colors.black.bgCyan(' SSH ') + ` ${session.connection.host}: session closed\r\n`)
if (!this.reconnectOffered) {
this.reconnectOffered = true
this.write('Press any key to reconnect\r\n')
this.input$.pipe(first()).subscribe(() => {
this.reconnect()
})
}
}
}))
this.write('\r\n' + colors.black.bgCyan(' SSH ') + ` Connecting to ${session.connection.host}\r\n`) this.write('\r\n' + colors.black.bgCyan(' SSH ') + ` Connecting to ${session.connection.host}\r\n`)
@ -158,6 +141,31 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
} }
} }
protected attachSessionHandlers () {
const session = this.session!
super.attachSessionHandlers()
this.attachSessionHandler(session.destroyed$.subscribe(() => {
if (
// Ctrl-D
this.recentInputs.charCodeAt(this.recentInputs.length - 1) === 4 ||
this.recentInputs.endsWith('exit\r')
) {
// User closed the session
this.destroy()
} else {
// Session was closed abruptly
this.write('\r\n' + colors.black.bgCyan(' SSH ') + ` ${session.connection.host}: session closed\r\n`)
if (!this.reconnectOffered) {
this.reconnectOffered = true
this.write('Press any key to reconnect\r\n')
this.attachSessionHandler(this.input$.pipe(first()).subscribe(() => {
this.reconnect()
}))
}
}
}))
}
async initializeSession (): Promise<void> { async initializeSession (): Promise<void> {
this.reconnectOffered = false this.reconnectOffered = false
if (!this.connection) { if (!this.connection) {
@ -165,18 +173,17 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
return return
} }
this.session = this.ssh.createSession(this.connection) const session = this.ssh.createSession(this.connection)
this.setSession(session)
try { try {
await this.setupOneSession(this.session) await this.setupOneSession(session)
} catch (e) { } catch (e) {
this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n') this.write(colors.black.bgRed(' X ') + ' ' + colors.red(e.message) + '\r\n')
} }
this.attachSessionHandlers() await this.session!.start()
this.session!.resize(this.size.columns, this.size.rows)
await this.session.start()
this.session.resize(this.size.columns, this.size.rows)
} }
async getRecoveryToken (): Promise<RecoveryToken> { async getRecoveryToken (): Promise<RecoveryToken> {
@ -193,10 +200,6 @@ export class SSHTabComponent extends BaseTerminalTabComponent {
} }
async reconnect (): Promise<void> { async reconnect (): Promise<void> {
for (const s of this.sessionHandlers) {
s.unsubscribe()
}
this.sessionHandlers = []
this.session?.destroy() this.session?.destroy()
await this.initializeSession() await this.initializeSession()
this.session?.releaseInitialDataBuffer() this.session?.releaseInitialDataBuffer()

View File

@ -36,7 +36,7 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit
]), ]),
])] ])]
session?: BaseSession session: BaseSession|null = null
savedState?: any savedState?: any
@Input() zoom = 0 @Input() zoom = 0
@ -95,6 +95,7 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit
private bellPlayer: HTMLAudioElement private bellPlayer: HTMLAudioElement
private termContainerSubscriptions: Subscription[] = [] private termContainerSubscriptions: Subscription[] = []
private allFocusModeSubscription: Subscription|null = null private allFocusModeSubscription: Subscription|null = null
private sessionHandlers: Subscription[] = []
get input$ (): Observable<Buffer> { get input$ (): Observable<Buffer> {
if (!this.frontend) { if (!this.frontend) {
@ -568,26 +569,55 @@ export class BaseTerminalTabComponent extends BaseTabComponent implements OnInit
] ]
} }
setSession (session: BaseSession|null, destroyOnSessionClose = false) {
if (session) {
if (this.session) {
this.setSession(null)
}
this.detachSessionHandlers()
this.session = session
this.attachSessionHandlers(destroyOnSessionClose)
} else {
this.detachSessionHandlers()
this.session = null
}
}
protected attachSessionHandler (subscription: Subscription) {
this.sessionHandlers.push(subscription)
}
protected attachSessionHandlers (destroyOnSessionClose = false): void { protected attachSessionHandlers (destroyOnSessionClose = false): void {
if (!this.session) { if (!this.session) {
throw new Error('Session not set') throw new Error('Session not set')
} }
// this.session.output$.bufferTime(10).subscribe((datas) => { // this.session.output$.bufferTime(10).subscribe((datas) => {
this.session.output$.subscribe(data => { this.attachSessionHandler(this.session.output$.subscribe(data => {
if (this.enablePassthrough) { if (this.enablePassthrough) {
this.zone.run(() => { this.zone.run(() => {
this.output.next(data) this.output.next(data)
this.write(data) this.write(data)
}) })
} }
}) }))
if (destroyOnSessionClose) { if (destroyOnSessionClose) {
this.sessionCloseSubscription = this.session.closed$.subscribe(() => { this.attachSessionHandler(this.sessionCloseSubscription = this.session.closed$.subscribe(() => {
this.frontend?.destroy() this.frontend?.destroy()
this.destroy() this.destroy()
}) }))
} }
this.attachSessionHandler(this.session.destroyed$.subscribe(() => {
this.setSession(null)
}))
}
protected detachSessionHandlers () {
for (const s of this.sessionHandlers) {
s.unsubscribe()
}
this.sessionHandlers = []
} }
} }

View File

@ -16,7 +16,7 @@ export class TerminalFrontendService {
private hotkeys: HotkeysService, private hotkeys: HotkeysService,
) { } ) { }
getFrontend (session?: BaseSession): Frontend { getFrontend (session?: BaseSession|null): Frontend {
if (!session) { if (!session) {
const frontend: Frontend = new { const frontend: Frontend = new {
xterm: XTermFrontend, xterm: XTermFrontend,