refactor(app-show): refactor component (#961)

* refactor(app-show): refactor component

* chore: remove precommit hook for the time being

* chore: fix mutation by spreading

Co-authored-by: Drew Ansbacher <drew.ansbacher@spiredigital.com>
This commit is contained in:
Alex Inkin
2021-12-29 18:27:17 +03:00
committed by Aiden McClelland
parent dbc159c82e
commit 5e681aa3fb
42 changed files with 2446 additions and 784 deletions

6
ui/.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": false,
"arrowParens": "avoid",
"trailingComma": "all"
}

View File

@@ -94,10 +94,7 @@
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
},
"ionic-cordova-build": {

1356
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,6 +37,13 @@
"uuid": "^8.3.2",
"zone.js": "^0.11.4"
},
"lint-staged": {
"*.{js,ts,html,md,less,json}": [
"prettier --write",
"git add"
],
"*.ts": "tslint"
},
"devDependencies": {
"@angular-devkit/build-angular": "^12.2.4",
"@angular/cli": "^12.2.4",
@@ -49,7 +56,10 @@
"@types/mustache": "^4.1.2",
"@types/node": "^16.7.13",
"@types/uuid": "^8.3.1",
"husky": "^4.3.8",
"lint-staged": "^12.1.2",
"node-html-parser": "^4.1.4",
"prettier": "^2.5.1",
"raw-loader": "^4.0.2",
"ts-node": "^10.2.0",
"tslint": "^6.1.3",

View File

@@ -8,9 +8,7 @@ import { HttpClientModule } from '@angular/common/http'
import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module'
import { ApiService } from './services/api/embassy-api.service'
import { ApiServiceFactory } from './services/api/api.service.factory'
import { PatchDbServiceFactory } from './services/patch-db/patch-db.factory'
import { HttpService } from './services/http.service'
import { ConfigService } from './services/config.service'
import { QrCodeModule } from 'ng-qrcode'
import { OSWelcomePageModule } from './modals/os-welcome/os-welcome.module'
@@ -22,6 +20,10 @@ import { FormBuilder } from '@angular/forms'
import { GenericInputComponentModule } from './modals/generic-input/generic-input.component.module'
import { AuthService } from './services/auth.service'
import { GlobalErrorHandler } from './services/global-error-handler.service'
import { MockApiService } from './services/api/embassy-mock-api.service'
import { LiveApiService } from './services/api/embassy-live-api.service'
const { mocks } = require('../../config.json')
@NgModule({
declarations: [AppComponent],
@@ -54,8 +56,7 @@ import { GlobalErrorHandler } from './services/global-error-handler.service'
},
{
provide: ApiService,
useFactory: ApiServiceFactory,
deps: [ConfigService, HttpService, LocalStorageBootstrap],
useClass: mocks.enabled ? MockApiService : LiveApiService,
},
{
provide: PatchDbService,

View File

@@ -24,7 +24,7 @@
slot="end"
fill="clear"
color="primary"
(click)="launchUi(pkg.entry)"
(click)="launchUi()"
[disabled]="!(pkg.entry.state | isLaunchable: status:manifest.interfaces)"
>
<ion-icon slot="icon-only" name="open-outline"></ion-icon>

View File

@@ -1,47 +1,40 @@
import { DOCUMENT } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
Inject,
Input,
} from "@angular/core";
} from '@angular/core'
import {
PackageMainStatus,
PackageDataEntry, Manifest,
} from "src/app/services/patch-db/data-model";
import { ConfigService } from "src/app/services/config.service";
import { PkgInfo } from "src/app/util/get-package-info";
PackageDataEntry,
Manifest,
} from 'src/app/services/patch-db/data-model'
import { PkgInfo } from 'src/app/util/get-package-info'
import { UiLauncherService } from 'src/app/services/ui-launcher.service'
@Component({
selector: "app-list-pkg",
templateUrl: "app-list-pkg.component.html",
selector: 'app-list-pkg',
templateUrl: 'app-list-pkg.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListPkgComponent {
@Input()
pkg: PkgInfo;
pkg: PkgInfo
@Input()
connectionFailure = false;
connectionFailure = false
constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly config: ConfigService
) {}
constructor(private readonly launcherService: UiLauncherService) {}
get status(): PackageMainStatus {
return this.pkg.entry.installed?.status.main.status;
return this.pkg.entry.installed?.status.main.status
}
get manifest(): Manifest {
return this.pkg.entry.manifest;
return this.pkg.entry.manifest
}
launchUi(pkg: PackageDataEntry): void {
this.document.defaultView.open(
this.config.launchableURL(pkg),
"_blank",
"noreferrer"
);
launchUi(): void {
this.launcherService.launch(this.pkg.entry)
}
}

View File

@@ -2,12 +2,23 @@ import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppShowPage, HealthColorPipe } from './app-show.page'
import { AppShowPage } from './app-show.page'
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
import { InstallWizardComponentModule } from 'src/app/components/install-wizard/install-wizard.component.module'
import { AppConfigPageModule } from 'src/app/modals/app-config/app-config.module'
import { MarkdownPageModule } from 'src/app/modals/markdown/markdown.module'
import { AppShowHeaderComponent } from './components/app-show-header/app-show-header.component'
import { AppShowProgressComponent } from './components/app-show-progress/app-show-progress.component'
import { AppShowStatusComponent } from './components/app-show-status/app-show-status.component'
import { AppShowDependenciesComponent } from './components/app-show-dependencies/app-show-dependencies.component'
import { AppShowMenuComponent } from './components/app-show-menu/app-show-menu.component'
import { AppShowHealthChecksComponent } from './components/app-show-health-checks/app-show-health-checks.component'
import { HealthColorPipe } from './pipes/health-color.pipe'
import { ToHealthChecksPipe } from './pipes/to-health-checks.pipe'
import { ToButtonsPipe } from './pipes/to-buttons.pipe'
import { ToDependenciesPipe } from './pipes/to-dependencies.pipe'
import { ToStatusPipe } from './pipes/to-status.pipe'
const routes: Routes = [
{
@@ -20,6 +31,16 @@ const routes: Routes = [
declarations: [
AppShowPage,
HealthColorPipe,
ToHealthChecksPipe,
ToButtonsPipe,
ToDependenciesPipe,
ToStatusPipe,
AppShowHeaderComponent,
AppShowProgressComponent,
AppShowStatusComponent,
AppShowDependenciesComponent,
AppShowMenuComponent,
AppShowHealthChecksComponent,
],
imports: [
CommonModule,
@@ -32,5 +53,4 @@ const routes: Routes = [
MarkdownPageModule,
],
})
export class AppShowPageModule { }
export class AppShowPageModule {}

View File

@@ -1,158 +1,40 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="services"></ion-back-button>
</ion-buttons>
<ion-item lines="none" color="light">
<ion-avatar slot="start">
<img [src]="pkg['static-files'].icon" />
</ion-avatar>
<ion-label style="text-overflow: ellipsis;">
<h1 style="font-family: 'Montserrat';" [class.less-large]="pkg.manifest.title.length > 20">
{{ pkg.manifest.title }}
</h1>
<h2>{{ pkg.manifest.version | displayEmver }}</h2>
</ion-label>
</ion-item>
</ion-toolbar>
</ion-header>
<ng-container *ngIf="pkg$ | async as pkg">
<app-show-header [pkg]="pkg"></app-show-header>
<ion-content>
<ion-item-group>
<!-- ** status ** -->
<ion-item-divider>Status</ion-item-divider>
<ion-item>
<ion-label style="overflow: visible;">
<status
[disconnected]="connectionFailure"
size="x-large"
weight="500"
[installProgress]="installProgress?.totalProgress"
[rendering]="PR[statuses.primary]"
[sigtermTimeout]="pkg.manifest.main['sigterm-timeout']"
></status>
</ion-label>
<ng-container *ngIf="pkg.state === PackageState.Installed && !connectionFailure">
<ion-button slot="end" class="action-button" *ngIf="pkg.manifest.interfaces | hasUi" [disabled]="!(pkg.state | isLaunchable : pkg.installed.status.main.status : pkg.manifest.interfaces)" (click)="launchUi()">
Launch UI
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
<ion-button slot="end" class="action-button" *ngIf="!pkg.installed.status.configured" (click)="presentModalConfig({ pkgId })">
Configure
</ion-button>
<ion-button slot="end" class="action-button" *ngIf="statuses.primary === PS.Running" color="danger" (click)="stop()">
Stop
</ion-button>
<ion-button slot="end" class="action-button" *ngIf="statuses.primary === PS.Stopped && pkg.installed.status.configured" color="success" (click)="tryStart()">
Start
</ion-button>
</ng-container>
</ion-item>
<!-- ** installed ** -->
<ng-container *ngIf="pkg.state === PackageState.Installed">
<!-- ** !backing-up ** -->
<ng-container *ngIf="statuses.primary !== PS.BackingUp">
<ion-content *ngIf="pkg | toDependencies | async as dependencies">
<ion-item-group *ngIf="pkg | toStatus as status">
<!-- ** status ** -->
<app-show-status
[pkg]="pkg"
[connectionFailure]="connectionFailure$ | async"
[dependencies]="dependencies"
[status]="status"
></app-show-status>
<!-- ** installed && !backing-up ** -->
<ng-container *ngIf="isInstalled(pkg, status)">
<!-- ** health checks ** -->
<ng-container *ngIf="statuses.primary === PS.Running && !(healthChecks | empty)">
<ion-item-divider>Health Checks</ion-item-divider>
<ng-container *ngIf="connectionFailure">
<ion-item *ngFor="let health of healthChecks | keyvalue">
<ion-avatar slot="start">
<ion-skeleton-text style="width: 20px; height: 20px; border-radius: 0;"></ion-skeleton-text>
</ion-avatar>
<ion-label>
<ion-skeleton-text style="width: 100px; margin-bottom: 10px;"></ion-skeleton-text>
<ion-skeleton-text style="width: 150px; margin-bottom: 10px;"></ion-skeleton-text>
</ion-label>
</ion-item>
</ng-container>
<ng-container *ngIf="!connectionFailure">
<ion-item button *ngFor="let health of healthChecks | keyvalue : asIsOrder" (click)="presentAlertDescription(health.key)">
<ng-container *ngIf="health.value?.result as result; else noResult">
<ion-spinner class="icon-spinner" color="primary" slot="start" *ngIf="[HealthResult.Starting, HealthResult.Loading] | includes : result"></ion-spinner>
<ion-icon slot="start" *ngIf="result === HealthResult.Success" name="checkmark" color="success"></ion-icon>
<ion-icon slot="start" *ngIf="result === HealthResult.Failure" name="warning-outline" color="warning"></ion-icon>
<ion-icon slot="start" *ngIf="result === HealthResult.Disabled" name="remove" color="dark"></ion-icon>
<ion-label>
<h2 style="font-weight: bold;">{{ pkg.manifest['health-checks'][health.key].name }}</h2>
<ion-text [color]="result | healthColor">
<p>
<span *ngIf="!([HealthResult.Failure, HealthResult.Loading] | includes : result)">{{ result | titlecase }}</span>
<span *ngIf="result === HealthResult.Starting">...</span>
<span *ngIf="result === HealthResult.Failure">{{ $any(health.value).error }}</span>
<span *ngIf="result === HealthResult.Loading">{{ $any(health.value).message }}</span>
</p>
</ion-text>
</ion-label>
</ng-container>
<ng-template #noResult>
<ion-spinner class="icon-spinner" color="dark" slot="start"></ion-spinner>
<ion-label>
<h2 style="font-weight: bold;">{{ health.key }}</h2>
<p>Awaiting result...</p>
</ion-label>
</ng-template>
</ion-item>
</ng-container>
</ng-container>
<app-show-health-checks
*ngIf="isRunning(status)"
[pkg]="pkg"
[connectionFailure]="connectionFailure$ | async"
></app-show-health-checks>
<!-- ** dependencies ** -->
<ng-container *ngIf="dependencies.length">
<ion-item-divider>Dependencies</ion-item-divider>
<!-- dependencies are a subset of the pkg.manifest.dependencies that are currently required as determined by the service config -->
<ion-item button *ngFor="let dep of dependencies" (click)="dep.action()">
<ion-thumbnail slot="start">
<img [src]="dep.icon" />
</ion-thumbnail>
<ion-label>
<h2 class="inline" style="font-family: 'Montserrat'">
<ion-icon style="padding-right: 4px;" *ngIf="!!dep.errorText" slot="start" name="warning-outline" color="warning"></ion-icon>
{{ dep.title }}
</h2>
<p>{{ dep.version | displayEmver }}</p>
<p>
<ion-text [color]="!!dep.errorText ? 'warning' : 'success'">{{ dep.errorText || 'satisfied' }}</ion-text>
</p>
</ion-label>
<ion-button *ngIf="dep.actionText" slot="end" fill="clear">
{{ dep.actionText }}
<ion-icon slot="end" name="arrow-forward"></ion-icon>
</ion-button>
</ion-item>
</ng-container>
<app-show-dependencies
*ngIf="dependencies.length"
[dependencies]="dependencies"
></app-show-dependencies>
<!-- ** menu ** -->
<ion-item-divider>Menu</ion-item-divider>
<ion-item button detail *ngFor="let button of buttons" (click)="button.action()">
<ion-icon slot="start" [name]="button.icon"></ion-icon>
<ion-label>
<h2>{{ button.title }}</h2>
<p *ngIf="button.description">{{ button.description }}</p>
</ion-label>
</ion-item>
<app-show-menu [buttons]="pkg | toButtons"></app-show-menu>
</ng-container>
</ion-item-group>
<!-- ** installing, updating, restoring ** -->
<ng-container *ngIf="showProgress(pkg)">
<app-show-progress
*ngIf="pkg['install-progress'] | installState as installProgress"
[pkg]="pkg"
[installProgress]="installProgress"
></app-show-progress>
</ng-container>
</ion-item-group>
<!-- ** installing, updating, restoring ** -->
<div *ngIf="([PackageState.Installing, PackageState.Updating, PackageState.Restoring] | includes : pkg.state) && installProgress" style="padding: 16px;">
<p>Downloading: {{ installProgress.downloadProgress }}%</p>
<ion-progress-bar
[color]="pkg['install-progress']['download-complete'] ? 'success' : 'secondary'"
[value]="installProgress.downloadProgress / 100"
[buffer]="!installProgress.downloadProgress ? 0 : 1"
></ion-progress-bar>
<p>Validating: {{ installProgress.validateProgress }}%</p>
<ion-progress-bar
[color]="pkg['install-progress']['validation-complete'] ? 'success' : 'secondary'"
[value]="installProgress.validateProgress / 100"
[buffer]="installProgress.downloadProgress === 100 && !installProgress.validateProgress ? 0 : 1"
></ion-progress-bar>
<p>Unpacking: {{ installProgress.unpackProgress }}%</p>
<ion-progress-bar
[color]="pkg['install-progress']['unpack-complete'] ? 'success' : 'secondary'"
[value]="installProgress.unpackProgress / 100"
[buffer]="installProgress.validateProgress === 100 && !installProgress.unpackProgress ? 0 : 1"
></ion-progress-bar>
</div>
</ion-content>
</ion-content>
</ng-container>

View File

@@ -1,14 +0,0 @@
.less-large {
font-size: 18px !important;
}
.action-button {
margin: 10px;
min-height: 36px;
min-width: 120px;
}
.icon-spinner {
height: 20px;
width: 20px;
}

View File

@@ -1,469 +1,73 @@
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 { ChangeDetectionStrategy, Component } from '@angular/core'
import { NavController } from '@ionic/angular'
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 { filter } from 'rxjs/operators'
import { MarkdownPage } from 'src/app/modals/markdown/markdown.page'
import { Pipe, PipeTransform } from '@angular/core'
import { packageLoadingProgress, ProgressData } from 'src/app/util/package-loading-progress'
import {
PackageDataEntry,
PackageState,
} from 'src/app/services/patch-db/data-model'
import {
PackageStatus,
PrimaryStatus,
} from 'src/app/services/pkg-status-rendering.service'
import {
ConnectionFailure,
ConnectionService,
} from 'src/app/services/connection.service'
import { map, startWith } from 'rxjs/operators'
import { ActivatedRoute } from '@angular/router'
const STATES = [
PackageState.Installing,
PackageState.Updating,
PackageState.Restoring,
]
@Component({
selector: 'app-show',
templateUrl: './app-show.page.html',
styleUrls: ['./app-show.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowPage {
PackageState = PackageState
DependencyErrorType = DependencyErrorType
Math = Math
HealthResult = HealthResult
PS = PrimaryStatus
DS = DependencyStatus
PR = PrimaryRendering
private readonly pkgId = this.route.snapshot.paramMap.get('pkgId')
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
readonly pkg$ = this.patch.watch$('package-data', this.pkgId).pipe(
map(pkg => {
// if package disappears, navigate to list page
if (!pkg) {
this.navCtrl.navigateRoot('/services')
}
@ViewChild(IonContent) content: IonContent
subs: Subscription[] = []
return { ...pkg }
}),
startWith(this.patch.getData()['package-data'][this.pkgId]),
)
constructor (
private readonly alertCtrl: AlertController,
readonly connectionFailure$ = this.connectionService
.watchFailure$()
.pipe(map(failure => failure !== ConnectionFailure.None))
constructor(
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 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']) ? packageLoadingProgress(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()
isInstalled(
{ state }: PackageDataEntry,
{ primary }: PackageStatus,
): boolean {
return (
state === PackageState.Installed && primary !== PrimaryStatus.BackingUp
)
}
ngAfterViewInit () {
this.content.scrollToPoint(undefined, 1)
isRunning({ primary }: PackageStatus): boolean {
return primary === PrimaryStatus.Running
}
ngOnDestroy () {
this.subs.forEach(sub => sub.unsubscribe())
}
launchUi (): void {
window.open(this.config.launchableURL(this.pkg), '_blank', 'noreferrer')
}
async stop (): Promise<void> {
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<void> {
if (this.dependencies.some(d => !!d.errorText)) {
const depErrMsg = `${this.pkg.manifest.title} has unmet dependencies. It will not work as expected.`
const proceed = await this.presentAlertStart(depErrMsg)
if (!proceed) return
}
const alertMsg = this.pkg.manifest.alerts.start
if (!!alertMsg) {
const proceed = await this.presentAlertStart(alertMsg)
if (!proceed) return
}
this.start()
}
async donate (): Promise<void> {
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<void> {
switch (action) {
case 'install':
case 'update':
return this.installDep(id)
case 'configure':
return this.configureDep(id)
}
}
async presentModalConfig (props: { pkgId: string, dependentInfo?: DependentInfo }): Promise<void> {
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()
}
async presentAlertDescription (id: string) {
const health = this.pkg.manifest['health-checks'][id]
const alert = await this.alertCtrl.create({
header: 'Health Check',
subHeader: health.name,
message: health.description,
buttons: [
{
text: `OK`,
handler: () => {
alert.dismiss()
},
cssClass: 'enter-click',
},
],
})
await alert.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'
}
errorText = `${errorText}. ${ this.pkg.manifest.title} will not work as expected.`
}
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<void> {
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<void> {
const dependentInfo: DependentInfo = {
id: this.pkgId,
title: this.pkg.manifest.title,
}
await this.presentModalConfig({
pkgId: dependencyId,
dependentInfo,
})
}
private async presentAlertStart (message: string): Promise<boolean> {
return new Promise(async resolve => {
const alert = await this.alertCtrl.create({
header: 'Warning',
message,
buttons: [
{
text: 'Cancel',
role: 'cancel',
handler: () => {
resolve(false)
},
},
{
text: 'Continue',
handler: () => {
resolve(true)
},
cssClass: 'enter-click',
},
],
})
await alert.present()
})
}
private async start (): Promise<void> {
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',
},
// config
{
action: async () => this.presentModalConfig({ pkgId: this.pkgId }),
title: 'Config',
description: `Customize ${pkgTitle}`,
icon: 'construct-outline',
},
// properties
{
action: () => this.navCtrl.navigateForward(['properties'], { relativeTo: this.route }),
title: 'Properties',
description: 'Runtime information, credentials, and other values of interest',
icon: 'briefcase-outline',
},
// actions
{
action: () => this.navCtrl.navigateForward(['actions'], { relativeTo: this.route }),
title: 'Actions',
description: `Uninstall and other commands specific to ${pkgTitle}`,
icon: 'flash-outline',
},
// interfaces
{
action: () => this.navCtrl.navigateForward(['interfaces'], { relativeTo: this.route }),
title: 'Interfaces',
description: 'User and machine access points',
icon: 'desktop-outline',
},
{
action: () => this.navCtrl.navigateForward(['logs'], { relativeTo: this.route }),
title: 'Logs',
description: 'Raw, unfiltered service logs',
icon: 'receipt-outline',
},
{
action: () => this.navCtrl.navigateForward([`marketplace/${this.pkgId}`]),
title: 'Marketplace',
description: 'View service in marketplace',
icon: 'storefront-outline',
},
{
action: () => this.donate(),
title: 'Donate',
description: `Support ${pkgTitle}`,
icon: 'logo-bitcoin',
},
]
}
asIsOrder () {
return 0
showProgress({ state }: PackageDataEntry): boolean {
return STATES.includes(state)
}
}
interface DependencyInfo {
id: string
title: string
icon: string
version: string
errorText: string
actionText: string
action: () => any
}
interface Button {
title: string
description: string
icon: string
action: Function
}
@Pipe({
name: 'healthColor',
})
export class HealthColorPipe implements PipeTransform {
transform (val: HealthResult): string {
switch (val) {
case HealthResult.Success: return 'success'
case HealthResult.Failure: return 'warning'
case HealthResult.Disabled: return 'dark'
case HealthResult.Starting:
case HealthResult.Loading: return 'primary'
}
}
}

View File

@@ -0,0 +1,29 @@
<ion-item-divider>Dependencies</ion-item-divider>
<!-- dependencies are a subset of the pkg.manifest.dependencies that are currently required as determined by the service config -->
<ion-item button *ngFor="let dep of dependencies" (click)="dep.action()">
<ion-thumbnail slot="start">
<img [src]="dep.icon" alt="" />
</ion-thumbnail>
<ion-label>
<h2 class="inline">
<ion-icon
*ngIf="!!dep.errorText"
class="icon"
slot="start"
name="warning-outline"
color="warning"
></ion-icon>
{{ dep.title }}
</h2>
<p>{{ dep.version | displayEmver }}</p>
<p>
<ion-text [color]="dep.errorText ? 'warning' : 'success'">
{{ dep.errorText || 'satisfied' }}
</ion-text>
</p>
</ion-label>
<ion-button *ngIf="dep.actionText" slot="end" fill="clear">
{{ dep.actionText }}
<ion-icon slot="end" name="arrow-forward"></ion-icon>
</ion-button>
</ion-item>

View File

@@ -0,0 +1,7 @@
.inline {
font-family: 'Montserrat', sans-serif;
}
.icon {
padding-right: 4px;
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { DependencyInfo } from '../../pipes/to-dependencies.pipe'
@Component({
selector: 'app-show-dependencies',
templateUrl: './app-show-dependencies.component.html',
styleUrls: ['./app-show-dependencies.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowDependenciesComponent {
@Input()
dependencies: DependencyInfo[] = []
}

View File

@@ -0,0 +1,18 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="services"></ion-back-button>
</ion-buttons>
<ion-item lines="none" color="light">
<ion-avatar slot="start">
<img [src]="pkg['static-files'].icon" alt="" />
</ion-avatar>
<ion-label>
<h1 class="name" [class.less-large]="pkg.manifest.title.length > 20">
{{ pkg.manifest.title }}
</h1>
<h2>{{ pkg.manifest.version | displayEmver }}</h2>
</ion-label>
</ion-item>
</ion-toolbar>
</ion-header>

View File

@@ -0,0 +1,7 @@
.name {
font-family: 'Montserrat', sans-serif;
}
.less-large {
font-size: 18px !important;
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
@Component({
selector: 'app-show-header',
templateUrl: './app-show-header.component.html',
styleUrls: ['./app-show-header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowHeaderComponent {
@Input()
pkg: PackageDataEntry
}

View File

@@ -0,0 +1,78 @@
<ng-container
*ngIf="pkg | toHealthChecks | async | keyvalue: asIsOrder as checks"
>
<ion-item-divider>Health Checks</ion-item-divider>
<ng-container *ngIf="connectionFailure; else connected">
<ion-item *ngFor="let health of checks">
<ion-avatar slot="start">
<ion-skeleton-text class="avatar"></ion-skeleton-text>
</ion-avatar>
<ion-label>
<ion-skeleton-text class="label"></ion-skeleton-text>
<ion-skeleton-text class="description"></ion-skeleton-text>
</ion-label>
</ion-item>
</ng-container>
<ng-template #connected>
<ion-item
*ngFor="let health of checks"
button
(click)="presentAlertDescription(health.key)"
>
<ng-container *ngIf="health.value?.result as result; else noResult">
<ion-spinner
*ngIf="isLoading(result)"
class="icon-spinner"
color="primary"
slot="start"
></ion-spinner>
<ion-icon
*ngIf="result === HealthResult.Success"
slot="start"
name="checkmark"
color="success"
></ion-icon>
<ion-icon
*ngIf="result === HealthResult.Failure"
slot="start"
name="warning-outline"
color="warning"
></ion-icon>
<ion-icon
*ngIf="result === HealthResult.Disabled"
slot="start"
name="remove"
color="dark"
></ion-icon>
<ion-label>
<h2 class="bold">
{{ pkg.manifest['health-checks'][health.key].name }}
</h2>
<ion-text [color]="result | healthColor">
<p>
<span *ngIf="isReady(result)">{{ result | titlecase }}</span>
<span *ngIf="result === HealthResult.Starting">...</span>
<span *ngIf="result === HealthResult.Failure">
{{ $any(health.value).error }}
</span>
<span *ngIf="result === HealthResult.Loading">
{{ $any(health.value).message }}
</span>
</p>
</ion-text>
</ion-label>
</ng-container>
<ng-template #noResult>
<ion-spinner
class="icon-spinner"
color="dark"
slot="start"
></ion-spinner>
<ion-label>
<h2 class="bold">{{ health.key }}</h2>
<p>Awaiting result...</p>
</ion-label>
</ng-template>
</ion-item>
</ng-template>
</ng-container>

View File

@@ -0,0 +1,24 @@
.icon-spinner {
height: 20px;
width: 20px;
}
.avatar {
width: 20px;
height: 20px;
border-radius: 0;
}
.label {
width: 100px;
margin-bottom: 10px;
}
.description {
width: 150px;
margin-bottom: 10px;
}
.bold {
font-weight: bold;
}

View File

@@ -0,0 +1,56 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { AlertController } from '@ionic/angular'
import {
HealthResult,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
@Component({
selector: 'app-show-health-checks',
templateUrl: './app-show-health-checks.component.html',
styleUrls: ['./app-show-health-checks.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowHealthChecksComponent {
@Input()
pkg: PackageDataEntry
@Input()
connectionFailure = false
HealthResult = HealthResult
constructor(private readonly alertCtrl: AlertController) {}
isLoading(result: HealthResult): boolean {
return result === HealthResult.Starting || result === HealthResult.Loading
}
isReady(result: HealthResult): boolean {
return result !== HealthResult.Failure && result !== HealthResult.Loading
}
async presentAlertDescription(id: string) {
const health = this.pkg.manifest['health-checks'][id]
const alert = await this.alertCtrl.create({
header: 'Health Check',
subHeader: health.name,
message: health.description,
buttons: [
{
text: `OK`,
handler: () => {
alert.dismiss()
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
asIsOrder() {
return 0
}
}

View File

@@ -0,0 +1,13 @@
<ion-item-divider>Menu</ion-item-divider>
<ion-item
*ngFor="let button of buttons"
button
detail
(click)="button.action()"
>
<ion-icon slot="start" [name]="button.icon"></ion-icon>
<ion-label>
<h2>{{ button.title }}</h2>
<p *ngIf="button.description">{{ button.description }}</p>
</ion-label>
</ion-item>

View File

@@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { Button } from '../../pipes/to-buttons.pipe'
@Component({
selector: 'app-show-menu',
templateUrl: './app-show-menu.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowMenuComponent {
@Input()
buttons: Button[] = []
}

View File

@@ -0,0 +1,20 @@
<p>Downloading: {{ installProgress.downloadProgress }}%</p>
<ion-progress-bar
[color]="getColor('download-complete')"
[value]="installProgress.downloadProgress / 100"
[buffer]="!installProgress.downloadProgress ? 0 : 1"
></ion-progress-bar>
<p>Validating: {{ installProgress.validateProgress }}%</p>
<ion-progress-bar
[color]="getColor('validation-complete')"
[value]="installProgress.validateProgress / 100"
[buffer]="validationBuffer"
></ion-progress-bar>
<p>Unpacking: {{ installProgress.unpackProgress }}%</p>
<ion-progress-bar
[color]="getColor('unpack-complete')"
[value]="installProgress.unpackProgress / 100"
[buffer]="unpackingBuffer"
></ion-progress-bar>

View File

@@ -0,0 +1,4 @@
:host {
display: block;
padding: 16px;
}

View File

@@ -0,0 +1,38 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import {
InstallProgress,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { ProgressData } from 'src/app/util/package-loading-progress'
@Component({
selector: 'app-show-progress',
templateUrl: './app-show-progress.component.html',
styleUrls: ['./app-show-progress.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowProgressComponent {
@Input()
pkg: PackageDataEntry
@Input()
installProgress: ProgressData
get unpackingBuffer(): number {
return this.installProgress.validateProgress === 100 &&
!this.installProgress.unpackProgress
? 0
: 1
}
get validationBuffer(): number {
return this.installProgress.downloadProgress === 100 &&
!this.installProgress.validateProgress
? 0
: 1
}
getColor(action: keyof InstallProgress): string {
return this.pkg['install-progress'][action] ? 'success' : 'secondary'
}
}

View File

@@ -0,0 +1,51 @@
<ion-item-divider>Status</ion-item-divider>
<ion-item>
<ion-label class="label">
<status
size="x-large"
weight="500"
[disconnected]="connectionFailure"
[installProgress]="(progress | installState)?.totalProgress"
[rendering]="PR[status.primary]"
[sigtermTimeout]="pkg.manifest.main['sigterm-timeout']"
></status>
</ion-label>
<ng-container *ngIf="isInstalled">
<ion-button
*ngIf="interfaces | hasUi"
slot="end"
class="action-button"
[disabled]="!(pkg.state | isLaunchable: pkgStatus.main.status:interfaces)"
(click)="launchUi()"
>
Launch UI
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
<ion-button
*ngIf="!pkgStatus.configured"
slot="end"
class="action-button"
(click)="presentModalConfig()"
>
Configure
</ion-button>
<ion-button
*ngIf="isRunning"
slot="end"
class="action-button"
color="danger"
(click)="stop()"
>
Stop
</ion-button>
<ion-button
*ngIf="isStopped"
slot="end"
class="action-button"
color="success"
(click)="tryStart()"
>
Start
</ion-button>
</ng-container>
</ion-item>

View File

@@ -0,0 +1,9 @@
.label {
overflow: visible;
}
.action-button {
margin: 10px;
min-height: 36px;
min-width: 120px;
}

View File

@@ -0,0 +1,193 @@
import {
ChangeDetectionStrategy,
Component,
Inject,
Input,
} from '@angular/core'
import { UiLauncherService } from 'src/app/services/ui-launcher.service'
import {
InstallProgress,
InterfaceDef,
PackageDataEntry,
PackageState,
Status,
} from 'src/app/services/patch-db/data-model'
import {
PackageStatus,
PrimaryRendering,
PrimaryStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { isEmptyObject } from 'src/app/util/misc.util'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import {
AlertController,
LoadingController,
ModalController,
} from '@ionic/angular'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ModalService } from 'src/app/services/modal.service'
import { DependencyInfo } from '../../pipes/to-dependencies.pipe'
@Component({
selector: 'app-show-status',
templateUrl: './app-show-status.component.html',
styleUrls: ['./app-show-status.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowStatusComponent {
@Input()
pkg: PackageDataEntry
@Input()
connectionFailure = false
@Input()
status: PackageStatus
@Input()
dependencies: DependencyInfo[] = []
PR = PrimaryRendering
constructor(
private readonly alertCtrl: AlertController,
private readonly errToast: ErrorToastService,
private readonly loadingCtrl: LoadingController,
private readonly modalCtrl: ModalController,
private readonly embassyApi: ApiService,
private readonly wizardBaker: WizardBaker,
private readonly patch: PatchDbService,
private readonly launcherService: UiLauncherService,
private readonly modalService: ModalService,
) {}
get interfaces(): Record<string, InterfaceDef> {
return this.pkg.manifest.interfaces
}
get progress(): InstallProgress | undefined {
return this.pkg['install-progress']
}
get pkgStatus(): Status {
return this.pkg.installed.status
}
get isInstalled(): boolean {
return this.pkg.state === PackageState.Installed && !this.connectionFailure
}
get isRunning(): boolean {
return this.status.primary === PrimaryStatus.Running
}
get isStopped(): boolean {
return (
this.status.primary === PrimaryStatus.Stopped && this.pkgStatus.configured
)
}
launchUi(): void {
this.launcherService.launch(this.pkg)
}
async presentModalConfig(): Promise<void> {
return this.modalService.presentModalConfig({ pkgId: this.pkg.manifest.id })
}
async tryStart(): Promise<void> {
if (this.dependencies.some(d => !!d.errorText)) {
const depErrMsg = `${this.pkg.manifest.title} has unmet dependencies. It will not work as expected.`
const proceed = await this.presentAlertStart(depErrMsg)
if (!proceed) return
}
const alertMsg = this.pkg.manifest.alerts.start
if (!!alertMsg) {
const proceed = await this.presentAlertStart(alertMsg)
if (!proceed) return
}
this.start()
}
async stop(): Promise<void> {
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,
}),
)
}
}
private async start(): Promise<void> {
const loader = await this.loadingCtrl.create({
message: `Starting...`,
spinner: 'lines',
cssClass: 'loader',
})
await loader.present()
try {
await this.embassyApi.startPackage({ id: this.pkg.manifest.id })
} catch (e) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async presentAlertStart(message: string): Promise<boolean> {
return new Promise(async resolve => {
const alert = await this.alertCtrl.create({
header: 'Warning',
message,
buttons: [
{
text: 'Cancel',
role: 'cancel',
handler: () => {
resolve(false)
},
},
{
text: 'Continue',
handler: () => {
resolve(true)
},
cssClass: 'enter-click',
},
],
})
await alert.present()
})
}
}

View File

@@ -0,0 +1,21 @@
import { Pipe, PipeTransform } from '@angular/core'
import { HealthResult } from 'src/app/services/patch-db/data-model'
@Pipe({
name: 'healthColor',
})
export class HealthColorPipe implements PipeTransform {
transform(val: HealthResult): string {
switch (val) {
case HealthResult.Success:
return 'success'
case HealthResult.Failure:
return 'warning'
case HealthResult.Disabled:
return 'dark'
case HealthResult.Starting:
case HealthResult.Loading:
return 'primary'
}
}
}

View File

@@ -0,0 +1,125 @@
import { Inject, Pipe, PipeTransform } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { DOCUMENT } from '@angular/common'
import { AlertController, ModalController, NavController } from '@ionic/angular'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { MarkdownPage } from 'src/app/modals/markdown/markdown.page'
import { ModalService } from 'src/app/services/modal.service'
export interface Button {
title: string
description: string
icon: string
action: Function
}
@Pipe({
name: 'toButtons',
})
export class ToButtonsPipe implements PipeTransform {
constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly alertCtrl: AlertController,
private readonly route: ActivatedRoute,
private readonly navCtrl: NavController,
private readonly modalCtrl: ModalController,
private readonly modalService: ModalService,
) {}
transform(pkg: PackageDataEntry): Button[] {
const pkgTitle = pkg.manifest.title
return [
// instructions
{
action: () => this.presentModalInstructions(pkg),
title: 'Instructions',
description: `Understand how to use ${pkgTitle}`,
icon: 'list-outline',
},
// config
{
action: async () =>
this.modalService.presentModalConfig({ pkgId: pkg.manifest.id }),
title: 'Config',
description: `Customize ${pkgTitle}`,
icon: 'construct-outline',
},
// properties
{
action: () =>
this.navCtrl.navigateForward(['properties'], {
relativeTo: this.route,
}),
title: 'Properties',
description:
'Runtime information, credentials, and other values of interest',
icon: 'briefcase-outline',
},
// actions
{
action: () =>
this.navCtrl.navigateForward(['actions'], { relativeTo: this.route }),
title: 'Actions',
description: `Uninstall and other commands specific to ${pkgTitle}`,
icon: 'flash-outline',
},
// interfaces
{
action: () =>
this.navCtrl.navigateForward(['interfaces'], {
relativeTo: this.route,
}),
title: 'Interfaces',
description: 'User and machine access points',
icon: 'desktop-outline',
},
// logs
{
action: () =>
this.navCtrl.navigateForward(['logs'], { relativeTo: this.route }),
title: 'Logs',
description: 'Raw, unfiltered service logs',
icon: 'receipt-outline',
},
// view in marketplace
{
action: () => this.navCtrl.navigateForward([`marketplace/${pkg.manifest.id}`]),
title: 'Marketplace',
description: 'View service in marketplace',
icon: 'storefront-outline',
},
{
action: () => this.donate(pkg),
title: 'Donate',
description: `Support ${pkgTitle}`,
icon: 'logo-bitcoin',
},
]
}
private async presentModalInstructions(pkg: PackageDataEntry) {
const modal = await this.modalCtrl.create({
componentProps: {
title: 'Instructions',
contentUrl: pkg['static-files']['instructions'],
},
component: MarkdownPage,
})
await modal.present()
}
private async donate({ manifest }: PackageDataEntry): Promise<void> {
const url = manifest['donation-url']
if (url) {
this.document.defaultView.open(url, '_blank', 'noreferrer')
} else {
const alert = await this.alertCtrl.create({
header: 'Not Accepting Donations',
message: `The developers of ${manifest.title} have not provided a donation URL. Please contact them directly if you insist on giving them money.`,
})
await alert.present()
}
}
}

View File

@@ -0,0 +1,168 @@
import { Pipe, PipeTransform } from '@angular/core'
import { NavigationExtras } from '@angular/router'
import { NavController } from '@ionic/angular'
import { combineLatest, Observable } from 'rxjs'
import { filter, map } from 'rxjs/operators'
import { DependentInfo, exists } from 'src/app/util/misc.util'
import {
DependencyError,
DependencyErrorType,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ModalService } from 'src/app/services/modal.service'
export interface DependencyInfo {
id: string
title: string
icon: string
version: string
errorText: string
actionText: string
action: () => any
}
@Pipe({
name: 'toDependencies',
})
export class ToDependenciesPipe implements PipeTransform {
constructor(
private readonly patch: PatchDbService,
private readonly navCtrl: NavController,
private readonly modalService: ModalService,
) {}
transform(pkg: PackageDataEntry): Observable<DependencyInfo[]> {
return combineLatest([
this.patch.watch$(
'package-data',
pkg.manifest.id,
'installed',
'current-dependencies',
),
this.patch.watch$(
'package-data',
pkg.manifest.id,
'installed',
'status',
'dependency-errors',
),
]).pipe(
filter(deps => deps.every(exists)),
map(([currentDeps, depErrors]) =>
Object.keys(currentDeps)
.filter(id => !!pkg.manifest.dependencies[id])
.map(id => this.setDepValues(pkg, id, depErrors)),
),
)
}
private setDepValues(
pkg: PackageDataEntry,
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(pkg, 'install', id)
// incorrect version
} else if (error.type === DependencyErrorType.IncorrectVersion) {
errorText = 'Incorrect version'
actionText = 'Update'
action = () => this.fixDep(pkg, '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(pkg, 'configure', id)
} else if (error.type === DependencyErrorType.Transitive) {
errorText = 'Dependency has a dependency issue'
}
errorText = `${errorText}. ${pkg.manifest.title} will not work as expected.`
}
const depInfo = pkg.installed['dependency-info'][id]
return {
id,
version: pkg.manifest.dependencies[id].version,
title: depInfo.manifest.title,
icon: depInfo.icon,
errorText,
actionText,
action,
}
}
async fixDep(
pkg: PackageDataEntry,
action: 'install' | 'update' | 'configure',
id: string,
): Promise<void> {
switch (action) {
case 'install':
case 'update':
return this.installDep(pkg, id)
case 'configure':
return this.configureDep(pkg, id)
}
}
private async installDep(
pkg: PackageDataEntry,
depId: string,
): Promise<void> {
const version = pkg.manifest.dependencies[depId].version
const dependentInfo: DependentInfo = {
id: pkg.manifest.id,
title: pkg.manifest.title,
version,
}
const navigationExtras: NavigationExtras = {
state: { dependentInfo },
}
await this.navCtrl.navigateForward(
`/marketplace/${depId}`,
navigationExtras,
)
}
private async configureDep(
pkg: PackageDataEntry,
dependencyId: string,
): Promise<void> {
const dependentInfo: DependentInfo = {
id: pkg.manifest.id,
title: pkg.manifest.title,
}
await this.modalService.presentModalConfig({
pkgId: dependencyId,
dependentInfo,
})
}
}

View File

@@ -0,0 +1,42 @@
import { Inject, Pipe, PipeTransform } from '@angular/core'
import {
HealthCheckResult,
PackageDataEntry,
PackageMainStatus,
} from 'src/app/services/patch-db/data-model'
import { exists, isEmptyObject } from 'src/app/util/misc.util'
import { filter, map, startWith } from 'rxjs/operators'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { Observable } from 'rxjs'
@Pipe({
name: 'toHealthChecks',
})
export class ToHealthChecksPipe implements PipeTransform {
constructor(private readonly patch: PatchDbService) {}
transform(
pkg: PackageDataEntry,
): Observable<Record<string, HealthCheckResult | null>> | null {
const healthChecks = Object.keys(pkg.manifest['health-checks']).reduce(
(obj, key) => ({ ...obj, [key]: null }),
{},
)
const healthChecks$ = this.patch
.watch$('package-data', pkg.manifest.id, 'installed', 'status', 'main')
.pipe(
filter(obj => exists(obj)),
map(main =>
// Question: is this ok or do we have to use Object.keys
// to maintain order and the keys initially present in pkg?
main.status === PackageMainStatus.Running
? main.health
: healthChecks,
),
startWith(healthChecks),
)
return isEmptyObject(healthChecks) ? null : healthChecks$
}
}

View File

@@ -0,0 +1,15 @@
import { Pipe, PipeTransform } from '@angular/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import {
PackageStatus,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
@Pipe({
name: 'toStatus',
})
export class ToStatusPipe implements PipeTransform {
transform(pkg: PackageDataEntry): PackageStatus {
return renderPkgStatus(pkg)
}
}

View File

@@ -1,12 +1,15 @@
import { Pipe, PipeTransform } from '@angular/core'
import { InstallProgress } from '../services/patch-db/data-model'
import { packageLoadingProgress, ProgressData } from '../util/package-loading-progress'
import {
packageLoadingProgress,
ProgressData,
} from '../util/package-loading-progress'
@Pipe({
name: 'installState',
})
export class InstallState implements PipeTransform {
transform (loadData: InstallProgress): ProgressData {
transform(loadData: InstallProgress): ProgressData | null {
return packageLoadingProgress(loadData)
}
}

View File

@@ -1,13 +0,0 @@
import { HttpService } from '../http.service'
import { MockApiService } from './embassy-mock-api.service'
import { LiveApiService } from './embassy-live-api.service'
import { ConfigService } from '../config.service'
import { LocalStorageBootstrap } from '../patch-db/local-storage-bootstrap'
export function ApiServiceFactory (config: ConfigService, http: HttpService, bootstrapper: LocalStorageBootstrap) {
if (config.mocks.enabled) {
return new MockApiService(bootstrapper)
} else {
return new LiveApiService(http)
}
}

View File

@@ -0,0 +1,28 @@
import { Injectable } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ModalController } from '@ionic/angular'
import { DependentInfo } from 'src/app/util/misc.util'
import { AppConfigPage } from 'src/app/modals/app-config/app-config.page'
@Injectable({
providedIn: 'root',
})
export class ModalService {
constructor(
private readonly route: ActivatedRoute,
private readonly modalCtrl: ModalController,
) {}
async presentModalConfig(componentProps: ComponentProps): Promise<void> {
const modal = await this.modalCtrl.create({
component: AppConfigPage,
componentProps,
})
await modal.present()
}
}
interface ComponentProps {
pkgId: string
dependentInfo?: DependentInfo
}

View File

@@ -122,14 +122,14 @@ export interface Manifest {
stop: string | null
}
main: ActionImpl
'health-checks': { [id: string]: ActionImpl & { name: string, description: string } }
'health-checks': Record<string, ActionImpl & { name: string, description: string }>
config: ConfigActions | null
volumes: { [id: string]: Volume }
volumes: Record<string, Volume>
'min-os-version': string
interfaces: { [id: string]: InterfaceDef }
interfaces: Record<string, InterfaceDef>
backup: BackupActions
migrations: Migrations
actions: { [id: string]: Action }
actions: Record<string, Action>
permissions: any // @TODO 0.3.1
dependencies: DependencyInfo
}

View File

@@ -1,11 +1,18 @@
import { isEmptyObject } from '../util/misc.util'
import { PackageDataEntry, PackageMainStatus, PackageState, Status } from './patch-db/data-model'
import {
PackageDataEntry,
PackageMainStatus,
PackageState,
Status,
} from './patch-db/data-model'
export function renderPkgStatus (pkg: PackageDataEntry): {
primary: PrimaryStatus,
dependency: DependencyStatus | null,
export interface PackageStatus {
primary: PrimaryStatus
dependency: DependencyStatus | null
health: HealthStatus | null
} {
}
export function renderPkgStatus(pkg: PackageDataEntry): PackageStatus {
let primary: PrimaryStatus
let dependency: DependencyStatus | null = null
let health: HealthStatus | null = null
@@ -21,7 +28,7 @@ export function renderPkgStatus (pkg: PackageDataEntry): {
return { primary, dependency, health }
}
function getPrimaryStatus (status: Status): PrimaryStatus {
function getPrimaryStatus(status: Status): PrimaryStatus {
if (!status.configured) {
return PrimaryStatus.NeedsConfig
} else {
@@ -29,7 +36,7 @@ function getPrimaryStatus (status: Status): PrimaryStatus {
}
}
function getDependencyStatus (pkg: PackageDataEntry): DependencyStatus {
function getDependencyStatus(pkg: PackageDataEntry): DependencyStatus {
const installed = pkg.installed
if (isEmptyObject(installed['current-dependencies'])) return null
@@ -39,7 +46,7 @@ function getDependencyStatus (pkg: PackageDataEntry): DependencyStatus {
return depIds.length ? DependencyStatus.Warning : DependencyStatus.Satisfied
}
function getHealthStatus (status: Status): HealthStatus {
function getHealthStatus(status: Status): HealthStatus {
if (status.main.status === PackageMainStatus.Running) {
const values = Object.values(status.main.health)
if (values.some(h => h.result === 'failure')) {

View File

@@ -0,0 +1,22 @@
import { Inject, Injectable } from '@angular/core'
import { DOCUMENT } from '@angular/common'
import { PackageDataEntry } from './patch-db/data-model'
import { ConfigService } from './config.service'
@Injectable({
providedIn: 'root',
})
export class UiLauncherService {
constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly config: ConfigService,
) {}
launch(pkg: PackageDataEntry): void {
this.document.defaultView.open(
this.config.launchableURL(pkg),
'_blank',
'noreferrer',
)
}
}

View File

@@ -13,15 +13,13 @@ import {
} from './package-loading-progress'
import { Subscription } from 'rxjs'
export function getPackageInfo (entry: PackageDataEntry): PkgInfo {
export function getPackageInfo(entry: PackageDataEntry): PkgInfo {
const statuses = renderPkgStatus(entry)
return {
entry,
primaryRendering: PrimaryRendering[statuses.primary],
installProgress: !isEmptyObject(entry['install-progress'])
? packageLoadingProgress(entry['install-progress'])
: undefined,
installProgress: packageLoadingProgress(entry['install-progress']),
error:
statuses.health === HealthStatus.Failure ||
statuses.dependency === DependencyStatus.Warning,
@@ -31,7 +29,7 @@ export function getPackageInfo (entry: PackageDataEntry): PkgInfo {
export interface PkgInfo {
entry: PackageDataEntry
primaryRendering: StatusRendering
installProgress: ProgressData
installProgress: ProgressData | null
error: boolean
sub?: Subscription | null
}

View File

@@ -1,8 +1,13 @@
import { InstallProgress } from 'src/app/services/patch-db/data-model'
import { isEmptyObject } from './misc.util'
export function packageLoadingProgress (
export function packageLoadingProgress(
loadData: InstallProgress,
): ProgressData {
): ProgressData | null {
if (isEmptyObject(loadData)) {
return null
}
let {
downloaded,
validated,
@@ -28,11 +33,10 @@ export function packageLoadingProgress (
unpackWeight * unpacked,
)
const denominator = Math.floor(
size * (downloadWeight + validateWeight + unpackWeight),
)
const totalProgress = Math.floor(100 * numerator / denominator)
const totalProgress = Math.floor((100 * numerator) / denominator)
return {
totalProgress,

View File

@@ -2,7 +2,6 @@
"rules": {
"no-unused-variable": true,
"no-unused-expression": true,
"space-before-function-paren": true,
"semicolon": [
true,
"never"
@@ -24,8 +23,7 @@
"check-type",
"check-typecast",
"check-type-operator",
"check-preblock",
"check-postbrace"
"check-preblock"
],
"trailing-comma": [
true,