import { Component, ViewChild } from '@angular/core' import { AlertController, NavController, ModalController, IonContent, LoadingController } from '@ionic/angular' import { ApiService } from 'src/app/services/api/embassy-api.service' import { ActivatedRoute, NavigationExtras } from '@angular/router' import { DependentInfo, exists, isEmptyObject } from 'src/app/util/misc.util' import { combineLatest, Subscription } from 'rxjs' import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component' import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards' import { ConfigService } from 'src/app/services/config.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { DependencyError, DependencyErrorType, HealthCheckResult, HealthResult, PackageDataEntry, PackageMainStatus, PackageState } from 'src/app/services/patch-db/data-model' import { DependencyStatus, HealthStatus, PrimaryRendering, PrimaryStatus, renderPkgStatus } from 'src/app/services/pkg-status-rendering.service' import { ConnectionFailure, ConnectionService } from 'src/app/services/connection.service' import { ErrorToastService } from 'src/app/services/error-toast.service' import { AppConfigPage } from 'src/app/modals/app-config/app-config.page' import { PackageLoadingService, ProgressData } from 'src/app/services/package-loading.service' import { filter } from 'rxjs/operators' import { MarkdownPage } from 'src/app/modals/markdown/markdown.page' @Component({ selector: 'app-show', templateUrl: './app-show.page.html', styleUrls: ['./app-show.page.scss'], }) export class AppShowPage { PackageState = PackageState DependencyErrorType = DependencyErrorType Math = Math HealthResult = HealthResult PS = PrimaryStatus DS = DependencyStatus PR = PrimaryRendering pkgId: string pkg: PackageDataEntry hideLAN: boolean buttons: Button[] = [] dependencies: DependencyInfo[] = [] statuses: { primary: PrimaryStatus dependency: DependencyStatus health: HealthStatus } = { } as any connectionFailure: boolean loading = true healthChecks: { [id: string]: HealthCheckResult | null } installProgress: ProgressData @ViewChild(IonContent) content: IonContent subs: Subscription[] = [] constructor ( private readonly alertCtrl: AlertController, private readonly route: ActivatedRoute, private readonly navCtrl: NavController, private readonly errToast: ErrorToastService, private readonly loadingCtrl: LoadingController, private readonly modalCtrl: ModalController, private readonly embassyApi: ApiService, private readonly wizardBaker: WizardBaker, private readonly config: ConfigService, private readonly packageLoadingService: PackageLoadingService, private readonly patch: PatchDbService, private readonly connectionService: ConnectionService, ) { } async ngOnInit () { this.pkgId = this.route.snapshot.paramMap.get('pkgId') this.pkg = this.patch.getData()['package-data'][this.pkgId] this.statuses = renderPkgStatus(this.pkg) this.healthChecks = Object.keys(this.pkg.manifest['health-checks']).reduce((obj, key) => { obj[key] = null return obj }, { }) this.subs = [ // 1 this.patch.watch$('package-data', this.pkgId) .subscribe(pkg => { // if package disappears, navigate to list page if (!pkg) { this.navCtrl.navigateRoot('/services') return } this.pkg = pkg this.statuses = renderPkgStatus(pkg) this.installProgress = !isEmptyObject(pkg['install-progress']) ? this.packageLoadingService.transform(pkg['install-progress']) : undefined }), // 2 combineLatest([ this.patch.watch$('package-data', this.pkgId, 'installed', 'current-dependencies'), this.patch.watch$('package-data', this.pkgId, 'installed', 'status', 'dependency-errors'), ]) .pipe( filter(([currentDeps, depErrors]) => exists(currentDeps) && exists(depErrors)), ) .subscribe(([currentDeps, depErrors]) => { this.dependencies = Object.keys(currentDeps) .filter(id => !!this.pkg.manifest.dependencies[id]) .map(id => { return this.setDepValues(id, depErrors) }) }), // 3 this.patch.watch$('package-data', this.pkgId, 'installed', 'status', 'main') .pipe( filter(obj => exists(obj)), ) .subscribe(main => { if (main.status === PackageMainStatus.Running) { Object.keys(this.healthChecks).forEach(key => { this.healthChecks[key] = main.health[key] }) } else { Object.keys(this.healthChecks).forEach(key => { this.healthChecks[key] = null }) } }), // 4 this.connectionService.watchFailure$() .subscribe(connectionFailure => { this.connectionFailure = connectionFailure !== ConnectionFailure.None }), ] this.setButtons() } ngAfterViewInit () { this.content.scrollToPoint(undefined, 1) } ngOnDestroy () { this.subs.forEach(sub => sub.unsubscribe()) } launchUi (): void { window.open(this.config.launchableURL(this.pkg), '_blank', 'noreferrer') } async stop (): Promise { const { id, title, version } = this.pkg.manifest if (isEmptyObject(this.pkg.installed['current-dependents'])) { const loader = await this.loadingCtrl.create({ message: `Stopping...`, spinner: 'lines', cssClass: 'loader', }) await loader.present() try { await this.embassyApi.stopPackage({ id }) } catch (e) { this.errToast.present(e) } finally { loader.dismiss() } } else { wizardModal( this.modalCtrl, this.wizardBaker.stop({ id, title, version, }), ) } } async tryStart (): Promise { const message = this.pkg.manifest.alerts.start if (message) { this.presentAlertStart(message) } else { this.start() } } async donate (): Promise { const url = this.pkg.manifest['donation-url'] if (url) { window.open(url, '_blank', 'noreferrer') } else { const alert = await this.alertCtrl.create({ header: 'Not Accepting Donations', message: `The developers of ${this.pkg.manifest.title} have not provided a donation URL. Please contact them directly if you insist on giving them money.`, }) await alert.present() } } async fixDep (action: 'install' | 'update' | 'configure', id: string): Promise { switch (action) { case 'install': case 'update': return this.installDep(id) case 'configure': return this.configureDep(id) } } async presentModalConfig (props: { pkgId: string, dependentInfo?: DependentInfo }): Promise { const modal = await this.modalCtrl.create({ component: AppConfigPage, componentProps: props, }) await modal.present() } async presentModalInstructions () { const modal = await this.modalCtrl.create({ componentProps: { title: 'Instructions', contentUrl: this.pkg['static-files']['instructions'], }, component: MarkdownPage, }) await modal.present() } private setDepValues (id: string, errors: { [id: string]: DependencyError }): DependencyInfo { let errorText = '' let actionText = 'View' let action: () => any = () => this.navCtrl.navigateForward(`/services/${id}`) const error = errors[id] if (error) { // health checks failed if ([DependencyErrorType.InterfaceHealthChecksFailed, DependencyErrorType.HealthChecksFailed].includes(error.type)) { errorText = 'Health check failed' // not installed } else if (error.type === DependencyErrorType.NotInstalled) { errorText = 'Not installed' actionText = 'Install' action = () => this.fixDep('install', id) // incorrect version } else if (error.type === DependencyErrorType.IncorrectVersion) { errorText = 'Incorrect version' actionText = 'Update' action = () => this.fixDep('update', id) // not running } else if (error.type === DependencyErrorType.NotRunning) { errorText = 'Not running' actionText = 'Start' // config unsatisfied } else if (error.type === DependencyErrorType.ConfigUnsatisfied) { errorText = 'Config not satisfied' actionText = 'Auto config' action = () => this.fixDep('configure', id) } else if (error.type === DependencyErrorType.Transitive) { errorText = 'Dependency has a dependency issue' } } const depInfo = this.pkg.installed['dependency-info'][id] return { id, version: this.pkg.manifest.dependencies[id].version, title: depInfo.manifest.title, icon: depInfo.icon, errorText, actionText, action, } } private async installDep (depId: string): Promise { const version = this.pkg.manifest.dependencies[depId].version const dependentInfo: DependentInfo = { id: this.pkgId, title: this.pkg.manifest.title, version, } const navigationExtras: NavigationExtras = { state: { dependentInfo }, } await this.navCtrl.navigateForward(`/marketplace/${depId}`, navigationExtras) } private async configureDep (dependencyId: string): Promise { const dependentInfo: DependentInfo = { id: this.pkgId, title: this.pkg.manifest.title, } await this.presentModalConfig({ pkgId: dependencyId, dependentInfo, }) } private async presentAlertStart (message: string): Promise { const alert = await this.alertCtrl.create({ header: 'Warning', message, buttons: [ { text: 'Cancel', role: 'cancel', }, { text: 'Start', handler: () => { this.start() }, cssClass: 'enter-click', }, ], }) await alert.present() } private async start (): Promise { const loader = await this.loadingCtrl.create({ message: `Starting...`, spinner: 'lines', cssClass: 'loader', }) await loader.present() try { await this.embassyApi.startPackage({ id: this.pkgId }) } catch (e) { this.errToast.present(e) } finally { loader.dismiss() } } private setButtons (): void { const pkgTitle = this.pkg.manifest.title this.buttons = [ // instructions { action: () => this.presentModalInstructions(), title: 'Instructions', description: `Understand how to use ${pkgTitle}`, icon: 'list-outline', color: 'danger', }, // config { action: async () => this.presentModalConfig({ pkgId: this.pkgId }), title: 'Config', description: `Customize ${pkgTitle}`, icon: 'construct-outline', color: 'danger', }, // properties { action: () => this.navCtrl.navigateForward(['properties'], { relativeTo: this.route }), title: 'Properties', description: 'Runtime information, credentials, and other values of interest', icon: 'briefcase-outline', color: 'danger', }, // actions { action: () => this.navCtrl.navigateForward(['actions'], { relativeTo: this.route }), title: 'Actions', description: `Uninstall and other commands specific to ${pkgTitle}`, icon: 'flash-outline', color: 'danger', }, // interfaces { action: () => this.navCtrl.navigateForward(['interfaces'], { relativeTo: this.route }), title: 'Interfaces', description: 'User and machine access points', icon: 'desktop-outline', color: 'danger', }, // metrics // { // action: () => this.navCtrl.navigateForward(['metrics'], { relativeTo: this.route }), // title: 'Monitor', // description: 'View system usage', // icon: 'pulse-outline', // color: 'danger', // }, // logs { action: () => this.navCtrl.navigateForward(['logs'], { relativeTo: this.route }), title: 'Logs', description: 'Raw, unfiltered service logs', icon: 'receipt-outline', color: 'danger', }, { action: () => this.donate(), title: 'Donate', description: `Support ${pkgTitle}`, icon: 'logo-bitcoin', color: 'danger', }, ] } asIsOrder () { return 0 } } interface DependencyInfo { id: string title: string icon: string version: string errorText: string actionText: string action: () => any } interface Button { title: string description: string icon: string color: string action: Function }