feat: move all frontend projects under the same Angular workspace (#1141)

* feat: move all frontend projects under the same Angular workspace

* Refactor/angular workspace (#1154)

* update frontend build steps

Co-authored-by: waterplea <alexander@inkin.ru>
Co-authored-by: Matt Hill <matthewonthemoon@gmail.com>
Co-authored-by: Lucy Cifferello <12953208+elvece@users.noreply.github.com>
This commit is contained in:
Aiden McClelland
2022-01-31 14:01:33 -07:00
committed by GitHub
parent 7e6c852ebd
commit 574539faec
504 changed files with 11569 additions and 78972 deletions

View File

@@ -0,0 +1,14 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="embassy"></ion-back-button>
</ion-buttons>
<ion-title>{{ title }}</ion-title>
<ion-buttons slot="end">
<ion-button [disabled]="backupService.loading" (click)="refresh()">
Refresh
<ion-icon slot="end" name="refresh"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>

View File

@@ -0,0 +1,16 @@
<div class="inline">
<h2 *ngIf="type === 'create'; else restore">
<ion-icon name="cloud-outline" color="success"></ion-icon>
{{ hasValidBackup ? 'Available, contains existing backup' : 'Available for fresh backup' }}
</h2>
<ng-template #restore>
<h2 *ngIf="hasValidBackup">
<ion-icon name="cloud-done-outline" color="success"></ion-icon>
Embassy backup detected
</h2>
<h2 *ngIf="!hasValidBackup">
<ion-icon name="cloud-offline-outline" color="danger"></ion-icon>
No Embassy backup
</h2>
</ng-template>
</div>

View File

@@ -0,0 +1,82 @@
<!-- loading -->
<text-spinner *ngIf="backupService.loading; else loaded" [text]="loadingText"></text-spinner>
<!-- loaded -->
<ng-template #loaded>
<!-- error -->
<ion-item *ngIf="backupService.loadingError; else noError">
<ion-label>
<ion-text color="danger">
{{ backupService.loadingError }}
</ion-text>
</ion-label>
</ion-item>
<ng-template #noError>
<ion-item-group>
<!-- ** cifs ** -->
<ion-item-divider>Shared Network Folders</ion-item-divider>
<ion-item>
<ion-label>
<h2>
Shared folders are the recommended way to create Embassy backups. View the <a href="https://docs.start9.com/user-manual/general/backups.html#shared-network-folder" target="_blank" noreferrer>Instructions</a>
</h2>
</ion-label>
</ion-item>
<!-- add new cifs -->
<ion-item button detail="false" (click)="presentModalAddCifs()">
<ion-icon slot="start" name="add" size="large" color="dark"></ion-icon>
<ion-label>New shared folder</ion-label>
</ion-item>
<!-- cifs list -->
<ng-container *ngFor="let target of backupService.cifs; let i = index">
<ion-item button *ngIf="target.entry as cifs" (click)="presentActionCifs(target, i)">
<ion-icon slot="start" name="folder-open-outline" size="large"></ion-icon>
<ion-label>
<h1>{{ cifs.path.split('/').pop() }}</h1>
<ng-container *ngIf="cifs.mountable">
<backup-drives-status [type]="type" [hasValidBackup]="target.hasValidBackup"></backup-drives-status>
</ng-container>
<h2 *ngIf="!cifs.mountable" class="inline">
<ion-icon name="cellular-outline" color="danger"></ion-icon>
Unable to connect
</h2>
<p>Hostname: {{ cifs.hostname }}</p>
<p>Path: {{ cifs.path }}</p>
</ion-label>
</ion-item>
</ng-container>
<!-- ** drives ** -->
<ion-item-divider>Physical Drives</ion-item-divider>
<!-- no drives -->
<ion-item *ngIf="!backupService.drives.length; else hasDrives" class="ion-padding-bottom">
<ion-label>
<h2>
<ion-text color="warning">
Warning! Plugging a 2nd physical drive directly into your Embassy can lead to data corruption.
</ion-text>
</h2>
<br />
<h2>
To backup to a physical drive, please follow the <a href="https://docs.start9.com/user-manual/general/backups.html#physical-drive" target="_blank" noreferrer>instructions</a>.
</h2>
</ion-label>
</ion-item>
<!-- drives detected -->
<ng-template #hasDrives>
<ion-item button *ngFor="let target of backupService.drives" (click)="select(target)">
<ion-icon slot="start" name="save-outline" size="large"></ion-icon>
<ng-container *ngIf="target.entry as drive">
<ion-label>
<h1>{{ drive.label || drive.logicalname }}</h1>
<backup-drives-status [type]="type" [hasValidBackup]="target.hasValidBackup"></backup-drives-status>
<p>{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model || 'Unknown Model' }}</p>
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
</ion-label>
</ng-container>
</ion-item>
</ng-template>
</ion-item-group>
</ng-template>
</ng-template>

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { BackupDrivesComponent, BackupDrivesHeaderComponent, BackupDrivesStatusComponent } from './backup-drives.component'
import { SharingModule } from '../../modules/sharing.module'
import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module'
@NgModule({
declarations: [
BackupDrivesComponent,
BackupDrivesHeaderComponent,
BackupDrivesStatusComponent,
],
imports: [
CommonModule,
IonicModule,
SharingModule,
GenericFormPageModule,
],
exports: [
BackupDrivesComponent,
BackupDrivesHeaderComponent,
BackupDrivesStatusComponent,
],
})
export class BackupDrivesComponentModule { }

View File

@@ -0,0 +1,274 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { BackupService } from './backup.service'
import { CifsBackupTarget, DiskBackupTarget, RR } from 'src/app/services/api/api.types'
import { ActionSheetController, AlertController, LoadingController, ModalController } from '@ionic/angular'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { MappedBackupTarget } from 'src/app/util/misc.util'
@Component({
selector: 'backup-drives',
templateUrl: './backup-drives.component.html',
styleUrls: ['./backup-drives.component.scss'],
})
export class BackupDrivesComponent {
@Input() type: 'create' | 'restore'
@Output() onSelect: EventEmitter<MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>> = new EventEmitter()
loadingText: string
constructor (
private readonly loadingCtrl: LoadingController,
private readonly actionCtrl: ActionSheetController,
private readonly alertCtrl: AlertController,
private readonly modalCtrl: ModalController,
private readonly embassyApi: ApiService,
private readonly errToast: ErrorToastService,
public readonly backupService: BackupService,
) { }
ngOnInit () {
this.loadingText = this.type === 'create' ? 'Fetching Backup Targets' : 'Fetching Backup Sources'
this.backupService.getBackupTargets()
}
select (target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>): void {
if (target.entry.type === 'cifs' && !target.entry.mountable) {
const message = 'Unable to connect to shared folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.'
this.presentAlertError(message)
return
}
if (this.type === 'restore' && !target.hasValidBackup) {
const message = `${target.entry.type === 'cifs' ? 'Shared folder' : 'Drive partition'} does not contain a valid Embassy backup.`
this.presentAlertError(message)
return
}
this.onSelect.emit(target)
}
async presentModalAddCifs (): Promise<void> {
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: 'New Shared Folder',
spec: CifsSpec,
buttons: [
{
text: 'Save',
handler: (value: RR.AddBackupTargetReq) => {
return this.addCifs(value)
},
isSubmit: true,
},
],
},
})
await modal.present()
}
async presentActionCifs (target: MappedBackupTarget<CifsBackupTarget>, index: number): Promise<void> {
const entry = target.entry as CifsBackupTarget
const action = await this.actionCtrl.create({
header: entry.hostname,
subHeader: 'Shared Folder',
mode: 'ios',
buttons: [
{
text: 'Forget',
icon: 'trash',
role: 'destructive',
handler: () => {
this.deleteCifs(target.id, index)
},
},
{
text: 'Edit',
icon: 'pencil',
handler: () => {
this.presentModalEditCifs(target.id, entry, index)
},
},
{
text: this.type === 'create' ? 'Create Backup' : 'Restore From Backup',
icon: this.type === 'create' ? 'cloud-upload-outline' : 'cloud-download-outline',
handler: () => {
this.select(target)
},
},
],
})
await action.present()
}
private async presentAlertError (message: string): Promise<void> {
const alert = await this.alertCtrl.create({
header: 'Error',
message,
buttons: ['OK'],
})
await alert.present()
}
private async addCifs (value: RR.AddBackupTargetReq): Promise<boolean> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Testing connectivity to shared folder...',
cssClass: 'loader',
})
await loader.present()
try {
const res = await this.embassyApi.addBackupTarget(value)
const [id, entry] = Object.entries(res)[0]
this.backupService.cifs.unshift({
id,
hasValidBackup: this.backupService.hasValidBackup(entry),
entry,
})
return true
} catch (e) {
this.errToast.present(e)
return false
} finally {
loader.dismiss()
}
}
private async presentModalEditCifs (id: string, entry: CifsBackupTarget, index: number): Promise<void> {
const { hostname, path, username } = entry
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: 'Update Shared Folder',
spec: CifsSpec,
buttons: [
{
text: 'Save',
handler: (value: RR.AddBackupTargetReq) => {
return this.editCifs({ id, ...value }, index)
},
isSubmit: true,
},
],
initialValue: {
hostname,
path,
username,
},
},
})
await modal.present()
}
private async editCifs (value: RR.UpdateBackupTargetReq, index: number): Promise<void> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Testing connectivity to shared folder...',
cssClass: 'loader',
})
await loader.present()
try {
const res = await this.embassyApi.updateBackupTarget(value)
const entry = Object.values(res)[0]
this.backupService.cifs[index].entry = entry
} catch (e) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async deleteCifs (id: string, index: number): Promise<void> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Removing...',
cssClass: 'loader',
})
await loader.present()
try {
await this.embassyApi.removeBackupTarget({ id })
this.backupService.cifs.splice(index, 1)
} catch (e) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
}
@Component({
selector: 'backup-drives-header',
templateUrl: './backup-drives-header.component.html',
styleUrls: ['./backup-drives.component.scss'],
})
export class BackupDrivesHeaderComponent {
@Input() title: string
@Output() onClose: EventEmitter<void> = new EventEmitter()
constructor (
public readonly backupService: BackupService,
) { }
refresh () {
this.backupService.getBackupTargets()
}
}
@Component({
selector: 'backup-drives-status',
templateUrl: './backup-drives-status.component.html',
styleUrls: ['./backup-drives.component.scss'],
})
export class BackupDrivesStatusComponent {
@Input() type: string
@Input() hasValidBackup: boolean
}
const CifsSpec: ConfigSpec = {
hostname: {
type: 'string',
name: 'Hostname',
description: 'The hostname of your target device on the Local Area Network.',
placeholder: `e.g. 'My Computer' OR 'my-computer.local'`,
pattern: '^[a-zA-Z0-9._-]+( [a-zA-Z0-9]+)*$',
'pattern-description': `Must be a valid hostname. e.g. 'My Computer' OR 'my-computer.local'`,
nullable: false,
masked: false,
copyable: false,
},
path: {
type: 'string',
name: 'Path',
description: 'The directory path to the shared folder on your target device.',
placeholder: 'e.g. /Desktop/my-folder',
nullable: false,
masked: false,
copyable: false,
},
username: {
type: 'string',
name: 'Username',
description: 'The username of the user account on your target device.',
nullable: false,
masked: false,
copyable: false,
},
password: {
type: 'string',
name: 'Password',
description: 'The password of the user account on your target device.',
nullable: true,
masked: true,
copyable: false,
},
}

View File

@@ -0,0 +1,58 @@
import { Injectable } from '@angular/core'
import { IonicSafeString } from '@ionic/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { getErrorMessage } from 'src/app/services/error-toast.service'
import { BackupTarget, CifsBackupTarget, DiskBackupTarget } from 'src/app/services/api/api.types'
import { Emver } from 'src/app/services/emver.service'
import { MappedBackupTarget } from 'src/app/util/misc.util'
@Injectable({
providedIn: 'root',
})
export class BackupService {
cifs: MappedBackupTarget<CifsBackupTarget>[]
drives: MappedBackupTarget<DiskBackupTarget>[]
loading = true
loadingError: string | IonicSafeString
constructor (
private readonly embassyApi: ApiService,
private readonly emver: Emver,
) { }
async getBackupTargets (): Promise<void> {
this.loading = true
try {
const targets = await this.embassyApi.getBackupTargets({ })
// cifs
this.cifs = Object.entries(targets)
.filter(([_, target]) => target.type === 'cifs')
.map(([id, cifs]) => {
return {
id,
hasValidBackup: this.hasValidBackup(cifs),
entry: cifs as CifsBackupTarget,
}
})
// drives
this.drives = Object.entries(targets)
.filter(([_, target]) => target.type === 'disk')
.map(([id, drive]) => {
return {
id,
hasValidBackup: this.hasValidBackup(drive),
entry: drive as DiskBackupTarget,
}
})
} catch (e) {
this.loadingError = getErrorMessage(e)
} finally {
this.loading = false
}
}
hasValidBackup (target: BackupTarget): boolean {
return [0, 1].includes(this.emver.compare(target['embassy-os']?.version, '0.3.0'))
}
}

View File

@@ -0,0 +1,4 @@
<div style="position: relative; margin-right: 1vh;">
<ion-badge mode="md" class="md-badge" *ngIf="unreadCount && !sidebarOpen" color="danger">{{ unreadCount }}</ion-badge>
<ion-menu-button color="dark"></ion-menu-button>
</div>

View File

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

View File

@@ -0,0 +1,9 @@
.md-badge {
background-color: var(--ion-color-danger);
position: absolute;
top: -8px;
left: 56%;
border-radius: 6px;
z-index: 1;
font-size: 80%;
}

View File

@@ -0,0 +1,39 @@
import { Component } from '@angular/core'
import { SplitPaneTracker } from 'src/app/services/split-pane.service'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { combineLatest, Subscription } from 'rxjs'
@Component({
selector: 'badge-menu-button',
templateUrl: './badge-menu.component.html',
styleUrls: ['./badge-menu.component.scss'],
})
export class BadgeMenuComponent {
unreadCount: number
sidebarOpen: boolean
subs: Subscription[] = []
constructor (
private readonly splitPane: SplitPaneTracker,
private readonly patch: PatchDbService,
) { }
ngOnInit () {
this.subs = [
combineLatest([
this.patch.watch$('server-info', 'unread-notification-count'),
this.splitPane.sidebarOpen$,
])
.subscribe(([unread, menu]) => {
this.unreadCount = unread
this.sidebarOpen = menu
}),
]
}
ngOnDestroy () {
this.subs.forEach(sub => sub.unsubscribe())
}
}

View File

@@ -0,0 +1,37 @@
<div [hidden]="!control.dirty && !control.touched" class="validation-error">
<!-- primitive -->
<p *ngIf="control.hasError('required')">
{{ spec.name }} is required
</p>
<!-- string -->
<p *ngIf="control.hasError('pattern')">
{{ spec['pattern-description'] }}
</p>
<!-- number -->
<ng-container *ngIf="spec.type === 'number'">
<p *ngIf="control.hasError('numberNotInteger')">
{{ spec.name }} must be an integer
</p>
<p *ngIf="control.hasError('numberNotInRange')">
{{ control.errors['numberNotInRange'].value }}
</p>
<p *ngIf="control.hasError('notNumber')">
{{ spec.name }} must be a number
</p>
</ng-container>
<!-- list -->
<ng-container *ngIf="spec.type === 'list'">
<p *ngIf="control.hasError('listNotInRange')">
{{ control.errors['listNotInRange'].value }}
</p>
<p *ngIf="control.hasError('listNotUnique')">
{{ control.errors['listNotUnique'].value }}
</p>
<p *ngIf="control.hasError('listItemIssue')">
{{ control.errors['listItemIssue'].value }}
</p>
</ng-container>
</div>

View File

@@ -0,0 +1,17 @@
<ion-button *ngIf="data.spec.description" class="slot-start" fill="clear" size="small" (click)="presentAlertDescription()">
<ion-icon name="help-circle-outline"></ion-icon>
</ion-button>
<!-- this is a button for css purposes only -->
<ion-button *ngIf="data.invalid" class="slot-start" fill="clear" size="small" color="danger">
<ion-icon name="warning-outline"></ion-icon>
</ion-button>
<span>{{ data.spec.name }}</span>
<ion-text color="success" *ngIf="data.new">&nbsp;(New)</ion-text>
<ion-text color="warning" *ngIf="data.edited">&nbsp;(Edited)</ion-text>
<span *ngIf="(['string', 'number'] | includes : data.spec.type) && !$any(data.spec).nullable">&nbsp;*</span>
<span *ngIf="data.spec.type === 'list' && Range.from(data.spec.range).min">&nbsp;*</span>

View File

@@ -0,0 +1,243 @@
<ion-item-group [formGroup]="formGroup">
<div *ngFor="let entry of formGroup.controls | keyvalue : asIsOrder">
<!-- union enum -->
<ng-container *ngIf="unionSpec && entry.key === unionSpec.tag.id">
<p class="input-label">{{ unionSpec.tag.name }}</p>
<ion-item>
<ion-button *ngIf="unionSpec.tag.description" class="slot-start" fill="clear" size="small" (click)="presentUnionTagDescription(unionSpec.tag.name, unionSpec.tag.description)">
<ion-icon name="help-circle-outline"></ion-icon>
</ion-button>
<ion-label>{{ unionSpec.tag.name }}</ion-label>
<!-- class enter-click disables the enter click on the modal behind the select -->
<ion-select
[interfaceOptions]="{ message: getWarningText(unionSpec.warning), cssClass: 'enter-click' }"
slot="end"
placeholder="Select"
[formControlName]="unionSpec.tag.id"
[selectedText]="unionSpec.tag['variant-names'][entry.value.value]"
(ionChange)="updateUnion($event)"
>
<ion-select-option *ngFor="let option of Object.keys(unionSpec.variants)" [value]="option">
{{ unionSpec.tag['variant-names'][option] }}
</ion-select-option>
</ion-select>
</ion-item>
</ng-container>
<ng-container *ngIf="objectSpec[entry.key] as spec">
<!-- primitive -->
<ng-container *ngIf="['string', 'number', 'boolean', 'enum'] | includes : spec.type">
<!-- label -->
<h4 class="input-label">
<form-label [data]="{
spec: spec,
new: current && current[entry.key] === undefined,
edited: entry.value.dirty
}"></form-label>
</h4>
<!-- string or number -->
<ion-item color="dark" *ngIf="spec.type === 'string' || spec.type === 'number'">
<ion-input
[type]="spec.type === 'string' && spec.masked && !unmasked[entry.key] ? 'password' : 'text'"
[inputmode]="spec.type === 'number' ? 'tel' : 'text'"
[placeholder]="spec.placeholder || 'Enter ' + spec.name"
[formControlName]="entry.key"
(ionFocus)="presentAlertChangeWarning(entry.key, spec)"
(ionChange)="handleInputChange()"
>
</ion-input>
<ion-button *ngIf="spec.type === 'string' && spec.masked" slot="end" fill="clear" color="light" (click)="unmasked[entry.key] = !unmasked[entry.key]">
<ion-icon slot="icon-only" [name]="unmasked[entry.key] ? 'eye-off-outline' : 'eye-outline'" size="small"></ion-icon>
</ion-button>
<ion-note *ngIf="spec.type === 'number' && spec.units" slot="end" color="light" style="font-size: medium;">{{ spec.units }}</ion-note>
</ion-item>
<!-- boolean -->
<ion-item *ngIf="spec.type === 'boolean'">
<ion-label>{{ spec.name }}</ion-label>
<ion-toggle slot="end" [formControlName]="entry.key" (ionChange)="handleBooleanChange(entry.key, spec)"></ion-toggle>
</ion-item>
<!-- enum -->
<ion-item *ngIf="spec.type === 'enum'">
<ion-label>{{ spec.name }}</ion-label>
<!-- class enter-click disables the enter click on the modal behind the select -->
<ion-select
[interfaceOptions]="{ message: getWarningText(spec.warning), cssClass: 'enter-click' }"
slot="end"
placeholder="Select"
[formControlName]="entry.key"
[selectedText]="spec['value-names'][formGroup.get(entry.key).value]"
>
<ion-select-option *ngFor="let option of spec.values" [value]="option">
{{ spec['value-names'][option] }}
</ion-select-option>
</ion-select>
</ion-item>
</ng-container>
<!-- object or union -->
<ng-container *ngIf="spec.type === 'object' || spec.type ==='union'">
<!-- label -->
<ion-item-divider (click)="toggleExpandObject(entry.key)" style="cursor: pointer;">
<form-label [data]="{
spec: spec,
new: current && current[entry.key] === undefined,
edited: entry.value.dirty
}"
></form-label>
<ion-icon
slot="end"
name="chevron-up"
[ngStyle]="{
'transform': objectDisplay[entry.key].expanded ? 'rotate(0deg)' : 'rotate(180deg)',
'transition': 'transform 0.25s ease-out'
}"
></ion-icon>
</ion-item-divider>
<!-- body -->
<div
[id]="getElementId(entry.key)"
[ngStyle]="{
'max-height': objectDisplay[entry.key].height,
'overflow': 'hidden',
'transition-property': 'max-height',
'transition-duration': '.25s'
}"
>
<div class="nested-wrapper">
<form-object
[objectSpec]="
spec.type === 'union' ?
spec.variants[$any(entry.value).controls[spec.tag.id].value] :
spec.spec"
[formGroup]="$any(entry.value)"
[current]="current ? current[entry.key] : undefined"
[unionSpec]="spec.type === 'union' ? spec : undefined"
(onExpand)="resize(entry.key)"
></form-object>
</div>
</div>
</ng-container>
<!-- list (not enum) -->
<ng-container *ngIf="spec.type === 'list' && spec.subtype !== 'enum'">
<ng-container *ngIf="formGroup.get(entry.key) as formArr" [formArrayName]="entry.key">
<!-- label -->
<ion-item-divider>
<form-label [data]="{
spec: spec,
new: current && current[entry.key] === undefined,
edited: entry.value.dirty
}"></form-label>
<ion-button fill="clear" color="primary" slot="end" (click)="addListItemWrapper(entry.key, spec)">
<ion-icon slot="start" name="add"></ion-icon>
Add
</ion-button>
</ion-item-divider>
<!-- body -->
<div class="nested-wrapper">
<div
*ngFor="let abstractControl of $any(formArr).controls; let i = index;"
class="ion-padding-top"
>
<!-- nested -->
<ng-container *ngIf="spec.subtype === 'object' || spec.subtype === 'union'">
<!-- nested label -->
<ion-item button (click)="toggleExpandListObject(entry.key, i)">
<form-label [data]="{
spec: $any({ name: objectListDisplay[entry.key][i].displayAs || 'Entry ' + (i + 1) }),
new: false,
edited: abstractControl.dirty,
invalid: abstractControl.invalid
}"></form-label>
<ion-icon
slot="end"
name="chevron-up"
[ngStyle]="{
'transform': objectListDisplay[entry.key][i].expanded ? 'rotate(0deg)' : 'rotate(180deg)',
'transition': 'transform 0.4s ease-out'
}"
></ion-icon>
</ion-item>
<!-- nested body -->
<div
[id]="getElementId(entry.key, i)"
[ngStyle]="{
'max-height': objectListDisplay[entry.key][i].height,
'overflow': 'hidden',
'transition-property': 'max-height',
'transition-duration': '.5s',
'transition-delay': '.05s'
}"
>
<form-object
[objectSpec]="
spec.subtype === 'union' ?
$any(spec.spec).variants[abstractControl.controls[$any(spec.spec).tag.id].value] :
$any(spec.spec).spec"
[formGroup]="abstractControl"
[current]="current && current[entry.key] ? current[entry.key][i] : undefined"
[unionSpec]="spec.subtype === 'union' ? $any(spec.spec) : undefined"
(onInputChange)="updateLabel(entry.key, i, spec.spec['display-as'])"
(onExpand)="resize(entry.key, i)"
></form-object>
<div style="text-align: right; padding-top: 12px;">
<ion-button fill="clear" (click)="presentAlertDelete(entry.key, i)" color="danger">
<ion-icon slot="start" name="close"></ion-icon>
Delete
</ion-button>
</div>
</div>
</ng-container>
<!-- string or number -->
<ion-item-group *ngIf="spec.subtype === 'string' || spec.subtype === 'number'">
<ion-item color="dark">
<ion-input
[type]="$any(spec.spec).masked ? 'password' : 'text'"
[inputmode]="spec.subtype === 'number' ? 'tel' : 'text'"
[placeholder]="$any(spec.spec).placeholder || 'Enter ' + spec.name"
[formControlName]="i"
>
</ion-input>
<ion-button slot="end" color="danger" (click)="presentAlertDelete(entry.key, i)">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-item>
<form-error
*ngIf="abstractControl.errors"
[control]="abstractControl"
[spec]="$any(spec.spec)"
>
</form-error>
</ion-item-group>
</div>
</div>
</ng-container>
</ng-container>
<!-- list (enum) -->
<ng-container *ngIf="spec.type === 'list' && spec.subtype === 'enum'">
<ng-container *ngIf="formGroup.get(entry.key) as formArr" [formArrayName]="entry.key">
<!-- label -->
<p class="input-label">
<form-label [data]="{
spec: spec,
new: current && current[entry.key] === undefined,
edited: entry.value.dirty
}"></form-label>
</p>
<!-- list -->
<ion-item button detail="false" color="dark" (click)="presentModalEnumList(entry.key, $any(spec), formArr.value)">
<ion-label style="white-space: nowrap !important;">
<h2>{{ getEnumListDisplay(formArr.value, $any(spec.spec)) }}</h2>
</ion-label>
<ion-button slot="end" fill="clear" color="light">
<ion-icon slot="icon-only" name="chevron-down"></ion-icon>
</ion-button>
</ion-item>
</ng-container>
</ng-container>
<form-error
*ngIf="formGroup.get(entry.key).errors"
[control]="$any(formGroup.get(entry.key))"
[spec]="spec"
>
</form-error>
</ng-container>
</div>
</ion-item-group>

View File

@@ -0,0 +1,29 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormObjectComponent, FormLabelComponent, FormErrorComponent } from './form-object.component'
import { IonicModule } from '@ionic/angular'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { SharingModule } from 'src/app/modules/sharing.module'
import { EnumListPageModule } from 'src/app/modals/enum-list/enum-list.module'
@NgModule({
declarations: [
FormObjectComponent,
FormLabelComponent,
FormErrorComponent,
],
imports: [
CommonModule,
IonicModule,
FormsModule,
ReactiveFormsModule,
SharingModule,
EnumListPageModule,
],
exports: [
FormObjectComponent,
FormLabelComponent,
FormErrorComponent,
],
})
export class FormObjectComponentModule { }

View File

@@ -0,0 +1,26 @@
.slot-start {
display: inline-block;
vertical-align: middle;
}
ion-input {
font-weight: 500;
--placeholder-font-weight: 400;
}
ion-item-divider {
text-transform: unset;
--padding-start: 0;
border-bottom: 1px solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, 0.13))))
}
.nested-wrapper {
padding: 0 0 30px 30px;
}
.validation-error {
p {
font-size: small;
color: var(--ion-color-danger);
}
}

View File

@@ -0,0 +1,320 @@
import { Component, Input, Output, EventEmitter } from '@angular/core'
import { AbstractFormGroupDirective, FormArray, FormGroup } from '@angular/forms'
import { AlertButton, AlertController, IonicSafeString, ModalController } from '@ionic/angular'
import { ConfigSpec, ListValueSpecOf, ValueSpec, ValueSpecBoolean, ValueSpecList, ValueSpecListOf, ValueSpecUnion } from 'src/app/pkg-config/config-types'
import { FormService } from 'src/app/services/form.service'
import { Range } from 'src/app/pkg-config/config-utilities'
import { EnumListPage } from 'src/app/modals/enum-list/enum-list.page'
import { pauseFor } from 'src/app/util/misc.util'
import { v4 } from 'uuid'
const Mustache = require('mustache')
@Component({
selector: 'form-object',
templateUrl: './form-object.component.html',
styleUrls: ['./form-object.component.scss'],
})
export class FormObjectComponent {
@Input() objectSpec: ConfigSpec
@Input() formGroup: FormGroup
@Input() unionSpec: ValueSpecUnion
@Input() current: { [key: string]: any }
@Input() showEdited: boolean = false
@Output() onInputChange = new EventEmitter<void>()
@Output() onExpand = new EventEmitter<void>()
warningAck: { [key: string]: boolean } = { }
unmasked: { [key: string]: boolean } = { }
objectDisplay: { [key: string]: { expanded: boolean, height: string } } = { }
objectListDisplay: { [key: string]: { expanded: boolean, height: string, displayAs: string }[] } = { }
private objectId = v4()
Object = Object
constructor (
private readonly alertCtrl: AlertController,
private readonly modalCtrl: ModalController,
private readonly formService: FormService,
) { }
ngOnInit () {
Object.keys(this.objectSpec).forEach(key => {
const spec = this.objectSpec[key]
if (spec.type === 'list' && ['object', 'union'].includes(spec.subtype)) {
this.objectListDisplay[key] = [];
(this.formGroup.get(key).value as any[]).forEach((obj, index) => {
const displayAs = (spec.spec as ListValueSpecOf<'object'>)['display-as']
this.objectListDisplay[key][index] = {
expanded: false,
height: '0px',
displayAs: displayAs ? (Mustache as any).render(displayAs, obj) : '',
}
})
} else if (['object', 'union'].includes(spec.type)) {
this.objectDisplay[key] = {
expanded: false,
height: '0px',
}
}
})
}
getEnumListDisplay (arr: string[], spec: ListValueSpecOf<'enum'>): string {
return arr.map((v: string) => spec['value-names'][v]).join(', ')
}
updateUnion (e: any): void {
const primary = this.unionSpec.tag.id
Object.keys(this.formGroup.controls).forEach(control => {
if (control === primary) return
this.formGroup.removeControl(control)
})
const unionGroup = this.formService.getUnionObject(this.unionSpec as ValueSpecUnion, e.detail.value)
Object.keys(unionGroup.controls).forEach(control => {
if (control === primary) return
this.formGroup.addControl(control, unionGroup.controls[control])
})
Object.entries(this.unionSpec.variants[e.detail.value]).forEach(([key, value]) => {
if (['object', 'union'].includes(value.type)) {
this.objectDisplay[key] = {
expanded: false,
height: '0px',
}
}
})
this.onExpand.emit()
}
resize (key: string, i?: number): void {
setTimeout(() => {
if (i !== undefined) {
this.objectListDisplay[key][i].height = this.getDocSize(key, i)
} else {
this.objectDisplay[key].height = this.getDocSize(key)
}
this.onExpand.emit()
}, 250) // 250 to match transition-duration, defined in html
}
addListItemWrapper (key: string, spec: ValueSpec) {
this.presentAlertChangeWarning(key, spec, () => this.addListItem(key))
}
addListItem (key: string, markDirty = true, val?: string): void {
const arr = this.formGroup.get(key) as FormArray
if (markDirty) arr.markAsDirty()
const listSpec = this.objectSpec[key] as ValueSpecList
const newItem = this.formService.getListItem(listSpec, val)
newItem.markAllAsTouched()
arr.insert(0, newItem)
if (['object', 'union'].includes(listSpec.subtype)) {
const displayAs = (listSpec.spec as ListValueSpecOf<'object'>)['display-as']
this.objectListDisplay[key].unshift({
height: '0px',
expanded: true,
displayAs: displayAs ? Mustache.render(displayAs, newItem.value) : '',
})
pauseFor(200).then(() => {
this.objectListDisplay[key][0].height = this.getDocSize(key, 0)
})
}
}
toggleExpandObject (key: string) {
this.objectDisplay[key].expanded = !this.objectDisplay[key].expanded
this.objectDisplay[key].height = this.objectDisplay[key].expanded ? this.getDocSize(key) : '0px'
this.onExpand.emit()
}
toggleExpandListObject (key: string, i: number) {
this.objectListDisplay[key][i].expanded = !this.objectListDisplay[key][i].expanded
this.objectListDisplay[key][i].height = this.objectListDisplay[key][i].expanded ? this.getDocSize(key, i) : '0px'
}
updateLabel (key: string, i: number, displayAs: string) {
this.objectListDisplay[key][i].displayAs = displayAs ? Mustache.render(displayAs, this.formGroup.get(key).value[i]) : ''
}
getWarningText (text: string): IonicSafeString {
if (text) return new IonicSafeString(`<ion-text color="warning">${text}</ion-text>`)
}
handleInputChange () {
this.onInputChange.emit()
}
handleBooleanChange (key: string, spec: ValueSpecBoolean) {
if (spec.warning) {
const current = this.formGroup.get(key).value
const cancelFn = () => this.formGroup.get(key).setValue(!current)
this.presentAlertChangeWarning(key, spec, undefined, cancelFn)
}
}
async presentModalEnumList (key: string, spec: ValueSpecListOf<'enum'>, current: string[]) {
const modal = await this.modalCtrl.create({
componentProps: {
key,
spec,
current,
},
component: EnumListPage,
})
modal.onWillDismiss().then((res: { data: string[] }) => {
const data = res.data
if (!data) return
this.updateEnumList(key, current, data)
})
await modal.present()
}
async presentAlertChangeWarning (key: string, spec: ValueSpec, okFn?: Function, cancelFn?: Function) {
if (!spec.warning || this.warningAck[key]) return okFn ? okFn() : null
this.warningAck[key] = true
const buttons: AlertButton[] = [
{
text: 'Ok',
handler: () => {
if (okFn) okFn()
},
cssClass: 'enter-click',
},
]
if (okFn || cancelFn) {
buttons.unshift({
text: 'Cancel',
handler: () => {
if (cancelFn) cancelFn()
},
})
}
const alert = await this.alertCtrl.create({
header: 'Warning',
subHeader: `Editing ${spec.name} has consequences:`,
message: spec.warning,
buttons,
})
await alert.present()
}
async presentAlertDelete (key: string, index: number) {
const alert = await this.alertCtrl.create({
header: 'Confirm',
message: 'Are you sure you want to delete this entry?',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Delete',
handler: () => {
this.deleteListItem(key, index)
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
private deleteListItem (key: string, index: number, markDirty = true): void {
if (this.objectListDisplay[key]) this.objectListDisplay[key][index].height = '0px'
const arr = this.formGroup.get(key) as FormArray
if (markDirty) arr.markAsDirty()
pauseFor(250).then(() => {
if (this.objectListDisplay[key]) this.objectListDisplay[key].splice(index, 1)
arr.removeAt(index)
})
}
private updateEnumList (key: string, current: string[], updated: string[]) {
this.formGroup.get(key).markAsDirty()
for (let i = current.length - 1; i >= 0; i--) {
if (!updated.includes(current[i])) {
this.deleteListItem(key, i, false)
}
}
updated.forEach(val => {
if (!current.includes(val)) {
this.addListItem(key, false, val)
}
})
}
private getDocSize (key: string, index = 0) {
const element = document.getElementById(this.getElementId(key, index))
return `${element.scrollHeight}px`
}
getElementId (key: string, index = 0): string {
return `${key}-${index}-${this.objectId}`
}
async presentUnionTagDescription (name: string, description: string) {
const alert = await this.alertCtrl.create({
header: name,
message: description,
})
await alert.present()
}
asIsOrder () {
return 0
}
}
interface HeaderData {
spec: ValueSpec
edited: boolean
new: boolean
invalid?: boolean
}
@Component({
selector: 'form-label',
templateUrl: './form-label.component.html',
styleUrls: ['./form-object.component.scss'],
})
export class FormLabelComponent {
Range = Range
@Input() data: HeaderData
constructor (
private readonly alertCtrl: AlertController,
) { }
async presentAlertDescription () {
const { name, description } = this.data.spec
const alert = await this.alertCtrl.create({
header: name,
message: description,
})
await alert.present()
}
}
@Component({
selector: 'form-error',
templateUrl: './form-error.component.html',
styleUrls: ['./form-object.component.scss'],
})
export class FormErrorComponent {
@Input() control: AbstractFormGroupDirective
@Input() spec: ValueSpec
}

View File

@@ -0,0 +1,10 @@
<div class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label [color]="params.titleColor" style="font-size: xx-large; font-weight: bold;">
{{ params.title }}
</ion-label>
</div>
<div class="long-message" [innerHTML]="params.message | markdown"></div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,20 @@
import { Component, Input } from '@angular/core'
import { BehaviorSubject, Subject } from 'rxjs'
@Component({
selector: 'alert',
templateUrl: './alert.component.html',
styleUrls: ['../install-wizard.component.scss'],
})
export class AlertComponent {
@Input() params: {
title: string
message: string
titleColor: string
}
loading$ = new BehaviorSubject(false)
cancel$ = new Subject<void>()
load () { }
}

View File

@@ -0,0 +1,4 @@
<div *ngIf="loading$ | async" class="center-spinner">
<ion-spinner color="warning" name="lines"></ion-spinner>
<ion-label class="long-message">{{ message }}</ion-label>
</div>

View File

@@ -0,0 +1,18 @@
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'
@NgModule({
declarations: [
CompleteComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
],
exports: [CompleteComponent],
})
export class CompleteComponentModule { }

View File

@@ -0,0 +1,45 @@
import { Component, Input } from '@angular/core'
import { BehaviorSubject, from, Subject } from 'rxjs'
import { takeUntil } from 'rxjs/operators'
import { capitalizeFirstLetter } from 'src/app/util/misc.util'
import { markAsLoadingDuring$ } from '../loadable'
import { WizardAction } from '../wizard-types'
@Component({
selector: 'complete',
templateUrl: './complete.component.html',
styleUrls: ['../install-wizard.component.scss'],
})
export class CompleteComponent {
@Input() params: {
action: WizardAction
verb: string // loader verb: '*stopping* ...'
title: string
executeAction: () => Promise<any>
}
@Input() transitions: {
cancel: () => any
next: (prevResult?: any) => any
final: () => any
error: (e: Error) => any
}
loading$ = new BehaviorSubject(false)
cancel$ = new Subject<void>()
message: string
load () {
markAsLoadingDuring$(this.loading$, from(this.params.executeAction())).pipe(takeUntil(this.cancel$)).subscribe(
{
error: e => this.transitions.error(new Error(`${this.params.action} failed: ${e.message || e}`)),
complete: () => this.transitions.final(),
},
)
}
ngOnInit () {
this.message = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
}
}

View File

@@ -0,0 +1,37 @@
<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 *ngIf="!(loading$ | async) && !!dependentViolation" 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;">
WARNING
</ion-label>
</div>
<div class="long-message">
{{ dependentViolation }}
</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);">
<ion-text color="warning">Affected Services</ion-text>
</div>
<ion-item
style="--ion-item-background: margin-top: 5px"
*ngFor="let dep of dependentBreakages | keyvalue"
>
<ion-thumbnail style="position: relative; height: 4vh; width: 4vh" slot="start">
<img [src]="patch.data['package-data'][dep.key]['static-files'].icon" />
</ion-thumbnail>
<ion-label>
<h5>{{ patch.data['package-data'][dep.key].manifest.title }}</h5>
</ion-label>
</ion-item>
</div>
</div>
</div>

View File

@@ -0,0 +1,20 @@
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'
@NgModule({
declarations: [
DependentsComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [DependentsComponent],
})
export class DependentsComponentModule { }

View File

@@ -0,0 +1,58 @@
import { Component, Input } from '@angular/core'
import { BehaviorSubject, from, Subject } from 'rxjs'
import { takeUntil, tap } from 'rxjs/operators'
import { Breakages } from 'src/app/services/api/api.types'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { capitalizeFirstLetter, isEmptyObject } from 'src/app/util/misc.util'
import { markAsLoadingDuring$ } from '../loadable'
import { WizardAction } from '../wizard-types'
@Component({
selector: 'dependents',
templateUrl: './dependents.component.html',
styleUrls: ['../install-wizard.component.scss'],
})
export class DependentsComponent {
@Input() params: {
title: string,
action: WizardAction, //Are you sure you want to *uninstall*...,
verb: string, // *Uninstalling* will cause problems...
fetchBreakages: () => Promise<Breakages>
}
@Input() transitions: {
cancel: () => any
next: (prevResult?: any) => any
final: () => any
error: (e: Error) => any
}
dependentBreakages: Breakages
dependentViolation: string | undefined
loading$ = new BehaviorSubject(false)
cancel$ = new Subject<void>()
constructor (
public readonly patch: PatchDbService,
) { }
load () {
markAsLoadingDuring$(this.loading$, from(this.params.fetchBreakages()))
.pipe(
takeUntil(this.cancel$),
tap(breakages => this.dependentBreakages = breakages),
)
.subscribe(
{
complete: () => {
if (this.dependentBreakages && !isEmptyObject(this.dependentBreakages)) {
this.dependentViolation = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title} will prohibit the following services from functioning properly and may cause them to stop if they are currently running.`
} else {
this.transitions.next()
}
},
error: (e: Error) => this.transitions.error(new Error(`Fetching dependent service information failed: ${e.message || e}`)),
},
)
}
}

View File

@@ -0,0 +1,67 @@
<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;">{{ params.toolbar.version | displayEmver }}</ion-text></h3>
</ion-label>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-slides *ngIf="!error" id="slide-show" style="--bullet-background: white" pager="false">
<ion-slide *ngFor="let def of params.slideDefinitions">
<!-- We can pass [transitions]="transitions" into the component if logic within the component needs to trigger a transition (not just bottom bar) -->
<alert #components *ngIf="def.slide.selector === 'alert'" [params]="def.slide.params" style="width: 100%;"></alert>
<notes #components *ngIf="def.slide.selector === 'notes'" [params]="def.slide.params" style="width: 100%;"></notes>
<dependents #components *ngIf="def.slide.selector === 'dependents'" [params]="def.slide.params" [transitions]="transitions"></dependents>
<complete #components *ngIf="def.slide.selector === 'complete'" [params]="def.slide.params" [transitions]="transitions"></complete>
</ion-slide>
</ion-slides>
<div *ngIf="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="!initializing && !error">
<!-- cancel button if loading/not loading -->
<ion-button slot="start" *ngIf="(currentSlide.loading$ | async) && currentBottomBar.cancel.whileLoading as cancel" (click)="transitions.cancel()" class="toolbar-button" fill="outline">
<ion-text *ngIf="cancel.text" [class.smaller-text]="cancel.text.length > 16">{{ cancel.text }}</ion-text>
<ion-icon *ngIf="!cancel.text" name="close"></ion-icon>
</ion-button>
<ion-button slot="start" *ngIf="!(currentSlide.loading$ | async) && currentBottomBar.cancel.afterLoading as cancel" (click)="transitions.cancel()" class="toolbar-button" fill="outline">
<ion-text *ngIf="cancel.text" [class.smaller-text]="cancel.text.length > 16">{{ cancel.text }}</ion-text>
<ion-icon *ngIf="!cancel.text" name="close"></ion-icon>
</ion-button>
<!-- next/finish buttons -->
<ng-container *ngIf="!(currentSlide.loading$ | async)">
<!-- next -->
<ion-button slot="end" *ngIf="currentBottomBar.next as next" (click)="callTransition(transitions.next)" fill="outline" class="toolbar-button enter-click" [class.no-click]="transitioning">
<ion-text [class.smaller-text]="next.length > 16">{{ next }}</ion-text>
</ion-button>
<!-- finish -->
<ion-button slot="end" *ngIf="currentBottomBar.finish as finish" (click)="callTransition(transitions.final)" fill="outline" class="toolbar-button enter-click" [class.no-click]="transitioning">
<ion-text [class.smaller-text]="finish.length > 16">{{ finish }}</ion-text>
</ion-button>
</ng-container>
</ng-container>
<ng-container *ngIf="error">
<ion-button slot="start" (click)="transitions.final()" style="text-transform: capitalize; font-weight: bolder;" color="danger">Dismiss</ion-button>
</ng-container>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,28 @@
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 { DependentsComponentModule } from './dependents/dependents.component.module'
import { CompleteComponentModule } from './complete/complete.component.module'
import { NotesComponentModule } from './notes/notes.component.module'
import { AlertComponentModule } from './alert/alert.component.module'
@NgModule({
declarations: [
InstallWizardComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
DependentsComponentModule,
CompleteComponentModule,
NotesComponentModule,
AlertComponentModule,
],
exports: [InstallWizardComponent],
})
export class InstallWizardComponentModule { }

View File

@@ -0,0 +1,81 @@
.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;
text-align: left;
}
@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;
text-align: left;
}
}
.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,131 @@
import { Component, Input, NgZone, QueryList, ViewChild, ViewChildren } from '@angular/core'
import { IonContent, IonSlides, ModalController } from '@ionic/angular'
import { capitalizeFirstLetter, pauseFor } from 'src/app/util/misc.util'
import { CompleteComponent } from './complete/complete.component'
import { DependentsComponent } from './dependents/dependents.component'
import { AlertComponent } from './alert/alert.component'
import { NotesComponent } from './notes/notes.component'
import { 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 {
transitioning = false
@Input() params: {
// defines each slide along with bottom bar
slideDefinitions: SlideDefinition[]
toolbar: TopbarParams
}
// content container so we can scroll to top between slide transitions
@ViewChild(IonContent) contentContainer: IonContent
// slide container gives us hook into ion-slide, allowing for slide transitions
@ViewChild(IonSlides) slideContainer: IonSlides
//a slide component gives us hook into a slide. Allows us to call load when slide comes into view
@ViewChildren('components')
slideComponentsQL: QueryList<Loadable>
get slideComponents (): Loadable[] { return this.slideComponentsQL.toArray() }
private slideIndex = 0
get currentSlide (): Loadable {
return this.slideComponents[this.slideIndex]
}
get currentBottomBar (): SlideDefinition['bottomBar'] {
return this.params.slideDefinitions[this.slideIndex].bottomBar
}
initializing = true
error = ''
constructor (
private readonly modalController: ModalController,
private readonly zone: NgZone,
) { }
ngAfterViewInit () {
this.currentSlide.load()
this.slideContainer.update()
this.slideContainer.lockSwipes(true)
}
ionViewDidEnter () {
this.initializing = false
}
// process bottom bar buttons
private transition = (info: { next: any } | { error: Error } | { cancelled: true } | { final: true }) => {
const i = info as { next?: any, error?: Error, cancelled?: true, final?: true }
if (i.cancelled) this.currentSlide.cancel$.next()
if (i.final || i.cancelled) return this.modalController.dismiss(i)
if (i.error) return this.error = capitalizeFirstLetter(i.error.message)
this.moveToNextSlide(i.next)
}
// bottom bar button callbacks. Pass this into components if they need to trigger slide transitions independent of the bottom bar clicks
transitions = {
next: (prevResult: any) => this.transition({ next: prevResult || this.currentSlide.result }),
cancel: () => this.transition({ cancelled: true }),
final: () => this.transition({ final: true }),
error: (e: Error) => this.transition({ error: e }),
}
private async moveToNextSlide (prevResult?: any) {
if (this.slideComponents[this.slideIndex + 1] === undefined) { return this.transition({ final: true }) }
this.zone.run(async () => {
this.slideComponents[this.slideIndex + 1].load(prevResult)
await pauseFor(50) // give the load ^ opportunity to propogate into slide before sliding it into view
this.slideIndex += 1
await this.slideContainer.lockSwipes(false)
await this.contentContainer.scrollToTop()
await this.slideContainer.slideNext(500)
await this.slideContainer.lockSwipes(true)
})
}
async callTransition (transition: Function) {
this.transitioning = true
await transition()
this.transitioning = false
}
}
export interface SlideDefinition {
slide:
{ selector: 'dependents', params: DependentsComponent['params'] } |
{ selector: 'complete', params: CompleteComponent['params'] } |
{ selector: 'alert', params: AlertComponent['params'] } |
{ selector: 'notes', params: NotesComponent['params'] }
bottomBar: {
cancel: {
// indicates the existence of a cancel button, and whether to have text or an icon 'x' by default.
afterLoading?: { text?: string },
whileLoading?: { text?: string }
}
// indicates the existence of next or finish buttons (should only have one)
next?: string
finish?: string
}
}
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,24 @@
import { BehaviorSubject, Observable, Subject } from 'rxjs'
import { concatMap, finalize } from 'rxjs/operators'
import { fromSync$, emitAfter$ } from 'src/app/util/rxjs.util'
export interface Loadable {
load: (prevResult?: any) => void
result?: any // fill this variable on slide 1 to get passed into the load on slide 2. If this variable is falsey, it will skip the next slide.
loading$: BehaviorSubject<boolean> // will be true during load function
cancel$: Subject<void> // will cancel load function
}
export function markAsLoadingDuring$<T> (trigger$: Subject<boolean>, o: Observable<T>): Observable<T> {
let shouldBeOn = true
const displayIfItsBeenAtLeast = 5 // ms
return fromSync$(() => {
emitAfter$(displayIfItsBeenAtLeast).subscribe(() => { if (shouldBeOn) trigger$.next(true) })
}).pipe(
concatMap(() => o),
finalize(() => {
trigger$.next(false)
shouldBeOn = false
}),
)
}

View File

@@ -0,0 +1,14 @@
<div class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label [color]="params.titleColor">
<h1>{{ params.title }}</h1>
<h2>{{ params.headline }}</h2>
</ion-label>
</div>
<div *ngFor="let note of params.notes | keyvalue : asIsOrder">
<h2>{{ note.key }}</h2>
<div class="long-message" [innerHTML]="note.value | markdown"></div>
</div>
</div>
</div>

View File

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

View File

@@ -0,0 +1,24 @@
import { Component, Input } from '@angular/core'
import { BehaviorSubject, Subject } from 'rxjs'
@Component({
selector: 'notes',
templateUrl: './notes.component.html',
styleUrls: ['../install-wizard.component.scss'],
})
export class NotesComponent {
@Input() params: {
notes: { [version: string]: string }
title: string
titleColor: string
headline: string
}
load () { }
loading$ = new BehaviorSubject(false)
cancel$ = new Subject<void>()
asIsOrder () {
return 0
}
}

View File

@@ -0,0 +1,334 @@
import { Injectable } from '@angular/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { Breakages } from 'src/app/services/api/api.types'
import { exists } from 'src/app/util/misc.util'
import { ApiService } from '../../services/api/embassy-api.service'
import { InstallWizardComponent, SlideDefinition, TopbarParams } from './install-wizard.component'
@Injectable({ providedIn: 'root' })
export class WizardBaker {
constructor (
private readonly embassyApi: ApiService,
) { }
update (values: {
id: string
title: string
version: string
installAlert?: string
}): InstallWizardComponent['params'] {
const { id, title, version, installAlert } = values
const action = 'update'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
installAlert ? {
slide: {
selector: 'alert',
params: {
title: 'Warning',
message: installAlert,
titleColor: 'warning',
},
},
bottomBar: {
cancel: {
afterLoading: { text: 'Cancel' },
},
next: 'Next',
},
} : undefined,
{
slide: {
selector: 'dependents',
params: {
action,
verb: 'updating',
title,
fetchBreakages: () => this.embassyApi.dryUpdatePackage({ id, version }).then(breakages => breakages),
},
},
bottomBar: {
cancel: {
afterLoading: { text: 'Cancel' },
},
next: 'Update Anyway',
},
},
{
slide: {
selector: 'complete',
params: {
action,
verb: 'beginning update for',
title,
executeAction: () => this.embassyApi.installPackage({ id, 'version-spec': version ? `=${version}` : undefined }),
},
},
bottomBar: {
cancel: { whileLoading: { } },
finish: 'Dismiss',
},
},
]
return { toolbar, slideDefinitions: slideDefinitions.filter(exists) }
}
updateOS (values: {
version: string
releaseNotes: { [version: string]: string }
headline: string
}): InstallWizardComponent['params'] {
const { version, releaseNotes, headline } = values
const action = 'update'
const title = 'EmbassyOS'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{
slide : {
selector: 'notes',
params: {
notes: releaseNotes,
title: 'Release Notes',
titleColor: 'dark',
headline,
},
},
bottomBar: {
cancel: {
afterLoading: { text: 'Cancel' },
},
next: 'Begin Update',
},
},
{
slide: {
selector: 'complete',
params: {
action,
verb: 'beginning update for',
title,
executeAction: () => this.embassyApi.updateServer({ }),
},
},
bottomBar: {
cancel: { whileLoading: { }},
finish: 'Dismiss',
},
},
]
return { toolbar, slideDefinitions: slideDefinitions.filter(exists) }
}
downgrade (values: {
id: string
title: string
version: string
installAlert?: string
}): InstallWizardComponent['params'] {
const { id, title, version, installAlert } = values
const action = 'downgrade'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
installAlert ? {
slide: {
selector: 'alert',
params: {
title: 'Warning',
message: installAlert,
titleColor: 'warning',
},
},
bottomBar: {
cancel: {
afterLoading: { text: 'Cancel' },
},
next: 'Next',
},
} : undefined,
{ slide: {
selector: 'dependents',
params: {
action,
verb: 'downgrading',
title,
fetchBreakages: () => this.embassyApi.dryUpdatePackage({ id, version }).then(breakages => breakages),
},
},
bottomBar: {
cancel: {
whileLoading: { },
afterLoading: { text: 'Cancel' },
},
next: 'Downgrade Anyway',
},
},
{ slide: {
selector: 'complete',
params: {
action,
verb: 'beginning downgrade for',
title,
executeAction: () => this.embassyApi.installPackage({ id, 'version-spec': version ? `=${version}` : undefined }),
},
},
bottomBar: {
cancel: { whileLoading: { } },
finish: 'Dismiss',
},
},
]
return { toolbar, slideDefinitions: slideDefinitions.filter(exists) }
}
uninstall (values: {
id: string
title: string
version: string
uninstallAlert?: string
}): InstallWizardComponent['params'] {
const { id, title, version, uninstallAlert } = values
const action = 'uninstall'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{
slide: {
selector: 'alert',
params: {
title: 'Warning',
message: uninstallAlert || defaultUninstallWarning(title),
titleColor: 'warning',
},
},
bottomBar: {
cancel: {
afterLoading: { text: 'Cancel' },
},
next: 'Continue' },
},
{
slide: {
selector: 'dependents',
params: {
action,
verb: 'uninstalling',
title,
fetchBreakages: () => this.embassyApi.dryUninstallPackage({ id }).then(breakages => breakages),
},
},
bottomBar: {
cancel: {
whileLoading: { },
afterLoading: { text: 'Cancel' },
},
next: 'Uninstall' },
},
{
slide: {
selector: 'complete',
params: {
action,
verb: 'uninstalling',
title,
executeAction: () => this.embassyApi.uninstallPackage({ id }),
},
},
bottomBar: {
finish: 'Dismiss',
cancel: {
whileLoading: { },
},
},
},
]
return { toolbar, slideDefinitions: slideDefinitions.filter(exists) }
}
stop (values: {
id: string
title: string
version: string
}): InstallWizardComponent['params'] {
const { title, version, id } = values
const action = 'stop'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{
slide: {
selector: 'dependents',
params: {
action,
verb: 'stopping',
title,
fetchBreakages: () => this.embassyApi.dryStopPackage({ id }).then(breakages => breakages),
},
},
bottomBar: {
cancel: {
whileLoading: { },
afterLoading: { text: 'Cancel' },
},
next: 'Stop Service',
},
},
{
slide: {
selector: 'complete',
params: {
action,
verb: 'stopping',
title,
executeAction: () => this.embassyApi.stopPackage({ id }),
},
},
bottomBar: {
finish: 'Dismiss',
cancel: {
whileLoading: { },
},
},
},
]
return { toolbar, slideDefinitions }
}
configure (values: {
pkg: PackageDataEntry
breakages: Breakages
}): InstallWizardComponent['params'] {
const { breakages, pkg } = values
const { title, version } = pkg.manifest
const action = 'configure'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{
slide: {
selector: 'dependents',
params: {
action,
verb: 'saving config for',
title, fetchBreakages: () => Promise.resolve(breakages),
},
},
bottomBar: {
cancel: {
afterLoading: { text: 'Cancel' },
},
next: 'Save Config Anyway' },
},
]
return { toolbar, slideDefinitions }
}
}
const defaultUninstallWarning = (serviceName: string) => `Uninstalling ${ serviceName } will result in the deletion of its data.`

View File

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

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { LogsPage } from './logs.page'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [LogsPage],
imports: [
CommonModule,
IonicModule,
SharingModule,
],
exports: [LogsPage],
})
export class LogsPageModule { }

View File

@@ -0,0 +1,39 @@
<ion-content
[scrollEvents]="true"
(ionScroll)="scrollEvent()"
style="height: 100%;"
class="ion-padding"
>
<ion-infinite-scroll id="scroller" *ngIf="!loading && needInfinite" position="top" threshold="0" (ionInfinite)="loadData($event)">
<ion-infinite-scroll-content loadingSpinner="lines"></ion-infinite-scroll-content>
</ion-infinite-scroll>
<text-spinner *ngIf="loading" text="Loading Logs"></text-spinner>
<div id="container">
<div id="template" style="white-space: pre-line; font-family: monospace;"></div>
</div>
<div id="button-div" *ngIf="!loading" style="width: 100%; text-align: center;">
<ion-button *ngIf="!loadingMore" (click)="loadMore()" strong color="dark">
Load More
<ion-icon slot="end" name="refresh"></ion-icon>
</ion-button>
<ion-spinner *ngIf="loadingMore" name="lines" color="warning"></ion-spinner>
</div>
<div
[ngStyle]="{
'position': 'fixed',
'bottom': '50px',
'right': isOnBottom ? '-52px' : '30px',
'background-color': 'var(--ion-color-medium)',
'border-radius': '100%',
'transition': 'right 0.4s ease-out'
}"
>
<ion-button style="width: 50px; height: 50px; --padding-start: 0px; --padding-end: 0px; --border-radius: 100%;" color="dark" (click)="scrollToBottom()" strong>
<ion-icon name="chevron-down"></ion-icon>
</ion-button>
</div>
</ion-content>

View File

@@ -0,0 +1,3 @@
#container {
padding-bottom: 16px;
}

View File

@@ -0,0 +1,112 @@
import { Component, Input, ViewChild } from '@angular/core'
import { IonContent } from '@ionic/angular'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { RR } from 'src/app/services/api/api.types'
var Convert = require('ansi-to-html')
var convert = new Convert({
bg: 'transparent',
})
@Component({
selector: 'logs',
templateUrl: './logs.page.html',
styleUrls: ['./logs.page.scss'],
})
export class LogsPage {
@ViewChild(IonContent) private content: IonContent
@Input() fetchLogs: (params: { before_flag?: boolean, limit?: number, cursor?: string }) => Promise<RR.LogsRes>
loading = true
loadingMore = false
logs: string
needInfinite = true
startCursor: string
endCursor: string
limit = 200
scrollToBottomButton = false
isOnBottom = true
constructor (
private readonly errToast: ErrorToastService,
) { }
ngOnInit () {
this.getLogs()
}
async fetch (isBefore: boolean = true) {
try {
const cursor = isBefore ? this.startCursor : this.endCursor
const logsRes = await this.fetchLogs({
cursor,
before_flag: !!cursor ? isBefore : undefined,
limit: this.limit,
})
if ((isBefore || this.startCursor) && logsRes['start-cursor']) {
this.startCursor = logsRes['start-cursor']
}
if ((!isBefore || !this.endCursor) && logsRes['end-cursor']) {
this.endCursor = logsRes['end-cursor']
}
this.loading = false
return logsRes.entries
} catch (e) {
this.errToast.present(e)
}
}
async getLogs () {
try {
// get logs
const logs = await this.fetch()
if (!logs.length) return
const container = document.getElementById('container')
const beforeContainerHeight = container.scrollHeight
const newLogs = document.getElementById('template').cloneNode(true) as HTMLElement
newLogs.innerHTML = logs.map(l => `${l.timestamp} ${convert.toHtml(l.message)}`).join('\n') + (logs.length ? '\n' : '')
container.prepend(newLogs)
const afterContainerHeight = container.scrollHeight
// scroll down
scrollBy(0, afterContainerHeight - beforeContainerHeight)
this.content.scrollToPoint(0, afterContainerHeight - beforeContainerHeight)
if (logs.length < this.limit) {
this.needInfinite = false
}
} catch (e) { }
}
async loadMore () {
try {
this.loadingMore = true
const logs = await this.fetch(false)
if (!logs.length) return this.loadingMore = false
const container = document.getElementById('container')
const newLogs = document.getElementById('template').cloneNode(true) as HTMLElement
newLogs.innerHTML = logs.map(l => `${l.timestamp} ${convert.toHtml(l.message)}`).join('\n') + (logs.length ? '\n' : '')
container.append(newLogs)
this.loadingMore = false
this.scrollEvent()
} catch (e) { }
}
scrollEvent () {
const buttonDiv = document.getElementById('button-div')
this.isOnBottom = buttonDiv && buttonDiv.getBoundingClientRect().top < window.innerHeight
}
scrollToBottom () {
this.content.scrollToBottom(500)
}
async loadData (e: any): Promise<void> {
await this.getLogs()
e.target.complete()
}
}

View File

@@ -0,0 +1 @@
<qr-code [value]="text" size="400"></qr-code>

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 'ng-qrcode'
@NgModule({
declarations: [
QRComponent,
],
imports: [
CommonModule,
IonicModule,
QrCodeModule,
],
exports: [QRComponent],
})
export class QRComponentModule { }

View File

@@ -0,0 +1,10 @@
import { Component, Input } from '@angular/core'
@Component({
selector: 'qr',
templateUrl: './qr.component.html',
styleUrls: ['./qr.component.scss'],
})
export class QRComponent {
@Input() text: string
}

View File

@@ -0,0 +1,30 @@
<ng-container *ngIf="groups">
<ion-item-group>
<ng-container *ngFor="let g of groupsArr">
<ion-item-divider>
<ion-skeleton-text animated style="width: 120px; height: 16px;"></ion-skeleton-text>
</ion-item-divider>
<ion-item *ngFor="let r of rowsArr">
<ion-label>
<ion-skeleton-text animated style="width: 200px; height: 14px;"></ion-skeleton-text>
</ion-label>
<ion-note slot="end">
<ion-skeleton-text animated style="width: 80px; height: 14px;"></ion-skeleton-text>
</ion-note>
</ion-item>
</ng-container>
</ion-item-group>
</ng-container>
<ng-container *ngIf="!groups">
<ion-item-group>
<ion-item *ngFor="let r of rowsArr">
<ion-label>
<ion-skeleton-text animated style="width: 200px; height: 14px;"></ion-skeleton-text>
</ion-label>
<ion-note slot="end">
<ion-skeleton-text animated style="width: 80px; height: 14px;"></ion-skeleton-text>
</ion-note>
</ion-item>
</ion-item-group>
</ng-container>

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { SkeletonListComponent } from './skeleton-list.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
@NgModule({
declarations: [
SkeletonListComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
],
exports: [SkeletonListComponent],
})
export class SkeletonListComponentModule { }

View File

@@ -0,0 +1,20 @@
import { Component, Input } from '@angular/core'
@Component({
selector: 'skeleton-list',
templateUrl: './skeleton-list.component.html',
styleUrls: ['./skeleton-list.component.scss'],
})
export class SkeletonListComponent {
@Input() groups: string
@Input() rows: string = '3'
groupsArr: number[] = []
rowsArr: number[] = []
ngOnInit () {
if (this.groups) {
this.groupsArr = Array(Number(this.groups)).fill(0).map((_, i) => i)
}
this.rowsArr = Array(Number(this.rows)).fill(0).map((_, i) => i)
}
}

View File

@@ -0,0 +1,23 @@
<p
[style.color]="disconnected ? 'gray' : 'var(--ion-color-' + rendering.color + ')'"
[style.font-size]="size"
[style.font-style]="style"
[style.font-weight]="weight"
>
<span *ngIf= "!installProgress">
{{ disconnected ? 'Unknown' : rendering.display }}
<span *ngIf="rendering.showDots" class="loading-dots"></span>
<span *ngIf="rendering.display === PR[PS.Stopping].display && (sigtermTimeout | durationToSeconds) > 30">This may take a while.</span>
</span>
<span *ngIf="installProgress">
<span *ngIf="installProgress < 99">
Installing
<span class="loading-dots"></span>{{ installProgress }}%
</span>
<span *ngIf="installProgress >= 99">
Finalizing install. This could take a minute
<span class="loading-dots"></span>
</span>
</span>
</p>

View File

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

View File

@@ -0,0 +1,21 @@
import { Component, Input } from '@angular/core'
import { PrimaryRendering, PrimaryStatus, StatusRendering } from 'src/app/services/pkg-status-rendering.service'
@Component({
selector: 'status',
templateUrl: './status.component.html',
styleUrls: ['./status.component.scss'],
})
export class StatusComponent {
PS = PrimaryStatus
PR = PrimaryRendering
@Input() rendering: StatusRendering
@Input() size?: string
@Input() style?: string = 'regular'
@Input() weight?: string = 'normal'
@Input() disconnected?: boolean = false
@Input() installProgress?: number
@Input() sigtermTimeout?: string
}

View File

@@ -0,0 +1,8 @@
<ion-grid style="height: 100%;">
<ion-row class="ion-align-items-center ion-text-center" style="height: 100%;">
<ion-col>
<ion-spinner name="lines" color="warning"></ion-spinner>
<p>{{ text }}</p>
</ion-col>
</ion-row>
</ion-grid>

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { TextSpinnerComponent } from './text-spinner.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
@NgModule({
declarations: [
TextSpinnerComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
],
exports: [TextSpinnerComponent],
})
export class TextSpinnerComponentModule { }

View File

@@ -0,0 +1,10 @@
import { Component, Input } from '@angular/core'
@Component({
selector: 'text-spinner',
templateUrl: './text-spinner.component.html',
styleUrls: ['./text-spinner.component.scss'],
})
export class TextSpinnerComponent {
@Input() text: string
}