enable UI during backups, fix state management bugs

This commit is contained in:
Matt Hill
2021-10-20 13:19:20 -06:00
committed by Aiden McClelland
parent d5dd37b165
commit 896069f1a1
37 changed files with 491 additions and 478 deletions

View File

@@ -2,8 +2,6 @@ import { NgModule } from '@angular/core'
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
import { AuthGuard } from './guards/auth.guard'
import { UnauthGuard } from './guards/unauth.guard'
import { MaintenanceGuard } from './guards/maintenance.guard'
import { UnmaintenanceGuard } from './guards/unmaintenance.guard'
const routes: Routes = [
{
@@ -18,30 +16,25 @@ const routes: Routes = [
},
{
path: 'embassy',
canActivate: [AuthGuard, MaintenanceGuard],
canActivateChild: [AuthGuard, MaintenanceGuard],
canActivate: [AuthGuard],
canActivateChild: [AuthGuard],
loadChildren: () => import('./pages/server-routes/server-routing.module').then(m => m.ServerRoutingModule),
},
{
path: 'maintenance',
canActivate: [AuthGuard, UnmaintenanceGuard],
loadChildren: () => import('./pages/maintenance/maintenance.module').then(m => m.MaintenancePageModule),
},
{
path: 'marketplace',
canActivate: [AuthGuard, MaintenanceGuard],
canActivateChild: [AuthGuard, MaintenanceGuard],
canActivate: [AuthGuard],
canActivateChild: [AuthGuard],
loadChildren: () => import('./pages/marketplace-routes/marketplace-routing.module').then(m => m.MarketplaceRoutingModule),
},
{
path: 'notifications',
canActivate: [AuthGuard, MaintenanceGuard],
canActivate: [AuthGuard],
loadChildren: () => import('./pages/notifications/notifications.module').then(m => m.NotificationsPageModule),
},
{
path: 'services',
canActivate: [AuthGuard, MaintenanceGuard],
canActivateChild: [AuthGuard, MaintenanceGuard],
canActivate: [AuthGuard],
canActivateChild: [AuthGuard],
loadChildren: () => import('./pages/apps-routes/apps-routing.module').then(m => m.AppsRoutingModule),
},
]

View File

@@ -87,6 +87,7 @@
<ion-icon name="information-circle-outline"></ion-icon>
<ion-icon name="key-outline"></ion-icon>
<ion-icon name="list-outline"></ion-icon>
<ion-icon name="lock-closed-outline"></ion-icon>
<ion-icon name="logo-bitcoin"></ion-icon>
<ion-icon name="mail-outline"></ion-icon>
<ion-icon name="medkit-outline"></ion-icon>

View File

@@ -240,16 +240,6 @@ export class AppComponent {
private watchStatus (): Subscription {
return this.patch.watch$('server-info', 'status')
.subscribe(status => {
const maintenance = '/maintenance'
const route = this.router.url
if (status === ServerStatus.Running && route.startsWith(maintenance)) {
this.showMenu = true
this.router.navigate([''], { replaceUrl: true })
}
if (status === ServerStatus.BackingUp && !route.startsWith(maintenance)) {
this.showMenu = false
this.router.navigate([maintenance], { replaceUrl: true })
}
if (status === ServerStatus.Updating) {
this.watchUpdateProgress()
}

View File

@@ -0,0 +1,16 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="close()">
<ion-icon [name]="type === 'backup' ? 'arrow-back' : 'close'"></ion-icon>
</ion-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,70 @@
<ion-item-group>
<!-- always -->
<ion-item class="ion-margin-bottom">
<ion-label>{{ message }}</ion-label>
</ion-item>
<!-- loading -->
<ng-container *ngIf="backupService.loading; else loaded">
<ion-item-divider>
<ion-skeleton-text animated style="width: 120px; height: 16px;"></ion-skeleton-text>
</ion-item-divider>
<ion-item>
<ion-avatar slot="start" style="margin-right: 24px;">
<ion-skeleton-text animated style="width: 30px; height: 30px; border-radius: 0;"></ion-skeleton-text>
</ion-avatar>
<ion-label>
<ion-skeleton-text animated style="width: 100px; height: 20px; margin-bottom: 12px;"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 50px; height: 16px; margin-bottom: 16px;"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 100px;"></ion-skeleton-text>
</ion-label>
</ion-item>
</ng-container>
<!-- 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 *ngIf="!backupService.drives.length; else hasDrives">
<ion-label>
<ion-text color="warning">
No drives found. Insert a backup drive into your Embassy and click "Refresh" above.
</ion-text>
</ion-label>
</ion-item>
<ng-template #hasDrives>
<ion-item-group>
<div *ngFor="let drive of backupService.drives">
<ion-item-divider>
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model || 'Unknown Model' }} - {{ drive.capacity | convertBytes }}
</ion-item-divider>
<ion-item button *ngFor="let partition of drive.partitions" [disabled]="type === 'restore' && !partition.hasBackup" (click)="handleSelect(partition)">
<ion-icon slot="start" name="save-outline" size="large"></ion-icon>
<ion-label>
<h1>{{ partition.label || partition.logicalname }}</h1>
<h2>{{ partition.capacity | convertBytes }}</h2>
<p *ngIf="partition.hasBackup">
<ion-text color="success">
Embassy backups detected
</ion-text>
</p>
</ion-label>
<ion-icon *ngIf="partition.hasBackup" slot="end" color="danger" name="lock-closed-outline"></ion-icon>
</ion-item>
</div>
</ion-item-group>
</ng-template>
</ng-template>
</ng-template>
</ion-item-group>

View File

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

View File

@@ -0,0 +1,63 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { BackupService } from './backup.service'
import { MappedPartitionInfo } 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: 'backup' | 'recover'
@Output() onSelect: EventEmitter<MappedPartitionInfo> = new EventEmitter()
message: string
constructor (
public readonly backupService: BackupService,
) { }
ngOnInit () {
if (this.type === 'backup') {
this.message = 'Select the drive where you want to create a backup of your Embassy.'
} else {
this.message = 'Select the drive containing the backup you would like to restore.'
}
this.backupService.getExternalDrives()
}
handleSelect (partition: MappedPartitionInfo): void {
this.onSelect.emit(partition)
}
}
@Component({
selector: 'backup-drives-header',
templateUrl: './backup-drives-header.component.html',
styleUrls: ['./backup-drives.component.scss'],
})
export class BackupDrivesHeaderComponent {
@Input() type: 'backup' | 'recover'
@Output() onClose: EventEmitter<void> = new EventEmitter()
title: string
constructor (
public readonly backupService: BackupService,
) { }
ngOnInit () {
if (this.type === 'backup') {
this.title = 'Create Backup'
} else {
this.title = 'Restore From Backup'
}
}
close (): void {
this.onClose.emit()
}
refresh () {
this.backupService.getExternalDrives()
}
}

View File

@@ -0,0 +1,45 @@
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 { MappedDriveInfo, MappedPartitionInfo } from 'src/app/util/misc.util'
import { Emver } from 'src/app/services/emver.service'
@Injectable({
providedIn: 'root',
})
export class BackupService {
drives: MappedDriveInfo[]
loading = true
loadingError: string | IonicSafeString
constructor (
private readonly embassyApi: ApiService,
private readonly emver: Emver,
) { }
async getExternalDrives (): Promise<void> {
this.loading = true
try {
const drives = await this.embassyApi.getDrives({ })
this.drives = drives.map(d => {
const partionInfo: MappedPartitionInfo[] = d.partitions.map(p => {
return {
...p,
hasBackup: [0, 1].includes(this.emver.compare(p['embassy-os']?.version, '0.3.0')),
}
})
return {
...d,
partitions: partionInfo,
}
})
} catch (e) {
this.loadingError = getErrorMessage(e)
} finally {
this.loading = false
}
}
}

View File

@@ -1,3 +0,0 @@
ion-note {
width: 50%;
}

View File

@@ -1,37 +0,0 @@
import { Injectable } from '@angular/core'
import { CanActivate, Router, CanActivateChild } from '@angular/router'
import { ServerStatus } from '../services/patch-db/data-model'
import { PatchDbService } from '../services/patch-db/patch-db.service'
@Injectable({
providedIn: 'root',
})
export class MaintenanceGuard implements CanActivate, CanActivateChild {
serverStatus: ServerStatus
constructor (
private readonly router: Router,
private readonly patch: PatchDbService,
) {
this.patch.watch$('server-info', 'status').subscribe(status => {
this.serverStatus = status
})
}
canActivate (): boolean {
return this.runServerStatusCheck()
}
canActivateChild (): boolean {
return this.runServerStatusCheck()
}
private runServerStatusCheck (): boolean {
if (ServerStatus.BackingUp === this.serverStatus) {
this.router.navigate(['/maintenance'], { replaceUrl: true })
return false
} else {
return true
}
}
}

View File

@@ -1,29 +0,0 @@
import { Injectable } from '@angular/core'
import { CanActivate, Router } from '@angular/router'
import { ServerStatus } from '../services/patch-db/data-model'
import { PatchDbService } from '../services/patch-db/patch-db.service'
@Injectable({
providedIn: 'root',
})
export class UnmaintenanceGuard implements CanActivate {
serverStatus: ServerStatus
constructor (
private readonly router: Router,
private readonly patch: PatchDbService,
) {
this.patch.watch$('server-info', 'status').subscribe(status => {
this.serverStatus = status
})
}
canActivate (): boolean {
if (ServerStatus.BackingUp === this.serverStatus) {
return true
} else {
this.router.navigate([''], { replaceUrl: true })
return false
}
}
}

View File

@@ -1,95 +1,5 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="dismiss()">
<ion-icon name="arrow-back"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>Restore From Backup</ion-title>
<ion-buttons slot="end">
<ion-button (click)="refresh()">
Refresh
<ion-icon slot="end" name="refresh"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<backup-drives-header type="restore" (onClose)="dismiss()"></backup-drives-header>
<ion-content class="ion-padding-top">
<ion-item class="ion-margin-bottom">
<ion-label>
<h2>
Select the drive containing the backup you would like to restore.
</h2>
</ion-label>
</ion-item>
<!-- loading -->
<ng-container *ngIf="loading">
<ion-item-divider>
<ion-skeleton-text animated style="width: 120px; height: 16px;"></ion-skeleton-text>
</ion-item-divider>
<ion-item>
<ion-avatar slot="start" style="margin-right: 24px;">
<ion-skeleton-text animated style="width: 30px; height: 30px; border-radius: 0;"></ion-skeleton-text>
</ion-avatar>
<ion-label>
<ion-skeleton-text animated style="width: 100px; height: 20px; margin-bottom: 12px;"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 50px; height: 16px; margin-bottom: 16px;"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 100px;"></ion-skeleton-text>
</ion-label>
</ion-item>
</ng-container>
<!-- not loading -->
<ng-container *ngIf="!loading">
<!-- error -->
<ion-item *ngIf="loadingError">
<ion-label>
<ion-text color="danger">
{{ loadingError }}
</ion-text>
</ion-label>
</ion-item>
<!-- no error -->
<ng-container *ngIf="!loadingError">
<ion-item *ngIf="!disks.length">
<ion-label>
<ion-text color="warning">
No drives found. Insert a backup drive into your Embassy and click "Refresh" above.
</ion-text>
</ion-label>
</ion-item>
<ion-item-group *ngIf="disks.length">
<div *ngFor="let disk of disks">
<ion-item-divider>
{{ disk.vendor || 'Unknown Vendor' }} - {{ disk.model || 'Unknown Model' }} - {{ disk.capacity | convertBytes }}
</ion-item-divider>
<ion-item button *ngFor="let partition of disk.partitions" [disabled]="!partition.hasBackup" (click)="presentModal(partition.logicalname)">
<ion-icon slot="start" name="save-outline" size="large"></ion-icon>
<ion-label>
<h1>{{ partition.label || partition.logicalname }}</h1>
<h2>{{ partition.capacity | convertBytes }}</h2>
<p *ngIf="partition.hasBackup">
<ion-text color="success">
Embassy backups detected
</ion-text>
</p>
</ion-label>
<ng-container *ngIf="partition.hasBackup">
<ion-icon *ngIf="!partition.backupInfo" slot="end" color="danger" name="lock-closed-outline"></ion-icon>
<ion-icon *ngIf="partition.backupInfo" slot="end" color="success" name="lock-open-outline"></ion-icon>
</ng-container>
</ion-item>
</div>
</ion-item-group>
</ng-container>
</ng-container>
<backup-drives type="restore" (onSelect)="presentModal($event)"></backup-drives>
</ion-content>

View File

@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { AppRestoreComponent } from './app-restore.component'
import { SharingModule } from '../../modules/sharing.module'
import { BackupDrivesComponentModule } from 'src/app/components/backup-drives/backup-drives.component.module'
@NgModule({
declarations: [AppRestoreComponent],
@@ -10,6 +11,7 @@ import { SharingModule } from '../../modules/sharing.module'
CommonModule,
IonicModule,
SharingModule,
BackupDrivesComponentModule,
],
exports: [AppRestoreComponent],

View File

@@ -1,9 +1,8 @@
import { Component, Input } from '@angular/core'
import { ModalController, IonicSafeString, AlertController } from '@ionic/angular'
import { ModalController, AlertController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GenericInputComponent } from 'src/app/modals/generic-input/generic-input.component'
import { getErrorMessage } from 'src/app/services/error-toast.service'
import { MappedDiskInfo, MappedPartitionInfo } from 'src/app/util/misc.util'
import { MappedPartitionInfo } from 'src/app/util/misc.util'
import { Emver } from 'src/app/services/emver.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { ConfigService } from 'src/app/services/config.service'
@@ -15,10 +14,7 @@ import { ConfigService } from 'src/app/services/config.service'
})
export class AppRestoreComponent {
@Input() pkg: PackageDataEntry
disks: MappedDiskInfo[]
loading = true
modal: HTMLIonModalElement
loadingError: string | IonicSafeString
constructor (
private readonly modalCtrl: ModalController,
@@ -28,40 +24,13 @@ export class AppRestoreComponent {
private readonly emver: Emver,
) { }
async ngOnInit () {
this.getExternalDisks()
dismiss () {
this.modalCtrl.dismiss()
}
async presentModal (partition: MappedPartitionInfo): Promise<void> {
this.modal = await this.modalCtrl.getTop()
}
async refresh () {
this.loading = true
await this.getExternalDisks()
}
async getExternalDisks (): Promise<void> {
try {
const disks = await this.embassyApi.getDisks({ })
this.disks = disks.map(d => {
const partionInfo: MappedPartitionInfo[] = d.partitions.map(p => {
return {
...p,
hasBackup: [0, 1].includes(this.emver.compare(p['embassy-os']?.version, '0.3.0')),
backupInfo: null,
}
})
return {
...d,
partitions: partionInfo,
}
})
} catch (e) {
this.loadingError = getErrorMessage(e)
} finally {
this.loading = false
}
}
async presentModal (logicalname: string): Promise<void> {
const modal = await this.modalCtrl.create({
componentProps: {
title: 'Decryption Required',
@@ -72,7 +41,7 @@ export class AppRestoreComponent {
useMask: true,
buttonText: 'Restore',
loadingText: 'Decrypting drive...',
submitFn: (value: string, loader: HTMLIonLoadingElement) => this.restore(logicalname, value, loader),
submitFn: (value: string, loader: HTMLIonLoadingElement) => this.restore(partition.logicalname, value, loader),
},
cssClass: 'alertlike-modal',
presentingElement: await this.modalCtrl.getTop(),
@@ -86,10 +55,6 @@ export class AppRestoreComponent {
await modal.present()
}
dismiss () {
this.modalCtrl.dismiss()
}
private async restore (logicalname: string, password: string, loader: HTMLIonLoadingElement): Promise<void> {
const { id, title } = this.pkg.manifest
@@ -100,7 +65,7 @@ export class AppRestoreComponent {
const pkgBackupInfo = backupInfo['package-backups'][id]
if (!pkgBackupInfo) {
throw new Error(`Disk does not contain a backup of ${title}`)
throw new Error(`Drive does not contain a backup of ${title}`)
}
if (this.emver.compare(pkgBackupInfo['os-version'], this.config.version) === 1) {

View File

@@ -19,7 +19,7 @@
<div style="margin: 0 0 24px 16px;">
<p class="input-label">{{ label }}</p>
<ion-item lines="none" color="dark">
<ion-input [type]="useMask && !unmasked ? 'password' : 'text'" [(ngModel)]="value" name="value" [placeholder]="placeholder" (ionChange)="error = ''"></ion-input>
<ion-input #mainInput [type]="useMask && !unmasked ? 'password' : 'text'" [(ngModel)]="value" name="value" [placeholder]="placeholder" (ionChange)="error = ''"></ion-input>
<ion-button slot="end" *ngIf="useMask" fill="clear" color="light" (click)="toggleMask()">
<ion-icon slot="icon-only" [name]="unmasked ? 'eye-off-outline' : 'eye-outline'" size="small"></ion-icon>
</ion-button>

View File

@@ -1,5 +1,5 @@
import { Component, Input } from '@angular/core'
import { ModalController, IonicSafeString, LoadingController } from '@ionic/angular'
import { Component, Input, ViewChild } from '@angular/core'
import { ModalController, IonicSafeString, LoadingController, IonInput } from '@ionic/angular'
import { getErrorMessage } from 'src/app/services/error-toast.service'
@Component({
@@ -8,6 +8,7 @@ import { getErrorMessage } from 'src/app/services/error-toast.service'
styleUrls: ['./generic-input.component.scss'],
})
export class GenericInputComponent {
@ViewChild('mainInput', { static: false }) elem: IonInput
@Input() title: string
@Input() message: string
@Input() warning: string
@@ -27,6 +28,10 @@ export class GenericInputComponent {
private readonly loadingCtrl: LoadingController,
) { }
ngAfterViewInit () {
setTimeout(() => this.elem.setFocus(), 400)
}
toggleMask () {
this.unmasked = !this.unmasked
}

View File

@@ -48,7 +48,9 @@ export class AppListPage {
.subscribe(data => {
this.loading = false
const pkgs = JSON.parse(JSON.stringify(data['package-data'])) as { [id: string]: PackageDataEntry }
this.recoveredPkgs = Object.entries(data['recovered-packages']).map(([id, val]) => {
this.recoveredPkgs = Object.entries(data['recovered-packages'])
.filter(([id, _]) => !pkgs[id])
.map(([id, val]) => {
return {
...val,
id,
@@ -60,7 +62,7 @@ export class AppListPage {
// add known pkgs in preferential order
this.order.forEach(id => {
if (pkgs[id]) {
this.pkgs.push(this.buildPkg(pkgs[id]))
this.pkgs.push(this.subscribeToPkg(pkgs[id]))
delete pkgs[id]
}
})
@@ -68,7 +70,7 @@ export class AppListPage {
// unshift unknown packages and set order in UI DB
if (!isEmptyObject(pkgs)) {
Object.values(pkgs).forEach(pkg => {
this.pkgs.unshift(this.buildPkg(pkg))
this.pkgs.unshift(this.subscribeToPkg(pkg))
this.order.unshift(pkg.manifest.id)
})
this.setOrder()
@@ -78,7 +80,7 @@ export class AppListPage {
this.empty = true
}
this.subs.push(this.subscribeBoth())
this.subs.push(this.watchNewlyRecovered())
})
this.subs.push(
@@ -164,7 +166,7 @@ export class AppListPage {
await alert.present()
}
private subscribeBoth (): Subscription {
private watchNewlyRecovered (): Subscription {
return combineLatest([this.watchPkgs(), this.patch.watch$('recovered-packages')])
.subscribe(([pkgs, recoveredPkgs]) => {
Object.keys(recoveredPkgs).forEach(id => {
@@ -186,6 +188,7 @@ export class AppListPage {
tap(pkgs => {
const ids = Object.keys(pkgs)
// remove uninstalled
this.pkgs.forEach((pkg, i) => {
const id = pkg.entry.manifest.id
if (!ids.includes(id)) {
@@ -201,7 +204,7 @@ export class AppListPage {
const pkg = this.pkgs.find(p => p.entry.manifest.id === id)
if (pkg) return
// otherwise add new entry to beginning of array
this.pkgs.unshift(this.buildPkg(pkgs[id]))
this.pkgs.unshift(this.subscribeToPkg(pkgs[id]))
})
}),
)
@@ -211,7 +214,7 @@ export class AppListPage {
this.api.setDbValue({ pointer: '/pkg-order', value: this.order })
}
private buildPkg (pkg: PackageDataEntry): PkgInfo {
private subscribeToPkg (pkg: PackageDataEntry): PkgInfo {
const pkgInfo: PkgInfo = {
entry: pkg,
primaryRendering: PrimaryRendering[renderPkgStatus(pkg).primary],

View File

@@ -92,7 +92,7 @@
</ion-thumbnail>
<ion-label>
<h2 class="inline" style="font-family: 'Montserrat'">
<ion-icon *ngIf="!!dep.errorText" slot="start" name="warning-outline" color="warning"></ion-icon>
<ion-icon style="padding-right: 4px;" *ngIf="!!dep.errorText" slot="start" name="warning-outline" color="warning"></ion-icon>
{{ dep.title }}
</h2>
<p>{{ dep.version | displayEmver }}</p>
@@ -121,7 +121,7 @@
</ion-item-group>
<!-- ** installing or updating ** -->
<div *ngIf="[PackageState.Installing, PackageState.Updating] | includes : pkg.state" style="padding: 16px;">
<div *ngIf="([PackageState.Installing, PackageState.Updating] | includes : pkg.state) && installProgress" style="padding: 16px;">
<p>Downloading: {{ installProgress.downloadProgress }}%</p>
<ion-progress-bar
[color]="pkg['install-progress']['download-complete'] ? 'success' : 'secondary'"

View File

@@ -11,14 +11,4 @@
.icon-spinner {
height: 20px;
width: 20px;
}
.inline {
* {
display: inline-block;
vertical-align: middle;
}
ion-icon {
padding-right: 4px;
}
}

View File

@@ -14,6 +14,7 @@ import { ConnectionFailure, ConnectionService } from 'src/app/services/connectio
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { AppConfigPage } from 'src/app/modals/app-config/app-config.page'
import { PackageLoadingService, ProgressData } from 'src/app/services/package-loading.service'
import { filter } from 'rxjs/operators'
@Component({
selector: 'app-show',
@@ -83,29 +84,41 @@ export class AppShowPage {
// 2
this.patch.watch$('package-data', this.pkgId, 'installed', 'current-dependencies')
.pipe(
filter(obj => exists(obj)),
)
.subscribe(currentDeps => {
// unsubscribe to deleted
this.dependencies.forEach(dep => {
if (!currentDeps[dep.id]) {
dep.sub.unsubscribe()
}
})
// remove deleted
this.dependencies.forEach((dep, i) => {
if (!currentDeps[dep.id]) {
dep.sub.unsubscribe()
this.dependencies.splice(i, 1)
}
})
this.dependencies = Object.keys(currentDeps).map(id => {
const version = this.pkg.manifest.dependencies[id]?.version
if (version) {
const dep = { id, version } as DependencyInfo
dep.sub = this.patch.watch$('package-data', id)
.subscribe(localDep => {
this.setDepValues(dep, localDep)
})
return dep
}
}).filter(exists)
// subscribe
Object.keys(currentDeps)
.filter(id => {
const inManifest = !!this.pkg.manifest.dependencies[id]
const exists = this.dependencies.find(d => d.id === id)
return inManifest && !exists
})
.forEach(id => {
const version = this.pkg.manifest.dependencies[id].version
const dep = { id, version } as DependencyInfo
dep.sub = this.patch.watch$('package-data', id)
.subscribe(localDep => {
this.setDepValues(dep, localDep)
})
this.dependencies.push(dep)
})
}),
// 3
this.patch.watch$('package-data', this.pkgId, 'installed', 'status', 'main')
.pipe(
filter(obj => exists(obj)),
)
.subscribe(main => {
if (main.status === PackageMainStatus.Running) {
this.healthChecks = { ...main.health }

View File

@@ -1,24 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router'
import { MaintenancePage } from './maintenance.page'
import { SharingModule } from 'src/app/modules/sharing.module'
const routes: Routes = [
{
path: '',
component: MaintenancePage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharingModule,
],
declarations: [MaintenancePage],
})
export class MaintenancePageModule { }

View File

@@ -1,14 +0,0 @@
<ion-content style="--background: black;">
<ion-grid style="height: 100%;">
<ion-row class="ion-align-items-center ion-text-center" style="height: 100%;">
<ion-col>
<ng-container>
<h1>Embassy backing up</h1>
<img src="assets/img/gifs/backing-up.gif" />
</ng-container>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -1,4 +0,0 @@
img {
width: 50%;
max-width: 500px;
}

View File

@@ -1,9 +0,0 @@
import { Component } from '@angular/core'
@Component({
selector: 'maintenance',
templateUrl: 'maintenance.page.html',
styleUrls: ['maintenance.page.scss'],
})
export class MaintenancePage { }

View File

@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { ServerBackupPage } from './server-backup.page'
import { RouterModule, Routes } from '@angular/router'
import { BackupDrivesComponentModule } from 'src/app/components/backup-drives/backup-drives.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
const routes: Routes = [
@@ -18,6 +19,7 @@ const routes: Routes = [
IonicModule,
RouterModule.forChild(routes),
SharingModule,
BackupDrivesComponentModule,
],
declarations: [
ServerBackupPage,

View File

@@ -1,88 +1,56 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Create Backup</ion-title>
<ion-buttons slot="end">
<ion-button (click)="refresh()">
Refresh
<ion-icon slot="end" name="refresh"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<!-- not backing up -->
<ng-container *ngIf="!backingUp">
<backup-drives-header type="backup" (onClose)="back()"></backup-drives-header>
<ion-content class="ion-padding-top">
<backup-drives type="backup" (onSelect)="presentModal($event)"></backup-drives>
</ion-content>
</ng-container>
<ion-content class="ion-padding-top">
<ion-item-group>
<!-- always -->
<ion-item class="ion-margin-bottom">
<ion-label>
<h2>
Select the drive where you want to create a backup of your Embassy.
</h2>
</ion-label>
</ion-item>
<!-- currently backing up -->
<ng-container *ngIf="backingUp">
<!-- loading -->
<ng-container *ngIf="loading">
<ion-item-divider>
<ion-skeleton-text animated style="width: 120px; height: 16px;"></ion-skeleton-text>
</ion-item-divider>
<ion-item>
<ion-avatar slot="start" style="margin-right: 24px;">
<ion-skeleton-text animated style="width: 30px; height: 30px; border-radius: 0;"></ion-skeleton-text>
</ion-avatar>
<ion-label>
<ion-skeleton-text animated style="width: 100px; height: 20px; margin-bottom: 12px;"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 50px; height: 16px; margin-bottom: 16px;"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 100px;"></ion-skeleton-text>
</ion-label>
</ion-item>
</ng-container>
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Backup Progress</ion-title>
</ion-toolbar>
</ion-header>
<!-- loaded -->
<ng-container *ngIf="!loading">
<!-- error -->
<ion-item *ngIf="loadingError">
<ion-label>
<ion-text color="danger">
{{ loadingError }}
</ion-text>
</ion-label>
</ion-item>
<ng-container *ngIf="!loadingError">
<ion-item *ngIf="!disks.length">
<ion-label>
<ion-text color="warning">
No drives found. Insert a backup drive into your Embassy and click "Refresh" above.
</ion-text>
</ion-label>
</ion-item>
<ion-item-group *ngIf="disks.length">
<div *ngFor="let disk of disks">
<ion-item-divider>
{{ disk.vendor || 'Unknown Vendor' }} - {{ disk.model || 'Unknown Model' }} - {{ disk.capacity | convertBytes }}
</ion-item-divider>
<ion-item button *ngFor="let partition of disk.partitions" (click)="presentModal(partition.logicalname)">
<ion-icon slot="start" name="save-outline" size="large"></ion-icon>
<ion-content class="ion-padding">
<ion-grid>
<ion-row>
<ion-col>
<ion-item-group>
<ion-item *ngFor="let pkg of pkgs">
<ion-avatar slot="start">
<img [src]="pkg.entry['static-files'].icon" />
</ion-avatar>
<ion-label>
<h1>{{ partition.label || partition.logicalname }}</h1>
<h2>{{ partition.capacity | convertBytes }}</h2>
<p *ngIf="partition.hasBackup">
<ion-text color="success">
Embassy backups detected
</ion-text>
</p>
{{ pkg.entry.manifest.title }}
</ion-label>
<!-- complete -->
<ion-note *ngIf="pkg.complete" class="inline" slot="end">
<ion-icon name="checkmark" color="success"></ion-icon>
&nbsp;
<ion-text color="success">Complete</ion-text>
</ion-note>
<!-- active -->
<ion-note *ngIf="pkg.active" class="inline" slot="end">
<ion-text color="dark">Backing up</ion-text>
&nbsp;
<ion-spinner color="dark" style="height: 12px; width: 12px;"></ion-spinner>
</ion-note>
<!-- queued -->
<ion-note *ngIf="!pkg.complete && !pkg.active" slot="end">
Waiting...
</ion-note>
</ion-item>
</div>
</ion-item-group>
</ng-container>
</ng-container>
</ion-item-group>
</ion-content>
</ion-item-group>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>
</ng-container>

View File

@@ -1,10 +1,12 @@
import { Component } from '@angular/core'
import { ModalController, IonicSafeString } from '@ionic/angular'
import { ModalController, NavController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GenericInputComponent } from 'src/app/modals/generic-input/generic-input.component'
import { getErrorMessage } from 'src/app/services/error-toast.service'
import { MappedDiskInfo, MappedPartitionInfo } from 'src/app/util/misc.util'
import { Emver } from 'src/app/services/emver.service'
import { MappedPartitionInfo } from 'src/app/util/misc.util'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { PackageDataEntry, PackageMainStatus, ServerStatus } from 'src/app/services/patch-db/data-model'
import { Subscription } from 'rxjs'
import { take } from 'rxjs/operators'
@Component({
selector: 'server-backup',
@@ -12,68 +14,107 @@ import { Emver } from 'src/app/services/emver.service'
styleUrls: ['./server-backup.page.scss'],
})
export class ServerBackupPage {
disks: MappedDiskInfo[]
loading = true
loadingError: string | IonicSafeString
backingUp = false
pkgs: PkgInfo[] = []
subs: Subscription[]
constructor (
private readonly navCtrl: NavController,
private readonly modalCtrl: ModalController,
private readonly emver: Emver,
private readonly embassyApi: ApiService,
private readonly patch: PatchDbService,
) { }
ngOnInit () {
this.getExternalDisks()
}
async refresh () {
this.loading = true
await this.getExternalDisks()
}
async getExternalDisks (): Promise<void> {
try {
const disks = await this.embassyApi.getDisks({ })
this.disks = disks.map(d => {
const partionInfo: MappedPartitionInfo[] = d.partitions.map(p => {
return {
...p,
hasBackup: [0, 1].includes(this.emver.compare(p['embassy-os']?.version, '0.3.0')),
backupInfo: null,
}
})
return {
...d,
partitions: partionInfo,
this.subs = [
this.patch.watch$('server-info', 'status').subscribe(status => {
if (status === ServerStatus.BackingUp) {
this.backingUp = true
this.subscribeToBackup()
} else {
this.backingUp = false
this.pkgs.forEach(pkg => pkg.sub.unsubscribe())
}
})
} catch (e) {
this.loadingError = getErrorMessage(e)
} finally {
this.loading = false
}
}),
]
}
async presentModal (logicalname: string): Promise<void> {
ngOnDestroy () {
this.subs.forEach(sub => sub.unsubscribe())
this.pkgs.forEach(pkg => pkg.sub.unsubscribe())
}
back () {
this.navCtrl.back()
}
async presentModal (partition: MappedPartitionInfo): Promise<void> {
let message: string
if (partition.hasBackup) {
message = 'Enter your master password to decrypt this drive and update its backup. Depending on how much data was added or changed, this could be very fast, or it could take a while.'
} else {
message = 'Enter your master password to create an encrypted backup of your Embassy and all its installed services. Since this is a fresh backup, it could take a while. Future backups will likely be much faster.'
}
const m = await this.modalCtrl.create({
componentProps: {
title: 'Create Backup',
message: `Enter your master password to create an encrypted backup of your Embassy and all its installed services.`,
message,
label: 'Password',
placeholder: 'Enter password',
useMask: true,
buttonText: 'Create Backup',
loadingText: 'Beginning backup...',
submitFn: async (value: string) => await this.create(logicalname, value),
submitFn: async (password: string) => await this.create(partition.logicalname, password),
},
cssClass: 'alertlike-modal',
component: GenericInputComponent,
})
return await m.present()
await m.present()
}
private async create (logicalname: string, password: string): Promise<void> {
await this.embassyApi.createBackup({ logicalname, password })
}
private subscribeToBackup () {
this.patch.watch$('package-data')
.pipe(
take(1),
)
.subscribe(pkgs => {
const pkgArr = Object.values(pkgs)
const activeIndex = pkgArr.findIndex(pkg => pkg.installed.status.main.status === PackageMainStatus.BackingUp)
this.pkgs = pkgArr.map((pkg, i) => {
const pkgInfo = {
entry: pkg,
active: i === activeIndex,
complete: i < activeIndex,
sub: null,
}
return pkgInfo
})
// subscribe to pkg
this.pkgs.forEach(pkg => {
pkg.sub = this.patch.watch$('package-data', pkg.entry.manifest.id, 'installed', 'status', 'main', 'status').subscribe(status => {
if (status === PackageMainStatus.BackingUp) {
pkg.active = true
} else if (pkg.active) {
pkg.active = false
pkg.complete = true
}
})
})
})
}
}
interface PkgInfo {
entry: PackageDataEntry,
active: boolean
complete: boolean,
sub: Subscription,
}

View File

@@ -9,6 +9,20 @@
<ion-content class="ion-padding">
<ion-item-group>
<ion-item-divider><ion-text color="dark">Backups</ion-text></ion-item-divider>
<ion-item button routerLink="backup">
<ion-icon slot="start" name="save-outline"></ion-icon>
<ion-label>
<h2>Create Backup</h2>
<p>Back up your Embassy and all its services</p>
<p>
<ion-text color="warning">
Last Backup: {{ patch.data['server-info']['last-backup'] ? (patch.data['server-info']['last-backup'] | date: 'short') : 'never' }}
</ion-text>
</p>
</ion-label>
</ion-item>
<div *ngFor="let cat of settings | keyvalue : asIsOrder">
<ion-item-divider><ion-text color="dark">{{ cat.key }}</ion-text></ion-item-divider>
<ion-item [detail]="button.detail" button *ngFor="let button of cat.value" (click)="button.action()">

View File

@@ -105,15 +105,6 @@ export class ServerShowPage {
private setButtons (): void {
this.settings = {
'Backups': [
{
title: 'Create Backup',
description: 'Back up your Embassy and all its services',
icon: 'save-outline',
action: () => this.navCtrl.navigateForward(['backup'], { relativeTo: this.route }),
detail: true,
},
],
'Insights': [
{
title: 'About',

View File

@@ -974,7 +974,7 @@ export module Mock {
'signal-strength': 50,
}
export const Disks: RR.GetDisksRes = [
export const Drives: RR.GetDrivesRes = [
{
logicalname: '/dev/sda',
model: null,

View File

@@ -121,10 +121,10 @@ export module RR {
export type CreateBackupReq = WithExpire<{ logicalname: string, password: string }> // backup.create
export type CreateBackupRes = WithRevision<null>
// disk
// drive
export type GetDisksReq = { } // disk.list
export type GetDisksRes = DiskInfo[]
export type GetDrivesReq = { } // disk.list
export type GetDrivesRes = DriveInfo[]
export type GetBackupInfoReq = { logicalname: string, password: string } // disk.backup-info
export type GetBackupInfoRes = BackupInfo
@@ -287,7 +287,7 @@ export interface SessionMetadata {
export type PlatformType = 'cli' | 'ios' | 'ipad' | 'iphone' | 'android' | 'phablet' | 'tablet' | 'cordova' | 'capacitor' | 'electron' | 'pwa' | 'mobile' | 'mobileweb' | 'desktop' | 'hybrid'
export interface DiskInfo {
export interface DriveInfo {
logicalname: string
vendor: string | null
model: string | null
@@ -300,10 +300,10 @@ export interface PartitionInfo {
label: string | null
capacity: number
used: number | null
'embassy-os': EmbassyOsDiskInfo | null
'embassy-os': EmbassyOsDriveInfo | null
}
export interface EmbassyOsDiskInfo {
export interface EmbassyOsDriveInfo {
version: string
full: boolean
}

View File

@@ -126,9 +126,9 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
() => this.createBackupRaw(params),
)()
// disk
// drive
abstract getDisks (params: RR.GetDisksReq): Promise<RR.GetDisksRes>
abstract getDrives (params: RR.GetDrivesReq): Promise<RR.GetDrivesRes>
abstract getBackupInfo (params: RR.GetBackupInfoReq): Promise<RR.GetBackupInfoRes>

View File

@@ -191,9 +191,9 @@ export class LiveApiService extends ApiService {
return this.http.rpcRequest({ method: 'backup.create', params })
}
// disk
// drives
getDisks (params: RR.GetDisksReq): Promise <RR.GetDisksRes> {
getDrives (params: RR.GetDrivesReq): Promise <RR.GetDrivesRes> {
return this.http.rpcRequest({ method: 'disk.list', params })
}

View File

@@ -263,7 +263,7 @@ export class MockApiService extends ApiService {
async createBackupRaw (params: RR.CreateBackupReq): Promise<RR.CreateBackupRes> {
await pauseFor(2000)
const path = '/server-info/status'
const patch = [
let patch = [
{
op: PatchOp.REPLACE,
path,
@@ -271,8 +271,37 @@ export class MockApiService extends ApiService {
},
]
const res = await this.http.rpcRequest<WithRevision<null>>({ method: 'db.patch', params: { patch } })
setTimeout(() => {
const patch = [
const ids = ['bitcoind', 'lnd']
setTimeout(async () => {
for (let i = 0; i < ids.length; i++) {
const appPath = `/package-data/${ids[i]}/installed/status/main/status`
let appPatch = [
{
op: PatchOp.REPLACE,
path: appPath,
value: PackageMainStatus.BackingUp,
},
]
this.http.rpcRequest<WithRevision<null>>({ method: 'db.patch', params: { patch: appPatch } })
await pauseFor(8000)
appPatch = [
{
op: PatchOp.REPLACE,
path: appPath,
value: PackageMainStatus.Stopped,
},
]
this.http.rpcRequest<WithRevision<null>>({ method: 'db.patch', params: { patch: appPatch } })
}
await pauseFor(1000)
// set server back to running
patch = [
{
op: PatchOp.REPLACE,
path,
@@ -280,15 +309,16 @@ export class MockApiService extends ApiService {
},
]
this.http.rpcRequest<WithRevision<null>>({ method: 'db.patch', params: { patch } })
}, this.revertTime)
}, 200)
return res
}
// disk
// drives
async getDisks (params: RR.GetDisksReq): Promise<RR.GetDisksRes> {
async getDrives (params: RR.GetDrivesReq): Promise<RR.GetDrivesRes> {
await pauseFor(2000)
return Mock.Disks
return Mock.Drives
}
async getBackupInfo (params: RR.GetBackupInfoReq): Promise<RR.GetBackupInfoRes> {

View File

@@ -1,6 +1,6 @@
import { OperatorFunction } from 'rxjs'
import { map } from 'rxjs/operators'
import { BackupInfo, DiskInfo, PartitionInfo } from '../services/api/api.types'
import { DriveInfo, PartitionInfo } from '../services/api/api.types'
export type Omit<ObjectType, KeysType extends keyof ObjectType> = Pick<ObjectType, Exclude<keyof ObjectType, KeysType>>
export type PromiseRes<T> = { result: 'resolve', value: T } | { result: 'reject', value: Error }
@@ -193,11 +193,10 @@ export function debounce (delay: number = 300): MethodDecorator {
}
}
export interface MappedDiskInfo extends DiskInfo {
export interface MappedDriveInfo extends DriveInfo {
partitions: MappedPartitionInfo[]
}
export interface MappedPartitionInfo extends PartitionInfo {
hasBackup: boolean
backupInfo: BackupInfo | null
}

View File

@@ -120,6 +120,13 @@ ion-toast {
white-space: normal !important;
}
.inline {
* {
display: inline-block;
vertical-align: middle;
}
}
img {
border-radius: 100%;
}
@@ -132,13 +139,6 @@ ion-title {
font-family: 'Montserrat';
}
ion-note {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
ion-badge {
font-weight: bold;
}