New recovery flow

This commit is contained in:
Matt Hill
2021-10-23 20:11:10 -06:00
committed by Aiden McClelland
parent 221d99bfee
commit 17d0f9e533
38 changed files with 358 additions and 283 deletions

View File

@@ -28,7 +28,7 @@ export class AppComponent {
handleKeyboardEvent () { handleKeyboardEvent () {
const elems = document.getElementsByClassName('enter-click') const elems = document.getElementsByClassName('enter-click')
const elem = elems[elems.length - 1] as HTMLButtonElement const elem = elems[elems.length - 1] as HTMLButtonElement
if (!elem || elem.classList.contains('no-click')) return if (!elem || elem.classList.contains('no-click') || elem.disabled) return
if (elem) elem.click() if (elem) elem.click()
} }

View File

@@ -1,9 +1,7 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-buttons slot="start"> <ion-buttons slot="start">
<ion-button (click)="close()"> <pwa-back-button></pwa-back-button>
<ion-icon [name]="type === 'backup' ? 'arrow-back' : 'close'"></ion-icon>
</ion-button>
</ion-buttons> </ion-buttons>
<ion-title>{{ title }}</ion-title> <ion-title>{{ title }}</ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">

View File

@@ -20,7 +20,7 @@ export class BackupDrivesComponent {
if (this.type === 'backup') { if (this.type === 'backup') {
this.message = 'Select the drive where you want to create a backup of your Embassy.' this.message = 'Select the drive where you want to create a backup of your Embassy.'
} else { } else {
this.message = 'Select the drive containing the backup you would like to restore.' this.message = 'Select the drive containing backups you would like to restore.'
} }
this.backupService.getExternalDrives() this.backupService.getExternalDrives()
} }
@@ -37,26 +37,13 @@ export class BackupDrivesComponent {
styleUrls: ['./backup-drives.component.scss'], styleUrls: ['./backup-drives.component.scss'],
}) })
export class BackupDrivesHeaderComponent { export class BackupDrivesHeaderComponent {
@Input() type: 'backup' | 'restore' @Input() title: string
@Output() onClose: EventEmitter<void> = new EventEmitter() @Output() onClose: EventEmitter<void> = new EventEmitter()
title: string
constructor ( constructor (
public readonly backupService: BackupService, 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 () { refresh () {
this.backupService.getExternalDrives() this.backupService.getExternalDrives()
} }

View File

@@ -23,7 +23,9 @@ export class BackupService {
try { try {
const drives = await this.embassyApi.getDrives({ }) const drives = await this.embassyApi.getDrives({ })
this.drives = drives.map(d => { this.drives = drives
.filter(d => !d.internal)
.map(d => {
const partionInfo: MappedPartitionInfo[] = d.partitions.map(p => { const partionInfo: MappedPartitionInfo[] = d.partitions.map(p => {
return { return {
...p, ...p,

View File

@@ -5,6 +5,6 @@
[style.font-weight]="weight" [style.font-weight]="weight"
> >
{{ disconnected ? 'Unknown' : rendering.display }} {{ disconnected ? 'Unknown' : rendering.display }}
<ion-spinner *ngIf="rendering.showDots" class="dots dots-small" name="dots"></ion-spinner> <ion-spinner *ngIf="rendering.showDots" class="dots" name="dots"></ion-spinner>
<span *ngIf="installProgress">{{ installProgress }}%</span> <span *ngIf="installProgress">{{ installProgress }}%</span>
</p> </p>

View File

@@ -1,3 +0,0 @@
p {
margin: 0;
}

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { AppRecoverSelectPage } from './app-recover-select.page'
import { FormsModule } from '@angular/forms'
@NgModule({
declarations: [AppRecoverSelectPage],
imports: [
CommonModule,
IonicModule,
FormsModule,
],
exports: [AppRecoverSelectPage],
})
export class AppRecoverSelectPageModule { }

View File

@@ -0,0 +1,42 @@
<ion-header>
<ion-toolbar>
<ion-title>Select Services to Recover</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item-group>
<ion-item *ngFor="let option of options">
<ion-label>
<h2>{{ option.title }}</h2>
<p>Version {{ option.version }}</p>
<p>Backup made: {{ option.timestamp | date : 'short' }}</p>
<p *ngIf="!option.installed && !option['newer-eos']">
<ion-text color="success">Ready to recover</ion-text>
</p>
<p *ngIf="option.installed">
<ion-text color="warning">Unavailable. {{ option.title }} is already installed.</ion-text>
</p>
<p *ngIf="option['newer-eos']">
<ion-text color="danger">Unavailable. Backup was made on a newer version of EmbassyOS.</ion-text>
</p>
</ion-label>
<ion-checkbox slot="end" [(ngModel)]="option.checked" [disabled]="option.installed || option['newer-eos']" (ionChange)="handleChange()"></ion-checkbox>
</ion-item>
</ion-item-group>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="end" class="ion-padding-end">
<ion-button [disabled]="!hasSelection" fill="outline" (click)="recover()" class="enter-click">
Recover Selected
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,82 @@
import { Component, Input } from '@angular/core'
import { LoadingController, ModalController, IonicSafeString } from '@ionic/angular'
import { BackupInfo, PackageBackupInfo } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { Emver } from 'src/app/services/emver.service'
import { getErrorMessage } from 'src/app/services/error-toast.service'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
@Component({
selector: 'app-recover-select',
templateUrl: './app-recover-select.page.html',
styleUrls: ['./app-recover-select.page.scss'],
})
export class AppRecoverSelectPage {
@Input() logicalname: string
@Input() password: string
@Input() backupInfo: BackupInfo
options: (PackageBackupInfo & {
id: string
checked: boolean
installed: boolean
'newer-eos': boolean
})[]
hasSelection = false
error: string | IonicSafeString
constructor (
private readonly modalCtrl: ModalController,
private readonly loadingCtrl: LoadingController,
private readonly embassyApi: ApiService,
private readonly config: ConfigService,
private readonly emver: Emver,
private readonly patch: PatchDbService,
) { }
ngOnInit () {
this.options = Object.keys(this.backupInfo['package-backups']).map(id => {
return {
...this.backupInfo['package-backups'][id],
id,
checked: false,
installed: !!this.patch.data['package-data'][id],
'newer-eos': this.emver.compare(this.backupInfo['package-backups'][id]['os-version'], this.config.version) === 1,
}
})
}
dismiss () {
this.modalCtrl.dismiss()
}
handleChange () {
this.hasSelection = this.options.some(o => o.checked)
}
async recover (): Promise<void> {
const ids = this.options
.filter(option => !!option.checked)
.map(option => option.id)
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Beginning service recovery...',
cssClass: 'loader',
})
await loader.present()
try {
await this.embassyApi.restorePackages({
ids,
logicalname: this.logicalname,
password: this.password,
})
this.modalCtrl.dismiss(undefined, 'success')
} catch (e) {
this.error = getErrorMessage(e)
} finally {
loader.dismiss()
}
}
}

View File

@@ -1,5 +0,0 @@
<backup-drives-header type="restore" (onClose)="dismiss()"></backup-drives-header>
<ion-content class="ion-padding-top">
<backup-drives type="restore" (onSelect)="presentModal($event)"></backup-drives>
</ion-content>

View File

@@ -1,19 +0,0 @@
import { NgModule } from '@angular/core'
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],
imports: [
CommonModule,
IonicModule,
SharingModule,
BackupDrivesComponentModule,
],
exports: [AppRestoreComponent],
})
export class AppRestoreComponentModule { }

View File

@@ -1,114 +0,0 @@
import { Component, Input } from '@angular/core'
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 { 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'
@Component({
selector: 'app-restore',
templateUrl: './app-restore.component.html',
styleUrls: ['./app-restore.component.scss'],
})
export class AppRestoreComponent {
@Input() pkg: PackageDataEntry
modal: HTMLIonModalElement
constructor (
private readonly modalCtrl: ModalController,
private readonly alertCtrl: AlertController,
private readonly embassyApi: ApiService,
private readonly config: ConfigService,
private readonly emver: Emver,
) { }
dismiss () {
this.modalCtrl.dismiss()
}
async presentModal (partition: MappedPartitionInfo): Promise<void> {
this.modal = await this.modalCtrl.getTop()
const modal = await this.modalCtrl.create({
componentProps: {
title: 'Decryption Required',
message: 'Enter the password that was originally used to encrypt this backup.',
warning: `Warning! All current data for ${this.pkg.manifest.title} will be overwritten.`,
label: 'Password',
placeholder: 'Enter password',
useMask: true,
buttonText: 'Restore',
loadingText: 'Decrypting drive...',
submitFn: (value: string, loader: HTMLIonLoadingElement) => this.restore(partition.logicalname, value, loader),
},
cssClass: 'alertlike-modal',
presentingElement: await this.modalCtrl.getTop(),
component: GenericInputComponent,
})
modal.onWillDismiss().then(res => {
if (res.role === 'success') this.modal.dismiss(undefined, 'success')
})
await modal.present()
}
private async restore (logicalname: string, password: string, loader: HTMLIonLoadingElement): Promise<void> {
const { id, title } = this.pkg.manifest
const backupInfo = await this.embassyApi.getBackupInfo({
logicalname,
password,
})
const pkgBackupInfo = backupInfo['package-backups'][id]
if (!pkgBackupInfo) {
throw new Error(`Drive does not contain a backup of ${title}`)
}
if (this.emver.compare(pkgBackupInfo['os-version'], this.config.version) === 1) {
throw new Error(`The backup of ${title} you are attempting to restore was made on a newer version of EmbassyOS. Update EmbassyOS and try again.`)
}
const timestamp = new Date(pkgBackupInfo.timestamp).getTime()
const lastBackup = new Date(this.pkg.installed['last-backup']).getTime() // ok if last-backup is null
if (timestamp < lastBackup) {
const proceed = await this.presentAlertNewerBackup()
if (!proceed) {
throw new Error('Action cancelled')
}
}
loader.message = `Beginning Restore of ${title}`
await this.embassyApi.restorePackage({
id,
logicalname,
password,
})
}
private async presentAlertNewerBackup (): Promise<boolean> {
return new Promise(async resolve => {
const alert = await this.alertCtrl.create({
header: 'Outdated Backup',
message: `The backup you are attempting to restore is older than your most recent backup of ${this.pkg.manifest.title}. Are you sure you want to continue?`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
handler: () => resolve(false),
},
{
text: 'Continue',
handler: () => resolve(true),
cssClass: 'enter-click',
},
],
})
await alert.present()
})
}
}

View File

@@ -37,7 +37,7 @@ export class EnumListPage {
this.selectAll = !this.selectAll this.selectAll = !this.selectAll
} }
async toggleSelected (key: string) { toggleSelected (key: string) {
this.options[key] = !this.options[key] this.options[key] = !this.options[key]
} }

View File

@@ -19,7 +19,7 @@ export class GenericInputComponent {
@Input() useMask = false @Input() useMask = false
@Input() value = '' @Input() value = ''
@Input() loadingText = '' @Input() loadingText = ''
@Input() submitFn: (value: string, loader?: HTMLIonLoadingElement) => Promise<any> @Input() submitFn: (value: string) => Promise<any>
unmasked = false unmasked = false
error: string | IonicSafeString error: string | IonicSafeString
@@ -41,7 +41,11 @@ export class GenericInputComponent {
} }
async submit () { async submit () {
// @TODO validate input? const value = this.value.trim()
if (!value && !this.nullable) {
return
}
const loader = await this.loadingCtrl.create({ const loader = await this.loadingCtrl.create({
spinner: 'lines', spinner: 'lines',
@@ -51,7 +55,7 @@ export class GenericInputComponent {
await loader.present() await loader.present()
try { try {
await this.submitFn(this.value, loader) await this.submitFn(value)
this.modalCtrl.dismiss(undefined, 'success') this.modalCtrl.dismiss(undefined, 'success')
} catch (e) { } catch (e) {
this.error = getErrorMessage(e) this.error = getErrorMessage(e)

View File

@@ -6,7 +6,6 @@ import { AppActionsPage, AppActionsItemComponent } from './app-actions.page'
import { QRComponentModule } from 'src/app/components/qr/qr.component.module' import { QRComponentModule } from 'src/app/components/qr/qr.component.module'
import { SharingModule } from 'src/app/modules/sharing.module' import { SharingModule } from 'src/app/modules/sharing.module'
import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module' import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module'
import { AppRestoreComponentModule } from 'src/app/modals/app-restore/app-restore.component.module'
import { ActionSuccessPageModule } from 'src/app/modals/action-success/action-success.module' import { ActionSuccessPageModule } from 'src/app/modals/action-success/action-success.module'
const routes: Routes = [ const routes: Routes = [
@@ -24,7 +23,6 @@ const routes: Routes = [
QRComponentModule, QRComponentModule,
SharingModule, SharingModule,
GenericFormPageModule, GenericFormPageModule,
AppRestoreComponentModule,
ActionSuccessPageModule, ActionSuccessPageModule,
], ],
declarations: [ declarations: [

View File

@@ -7,21 +7,11 @@
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content class="ion-padding-top"> <ion-content class="ion-padding-top">
<ion-item-group *ngIf="pkg"> <ion-item-group *ngIf="pkg">
<!-- ** standard actions ** --> <!-- ** standard actions ** -->
<ion-item-divider>Standard Actions</ion-item-divider> <ion-item-divider>Standard Actions</ion-item-divider>
<app-actions-item
[action]="{
name: 'Restore From Backup',
description: 'All changes since backup will be lost.',
icon: 'color-wand-outline'
}"
(click)="restore()">
</app-actions-item>
<app-actions-item <app-actions-item
[action]="{ [action]="{
name: 'Uninstall', name: 'Uninstall',

View File

@@ -3,13 +3,12 @@ import { ActivatedRoute } from '@angular/router'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AlertController, IonContent, LoadingController, ModalController, NavController } from '@ionic/angular' import { AlertController, IonContent, LoadingController, ModalController, NavController } from '@ionic/angular'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { Action, Manifest, PackageDataEntry, PackageMainStatus } from 'src/app/services/patch-db/data-model' import { Action, PackageDataEntry, PackageMainStatus } from 'src/app/services/patch-db/data-model'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component' import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards' import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page' import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { ErrorToastService } from 'src/app/services/error-toast.service' import { ErrorToastService } from 'src/app/services/error-toast.service'
import { AppRestoreComponent } from 'src/app/modals/app-restore/app-restore.component'
import { isEmptyObject } from 'src/app/util/misc.util' import { isEmptyObject } from 'src/app/util/misc.util'
import { ActionSuccessPage } from 'src/app/modals/action-success/action-success.page' import { ActionSuccessPage } from 'src/app/modals/action-success/action-success.page'
@@ -120,21 +119,6 @@ export class AppActionsPage {
} }
} }
async restore (): Promise<void> {
const modal = await this.modalCtrl.create({
componentProps: {
pkg: this.pkg,
},
component: AppRestoreComponent,
})
modal.onWillDismiss().then(res => {
if (res.role === 'success') this.navCtrl.back()
})
await modal.present()
}
async uninstall () { async uninstall () {
const { id, title, version, alerts } = this.pkg.manifest const { id, title, version, alerts } = this.pkg.manifest
const data = await wizardModal( const data = await wizardModal(

View File

@@ -155,7 +155,7 @@ export class AppListPage {
role: 'cancel', role: 'cancel',
}, },
{ {
text: 'Execute', text: 'Delete',
handler: () => { handler: () => {
execute() execute()
}, },

View File

@@ -49,8 +49,8 @@
</ion-item> </ion-item>
<!-- ** installed ** --> <!-- ** installed ** -->
<ng-container *ngIf="pkg.state === PackageState.Installed"> <ng-container *ngIf="pkg.state === PackageState.Installed">
<!-- ** !restoring/backing-up ** --> <!-- ** !backing-up ** -->
<ng-container *ngIf="!([PS.BackingUp, PS.Restoring] | includes : statuses.primary)"> <ng-container *ngIf="statuses.primary !== PS.BackingUp">
<!-- ** health checks ** --> <!-- ** health checks ** -->
<ng-container *ngIf="statuses.primary === PS.Running && !(healthChecks | empty)"> <ng-container *ngIf="statuses.primary === PS.Running && !(healthChecks | empty)">
<ion-item-divider>Health Checks</ion-item-divider> <ion-item-divider>Health Checks</ion-item-divider>
@@ -126,8 +126,8 @@
</ng-container> </ng-container>
</ion-item-group> </ion-item-group>
<!-- ** installing or updating ** --> <!-- ** installing, updating, restoring ** -->
<div *ngIf="([PackageState.Installing, PackageState.Updating] | includes : pkg.state) && installProgress" style="padding: 16px;"> <div *ngIf="([PackageState.Installing, PackageState.Updating, PackageState.Restoring] | includes : pkg.state) && installProgress" style="padding: 16px;">
<p>Downloading: {{ installProgress.downloadProgress }}%</p> <p>Downloading: {{ installProgress.downloadProgress }}%</p>
<ion-progress-bar <ion-progress-bar
[color]="pkg['install-progress']['download-complete'] ? 'success' : 'secondary'" [color]="pkg['install-progress']['download-complete'] ? 'success' : 'secondary'"

View File

@@ -369,7 +369,7 @@ export class AppShowPage {
{ {
action: () => this.navCtrl.navigateForward(['actions'], { relativeTo: this.route }), action: () => this.navCtrl.navigateForward(['actions'], { relativeTo: this.route }),
title: 'Actions', title: 'Actions',
description: `Uninstall, recover from backup, and other commands specific to ${pkgTitle}`, description: `Uninstall and other commands specific to ${pkgTitle}`,
icon: 'flash-outline', icon: 'flash-outline',
color: 'danger', color: 'danger',
}, },

View File

@@ -0,0 +1,5 @@
<backup-drives-header title="Restore From Backup"></backup-drives-header>
<ion-content class="ion-padding-top">
<backup-drives type="restore" (onSelect)="presentModalPassword($event)"></backup-drives>
</ion-content>

View File

@@ -0,0 +1,30 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RestorePage } from './restore.component'
import { SharingModule } from 'src/app/modules/sharing.module'
import { BackupDrivesComponentModule } from 'src/app/components/backup-drives/backup-drives.component.module'
import { AppRecoverSelectPageModule } from 'src/app/modals/app-recover-select/app-recover-select.module'
const routes: Routes = [
{
path: '',
component: RestorePage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharingModule,
BackupDrivesComponentModule,
AppRecoverSelectPageModule,
],
declarations: [
RestorePage,
],
})
export class RestorePageModule { }

View File

@@ -0,0 +1,69 @@
import { Component } from '@angular/core'
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 { MappedPartitionInfo } from 'src/app/util/misc.util'
import { BackupInfo } from 'src/app/services/api/api.types'
import { AppRecoverSelectPage } from 'src/app/modals/app-recover-select/app-recover-select.page'
@Component({
selector: 'restore',
templateUrl: './restore.component.html',
styleUrls: ['./restore.component.scss'],
})
export class RestorePage {
constructor (
private readonly modalCtrl: ModalController,
private readonly navCtrl: NavController,
private readonly embassyApi: ApiService,
) { }
async presentModalPassword (partition: MappedPartitionInfo): Promise<void> {
const modal = await this.modalCtrl.create({
componentProps: {
title: 'Decryption Required',
message: 'Enter the password that was originally used to encrypt this backup. After decrypting the drive, you will select the services you want to restore.',
label: 'Password',
placeholder: 'Enter password',
useMask: true,
buttonText: 'Restore',
loadingText: 'Decrypting drive...',
submitFn: (password: string) => this.decryptDrive(partition.logicalname, password),
},
cssClass: 'alertlike-modal',
presentingElement: await this.modalCtrl.getTop(),
component: GenericInputComponent,
})
await modal.present()
}
private async decryptDrive (logicalname: string, password: string): Promise<void> {
const backupInfo = await this.embassyApi.getBackupInfo({
logicalname,
password,
})
this.presentModalSelect(logicalname, password, backupInfo)
}
async presentModalSelect (logicalname: string, password: string, backupInfo: BackupInfo): Promise<void> {
const modal = await this.modalCtrl.create({
componentProps: {
logicalname,
password,
backupInfo,
},
presentingElement: await this.modalCtrl.getTop(),
component: AppRecoverSelectPage,
})
modal.onWillDismiss().then(res => {
if (res.role === 'success') {
this.navCtrl.navigateRoot('/services')
}
})
await modal.present()
}
}

View File

@@ -1,8 +1,8 @@
<!-- not backing up --> <!-- not backing up -->
<ng-container *ngIf="!backingUp"> <ng-container *ngIf="!backingUp">
<backup-drives-header type="backup" (onClose)="back()"></backup-drives-header> <backup-drives-header title="Create Backup"></backup-drives-header>
<ion-content class="ion-padding-top"> <ion-content class="ion-padding-top">
<backup-drives type="backup" (onSelect)="presentModal($event)"></backup-drives> <backup-drives type="backup" (onSelect)="presentModalPassword($event)"></backup-drives>
</ion-content> </ion-content>
</ng-container> </ng-container>

View File

@@ -1,5 +1,5 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { ModalController, NavController } from '@ionic/angular' import { ModalController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GenericInputComponent } from 'src/app/modals/generic-input/generic-input.component' import { GenericInputComponent } from 'src/app/modals/generic-input/generic-input.component'
import { MappedPartitionInfo } from 'src/app/util/misc.util' import { MappedPartitionInfo } from 'src/app/util/misc.util'
@@ -19,7 +19,6 @@ export class ServerBackupPage {
subs: Subscription[] subs: Subscription[]
constructor ( constructor (
private readonly navCtrl: NavController,
private readonly modalCtrl: ModalController, private readonly modalCtrl: ModalController,
private readonly embassyApi: ApiService, private readonly embassyApi: ApiService,
private readonly patch: PatchDbService, private readonly patch: PatchDbService,
@@ -44,11 +43,7 @@ export class ServerBackupPage {
this.pkgs.forEach(pkg => pkg.sub.unsubscribe()) this.pkgs.forEach(pkg => pkg.sub.unsubscribe())
} }
back () { async presentModalPassword (partition: MappedPartitionInfo): Promise<void> {
this.navCtrl.back()
}
async presentModal (partition: MappedPartitionInfo): Promise<void> {
let message: string let message: string
if (partition.hasBackup) { 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.' 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.'
@@ -65,7 +60,7 @@ export class ServerBackupPage {
useMask: true, useMask: true,
buttonText: 'Create Backup', buttonText: 'Create Backup',
loadingText: 'Beginning backup...', loadingText: 'Beginning backup...',
submitFn: async (password: string) => await this.create(partition.logicalname, password), submitFn: (password: string) => this.create(partition.logicalname, password),
}, },
cssClass: 'alertlike-modal', cssClass: 'alertlike-modal',
component: GenericInputComponent, component: GenericInputComponent,

View File

@@ -26,6 +26,10 @@ const routes: Routes = [
path: 'preferences', path: 'preferences',
loadChildren: () => import('./preferences/preferences.module').then( m => m.PreferencesPageModule), loadChildren: () => import('./preferences/preferences.module').then( m => m.PreferencesPageModule),
}, },
{
path: 'restore',
loadChildren: () => import('./restore/restore.component.module').then( m => m.RestorePageModule),
},
{ {
path: 'sessions', path: 'sessions',
loadChildren: () => import('./sessions/sessions.module').then( m => m.SessionsPageModule), loadChildren: () => import('./sessions/sessions.module').then( m => m.SessionsPageModule),

View File

@@ -9,20 +9,6 @@
<ion-content class="ion-padding"> <ion-content class="ion-padding">
<ion-item-group> <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"> <div *ngFor="let cat of settings | keyvalue : asIsOrder">
<ion-item-divider><ion-text color="dark">{{ cat.key }}</ion-text></ion-item-divider> <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()"> <ion-item [detail]="button.detail" button *ngFor="let button of cat.value" (click)="button.action()">

View File

@@ -105,6 +105,22 @@ export class ServerShowPage {
private setButtons (): void { private setButtons (): void {
this.settings = { 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,
},
{
title: 'Restore From Backup',
description: 'Restore one or more services from a prior backup',
icon: 'color-wand-outline',
action: () => this.navCtrl.navigateForward(['restore'], { relativeTo: this.route }),
detail: true,
},
],
'Insights': [ 'Insights': [
{ {
title: 'About', title: 'About',

View File

@@ -990,6 +990,7 @@ export module Mock {
}, },
], ],
capacity: 1000000000000, capacity: 1000000000000,
internal: true,
}, },
{ {
logicalname: '/dev/sdb', logicalname: '/dev/sdb',
@@ -1015,6 +1016,7 @@ export module Mock {
}, },
], ],
capacity: 10000000000, capacity: 10000000000,
internal: false,
}, },
] ]
@@ -1023,10 +1025,17 @@ export module Mock {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
'package-backups': { 'package-backups': {
bitcoind: { bitcoind: {
title: 'Bitcoin Core',
version: '0.21.0', version: '0.21.0',
'os-version': '0.3.0', 'os-version': '0.3.0',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}, },
'btc-rpc-proxy': {
title: 'Bitcoin Proxy',
version: '0.2.2',
'os-version': '0.3.0',
timestamp: new Date().toISOString(),
},
}, },
} }
@@ -1614,11 +1623,9 @@ export module Mock {
installed: { installed: {
'last-backup': null, 'last-backup': null,
status: { status: {
configured: true, configured: false,
main: { main: {
status: PackageMainStatus.Running, status: PackageMainStatus.Stopped,
started: new Date().toISOString(),
health: { },
}, },
'dependency-errors': { }, 'dependency-errors': { },
}, },

View File

@@ -157,8 +157,8 @@ export module RR {
export type SetPackageConfigReq = WithExpire<DrySetPackageConfigReq> // package.config.set export type SetPackageConfigReq = WithExpire<DrySetPackageConfigReq> // package.config.set
export type SetPackageConfigRes = WithRevision<null> export type SetPackageConfigRes = WithRevision<null>
export type RestorePackageReq = WithExpire<{ id: string, logicalname: string, password: string }> // package.backup.restore export type RestorePackagesReq = WithExpire<{ ids: string[], logicalname: string, password: string }> // package.backup.restore
export type RestorePackageRes = WithRevision<null> export type RestorePackagesRes = WithRevision<null>
export type ExecutePackageActionReq = { id: string, 'action-id': string, input?: object } // package.action export type ExecutePackageActionReq = { id: string, 'action-id': string, input?: object } // package.action
export type ExecutePackageActionRes = ActionResponse export type ExecutePackageActionRes = ActionResponse
@@ -300,6 +300,7 @@ export interface DriveInfo {
model: string | null model: string | null
partitions: PartitionInfo[] partitions: PartitionInfo[]
capacity: number capacity: number
internal: boolean
} }
export interface PartitionInfo { export interface PartitionInfo {
@@ -319,14 +320,17 @@ export interface BackupInfo {
version: string, version: string,
timestamp: string, timestamp: string,
'package-backups': { 'package-backups': {
[id: string]: { [id: string]: PackageBackupInfo
version: string
'os-version': string
timestamp: string
}
} }
} }
export interface PackageBackupInfo {
title: string
version: string
'os-version': string
timestamp: string
}
export interface ServerSpecs { export interface ServerSpecs {
[key: string]: string | number [key: string]: string | number
} }

View File

@@ -154,9 +154,9 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
() => this.setPackageConfigRaw(params), () => this.setPackageConfigRaw(params),
)() )()
protected abstract restorePackageRaw (params: RR.RestorePackageReq): Promise<RR.RestorePackageRes> protected abstract restorePackagesRaw (params: RR.RestorePackagesReq): Promise<RR.RestorePackagesRes>
restorePackage = (params: RR.RestorePackageReq) => this.syncResponse( restorePackages = (params: RR.RestorePackagesReq) => this.syncResponse(
() => this.restorePackageRaw(params), () => this.restorePackagesRaw(params),
)() )()
abstract executePackageAction (params: RR.ExecutePackageActionReq): Promise<RR.ExecutePackageActionRes> abstract executePackageAction (params: RR.ExecutePackageActionReq): Promise<RR.ExecutePackageActionRes>

View File

@@ -236,8 +236,8 @@ export class LiveApiService extends ApiService {
return this.http.rpcRequest({ method: 'package.config.set', params }) return this.http.rpcRequest({ method: 'package.config.set', params })
} }
async restorePackageRaw (params: RR.RestorePackageReq): Promise <RR.RestorePackageRes> { async restorePackagesRaw (params: RR.RestorePackagesReq): Promise <RR.RestorePackagesRes> {
return this.http.rpcRequest({ method: 'package.restore', params }) return this.http.rpcRequest({ method: 'package.backup.restore', params })
} }
async executePackageAction (params: RR.ExecutePackageActionReq): Promise <RR.ExecutePackageActionRes> { async executePackageAction (params: RR.ExecutePackageActionReq): Promise <RR.ExecutePackageActionRes> {

View File

@@ -8,6 +8,7 @@ import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { Mock } from './api.fixures' import { Mock } from './api.fixures'
import { HttpService } from '../http.service' import { HttpService } from '../http.service'
import markdown from 'raw-loader!src/assets/markdown/md-sample.md' import markdown from 'raw-loader!src/assets/markdown/md-sample.md'
import { Operation } from 'fast-json-patch'
@Injectable() @Injectable()
export class MockApiService extends ApiService { export class MockApiService extends ApiService {
@@ -413,28 +414,38 @@ export class MockApiService extends ApiService {
return this.http.rpcRequest<WithRevision<null>>({ method: 'db.patch', params: { patch } }) return this.http.rpcRequest<WithRevision<null>>({ method: 'db.patch', params: { patch } })
} }
async restorePackageRaw (params: RR.RestorePackageReq): Promise<RR.RestorePackageRes> { async restorePackagesRaw (params: RR.RestorePackagesReq): Promise<RR.RestorePackagesRes> {
await pauseFor(2000) await pauseFor(2000)
const path = `/package-data/${params.id}/installed/status/main/status` const patch: Operation[] = params.ids.map(id => {
const patch = [
{ const initialProgress: InstallProgress = {
op: PatchOp.REPLACE, size: 120,
path, downloaded: 120,
value: PackageMainStatus.Restoring, 'download-complete': true,
}, validated: 0,
] 'validation-complete': false,
const res = await this.http.rpcRequest<WithRevision<null>>({ method: 'db.patch', params: { patch } }) unpacked: 0,
setTimeout(() => { 'unpack-complete': false,
const patch = [ }
{
op: PatchOp.REPLACE, const pkg: PackageDataEntry = {
path, ...Mock.LocalPkgs[id],
value: PackageMainStatus.Stopped, state: PackageState.Restoring,
}, 'install-progress': initialProgress,
] installed: undefined,
this.http.rpcRequest<WithRevision<null>>({ method: 'db.patch', params: { patch } }) }
}, this.revertTime)
return res setTimeout(async () => {
this.updateProgress(id, initialProgress)
}, 2000)
return {
op: 'add',
path: `/package-data/${id}`,
value: pkg,
}
})
return this.http.rpcRequest<WithRevision<null>>({ method: 'db.patch', params: { patch } })
} }
async executePackageAction (params: RR.ExecutePackageActionReq): Promise<RR.ExecutePackageActionRes> { async executePackageAction (params: RR.ExecutePackageActionReq): Promise<RR.ExecutePackageActionRes> {

View File

@@ -95,6 +95,7 @@ export enum PackageState {
Installed = 'installed', Installed = 'installed',
Updating = 'updating', Updating = 'updating',
Removing = 'removing', Removing = 'removing',
Restoring = 'restoring',
} }
export interface Manifest { export interface Manifest {
@@ -235,7 +236,7 @@ export interface Status {
'dependency-errors': { [id: string]: DependencyError | null } 'dependency-errors': { [id: string]: DependencyError | null }
} }
export type MainStatus = MainStatusStopped | MainStatusStopping | MainStatusRunning | MainStatusBackingUp | MainStatusRestoring export type MainStatus = MainStatusStopped | MainStatusStopping | MainStatusRunning | MainStatusBackingUp
export interface MainStatusStopped { export interface MainStatusStopped {
status: PackageMainStatus.Stopped status: PackageMainStatus.Stopped
@@ -256,17 +257,11 @@ export interface MainStatusBackingUp {
started: string | null // UTC date string started: string | null // UTC date string
} }
export interface MainStatusRestoring {
status: PackageMainStatus.Restoring
running: boolean
}
export enum PackageMainStatus { export enum PackageMainStatus {
Running = 'running', Running = 'running',
Stopping = 'stopping', Stopping = 'stopping',
Stopped = 'stopped', Stopped = 'stopped',
BackingUp = 'backing-up', BackingUp = 'backing-up',
Restoring = 'restoring',
} }
export type HealthCheckResult = HealthCheckResultStarting | export type HealthCheckResult = HealthCheckResultStarting |

View File

@@ -71,12 +71,12 @@ export enum PrimaryStatus {
Installing = 'installing', Installing = 'installing',
Updating = 'updating', Updating = 'updating',
Removing = 'removing', Removing = 'removing',
Restoring = 'restoring',
// status // status
Running = 'running', Running = 'running',
Stopping = 'stopping', Stopping = 'stopping',
Stopped = 'stopped', Stopped = 'stopped',
BackingUp = 'backing-up', BackingUp = 'backing-up',
Restoring = 'restoring',
// config // config
NeedsConfig = 'needs-config', NeedsConfig = 'needs-config',
} }
@@ -98,10 +98,10 @@ export const PrimaryRendering: { [key: string]: StatusRendering } = {
[PrimaryStatus.Installing]: { display: 'Installing', color: 'primary', showDots: true }, [PrimaryStatus.Installing]: { display: 'Installing', color: 'primary', showDots: true },
[PrimaryStatus.Updating]: { display: 'Updating', color: 'primary', showDots: true }, [PrimaryStatus.Updating]: { display: 'Updating', color: 'primary', showDots: true },
[PrimaryStatus.Removing]: { display: 'Removing', color: 'danger', showDots: true }, [PrimaryStatus.Removing]: { display: 'Removing', color: 'danger', showDots: true },
[PrimaryStatus.Restoring]: { display: 'Restoring', color: 'primary', showDots: true },
[PrimaryStatus.Stopping]: { display: 'Stopping', color: 'dark-shade', showDots: true }, [PrimaryStatus.Stopping]: { display: 'Stopping', color: 'dark-shade', showDots: true },
[PrimaryStatus.Stopped]: { display: 'Stopped', color: 'dark-shade', showDots: false }, [PrimaryStatus.Stopped]: { display: 'Stopped', color: 'dark-shade', showDots: false },
[PrimaryStatus.BackingUp]: { display: 'Backing Up', color: 'primary', showDots: true }, [PrimaryStatus.BackingUp]: { display: 'Backing Up', color: 'primary', showDots: true },
[PrimaryStatus.Restoring]: { display: 'Restoring', color: 'primary', showDots: true },
[PrimaryStatus.Running]: { display: 'Running', color: 'success', showDots: false }, [PrimaryStatus.Running]: { display: 'Running', color: 'success', showDots: false },
[PrimaryStatus.NeedsConfig]: { display: 'Needs Config', color: 'warning' }, [PrimaryStatus.NeedsConfig]: { display: 'Needs Config', color: 'warning' },
} }

View File

@@ -289,18 +289,9 @@ ion-loading {
} }
.dots { .dots {
vertical-align: middle; vertical-align: top;
margin-left: 8px; margin-left: 2px;
} margin-right: 2px;
.dots-small {
width: 12px !important;
height: 12px !important;
}
.dots-medium {
width: 16px !important;
height: 16px !important;
} }
h2 { h2 {