This commit is contained in:
Eugene Pankov
2021-06-25 21:55:40 +02:00
parent 663615fe06
commit 0689c984ff
24 changed files with 573 additions and 320 deletions

19
src/api.ts Normal file
View File

@@ -0,0 +1,19 @@
export interface User {
active_config: number
active_version: string
custom_connection_gateway: string|null
custom_connection_gateway_token: string|null
is_pro: boolean
}
export interface Config {
id: number
content: string
last_used_with_version: string
created_at: Date
modified_at: Date
}
export interface Version {
version: string
}

View File

@@ -1,24 +1,33 @@
import { NgModule } from '@angular/core';
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { BrowserModule } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { NgModule } from '@angular/core'
import { NgbDropdownModule, NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
import { BrowserModule } from '@angular/platform-browser'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { HttpClientModule, HttpClientXsrfModule } from '@angular/common/http'
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'
import { AppComponent } from './components/app.component'
import { MainComponent } from './components/main.component';
import { MainComponent } from './components/main.component'
import { ConfigModalComponent } from './components/configModal.component'
import { SettingsModalComponent } from './components/settingsModal.component'
@NgModule({
imports: [
BrowserModule,
BrowserAnimationsModule,
CommonModule,
FormsModule,
HttpClientModule,
HttpClientXsrfModule,
NgbDropdownModule,
NgbModalModule,
FontAwesomeModule,
],
declarations: [
AppComponent,
MainComponent,
ConfigModalComponent,
SettingsModalComponent,
],
bootstrap: [AppComponent]
})

View File

@@ -21,7 +21,9 @@ export class AppComponent {
constructor (
private loginService: LoginService,
) { }
) {
this.providers = [this.providers[0]] // only keep GH for now
}
async ngOnInit () {
await this.loginService.ready$.toPromise()

View File

@@ -0,0 +1,40 @@
.modal-body
.header(*ngIf='configService.activeConfig')
.d-flex.align-items-center.py-2.px-4
.me-auto
label Active config
.title {{configService.activeConfig.modified_at}}
button.btn.btn-semi.me-2((click)='configService.duplicateConfig()')
fa-icon([icon]='_copyIcon', [fixedWidth]='true')
button.btn.btn-semi((click)='deleteConfig()')
fa-icon([icon]='_deleteIcon', [fixedWidth]='true')
.d-flex.align-items-center.py-2.px-4(*ngIf='configService.activeVersion')
.me-auto App version:
div(ngbDropdown)
button.btn.btn-semi(ngbDropdownToggle) {{configService.activeVersion.version}}
div(ngbDropdownMenu)
a(
*ngFor='let version of versions',
ngbDropdownItem,
[class.active]='version == configService.activeVersion',
(click)='selectVersion(version)'
) {{version.version}}
div(*ngIf='configService.configs.length > 1')
.dropdown-header All configs
ng-container(*ngFor='let config of configService.configs')
a(
*ngIf='config !== configService.activeConfig',
ngbDropdownItem,
(click)='selectConfig(config)',
href='#'
) Config modified at {{config.modified_at}}
.p-3
button.btn.btn-semi.w-100((click)='configService.createNewConfig()')
fa-icon([icon]='_addIcon', [fixedWidth]='true')
span New config

View File

@@ -0,0 +1,29 @@
import { Component } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { AppConnectorService } from '../services/appConnector.service'
import { ConfigService } from '../services/config.service'
import { faPlus } from '@fortawesome/free-solid-svg-icons'
@Component({
selector: 'config-modal',
templateUrl: './configModal.component.pug',
// styleUrls: ['./settingsModal.component.scss'],
})
export class ConfigModalComponent {
_addIcon = faPlus
constructor (
private modalInstance: NgbActiveModal,
public appConnector: AppConnectorService,
public configService: ConfigService,
) {
}
async ngOnInit () {
}
cancel () {
this.modalInstance.dismiss()
}
}

View File

@@ -1,66 +1,14 @@
.sidebar
img.logo(src='{{_logo}}')
div(ngbDropdown, placement='bottom-right')
button.btn(ngbDropdownToggle)
fa-icon([icon]='_cogIcon', [fixedWidth]='true')
button.btn((click)='openConfig()')
fa-icon([icon]='_cogIcon', [fixedWidth]='true')
.config-menu(ngbDropdownMenu)
.header(*ngIf='getActiveConfig()')
.d-flex.align-items-center.py-2.px-4
.me-auto
label Active config
.title {{getActiveConfig().modified_at}}
button.btn((click)='openSettings()')
fa-icon([icon]='_settingsIcon', [fixedWidth]='true')
button.btn.btn-semi.me-2((click)='duplicateConfig()')
fa-icon([icon]='_copyIcon', [fixedWidth]='true')
button.btn.mt-auto((click)='logout()')
fa-icon([icon]='_logoutIcon', [fixedWidth]='true')
button.btn.btn-semi((click)='deleteConfig()')
fa-icon([icon]='_deleteIcon', [fixedWidth]='true')
.d-flex.align-items-center.py-2.px-4(*ngIf='activeVersion')
.me-auto App version:
div(ngbDropdown)
button.btn.btn-semi(ngbDropdownToggle) {{activeVersion.version}}
div(ngbDropdownMenu)
a(
*ngFor='let version of versions',
ngbDropdownItem,
[class.active]='version == activeVersion',
(click)='selectVersion(version)'
) {{version.version}}
div(*ngIf='configs.length > 1')
.dropdown-header All configs
ng-container(*ngFor='let config of configs')
a(
*ngIf='config !== getActiveConfig()',
ngbDropdownItem,
(click)='selectConfig(config)',
href='#'
) Config modified at {{config.modified_at}}
.p-3
button.btn.btn-semi.w-100((click)='createNewConfig()')
fa-icon([icon]='_addIcon', [fixedWidth]='true')
span New config
div(ngbDropdown, placement='bottom-right')
button.btn(ngbDropdownToggle)
fa-icon([icon]='_connectionIcon', [fixedWidth]='true')
div(ngbDropdownMenu)
.form-check.form-switch
input.form-check-input(type='checkbox', ngModel='!!loginService.user.custom_connection_gateway')
label(class='form-check-label') Use custom connection gateway
div(ngbDropdown, placement='bottom-right')
button.btn(ngbDropdownToggle)
fa-icon([icon]='_userIcon', [fixedWidth]='true')
div(ngbDropdownMenu)
a(ngbDropdownItem, (click)='logout()', href='#') Logout
.terminal([hidden]='!activeVersion')
.terminal([hidden]='!showApp')
iframe(#iframe)

View File

@@ -1,10 +1,15 @@
import * as semverGT from 'semver/functions/gt'
import { Component, ElementRef, ViewChild } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { AppConnectorService } from '../services/appConnector.service'
import { faCog, faUser, faCopy, faTrash, faPlus, faPlug } from '@fortawesome/free-solid-svg-icons'
import { faCog, faCopy, faTrash, faPlus, faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
import { LoginService } from '../services/login.service'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { SettingsModalComponent } from './settingsModal.component'
import { ConfigModalComponent } from './configModal.component'
import { ConfigService } from '../services/config.service'
import { combineLatest } from 'rxjs'
import { Config, Version } from '../api'
@Component({
selector: 'main',
@@ -14,21 +19,22 @@ import { LoginService } from '../services/login.service'
export class MainComponent {
_logo = require('../assets/logo.svg')
_cogIcon = faCog
_userIcon = faUser
_settingsIcon = faCog
_logoutIcon = faSignOutAlt
_copyIcon = faCopy
_addIcon = faPlus
_deleteIcon = faTrash
_connectionIcon = faPlug
configs: any[] = []
versions: any[] = []
activeVersion?: any
showApp = false
@ViewChild('iframe') iframe: ElementRef
constructor (
private appConnector: AppConnectorService,
public appConnector: AppConnectorService,
private http: HttpClient,
public loginService: LoginService,
private ngbModal: NgbModal,
private config: ConfigService,
) {
window.addEventListener('message', event => {
if (event.data === 'request-connector') {
@@ -39,68 +45,47 @@ export class MainComponent {
}
async ngAfterViewInit () {
this.configs = await this.http.get('/api/1/configs').toPromise()
this.versions = await this.http.get('/api/1/versions').toPromise()
this.versions.sort((a, b) => semverGT(a, b))
if (!this.configs.length) {
await this.createNewConfig()
}
this.selectConfig(this.configs[0])
}
async createNewConfig () {
this.configs.push(await this.http.post('/api/1/configs', {
content: '{}',
last_used_with_version: this.versions[0].version,
}).toPromise())
}
async duplicateConfig () {
const copy = {...this.appConnector.config, pk: undefined}
this.configs.push(await this.http.post('/api/1/configs', copy).toPromise())
combineLatest(
this.config.activeConfig$,
this.config.activeVersion$
).subscribe(([config, version]) => {
if (config && version) {
this.reloadApp(config, version)
}
})
this.config
await this.config.ready$.toPromise()
await this.config.selectDefaultConfig()
}
unloadApp () {
delete this.activeVersion
this.showApp = false
this.iframe.nativeElement.src = 'about:blank'
}
loadApp (version) {
async loadApp (config, version) {
this.showApp = true
this.iframe.nativeElement.src = '/terminal'
this.activeVersion = version
await this.http.patch(`/api/1/configs/${config.id}`, {
last_used_with_version: version.version,
}).toPromise()
}
getActiveConfig () {
return this.appConnector.config
}
selectVersion (version: any) {
reloadApp (config: Config, version: Version) {
// TODO check config incompatibility
this.unloadApp()
setTimeout(() => {
this.appConnector.version = version
this.loadApp(version)
this.appConnector.setState(config, version)
this.loadApp(config, version)
})
}
async selectConfig (config: any) {
let matchingVersion = this.versions.find(x => x.version === config.last_used_with_version)
if (!matchingVersion) {
// TODO ask to upgrade
matchingVersion = this.versions[0]
}
async openConfig () {
await this.ngbModal.open(ConfigModalComponent).result
}
this.appConnector.config = config
const result = await this.http.patch(`/api/1/configs/${config.id}`, {
last_used_with_version: matchingVersion.version,
}).toPromise()
Object.assign(config, result)
this.selectVersion(matchingVersion)
async openSettings () {
await this.ngbModal.open(SettingsModalComponent).result
}
async logout () {

View File

@@ -0,0 +1,32 @@
.modal-header
h5.modal-title Settings
.modal-body
.mb-3
.form-check.form-switch
input.form-check-input(
type='checkbox',
[(ngModel)]='customGatewayEnabled'
)
label(class='form-check-label') Use custom connection gateway
.mb-3(*ngIf='customGatewayEnabled')
.form-floating
input.form-control(
type='text',
[(ngModel)]='user.custom_connection_gateway',
placeholder='wss://1.2.3.4'
)
label Gateway address
.mb-3(*ngIf='customGatewayEnabled')
.form-floating
input.form-control(
type='password',
[(ngModel)]='user.custom_connection_gateway_token',
placeholder='123'
)
label Gateway authentication token
.modal-footer
button.btn.btn-primary((click)='apply()') Apply
button.btn.btn-secondary((click)='cancel()') Cancel

View File

@@ -0,0 +1,36 @@
import { Component } from '@angular/core'
import { LoginService } from '../services/login.service'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { User } from '../api'
@Component({
selector: 'settings-modal',
templateUrl: './settingsModal.component.pug',
// styleUrls: ['./settingsModal.component.scss'],
})
export class SettingsModalComponent {
user: User
customGatewayEnabled = false
constructor (
private modalInstance: NgbActiveModal,
private loginService: LoginService,
) {
this.user = { ...loginService.user }
this.customGatewayEnabled = !!this.user.custom_connection_gateway
}
async ngOnInit () {
}
async apply () {
Object.assign(this.loginService.user, this.user)
this.modalInstance.close()
await this.loginService.updateUser()
}
cancel () {
this.modalInstance.dismiss()
}
}

View File

@@ -4,6 +4,7 @@ import { debounceTime } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { LoginService } from '../services/login.service'
import { Config, Version } from '../api'
export class SocketProxy {
connect$ = new Subject<void>()
@@ -86,10 +87,9 @@ export class SocketProxy {
@Injectable({ providedIn: 'root' })
export class AppConnectorService {
config: any
version: any
user: any
private configUpdate = new Subject<string>()
private config: Config
private version: Version
constructor (
private http: HttpClient,
@@ -101,6 +101,11 @@ export class AppConnectorService {
})
}
setState (config: Config, version: Version) {
this.config = config
this.version = version
}
async loadConfig (): Promise<string> {
return this.config.content
}

View File

@@ -0,0 +1,83 @@
import * as semverGT from 'semver/functions/gt'
import { AsyncSubject, Subject } from 'rxjs'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Config, User, Version } from '../api'
import { LoginService } from './login.service'
@Injectable({ providedIn: 'root' })
export class ConfigService {
activeConfig$ = new Subject<Config>()
activeVersion$ = new Subject<Version>()
user: User
configs: Config[] = []
versions: Version[] = []
ready$ = new AsyncSubject<void>()
get activeConfig (): Config { return this._activeConfig }
get activeVersion (): Version { return this._activeVersion }
private _activeConfig: Config|null = null
private _activeVersion: Version|null = null
constructor (
private http: HttpClient,
private loginService: LoginService,
) {
this.init()
}
async updateUser () {
await this.http.put('/api/1/user', this.user).toPromise()
}
async createNewConfig () {
this.configs.push(await this.http.post('/api/1/configs', {
content: '{}',
last_used_with_version: this._activeVersion.version,
}).toPromise())
}
async duplicateActiveConfig () {
const copy = {...this._activeConfig, pk: undefined}
this.configs.push(await this.http.post('/api/1/configs', copy).toPromise())
}
async selectVersion (version: Version) {
this._activeVersion = version
this.activeVersion$.next(version)
}
async selectConfig (config: Config) {
let matchingVersion = this.versions.find(x => x.version === config.last_used_with_version)
if (!matchingVersion) {
// TODO ask to upgrade
matchingVersion = this.versions[0]
}
this._activeConfig = config
this.activeConfig$.next(config)
this.selectVersion(matchingVersion)
}
async selectDefaultConfig () {
await this.ready$.toPromise()
await this.loginService.ready$.toPromise()
this.selectConfig(this.configs.find(c => c.id === this.loginService.user.active_config) ?? this.configs[0])
}
private async init () {
this.configs = await this.http.get('/api/1/configs').toPromise()
this.versions = await this.http.get('/api/1/versions').toPromise()
this.versions.sort((a, b) => semverGT(a, b))
if (!this.configs.length) {
await this.createNewConfig()
}
this.ready$.next()
this.ready$.complete()
}
}

View File

@@ -1,20 +1,29 @@
import { AsyncSubject } from 'rxjs'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { User } from '../api'
@Injectable({ providedIn: 'root' })
export class LoginService {
user: any
user: User
ready$ = new AsyncSubject<void>()
constructor (private http: HttpClient) {
this.init()
}
async updateUser () {
await this.http.put('/api/1/user', this.user).toPromise()
}
private async init () {
const user = await this.http.get('/api/1/user').toPromise()
this.user = user
try {
this.user = await this.http.get('/api/1/user').toPromise()
} catch {
this.user = null
}
this.ready$.next()
this.ready$.complete()
}

View File

@@ -1,36 +1,6 @@
import './terminal-styles.scss'
Object.assign(window, {
// Buffer,
process: {
env: { },
argv: ['terminus'],
platform: 'darwin',
on: () => null,
stdout: {},
stderr: {},
resourcesPath: 'resources',
version: '14.0.0',
nextTick: (f, ...args) => setTimeout(() => f(...args)),
// cwd: () => '/',
},
global: window,
})
async function start () {
const modules = { }
Object.assign(window, {
require: (path) => {
if (modules[path]) {
return modules[path]
}
console.error('requiring real module', path)
},
})
await new Promise<void>(resolve => {
window.addEventListener('message', event => {
if (event.data === 'connector-ready') {
@@ -40,10 +10,11 @@ async function start () {
window.parent.postMessage('request-connector', '*')
})
const appVersion = window['__connector__'].getAppVersion()
const connector = window['__connector__']
async function loadPlugin (name, file = 'index.js') {
const url = `../app-dist/${appVersion}/${name}/dist/${file}`
const appVersion = connector.getAppVersion()
async function webRequire (url) {
console.log(`Loading ${url}`)
const e = document.createElement('script')
window['module'] = { exports: {} } as any
@@ -56,8 +27,11 @@ async function start () {
return window['module'].exports
}
await loadPlugin('web', 'preload.js')
await loadPlugin('web', 'bundle.js')
const baseUrl = `../app-dist/${appVersion}`
await webRequire(`${baseUrl}/web/dist/preload.js`)
await webRequire(`${baseUrl}/web/dist/bundle.js`)
const terminus = window['Terminus']
const pluginModules = []
for (const plugin of [
@@ -68,15 +42,13 @@ async function start () {
'terminus-community-color-schemes',
'terminus-web',
]) {
const mod = await loadPlugin(plugin)
modules[`resources/builtin-plugins/${plugin}`] = modules[plugin] = mod
pluginModules.push(mod)
pluginModules.push(await terminus.loadPlugin(`${baseUrl}/${plugin}`))
}
document.querySelector('app-root')['style'].display = 'flex'
const config = window['__connector__'].loadConfig()
window['bootstrapTerminus'](pluginModules, {
const config = connector.loadConfig()
terminus.bootstrap(pluginModules, {
config,
executable: 'web',
isFirstWindow: true,

View File

@@ -29,7 +29,7 @@
@import "~bootstrap/scss/list-group";
// @import "~bootstrap/scss/close";
// @import "~bootstrap/scss/toasts";
// @import "~bootstrap/scss/modal";
@import "~bootstrap/scss/modal";
// @import "~bootstrap/scss/tooltip";
// @import "~bootstrap/scss/popover";
// @import "~bootstrap/scss/carousel";
@@ -62,3 +62,7 @@
.dropdown-menu {
box-shadow: $dropdown-box-shadow;
}
.modal-footer {
background: #00000030;
}

View File

@@ -77,7 +77,7 @@ $link-hover-color: $white;
$link-hover-decoration: none;
$component-active-color: $white;
$component-active-bg: #2f3a42;
$component-active-bg: $blue;
$list-group-bg: $table-bg;
$list-group-border-color: $table-border-color;
@@ -113,7 +113,7 @@ $popover-max-width: 360px;
$btn-border-width: 2px;
$input-bg: #181e23;
$input-bg: $black;
$input-disabled-bg: #2e3235;
$input-color: #ddd;
@@ -129,6 +129,8 @@ $input-group-addon-bg: $input-bg;
$input-group-addon-border-color: transparent;
$input-group-btn-border-color: $input-bg;
$form-switch-color: rgba(255,255,255, .25);
$nav-tabs-border-radius: 0;
$nav-tabs-border-color: transparent;
$nav-tabs-border-width: 2px;
@@ -179,8 +181,7 @@ $custom-control-indicator-active-bg: rgba(255, 255, 0, 0.5);
$modal-content-bg: $body-bg;
$modal-content-border-color: $body-bg;
$modal-header-border-width: 0;
$modal-footer-border-color: #222;
$modal-footer-border-width: 1px;
$modal-footer-border-width: 0;
$modal-content-border-width: 0;
$progress-bg: $table-bg;