0.2.5 initial commit

Makefile incomplete
This commit is contained in:
Aiden McClelland
2020-11-23 13:44:28 -07:00
commit 95d3845906
503 changed files with 53448 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
<div style="position: relative; margin-right: 1vh;">
<ion-icon
*ngIf="(badge$ | async) && !(menuFixedOpen$ | async)"
size="medium"
color="dark"
[class.ios-badge]="isIos"
[class.md-badge]="!isIos"
name="alert-outline"
>
</ion-icon>
<ion-menu-button color="dark"></ion-menu-button>
</div>

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { BadgeMenuComponent } from './badge-menu.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
BadgeMenuComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [BadgeMenuComponent],
})
export class BadgeMenuComponentModule { }

View File

@@ -0,0 +1,17 @@
.ios-badge {
background-color: var(--ion-color-start9);
position: absolute;
top: 1px;
left: 62%;
border-radius: 5px;
z-index: 1;
}
.md-badge {
background-color: var(--ion-color-start9);
position: absolute;
top: -2px;
left: 56%;
border-radius: 5px;
z-index: 1;
}

View File

@@ -0,0 +1,27 @@
import { Component } from '@angular/core'
import { ServerModel } from '../../models/server-model'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { SplitPaneTracker } from 'src/app/services/split-pane.service'
import { isPlatform } from '@ionic/angular'
@Component({
selector: 'badge-menu-button',
templateUrl: './badge-menu.component.html',
styleUrls: ['./badge-menu.component.scss'],
})
export class BadgeMenuComponent {
badge$: Observable<boolean>
menuFixedOpen$: Observable<boolean>
isIos: boolean
constructor (
private readonly serverModel: ServerModel,
private readonly splitPane: SplitPaneTracker,
) {
this.menuFixedOpen$ = this.splitPane.$menuFixedOpenOnLeft$.asObservable()
this.badge$ = this.serverModel.watch().badge.pipe(map(i => i > 0))
this.isIos = isPlatform('ios')
}
}

View File

@@ -0,0 +1,24 @@
<ion-item-group>
<!-- error -->
<ng-container *ngIf="error">
<ion-item>
<ion-icon slot="start" name="warning-outline" color="danger" size="small"></ion-icon>
<ion-label><ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text></ion-label>
</ion-item>
<ion-item-divider *ngIf="spec.description || spec.changeWarning"></ion-item-divider>
</ng-container>
<!-- description -->
<ion-item *ngIf="spec.description">
<ion-label class="ion-text-wrap">
<p><ion-text color="dark">Description</ion-text></p>
<p>{{ spec.description }}</p>
</ion-label>
</ion-item>
<!-- warning -->
<ion-item *ngIf="spec.changeWarning">
<ion-label class="ion-text-wrap">
<p><ion-text color="warning">Warning!</ion-text></p>
<p>{{ spec.changeWarning }}</p>
</ion-label>
</ion-item>
</ion-item-group>

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { ConfigHeaderComponent } from './config-header.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
ConfigHeaderComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [ConfigHeaderComponent],
})
export class ConfigHeaderComponentModule { }

View File

@@ -0,0 +1,12 @@
import { Component, Input } from '@angular/core'
import { ValueSpec } from 'src/app/app-config/config-types'
@Component({
selector: 'config-header',
templateUrl: './config-header.component.html',
styleUrls: ['./config-header.component.scss'],
})
export class ConfigHeaderComponent {
@Input() spec: ValueSpec
@Input() error: string
}

View File

@@ -0,0 +1,14 @@
<div *ngFor="let dep of dependenciesToDisplay; let index = i">
<marketplace-dependency-item *ngIf="depType === 'available'" style="width: 100%"
[dep]="dep"
[hostApp]="hostApp"
[$loading$]="$loading$"
>
</marketplace-dependency-item>
<installed-dependency-item *ngIf="depType === 'installed'" style="width: 100%"
[dep]="dep"
[hostApp]="hostApp"
[$loading$]="$loading$"
>
</installed-dependency-item>
</div>

View File

@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { DependencyListComponent } from './dependency-list.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { InformationPopoverComponentModule } from '../information-popover/information-popover.component.module'
import { StatusComponentModule } from '../status/status.component.module'
import { InstalledDependencyItemComponentModule } from './installed-dependency-item/installed-dependency-item.component.module'
import { MarketplaceDependencyItemComponentModule } from './marketplace-dependency-item/marketplace-dependency-item.component.module'
@NgModule({
declarations: [
DependencyListComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
InformationPopoverComponentModule,
StatusComponentModule,
InstalledDependencyItemComponentModule,
MarketplaceDependencyItemComponentModule,
],
exports: [DependencyListComponent],
})
export class DependencyListComponentModule { }

View File

@@ -0,0 +1,30 @@
import { Component, Input } from '@angular/core'
import { BehaviorSubject } from 'rxjs'
import { AppDependency, BaseApp, isOptional } from 'src/app/models/app-types'
@Component({
selector: 'dependency-list',
templateUrl: './dependency-list.component.html',
styleUrls: ['./dependency-list.component.scss'],
})
export class DependencyListComponent {
@Input() depType: 'installed' | 'available' = 'available'
@Input() hostApp: BaseApp
@Input() dependencies: AppDependency[]
dependenciesToDisplay: AppDependency[]
@Input() $loading$: BehaviorSubject<boolean> = new BehaviorSubject(true)
constructor () { }
ngOnChanges () {
this.dependenciesToDisplay = this.dependencies.filter(dep =>
this.depType === 'available' ? !isOptional(dep) : true,
)
}
ngOnInit () {
this.dependenciesToDisplay = this.dependencies.filter(dep =>
this.depType === 'available' ? !isOptional(dep) : true,
)
}
}

View File

@@ -0,0 +1,33 @@
<ng-container *ngIf="{ loading: $loading$ | async, disabled: installing || ($loading$ | async) } as l" >
<ion-item
class="dependency"
lines="none"
>
<ion-avatar style="position: relative; height: 5vh; width: 5vh; margin: 0px;" slot="start">
<div *ngIf="!l.loading" class="badge" [style]="badgeStyle"></div>
<img [src]="dep.iconURL | iconParse" />
</ion-avatar>
<ion-label class="ion-text-wrap" style="padding: 1vh; padding-left: 2vh">
<h4 style="font-family: 'Montserrat'">{{ dep.title }}</h4>
<p style="font-size: small">{{ dep.versionSpec }}</p>
<p *ngIf="!l.loading" style="padding-top: 2px; position: relative; font-style: italic; font-size: smaller"><ion-text [color]="color">{{statusText}}</ion-text></p>
<p *ngIf="l.loading" style="padding-top: 2px; position: relative; font-style: italic; font-size: smaller"><ion-text color="medium">Refreshing</ion-text></p>
</ion-label>
<ion-button size="small" (click)="action()" *ngIf="!installing && !l.loading" color="primary" fill="outline" style="font-size: x-small">
{{actionText}}
</ion-button>
<div slot="end" *ngIf='installing || (l.loading)' >
<div *ngIf='installing && !(l.loading)' class="spinner">
<ion-spinner [color]="color" style="height: 3vh; width: 3vh" name="dots"></ion-spinner>
</div>
<div *ngIf='(l.loading)' class="spinner">
<ion-spinner [color]="medium" style="height: 3vh; width: 3vh" name="lines"></ion-spinner>
</div>
</div>
</ion-item>
</ng-container>

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { InstalledDependencyItemComponent } from './installed-dependency-item.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { InformationPopoverComponentModule } from '../../information-popover/information-popover.component.module'
import { StatusComponentModule } from '../../status/status.component.module'
@NgModule({
declarations: [InstalledDependencyItemComponent],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
InformationPopoverComponentModule,
StatusComponentModule,
],
exports: [InstalledDependencyItemComponent],
})
export class InstalledDependencyItemComponentModule { }

View File

@@ -0,0 +1,30 @@
.spinner {
background: rgba(0,0,0,0);
border-radius: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 5px;
}
.badge {
position: absolute; width: 2.5vh;
height: 2.5vh;
border-radius: 50px;
left: -1vh;
top: -1vh;
}
.xSmallText {
font-size: x-small !important;
}
.mediumText {
font-size: medium !important;
}
.opacityUp {
opacity: 0.75;
}

View File

@@ -0,0 +1,113 @@
import { Component, Input, OnInit } from '@angular/core'
import { NavigationExtras } from '@angular/router'
import { AlertController, NavController } from '@ionic/angular'
import { BehaviorSubject, Observable } from 'rxjs'
import { AppStatus } from 'src/app/models/app-model'
import { AppDependency, BaseApp, DependencyViolationSeverity, getInstalledViolationSeverity, getViolationSeverity, isInstalling, isMisconfigured, isMissing, isNotRunning, isVersionMismatch } from 'src/app/models/app-types'
import { Recommendation } from '../../recommendation-button/recommendation-button.component'
@Component({
selector: 'installed-dependency-item',
templateUrl: './installed-dependency-item.component.html',
styleUrls: ['./installed-dependency-item.component.scss'],
})
export class InstalledDependencyItemComponent implements OnInit {
@Input() dep: AppDependency
@Input() hostApp: BaseApp
@Input() $loading$: BehaviorSubject<boolean>
isLoading$: Observable<boolean>
color: string
installing = false
badgeStyle: string
violationSeverity: DependencyViolationSeverity
statusText: string
actionText: string
action: () => Promise<any>
constructor (private readonly navCtrl: NavController, private readonly alertCtrl: AlertController) { }
ngOnInit () {
this.violationSeverity = getInstalledViolationSeverity(this.dep)
const { color, statusText, installing, actionText, action } = this.getValues()
this.color = color
this.statusText = statusText
this.installing = installing
this.actionText = actionText
this.action = action
this.badgeStyle = `background: radial-gradient(var(--ion-color-${this.color}) 40%, transparent)`
}
isDanger () {
// installed dep violations are either REQUIRED or NONE, by getInstalledViolationSeverity above.
return [DependencyViolationSeverity.REQUIRED].includes(this.violationSeverity)
}
getValues (): { color: string, statusText: string, installing: boolean, actionText: string, action: () => Promise<any> } {
if (isInstalling(this.dep)) return { color: 'primary', statusText: 'Installing', installing: true, actionText: undefined, action: () => this.view() }
if (!this.isDanger()) return { color: 'success', statusText: 'Satisfied', installing: false, actionText: 'View', action: () => this.view() }
if (isMissing(this.dep)) return { color: 'warning', statusText: 'Not Installed', installing: false, actionText: 'Install', action: () => this.install() }
if (isVersionMismatch(this.dep)) return { color: 'warning', statusText: 'Incompatible Version Installed', installing: false, actionText: 'Update', action: () => this.install() }
if (isMisconfigured(this.dep)) return { color: 'warning', statusText: 'Incompatible Config', installing: false, actionText: 'Configure', action: () => this.configure() }
if (isNotRunning(this.dep)) return { color: 'warning', statusText: 'Not Running', installing: false, actionText: 'View', action: () => this.view() }
return { color: 'success', statusText: 'Satisfied', installing: false, actionText: 'View', action: () => this.view() }
}
async view () {
return this.navCtrl.navigateForward(`/services/installed/${this.dep.id}`)
}
async install () {
const verb = 'requires'
const description = `${this.hostApp.title} ${verb} an install of ${this.dep.title} satisfying ${this.dep.versionSpec}.`
const whyDependency = this.dep.description
const installationRecommendation: Recommendation = {
iconURL: this.hostApp.iconURL,
appId: this.hostApp.id,
description,
title: this.hostApp.title,
versionSpec: this.dep.versionSpec,
whyDependency,
}
const navigationExtras: NavigationExtras = {
state: { installationRecommendation },
}
return this.navCtrl.navigateForward(`/services/marketplace/${this.dep.id}`, navigationExtras)
}
async configure () {
if (this.dep.violation.name !== 'incompatible-config') return
const configViolationDesc = this.dep.violation.ruleViolations
const configViolationFormatted =
`<ul>${configViolationDesc.map(d => `<li>${d}</li>`).join('\n')}</ul>`
const configRecommendation: Recommendation = {
iconURL: this.hostApp.iconURL,
appId: this.hostApp.id,
description: configViolationFormatted,
title: this.hostApp.title,
}
const navigationExtras: NavigationExtras = {
state: { configRecommendation },
}
return this.navCtrl.navigateForward(`/services/installed/${this.dep.id}/config`, navigationExtras)
}
async presentAlertDescription() {
const description = `<p>${this.dep.description}<\p>`
const alert = await this.alertCtrl.create({
backdropDismiss: true,
message: description,
})
await alert.present()
}
}

View File

@@ -0,0 +1,45 @@
<ng-container *ngIf="{ loading: $loading$ | async, disabled: installing || ($loading$ | async) } as l" >
<ion-item
class="dependency"
style="--border-color: var(--ion-color-medium-shade)"
[lines]="presentAlertDescription ? 'inset' : 'full'"
>
<ion-avatar style="position: relative; height: 5vh; width: 5vh; margin: 0px;" slot="start">
<div *ngIf="!l.loading" class="badge" [style]="badgeStyle"></div>
<img [src]="dep.iconURL | iconParse" />
</ion-avatar>
<ion-label class="ion-text-wrap" style="padding: 1vh; padding-left: 2vh">
<h4 style="font-family: 'Montserrat'">{{ dep.title }}
<span *ngIf="recommended" style="font-family: 'Open Sans'; font-size: small; color: var(--ion-color-medium)">(recommended)</span>
</h4>
<p style="font-size: small">{{ dep.versionSpec }}</p>
<p *ngIf="!l.loading" style="padding-top: 2px; position: relative; font-style: italic; font-size: smaller"><ion-text [color]="color">{{statusText}}</ion-text></p>
<p *ngIf="l.loading" style="padding-top: 2px; position: relative; font-style: italic; font-size: smaller"><ion-text color="medium">Refreshing</ion-text></p>
</ion-label>
<ion-button size="small" (click)="presentAlertDescription=!presentAlertDescription" [disabled]="l.loading" color="medium" fill="clear" style="margin: 14px; font-size: small">
<ion-icon *ngIf="!presentAlertDescription" name="chevron-down"></ion-icon>
<ion-icon *ngIf="presentAlertDescription" name="chevron-up"></ion-icon>
</ion-button>
<ion-button size="small" (click)="toInstall()" *ngIf="!installing && !l.loading" color="primary" fill="outline" style="font-size: small">
{{actionText}}
</ion-button>
<div slot="end" *ngIf='installing || (l.loading)' style="margin: 0" >
<div *ngIf='installing && !(l.loading)' class="spinner">
<ion-spinner [color]="color" style="height: 3vh; width: 3vh" name="dots"></ion-spinner>
</div>
<div *ngIf='(l.loading)' class="spinner">
<ion-spinner [color]="medium" style="height: 3vh; width: 3vh" name="lines"></ion-spinner>
</div>
</div>
</ion-item>
<ion-item style="margin-bottom: 10px"*ngIf="presentAlertDescription" lines="none">
<div style="font-size: small; color: var(--ion-color-medium)" [innerHtml]="descriptionText"></div>
</ion-item>
<div style="height: 8px"></div>
</ng-container>

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { MarketplaceDependencyItemComponent } from './marketplace-dependency-item.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { InformationPopoverComponentModule } from '../../information-popover/information-popover.component.module'
import { StatusComponentModule } from '../../status/status.component.module'
@NgModule({
declarations: [MarketplaceDependencyItemComponent],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
InformationPopoverComponentModule,
StatusComponentModule,
],
exports: [MarketplaceDependencyItemComponent],
})
export class MarketplaceDependencyItemComponentModule { }

View File

@@ -0,0 +1,35 @@
.spinner {
background: rgba(0,0,0,0);
border-radius: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 14px;
}
.badge {
position: absolute; width: 2.5vh;
height: 2.5vh;
border-radius: 50px;
left: -1vh;
top: -1vh;
}
.xSmallText {
font-size: x-small !important;
}
.mediumText {
font-size: medium !important;
}
.opacityUp {
opacity: 0.75;
}
.dependency {
--padding-start: 20px;
--padding-end: 2px;
}

View File

@@ -0,0 +1,88 @@
import { Component, Input, OnInit } from '@angular/core'
import { NavigationExtras } from '@angular/router'
import { NavController } from '@ionic/angular'
import { BehaviorSubject, Observable } from 'rxjs'
import { AppDependency, BaseApp, DependencyViolationSeverity, getViolationSeverity, isOptional, isMissing, isInstalling, isRecommended, isVersionMismatch } from 'src/app/models/app-types'
import { Recommendation } from '../../recommendation-button/recommendation-button.component'
@Component({
selector: 'marketplace-dependency-item',
templateUrl: './marketplace-dependency-item.component.html',
styleUrls: ['./marketplace-dependency-item.component.scss'],
})
export class MarketplaceDependencyItemComponent implements OnInit {
@Input() dep: AppDependency
@Input() hostApp: BaseApp
@Input() $loading$: BehaviorSubject<boolean>
presentAlertDescription = false
isLoading$: Observable<boolean>
color: string
installing = false
recommended = false
badgeStyle: string
violationSeverity: DependencyViolationSeverity
statusText: string
actionText: 'View' | 'Get'
descriptionText: string
constructor (
private readonly navCtrl: NavController,
) { }
ngOnInit () {
this.violationSeverity = getViolationSeverity(this.dep)
if (isOptional(this.dep)) throw new Error('Do not display optional deps, satisfied or otherwise, on the AAL')
const { actionText, color, statusText, installing } = this.getValues()
this.color = color
this.statusText = statusText
this.installing = installing
this.recommended = isRecommended(this.dep)
this.actionText = actionText
this.badgeStyle = `background: radial-gradient(var(--ion-color-${this.color}) 40%, transparent)`
this.descriptionText = `<p>${this.dep.description}<\p>`
if (this.recommended) {
this.descriptionText = this.descriptionText + `<p>This service is not required: ${this.dep.optional}<\p>`
}
}
isDanger (): boolean {
return [DependencyViolationSeverity.REQUIRED, DependencyViolationSeverity.RECOMMENDED].includes(this.violationSeverity)
}
getValues (): { color: string, statusText: string, installing: boolean, actionText: 'View' | 'Get' } {
if (isInstalling(this.dep)) return { color: 'primary', statusText: 'Installing', installing: true, actionText: undefined }
if (!this.isDanger()) return { color: 'success', statusText: 'Satisfied', installing: false, actionText: 'View' }
if (isMissing(this.dep)) return { color: 'warning', statusText: 'Not Installed', installing: false, actionText: 'Get' }
if (isVersionMismatch(this.dep)) return { color: 'warning', statusText: 'Incompatible Version Installed', installing: false, actionText: 'Get' }
return { color: 'success', statusText: 'Satisfied', installing: false, actionText: 'View' }
}
async toInstall () {
if (this.actionText === 'View') return this.navCtrl.navigateForward(`/services/marketplace/${this.dep.id}`)
const verb = this.violationSeverity === DependencyViolationSeverity.REQUIRED ? 'requires' : 'recommends'
const description = `${this.hostApp.title} ${verb} an install of ${this.dep.title} satisfying ${this.dep.versionSpec}.`
const whyDependency = this.dep.description
const installationRecommendation: Recommendation = {
iconURL: this.hostApp.iconURL,
appId: this.hostApp.id,
description,
title: this.hostApp.title,
versionSpec: this.dep.versionSpec,
whyDependency,
}
const navigationExtras: NavigationExtras = {
state: { installationRecommendation },
}
return this.navCtrl.navigateForward(`/services/marketplace/${this.dep.id}`, navigationExtras)
}
}

View File

@@ -0,0 +1,6 @@
<ion-item lines="none" *ngIf="$error$ | async as error" class="notifier-item" style="margin-top: 12px">
<ion-label class="ion-text-wrap" color="danger"><p>{{ error }}</p></ion-label>
<ion-button *ngIf="dismissable" size="small" slot="end" fill="outline" color="danger" (click)="clear()">
<ion-icon style="height: 12px; width: 12px;" name="close"></ion-icon>
</ion-button>
</ion-item>

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { ErrorMessageComponent } from './error-message.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
ErrorMessageComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [ErrorMessageComponent],
})
export class ErrorMessageComponentModule { }

View File

@@ -0,0 +1,10 @@
.error-message {
--background: var(--ion-color-danger);
margin: 12px;
border-radius: 3px;
font-weight: bold;
}
.legacy-error-message {
margin: 5px;
}

View File

@@ -0,0 +1,19 @@
import { Component, Input, OnInit } from '@angular/core'
import { BehaviorSubject } from 'rxjs'
@Component({
selector: 'error-message',
templateUrl: './error-message.component.html',
styleUrls: ['./error-message.component.scss'],
})
export class ErrorMessageComponent implements OnInit {
@Input() $error$: BehaviorSubject<string | undefined> = new BehaviorSubject(undefined)
@Input() dismissable = true
constructor () { }
ngOnInit () { }
clear () {
this.$error$.next(undefined)
}
}

View File

@@ -0,0 +1,11 @@
<div style="padding: 15px;
border-style: solid;
border-width: 1px;
background: var(--ion-color-light);
border-radius: 10px;
border-color: var(--ion-color-warning);
color: white;
font-size: small;
"
[innerHTML]="unsafeInformation">
</div>

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { InformationPopoverComponent } from './information-popover.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
InformationPopoverComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [InformationPopoverComponent],
})
export class InformationPopoverComponentModule { }

View File

@@ -0,0 +1,18 @@
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
@Component({
selector: 'app-information-popover',
templateUrl: './information-popover.component.html',
styleUrls: ['./information-popover.component.scss'],
encapsulation: ViewEncapsulation.None,
})
export class InformationPopoverComponent implements OnInit {
@Input() title: string
@Input() information: string
unsafeInformation: SafeHtml
constructor (private sanitizer: DomSanitizer) { }
ngOnInit () {
this.unsafeInformation = this.sanitizer.bypassSecurityTrustHtml(this.information)
}
}

View File

@@ -0,0 +1,17 @@
<div *ngIf="!($loading$ | async) && !params.skipCompletionDialogue" class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label [color]="$color$ | async" style="font-size: xx-large; font-weight: bold;">
{{successText}}
</ion-label>
</div>
<div class="long-message">
{{summary}}
</div>
</div>
</div>
<div *ngIf="$loading$ | async" class="center-spinner">
<ion-spinner color="warning" name="lines"></ion-spinner>
<ion-label class="long-message">{{label}}</ion-label>
</div>

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { CompleteComponent } from './complete.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
CompleteComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [CompleteComponent],
})
export class CompleteComponentModule { }

View File

@@ -0,0 +1,82 @@
import { Component, Input, OnInit } from '@angular/core'
import { BehaviorSubject, from, Subject } from 'rxjs'
import { takeUntil } from 'rxjs/operators'
import { markAsLoadingDuring$ } from 'src/app/services/loader.service'
import { capitalizeFirstLetter } from 'src/app/util/misc.util'
import { Colorable, Loadable } from '../loadable'
import { WizardAction } from '../wizard-types'
@Component({
selector: 'complete',
templateUrl: './complete.component.html',
styleUrls: ['../install-wizard.component.scss'],
})
export class CompleteComponent implements OnInit, Loadable, Colorable {
@Input() params: {
action: WizardAction
verb: string //loader verb: '*stopping* ...'
title: string
executeAction: () => Promise<any>
skipCompletionDialogue?: boolean
}
@Input() finished: (info: { error?: Error, cancelled?: true, final?: true }) => Promise<any>
$loading$ = new BehaviorSubject(false)
$color$ = new BehaviorSubject('medium')
$cancel$ = new Subject<void>()
label: string
summary: string
successText: string
load () {
markAsLoadingDuring$(this.$loading$, from(this.params.executeAction())).pipe(takeUntil(this.$cancel$)).subscribe(
{ error: e => this.finished({ error: new Error(`${this.params.action} failed: ${e.message || e}`) }),
complete: () => this.params.skipCompletionDialogue && this.finished( { final: true} ),
},
)
}
constructor () { }
ngOnInit () {
switch (this.params.action) {
case 'install':
this.summary = `Installation of ${this.params.title} is now in progress. You will receive a notification when the installation has completed.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('primary')
this.successText = 'In Progress'
break
case 'downgrade':
this.summary = `Downgrade for ${this.params.title} is now in progress. You will receive a notification when the downgrade has completed.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('primary')
this.successText = 'In Progress'
break
case 'update':
this.summary = `Update for ${this.params.title} is now in progress. You will receive a notification when the update has completed.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('primary')
this.successText = 'In Progress'
break
case 'uninstall':
this.summary = `${capitalizeFirstLetter(this.params.title)} has been successfully uninstalled.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('success')
this.successText = 'Success'
break
case 'stop':
this.summary = `${capitalizeFirstLetter(this.params.title)} has been successfully stopped.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('success')
this.successText = 'Success'
break
case 'configure':
this.summary = `New config for ${this.params.title} has been successfully saved.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('success')
this.successText = 'Success'
break
}
}
}

View File

@@ -0,0 +1,31 @@
<div class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label [color]="$color$ | async" style="font-size: xx-large; font-weight: bold;">
{{label}}
</ion-label>
</div>
<div class="long-message">
{{longMessage}}
</div>
<div style="margin: 25px 0px;">
<ion-item
style="--ion-item-background: rgb(0,0,0,0); margin-top: 5px"
*ngFor="let dep of dependencyViolations"
>
<ion-avatar style="position: relative; height: 4vh; width: 4vh" slot="start">
<div class="badge" [style]="dep.badgeStyle"></div>
<img [src]="dep.iconURL | iconParse" />
</ion-avatar>
<ion-label>
<h5>{{dep.title}}</h5>
<ion-text color="medium" style="font-size: smaller">{{dep.versionSpec}}</ion-text>
</ion-label>
<ion-text [color]="dep.color" style="font-size: smaller; font-style: italic; margin-right: 5px;">{{dep.violation}}</ion-text>
<status *ngIf="dep.isInstalling" [appStatus]="'INSTALLING'" size="italics-small"></status>
</ion-item>
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { DependenciesComponent } from './dependencies.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { StatusComponentModule } from '../../status/status.component.module'
@NgModule({
declarations: [
DependenciesComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
StatusComponentModule,
],
exports: [DependenciesComponent],
})
export class DependenciesComponentModule { }

View File

@@ -0,0 +1,127 @@
import { Component, Input, OnInit } from '@angular/core'
import { PopoverController } from '@ionic/angular'
import { BehaviorSubject, Subject } from 'rxjs'
import { AppStatus } from 'src/app/models/app-model'
import { AppDependency, DependencyViolationSeverity, getViolationSeverity } from 'src/app/models/app-types'
import { displayEmver } from 'src/app/pipes/emver.pipe'
import { InformationPopoverComponent } from '../../information-popover/information-popover.component'
import { Colorable, Loadable } from '../loadable'
import { WizardAction } from '../wizard-types'
@Component({
selector: 'dependencies',
templateUrl: './dependencies.component.html',
styleUrls: ['../install-wizard.component.scss'],
})
export class DependenciesComponent implements OnInit, Loadable, Colorable {
@Input() params: {
action: WizardAction,
title: string,
version: string,
serviceRequirements: AppDependency[]
}
filteredServiceRequirements: AppDependency[]
$loading$ = new BehaviorSubject(false)
$cancel$ = new Subject<void>()
longMessage: string
dependencyViolations: {
iconURL: string
title: string,
versionSpec: string,
violation: string,
color: string,
badgeStyle: string
}[]
label: string
$color$ = new BehaviorSubject('medium')
constructor (private readonly popoverController: PopoverController) { }
load () {
this.$color$.next(this.$color$.getValue())
}
ngOnInit () {
this.filteredServiceRequirements = this.params.serviceRequirements.filter(dep => {
return [DependencyViolationSeverity.REQUIRED, DependencyViolationSeverity.RECOMMENDED].includes(getViolationSeverity(dep))
})
.filter(dep => ['incompatible-version', 'missing'].includes(dep.violation.name))
this.dependencyViolations = this.filteredServiceRequirements
.map(dep => ({
iconURL: dep.iconURL,
title: dep.title,
versionSpec: (dep.violation && dep.violation.name === 'incompatible-config' && 'reconfigure') || dep.versionSpec,
isInstalling: dep.violation && dep.violation.name === 'incompatible-status' && dep.violation.status === AppStatus.INSTALLING,
violation: renderViolation(dep),
color: 'medium',
badgeStyle: `background: radial-gradient(var(--ion-color-warning) 40%, transparent)`,
}))
this.setSeverityAttributes()
}
setSeverityAttributes () {
switch (getWorstViolationSeverity(this.filteredServiceRequirements)){
case DependencyViolationSeverity.REQUIRED:
this.longMessage = `${this.params.title} requires the installation of other services. Don't worry, you'll be able to install these requirements later.`
this.label = 'Notice'
this.$color$.next('dark')
break
case DependencyViolationSeverity.RECOMMENDED:
this.longMessage = `${this.params.title} recommends the installation of other services. Don't worry, you'll be able to install these requirements later.`
this.label = 'Notice'
this.$color$.next('dark')
break
default:
this.longMessage = `All installation requirements for ${this.params.title} version ${displayEmver(this.params.version)} are met.`
this.$color$.next('success')
this.label = `Ready`
}
}
async presentPopover (ev: any, information: string) {
const popover = await this.popoverController.create({
component: InformationPopoverComponent,
event: ev,
translucent: false,
showBackdrop: true,
backdropDismiss: true,
componentProps: {
information,
},
})
return popover.present()
}
}
function renderViolation1 (dep: AppDependency): string {
const severity = getViolationSeverity(dep)
switch (severity){
case DependencyViolationSeverity.REQUIRED: return 'mandatory'
case DependencyViolationSeverity.RECOMMENDED: return 'recommended'
case DependencyViolationSeverity.OPTIONAL: return 'optional'
case DependencyViolationSeverity.NONE: return 'none'
}
}
function renderViolation (dep: AppDependency): string {
const severity = renderViolation1(dep)
if (severity === 'none') return ''
switch (dep.violation.name){
case 'missing': return `${severity}`
case 'incompatible-version': return `${severity}`
case 'incompatible-config': return ``
case 'incompatible-status': return ''
default: return ''
}
}
function getWorstViolationSeverity (rs: AppDependency[]) : DependencyViolationSeverity {
if (!rs) return DependencyViolationSeverity.NONE
return rs.map(getViolationSeverity).sort( (a, b) => b - a )[0] || DependencyViolationSeverity.NONE
}

View File

@@ -0,0 +1,42 @@
<div>
<div *ngIf="!($loading$ | async)" class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label color="warning" style="font-size: xx-large; font-weight: bold;"
*ngIf="hasDependentViolation">
WARNING
</ion-label>
<ion-label color="success" style="font-size: x-large; font-weight: bold; text-transform: capitalize;"
*ngIf="!hasDependentViolation">
READY
</ion-label>
</div>
<div *ngIf="longMessage" class="long-message" >
{{longMessage}}
</div>
<div style="margin: 25px 0px;">
<div style="border-width: 0px 0px 1px 0px; font-size: unset; text-align: left; font-weight: bold; margin-left: 13px; border-style: solid; border-color: var(--ion-color-light-tint);"
*ngIf="hasDependentViolation"
>
<ion-text color="warning">Will Stop</ion-text>
</div>
<ion-item
style="--ion-item-background: rgb(0,0,0,0); margin-top: 5px"
*ngFor="let dep of dependentBreakages"
>
<ion-avatar style="position: relative; height: 4vh; width: 4vh" slot="start">
<img [src]="dep.iconURL | iconParse" />
</ion-avatar>
<ion-label>
<h5>{{dep.title}}</h5>
</ion-label>
</ion-item>
</div>
</div>
</div>
<div *ngIf="$loading$ | async" class="center-spinner">
<ion-spinner color="warning" name="lines"></ion-spinner>
<ion-label class="long-message">Checking for installed services which depend on {{params.title}}...</ion-label>
</div>
</div>

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { DependentsComponent } from './dependents.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { InformationPopoverComponentModule } from '../../information-popover/information-popover.component.module'
@NgModule({
declarations: [
DependentsComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
InformationPopoverComponentModule,
],
exports: [DependentsComponent],
})
export class DependentsComponentModule { }

View File

@@ -0,0 +1,59 @@
import { Component, Input, OnInit } from '@angular/core'
import { BehaviorSubject, from, Subject } from 'rxjs'
import { takeUntil, tap } from 'rxjs/operators'
import { DependentBreakage } from 'src/app/models/app-types'
import { markAsLoadingDuring$ } from 'src/app/services/loader.service'
import { capitalizeFirstLetter } from 'src/app/util/misc.util'
import { Colorable, Loadable } from '../loadable'
import { WizardAction } from '../wizard-types'
@Component({
selector: 'dependents',
templateUrl: './dependents.component.html',
styleUrls: ['../install-wizard.component.scss'],
})
export class DependentsComponent implements OnInit, Loadable, Colorable {
@Input() params: {
title: string,
action: WizardAction, //Are you sure you want to *uninstall*...,
verb: string, // *Uninstalling* will cause problems...
fetchBreakages: () => Promise<DependentBreakage[]>,
skipConfirmationDialogue?: boolean
}
@Input() finished: (info: { error?: Error, cancelled?: true, final?: true }) => Promise<any>
dependentBreakages: DependentBreakage[]
hasDependentViolation: boolean
longMessage: string | null = null
$color$ = new BehaviorSubject('medium') // this will display disabled while loading
$loading$ = new BehaviorSubject(false)
$cancel$ = new Subject<void>()
constructor () { }
ngOnInit () { }
load () {
this.$color$.next('medium')
markAsLoadingDuring$(this.$loading$, from(this.params.fetchBreakages())).pipe(
takeUntil(this.$cancel$),
tap(breakages => this.dependentBreakages = breakages || []),
).subscribe(
{
complete: () => {
this.hasDependentViolation = this.dependentBreakages && this.dependentBreakages.length > 0
if (this.hasDependentViolation) {
this.longMessage = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title} will cause the following services to STOP running. Starting them again will require additional actions.`
this.$color$.next('warning')
} else if (this.params.skipConfirmationDialogue) {
this.finished({ })
} else {
this.longMessage = `No other services installed on your Embassy will be affected by this action.`
this.$color$.next('success')
}
},
error: (e: Error) => this.finished({ error: new Error(`Fetching dependent service information failed: ${e.message || e}`) }),
},
)
}
}

View File

@@ -0,0 +1,52 @@
<ion-header style="height: 12vh">
<ion-toolbar>
<ion-label class="toolbar-label text-ellipses">
<h1 class="toolbar-title">{{ params.toolbar.title }}</h1>
<h3 style="font-size: large; font-style: italic">{{params.toolbar.action}} <ion-text style="font-size: medium;" color="medium">{{ params.toolbar.version | displayEmver }}</ion-text></h3>
</ion-label>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-slides *ngIf="!($error$ | async)" id="slide-show" style="--bullet-background: white" pager="false">
<ion-slide *ngFor="let slide of params.slideDefinitions">
<dependencies #components *ngIf="slide.selector === 'dependencies'" [params]="slide.params"></dependencies>
<dependents #components *ngIf="slide.selector === 'dependents'" [params]="slide.params" [finished]="finished"></dependents>
<complete #components *ngIf="slide.selector === 'complete'" [params]="slide.params" [finished]="finished"></complete>
</ion-slide>
</ion-slides>
<div *ngIf="$error$ | async as error" class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label color="danger" style="font-size: xx-large; font-weight: bold;">
Error
</ion-label>
</div>
<div class="long-message">
{{error}}
</div>
</div>
</div>
</ion-content>
<ion-footer>
<ion-toolbar style="padding: 8px;">
<ng-container *ngIf="!($error$ | async)">
<ion-button slot="start" *ngIf="($anythingLoading$ | async) && currentSlideDef.cancelButton.whileLoading as cancel" (click)="finished({ cancelled: true })" class="toolbar-button" fill="outline" color="medium">
<ion-text *ngIf="cancel.text as t">{{t}}</ion-text>
<ion-icon *ngIf="!cancel.text" name="close-outline"></ion-icon>
</ion-button>
<ion-button slot="start" *ngIf="!($anythingLoading$ | async) && currentSlideDef.cancelButton.afterLoading as cancel" (click)="finished({ cancelled: true })" class="toolbar-button" fill="outline" color="medium">
<ion-text *ngIf="cancel.text as t">{{t}}</ion-text>
<ion-icon *ngIf="!cancel.text" name="close-outline"></ion-icon>
</ion-button>
<ion-button slot="end" *ngIf="currentSlideDef.nextButton as nextButton" (click)="finished({})" [disabled]="$anythingLoading$ | async" fill="outline" class="toolbar-button" color="primary"><ion-text [class.smaller-text]="nextButton.length > 16">{{nextButton}}</ion-text></ion-button>
<ion-button slot="end" *ngIf="currentSlideDef.finishButton as finishButton" (click)="finished({ final: true })" [disabled]="$anythingLoading$ | async" fill="outline" class="toolbar-button" color="primary"><ion-text [class.smaller-text]="finishButton.length > 16">{{finishButton}}</ion-text></ion-button>
</ng-container>
<ng-container *ngIf="$error$ | async">
<ion-button slot="start" (click)="finished({ final: true })" style="text-transform: capitalize; font-weight: bolder;" color="danger">Dismiss</ion-button>
</ng-container>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { InstallWizardComponent } from './install-wizard.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { DependenciesComponentModule } from './dependencies/dependencies.component.module'
import { DependentsComponentModule } from './dependents/dependents.component.module'
import { CompleteComponentModule } from './complete/complete.component.module'
@NgModule({
declarations: [
InstallWizardComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
DependenciesComponentModule,
DependentsComponentModule,
CompleteComponentModule,
],
exports: [InstallWizardComponent],
})
export class InstallWizardComponentModule { }

View File

@@ -0,0 +1,79 @@
.toolbar-label {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
color: white;
padding: 8px 0px 8px 15px;
}
.toolbar-title {
font-size: x-large;
text-transform: capitalize;
border-style: solid;
border-width: 0px 0px 1px 0px;
border-color: #404040;
font-family: 'Montserrat';
}
.center-spinner {
min-height: 40vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
color:white;
}
.slide-content {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
color:white;
min-height: 40vh
}
.status-label {
font-size: xx-large;
font-weight: bold;
}
.long-message {
margin-left: 5%;
margin-right: 5%;
padding: 10px;
font-size: small;
border-width: 0px 0px 1px 0px;
border-color: #393b40;
}
@media (min-width:500px) {
.long-message {
margin-left: 5%;
margin-right: 5%;
padding: 10px;
font-size: medium;
border-width: 0px 0px 1px 0px;
border-color: #393b40;
}
}
.toolbar-button {
text-transform: capitalize;
font-weight: bolder;
}
.smaller-text {
font-size: 14px;
}
.badge {
position: absolute;
width: 2vh;
height: 2vh;
border-radius: 50px;
left: -0.75vh;
top: -0.75vh;
}

View File

@@ -0,0 +1,128 @@
import { Component, Input, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'
import { IonContent, IonSlides, ModalController } from '@ionic/angular'
import { BehaviorSubject, combineLatest, Subscription } from 'rxjs'
import { map } from 'rxjs/operators'
import { Cleanup } from 'src/app/util/cleanup'
import { capitalizeFirstLetter } from 'src/app/util/misc.util'
import { CompleteComponent } from './complete/complete.component'
import { DependenciesComponent } from './dependencies/dependencies.component'
import { DependentsComponent } from './dependents/dependents.component'
import { Colorable, Loadable } from './loadable'
import { WizardAction } from './wizard-types'
@Component({
selector: 'install-wizard',
templateUrl: './install-wizard.component.html',
styleUrls: ['./install-wizard.component.scss'],
})
export class InstallWizardComponent extends Cleanup implements OnInit {
@Input() params: {
// defines the slideshow in the html
slideDefinitions: SlideDefinition[]
toolbar: TopbarParams
}
// containers
@ViewChild(IonContent) contentContainer: IonContent
@ViewChild(IonSlides) slideContainer: IonSlides
//don't use this, use slideComponents instead.
@ViewChildren('components')
public slideComponentsQL: QueryList<Loadable & Colorable>
//don't use this, use currentSlide instead.
slideIndex = 0
get slideComponents (): (Loadable & Colorable)[] {
return this.slideComponentsQL.toArray()
}
get currentSlide (): (Loadable & Colorable) {
return this.slideComponents[this.slideIndex]
}
get currentSlideDef (): SlideDefinition {
return this.params.slideDefinitions[this.slideIndex]
}
$anythingLoading$: BehaviorSubject<boolean> = new BehaviorSubject(true)
$currentColor$: BehaviorSubject<string> = new BehaviorSubject('medium')
$error$ = new BehaviorSubject(undefined)
constructor (private readonly modalController: ModalController) { super() }
ngOnInit () { }
ngAfterViewInit () {
this.currentSlide.load()
this.slideContainer.update()
this.slideContainer.lockSwipes(true)
}
ionViewDidEnter () {
this.cleanup(
combineLatest(this.slideComponents.map(component => component.$loading$)).pipe(
map(loadings => !loadings.every(p => !p)),
).subscribe(this.$anythingLoading$),
combineLatest(this.slideComponents.map(component => component.$color$)).pipe(
map(colors => colors[this.slideIndex]),
).subscribe(this.$currentColor$),
)
}
finished = (info: { error?: Error, cancelled?: true, final?: true }) => {
if (info.cancelled) this.currentSlide.$cancel$.next()
if (info.final || info.cancelled) return this.modalController.dismiss(info)
if (info.error) return this.$error$.next(capitalizeFirstLetter(info.error.message))
this.slide()
}
private async slide () {
if (this.slideComponents[this.slideIndex + 1] === undefined) { return this.finished({ final: true }) }
this.slideIndex += 1
await this.slideContainer.lockSwipes(false)
await Promise.all([this.contentContainer.scrollToTop(), this.slideContainer.slideNext()])
await this.slideContainer.lockSwipes(true)
this.currentSlide.load()
}
}
export interface SlideCommon {
selector: string
cancelButton: {
// indicates the existence of a cancel button, and whether to have text or an icon 'x' by default.
afterLoading?: { text?: string },
whileLoading?: { text?: string }
}
nextButton?: string,
finishButton?: string
}
export type SlideDefinition = SlideCommon & (
{
selector: 'dependencies',
params: DependenciesComponent['params']
} | {
selector: 'dependents',
params: DependentsComponent['params']
} | {
selector: 'complete',
params: CompleteComponent['params']
}
)
export type TopbarParams = { action: WizardAction, title: string, version: string }
export async function wizardModal (
modalController: ModalController, params: InstallWizardComponent['params'],
): Promise<{ cancelled?: true, final?: true, modal: HTMLIonModalElement }> {
const modal = await modalController.create({
backdropDismiss: false,
cssClass: 'wizard-modal',
component: InstallWizardComponent,
componentProps: { params },
})
await modal.present()
return modal.onWillDismiss().then(({ data }) => ({ ...data, modal }))
}

View File

@@ -0,0 +1,11 @@
import { BehaviorSubject, Subject } from 'rxjs'
export interface Loadable {
load: () => void
$loading$: BehaviorSubject<boolean> //will be true during load function
$cancel$: Subject<void> //will cancel load function
}
export interface Colorable {
$color$: BehaviorSubject<string>
}

View File

@@ -0,0 +1,161 @@
import { Injectable } from '@angular/core'
import { AppModel, AppStatus } from 'src/app/models/app-model'
import { AppDependency, DependentBreakage, AppInstalledPreview } from '../../models/app-types'
import { ApiService } from '../../services/api/api.service'
import { InstallWizardComponent, SlideDefinition, TopbarParams } from './install-wizard.component'
@Injectable({ providedIn: 'root' })
export class WizardBaker {
constructor (private readonly apiService: ApiService, private readonly appModel: AppModel) { }
install (values: {
id: string, title: string, version: string, serviceRequirements: AppDependency[]
}): InstallWizardComponent['params'] {
const { id, title, version, serviceRequirements } = values
validate(id, exists, 'missing id')
validate(title, exists, 'missing title')
validate(version, exists, 'missing version')
validate(serviceRequirements, t => !!t && Array.isArray(t), 'missing serviceRequirements')
const action = 'install'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ selector: 'dependencies', cancelButton: { afterLoading: { text: 'Cancel' } }, nextButton: 'Install', params: {
action, title, version, serviceRequirements,
}},
{ selector: 'complete', finishButton: 'Dismiss', cancelButton: { whileLoading: { } }, params: {
action, verb: 'beginning installation for', title, executeAction: () => this.apiService.installApp(id, version).then(app => {
this.appModel.add({ ...app, status: AppStatus.INSTALLING })
}),
}},
]
return { toolbar, slideDefinitions }
}
update (values: {
id: string, title: string, version: string, serviceRequirements: AppDependency[]
}): InstallWizardComponent['params'] {
const { id, title, version, serviceRequirements } = values
validate(id, exists, 'missing id')
validate(title, exists, 'missing title')
validate(version, exists, 'missing version')
validate(serviceRequirements, t => !!t && Array.isArray(t), 'missing serviceRequirements')
const action = 'update'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ selector: 'dependencies', cancelButton: { afterLoading: { text: 'Cancel' } }, nextButton: 'Update', params: {
action, title, version, serviceRequirements,
}},
{ selector: 'dependents', cancelButton: { whileLoading: { }, afterLoading: { text: 'Cancel' } }, nextButton: 'Update Anyways', params: {
skipConfirmationDialogue: true, action, verb: 'updating', title, fetchBreakages: () => this.apiService.installApp(id, version, true).then( ({ breakages }) => breakages ),
}},
{ selector: 'complete', finishButton: 'Dismiss', cancelButton: { whileLoading: { } }, params: {
action, verb: 'beginning update for', title, executeAction: () => this.apiService.installApp(id, version).then(app => {
this.appModel.update({ id: app.id, status: AppStatus.INSTALLING })
}),
}},
]
return { toolbar, slideDefinitions }
}
downgrade (values: {
id: string, title: string, version: string, serviceRequirements: AppDependency[]
}): InstallWizardComponent['params'] {
const { id, title, version, serviceRequirements } = values
validate(id, exists, 'missing id')
validate(title, exists, 'missing title')
validate(version, exists, 'missing version')
validate(serviceRequirements, t => !!t && Array.isArray(t), 'missing serviceRequirements')
const action = 'downgrade'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ selector: 'dependencies', cancelButton: { afterLoading: { text: 'Cancel' } }, nextButton: 'Downgrade', params: {
action, title, version, serviceRequirements,
}},
{ selector: 'dependents', cancelButton: { whileLoading: { }, afterLoading: { text: 'Cancel' } }, nextButton: 'Downgrade Anyways', params: {
skipConfirmationDialogue: true, action, verb: 'downgrading', title, fetchBreakages: () => this.apiService.installApp(id, version, true).then( ({ breakages }) => breakages ),
}},
{ selector: 'complete', finishButton: 'Dismiss', cancelButton: { whileLoading: { } }, params: {
action, verb: 'beginning downgrade for', title, executeAction: () => this.apiService.installApp(id, version).then(app => {
this.appModel.update({ id: app.id, status: AppStatus.INSTALLING })
}),
}},
]
return { toolbar, slideDefinitions }
}
uninstall (values: {
id: string, title: string, version: string
}): InstallWizardComponent['params'] {
const { id, title, version } = values
validate(id, exists, 'missing id')
validate(title, exists, 'missing title')
validate(version, exists, 'missing version')
const action = 'uninstall'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ selector: 'dependents', cancelButton: { whileLoading: { }, afterLoading: { text: 'Cancel' } }, nextButton: 'Uninstall', params: {
action, verb: 'uninstalling', title, fetchBreakages: () => this.apiService.uninstallApp(id, true).then( ({ breakages }) => breakages ),
}},
{ selector: 'complete', finishButton: 'Dismiss', cancelButton: { whileLoading: { } }, params: {
action, verb: 'uninstalling', title, executeAction: () => this.apiService.uninstallApp(id).then(() => this.appModel.delete(id)),
}},
]
return { toolbar, slideDefinitions }
}
stop (values: {
breakages: DependentBreakage[], id: string, title: string, version: string
}): InstallWizardComponent['params'] {
const { breakages, title, version } = values
validate(breakages, t => !!t && Array.isArray(t), 'missing breakages')
validate(title, exists, 'missing title')
validate(version, exists, 'missing version')
const action = 'stop'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ selector: 'dependents', cancelButton: { afterLoading: { text: 'Cancel' } }, nextButton: 'Stop Anyways', params: {
action, verb: 'stopping', title, fetchBreakages: () => Promise.resolve(breakages),
}},
]
return { toolbar, slideDefinitions }
}
configure (values: {
breakages: DependentBreakage[], app: AppInstalledPreview
}): InstallWizardComponent['params'] {
const { breakages, app } = values
const { title, versionInstalled: version } = app
const action = 'configure'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ selector: 'dependents', cancelButton: { afterLoading: { text: 'Cancel' } }, nextButton: 'Save Config Anyways', params: {
action, verb: 'saving config for', title, fetchBreakages: () => Promise.resolve(breakages),
}},
]
return { toolbar, slideDefinitions }
}
}
function validate<T> (t: T, test: (t: T) => Boolean, desc: string) {
if (!test(t)) {
console.error('failed validation', desc, t)
throw new Error(desc)
}
}
const exists = t => !!t

View File

@@ -0,0 +1,7 @@
export type WizardAction =
'install'
| 'update'
| 'downgrade'
| 'uninstall'
| 'stop'
| 'configure'

View File

@@ -0,0 +1,16 @@
<ion-item button (click)="handleClick()">
<ion-icon size="small" slot="start" *ngIf="anno | annotationStatus: 'Added'" style="margin-right: 15px; color: rgba(0,0,0,0); background: radial-gradient(#2a4e8970, #2a4e8970 35%, transparent 35%, transparent);" name="ellipse"></ion-icon>
<ion-icon size="small" slot="start" *ngIf="anno | annotationStatus: 'NoChange'" style="margin-right: 15px; color: rgba(0,0,0,0); background: radial-gradient(#2a4e8970, #2a4e8970 35%, transparent 35%, transparent);" name="ellipse"></ion-icon>
<ion-icon size="small" slot="start" *ngIf="anno | annotationStatus: 'Edited'" style="margin-right: 15px" color="primary" name="ellipse"></ion-icon>
<ion-icon size="small" slot="start" *ngIf="anno | annotationStatus: 'Invalid'" style="margin-right: 15px" color="danger" name="warning-outline"></ion-icon>
<div class="organizer">
<ion-label class="ion-text-wrap">
<ion-text color="medium">{{ spec.name }}</ion-text>
<ion-text class="new-tag" *ngIf="anno | annotationStatus: 'Added'">(new)</ion-text>
</ion-label>
</div>
<ion-note *ngIf="displayValue" style="font-size: small;" [class.bold]="anno | annotationStatus: 'Edited'" slot="end">{{ displayValue }}</ion-note>
</ion-item>

View File

@@ -0,0 +1,10 @@
<div *ngFor="let keyval of (spec.type === 'object' ? spec.spec : spec.variants[value[spec.tag.id]]) | keyvalue: asIsOrder">
<object-config-item
[key]="keyval.key"
[spec]="keyval.value"
[value]="value[keyval.key]"
[anno]="annotations.members[keyval.key]"
(onClick)="handleClick(keyval.key)"
[class.add-margin]="keyval.key === 'advanced'"
></object-config-item>
</div>

View File

@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { ObjectConfigComponent, ObjectConfigItemComponent } from './object-config.component'
import { IonicModule } from '@ionic/angular'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
ObjectConfigComponent,
ObjectConfigItemComponent,
],
imports: [
CommonModule,
FormsModule,
IonicModule,
SharingModule,
],
exports: [
ObjectConfigComponent,
ObjectConfigItemComponent,
],
})
export class ObjectConfigComponentModule { }

View File

@@ -0,0 +1,43 @@
.add-margin {
margin: 0 16px;
}
.help-button {
position: relative;
bottom: 7px;
right: 9px;
}
.new-tag {
padding: 0px 5px;
font-weight: bold;
font-size: smaller;
color: #cecece;
font-style: italic;
}
.status-icon{
// width: 2%;
margin-right: 12px;
}
.bright {
color: white !important;
}
.bold {
font-weight: bold;
}
.invalid {
color: var(--ion-color-danger) !important;
}
.organizer {
display: flex;
align-items: center;
}
.name {
text-decoration: underline;
}

View File

@@ -0,0 +1,101 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { Annotation, Annotations } from '../../app-config/config-utilities'
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
import { ConfigCursor } from 'src/app/app-config/config-cursor'
import { ModalPresentable } from 'src/app/app-config/modal-presentable'
import { ValueSpecOf, ValueSpec } from 'src/app/app-config/config-types'
import { MaskPipe } from 'src/app/pipes/mask.pipe'
@Component({
selector: 'object-config',
templateUrl: './object-config.component.html',
styleUrls: ['./object-config.component.scss'],
})
export class ObjectConfigComponent extends ModalPresentable {
@Input() cursor: ConfigCursor<'object' | 'union'>
@Output() onEdit = new EventEmitter<boolean>()
spec: ValueSpecOf<'object' | 'union'>
value: object
annotations: Annotations<'object' | 'union'>
constructor (
trackingModalCtrl: TrackingModalController,
) {
super(trackingModalCtrl)
}
ngOnInit () {
this.spec = this.cursor.spec()
this.value = this.cursor.config()
this.annotations = this.cursor.getAnnotations()
}
async handleClick (key: string) {
const nextCursor = this.cursor.seekNext(key)
nextCursor.createFirstEntryForList()
await this.presentModal(nextCursor, () => {
this.onEdit.emit(true)
this.annotations = this.cursor.getAnnotations()
})
}
asIsOrder () {
return 0
}
}
@Component({
selector: 'object-config-item',
templateUrl: './object-config-item.component.html',
styleUrls: ['./object-config.component.scss'],
})
export class ObjectConfigItemComponent {
@Input() key: string
@Input() spec: ValueSpec
@Input() value: string | number
@Input() anno: Annotation
@Output() onClick = new EventEmitter<boolean>()
maskPipe: MaskPipe = new MaskPipe()
displayValue?: string | number | boolean
ngOnChanges () {
switch (this.spec.type) {
case 'string':
if (this.value) {
if (this.spec.masked) {
this.displayValue = this.maskPipe.transform(this.value as string, 4)
} else {
this.displayValue = this.value
}
} else {
this.displayValue = '-'
}
break
case 'boolean':
this.displayValue = String(this.value)
break
case 'number':
this.displayValue = this.value || '-'
if (this.displayValue && this.spec.units) {
this.displayValue = `${this.displayValue} ${this.spec.units}`
}
break
case 'enum':
this.displayValue = this.spec.valueNames[this.value]
break
case 'pointer':
this.displayValue = 'System Defined'
break
default:
return
}
}
async handleClick (): Promise<void> {
if (this.spec.type === 'pointer') return
this.onClick.emit(true)
}
}

View File

@@ -0,0 +1,3 @@
<ion-button (click)="navigateBack()">
<ion-icon slot="icon-only" name="arrow-back"></ion-icon>
</ion-button>

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { PwaBackComponent } from './pwa-back.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
PwaBackComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [PwaBackComponent],
})
export class PwaBackComponentModule { }

View File

@@ -0,0 +1,16 @@
import { Component } from '@angular/core'
import { PwaBackService } from 'src/app/services/pwa-back.service'
@Component({
selector: 'pwa-back-button',
templateUrl: './pwa-back.component.html',
styleUrls: ['./pwa-back.component.scss'],
})
export class PwaBackComponent {
constructor (
private readonly pwaBack: PwaBackService,
) { }
navigateBack () {
return this.pwaBack.back()
}
}

View File

@@ -0,0 +1 @@
<qrcode [qrdata]="text" [width]="width" errorCorrectionLevel="L"></qrcode>

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { QRComponent } from './qr.component'
import { IonicModule } from '@ionic/angular'
import { QRCodeModule } from 'angularx-qrcode'
@NgModule({
declarations: [
QRComponent,
],
imports: [
CommonModule,
IonicModule,
QRCodeModule,
],
exports: [QRComponent],
})
export class QRComponentModule { }

View File

@@ -0,0 +1,16 @@
import { Component, Input } from '@angular/core'
import { isPlatform } from '@ionic/angular'
@Component({
selector: 'qr',
templateUrl: './qr.component.html',
styleUrls: ['./qr.component.scss'],
})
export class QRComponent {
@Input() text: string
width: number
ngOnInit () {
this.width = isPlatform('ios') || isPlatform('android') ? 320 : 420
}
}

View File

@@ -0,0 +1,9 @@
<ion-fab id="recommendation" vertical="top" horizontal="end" slot="fixed">
<ion-fab-button [disabled]="disabled" style="border-style: solid;
border-radius: 100px;
border-color: #FFEB3B;
border-width: medium;
box-shadow: 0 0 10px white;" size="small" (click)="presentPopover($event)">
<img [src]="rec.iconURL | iconParse" />
</ion-fab-button>
</ion-fab>

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { RecommendationButtonComponent } from './recommendation-button.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
RecommendationButtonComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [RecommendationButtonComponent],
})
export class RecommendationButtonComponentModule { }

View File

@@ -0,0 +1,66 @@
import { Component, Input, OnInit } from '@angular/core'
import { Router } from '@angular/router'
import { PopoverController } from '@ionic/angular'
import { filter, take } from 'rxjs/operators'
import { Cleanup } from 'src/app/util/cleanup'
import { capitalizeFirstLetter } from 'src/app/util/misc.util'
import { InformationPopoverComponent } from '../information-popover/information-popover.component'
@Component({
selector: 'recommendation-button',
templateUrl: './recommendation-button.component.html',
styleUrls: ['./recommendation-button.component.scss'],
})
export class RecommendationButtonComponent extends Cleanup implements OnInit {
@Input() rec: Recommendation
@Input() raise?: { id: string }
constructor (private readonly router: Router, private readonly popoverController: PopoverController) {
super()
}
ngOnInit () {
if (!this.raise) return
const mainContent = document.getElementsByTagName('ion-app')[0]
const recButton = document.getElementById(this.raise.id)
mainContent.appendChild(recButton)
this.router.events.pipe(filter(e => !!(e as any).urlAfterRedirects, take(1))).subscribe((e: any) => {
recButton.remove()
})
}
disabled = false
async presentPopover (ev: any) {
const popover = await this.popoverController.create({
component: InformationPopoverComponent,
event: ev,
translucent: false,
showBackdrop: true,
backdropDismiss: true,
componentProps: {
information: `
<div style="font-size: medium; font-style: italic; margin: 5px 0px;">
${capitalizeFirstLetter(this.rec.title)} Installation Recommendations
</div>
<div>
${this.rec.description}
</div>`,
},
})
popover.onWillDismiss().then(() => {
this.disabled = false
})
this.disabled = true
return await popover.present()
}
}
export type Recommendation = {
title: string
appId: string
iconURL: string,
description: string,
versionSpec?: string
whyDependency?: string
}

View File

@@ -0,0 +1,24 @@
<p *ngIf="size === 'small'" style="margin: 0 0 4px 0;">
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots dots-small" name="dots" [color]="color"></ion-spinner>
</p>
<h3 *ngIf="size === 'italics-small'" style="margin: 0 0 4px 0;">
<ion-text [style]="style" style="font-size: small; font-style: italic; text-transform: lowercase;" [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots dots-small" name="dots" [color]="color"></ion-spinner>
</h3>
<h3 *ngIf="size === 'medium'">
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots dots-medium" name="dots" [color]="color"></ion-spinner>
</h3>
<h1 *ngIf="size === 'large'">
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots" name="dots" [color]="color"></ion-spinner>
</h1>
<h1 style="font-size: 18px; font-weight: 500" *ngIf="size === 'bold-large'">
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots" name="dots" [color]="color"></ion-spinner>
</h1>

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { StatusComponent } from './status.component'
import { IonicModule } from '@ionic/angular'
@NgModule({
declarations: [
StatusComponent,
],
imports: [
CommonModule,
IonicModule,
],
exports: [StatusComponent],
})
export class StatusComponentModule { }

View File

@@ -0,0 +1,32 @@
.icon-small {
width: auto;
height: 14px;
padding-left: 6px;
}
.icon-medium {
width: auto;
height: 18px;
padding-left: 8px;
}
.icon-large {
width: auto;
height: 24px;
padding-left: 12px;
}
.dots {
vertical-align: middle;
margin-left: 8px;
}
.dots-small {
width: 12px !important;
height: 12px !important;
}
.dots-medium {
width: 16px !important;
height: 16px !important;
}

View File

@@ -0,0 +1,56 @@
import { Component, Input } from '@angular/core'
import { AppStatus } from 'src/app/models/app-model'
import { ServerStatus } from 'src/app/models/server-model'
import { ServerStatusRendering, AppStatusRendering } from '../../util/status-rendering'
@Component({
selector: 'status',
templateUrl: './status.component.html',
styleUrls: ['./status.component.scss'],
})
export class StatusComponent {
@Input() appStatus?: AppStatus
@Input() serverStatus?: ServerStatus
@Input() size: 'small' | 'medium' | 'large' | 'italics-small' | 'bold-large' = 'large'
@Input() text: string = ''
color: string
display: string
showDots: boolean
style = ''
ngOnChanges () {
if (this.serverStatus) {
this.handleServerStatus()
} else if (this.appStatus) {
this.handleAppStatus()
}
}
handleServerStatus () {
let res = ServerStatusRendering[this.serverStatus]
if (!res) {
console.warn(`Received invalid server status from the server: `, this.serverStatus)
res = ServerStatusRendering[ServerStatus.UNKNOWN]
}
const { display, color, showDots } = res
this.display = display
this.color = color
this.showDots = showDots
}
handleAppStatus () {
let res = AppStatusRendering[this.appStatus]
if (!res) {
console.warn(`Received invalid app status from the server: `, this.appStatus)
res = AppStatusRendering[AppStatus.UNKNOWN]
}
const { display, color, showDots, style } = res
this.display = display + this.text
this.color = color
this.showDots = showDots
this.style = style
}
}