diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index 3eaac95df..6f97046d6 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -28,7 +28,7 @@ export class AppComponent { handleKeyboardEvent () { const elems = document.getElementsByClassName('enter-click') 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() } diff --git a/ui/src/app/components/backup-drives/backup-drives-header.component.html b/ui/src/app/components/backup-drives/backup-drives-header.component.html index 20e6f553b..a6c273222 100644 --- a/ui/src/app/components/backup-drives/backup-drives-header.component.html +++ b/ui/src/app/components/backup-drives/backup-drives-header.component.html @@ -1,9 +1,7 @@ - - - + {{ title }} diff --git a/ui/src/app/components/backup-drives/backup-drives.component.ts b/ui/src/app/components/backup-drives/backup-drives.component.ts index 3782bf4b2..b3e914727 100644 --- a/ui/src/app/components/backup-drives/backup-drives.component.ts +++ b/ui/src/app/components/backup-drives/backup-drives.component.ts @@ -20,7 +20,7 @@ export class BackupDrivesComponent { 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.message = 'Select the drive containing backups you would like to restore.' } this.backupService.getExternalDrives() } @@ -37,26 +37,13 @@ export class BackupDrivesComponent { styleUrls: ['./backup-drives.component.scss'], }) export class BackupDrivesHeaderComponent { - @Input() type: 'backup' | 'restore' + @Input() title: string @Output() onClose: EventEmitter = 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() } diff --git a/ui/src/app/components/backup-drives/backup.service.ts b/ui/src/app/components/backup-drives/backup.service.ts index d98bf3056..e3415ad88 100644 --- a/ui/src/app/components/backup-drives/backup.service.ts +++ b/ui/src/app/components/backup-drives/backup.service.ts @@ -23,7 +23,9 @@ export class BackupService { try { 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 => { return { ...p, diff --git a/ui/src/app/components/status/status.component.html b/ui/src/app/components/status/status.component.html index 353c72485..26bffec1b 100644 --- a/ui/src/app/components/status/status.component.html +++ b/ui/src/app/components/status/status.component.html @@ -5,6 +5,6 @@ [style.font-weight]="weight" > {{ disconnected ? 'Unknown' : rendering.display }} - + {{ installProgress }}%

diff --git a/ui/src/app/components/status/status.component.scss b/ui/src/app/components/status/status.component.scss index 44a919ec1..e69de29bb 100644 --- a/ui/src/app/components/status/status.component.scss +++ b/ui/src/app/components/status/status.component.scss @@ -1,3 +0,0 @@ -p { - margin: 0; -} diff --git a/ui/src/app/modals/app-recover-select/app-recover-select.module.ts b/ui/src/app/modals/app-recover-select/app-recover-select.module.ts new file mode 100644 index 000000000..cfab68f7d --- /dev/null +++ b/ui/src/app/modals/app-recover-select/app-recover-select.module.ts @@ -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 { } \ No newline at end of file diff --git a/ui/src/app/modals/app-recover-select/app-recover-select.page.html b/ui/src/app/modals/app-recover-select/app-recover-select.page.html new file mode 100644 index 000000000..17c6aa0f6 --- /dev/null +++ b/ui/src/app/modals/app-recover-select/app-recover-select.page.html @@ -0,0 +1,42 @@ + + + Select Services to Recover + + + + + + + + + + + + +

{{ option.title }}

+

Version {{ option.version }}

+

Backup made: {{ option.timestamp | date : 'short' }}

+

+ Ready to recover +

+

+ Unavailable. {{ option.title }} is already installed. +

+

+ Unavailable. Backup was made on a newer version of EmbassyOS. +

+
+ +
+
+
+ + + + + + Recover Selected + + + + diff --git a/ui/src/app/modals/app-restore/app-restore.component.scss b/ui/src/app/modals/app-recover-select/app-recover-select.page.scss similarity index 100% rename from ui/src/app/modals/app-restore/app-restore.component.scss rename to ui/src/app/modals/app-recover-select/app-recover-select.page.scss diff --git a/ui/src/app/modals/app-recover-select/app-recover-select.page.ts b/ui/src/app/modals/app-recover-select/app-recover-select.page.ts new file mode 100644 index 000000000..4f15c0f99 --- /dev/null +++ b/ui/src/app/modals/app-recover-select/app-recover-select.page.ts @@ -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 { + 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() + } + } +} diff --git a/ui/src/app/modals/app-restore/app-restore.component.html b/ui/src/app/modals/app-restore/app-restore.component.html deleted file mode 100644 index ae9f73bee..000000000 --- a/ui/src/app/modals/app-restore/app-restore.component.html +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/ui/src/app/modals/app-restore/app-restore.component.module.ts b/ui/src/app/modals/app-restore/app-restore.component.module.ts deleted file mode 100644 index c9c11f729..000000000 --- a/ui/src/app/modals/app-restore/app-restore.component.module.ts +++ /dev/null @@ -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 { } \ No newline at end of file diff --git a/ui/src/app/modals/app-restore/app-restore.component.ts b/ui/src/app/modals/app-restore/app-restore.component.ts deleted file mode 100644 index fa57aeeb2..000000000 --- a/ui/src/app/modals/app-restore/app-restore.component.ts +++ /dev/null @@ -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 { - 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 { - 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 { - 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() - }) - } -} diff --git a/ui/src/app/modals/enum-list/enum-list.page.ts b/ui/src/app/modals/enum-list/enum-list.page.ts index 6e13684da..7437efd6c 100644 --- a/ui/src/app/modals/enum-list/enum-list.page.ts +++ b/ui/src/app/modals/enum-list/enum-list.page.ts @@ -37,7 +37,7 @@ export class EnumListPage { this.selectAll = !this.selectAll } - async toggleSelected (key: string) { + toggleSelected (key: string) { this.options[key] = !this.options[key] } diff --git a/ui/src/app/modals/generic-input/generic-input.component.ts b/ui/src/app/modals/generic-input/generic-input.component.ts index cba486650..9b354c496 100644 --- a/ui/src/app/modals/generic-input/generic-input.component.ts +++ b/ui/src/app/modals/generic-input/generic-input.component.ts @@ -19,7 +19,7 @@ export class GenericInputComponent { @Input() useMask = false @Input() value = '' @Input() loadingText = '' - @Input() submitFn: (value: string, loader?: HTMLIonLoadingElement) => Promise + @Input() submitFn: (value: string) => Promise unmasked = false error: string | IonicSafeString @@ -41,7 +41,11 @@ export class GenericInputComponent { } async submit () { - // @TODO validate input? + const value = this.value.trim() + + if (!value && !this.nullable) { + return + } const loader = await this.loadingCtrl.create({ spinner: 'lines', @@ -51,7 +55,7 @@ export class GenericInputComponent { await loader.present() try { - await this.submitFn(this.value, loader) + await this.submitFn(value) this.modalCtrl.dismiss(undefined, 'success') } catch (e) { this.error = getErrorMessage(e) diff --git a/ui/src/app/pages/apps-routes/app-actions/app-actions.module.ts b/ui/src/app/pages/apps-routes/app-actions/app-actions.module.ts index ec80535f7..06e20ef09 100644 --- a/ui/src/app/pages/apps-routes/app-actions/app-actions.module.ts +++ b/ui/src/app/pages/apps-routes/app-actions/app-actions.module.ts @@ -6,7 +6,6 @@ import { AppActionsPage, AppActionsItemComponent } from './app-actions.page' import { QRComponentModule } from 'src/app/components/qr/qr.component.module' import { SharingModule } from 'src/app/modules/sharing.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' const routes: Routes = [ @@ -24,7 +23,6 @@ const routes: Routes = [ QRComponentModule, SharingModule, GenericFormPageModule, - AppRestoreComponentModule, ActionSuccessPageModule, ], declarations: [ diff --git a/ui/src/app/pages/apps-routes/app-actions/app-actions.page.html b/ui/src/app/pages/apps-routes/app-actions/app-actions.page.html index e3251c422..297a8d021 100644 --- a/ui/src/app/pages/apps-routes/app-actions/app-actions.page.html +++ b/ui/src/app/pages/apps-routes/app-actions/app-actions.page.html @@ -7,21 +7,11 @@
- - Standard Actions - - - - + + Health Checks @@ -126,8 +126,8 @@ - -
+ +

Downloading: {{ installProgress.downloadProgress }}%

this.navCtrl.navigateForward(['actions'], { relativeTo: this.route }), title: 'Actions', - description: `Uninstall, recover from backup, and other commands specific to ${pkgTitle}`, + description: `Uninstall and other commands specific to ${pkgTitle}`, icon: 'flash-outline', color: 'danger', }, diff --git a/ui/src/app/pages/server-routes/restore/restore.component.html b/ui/src/app/pages/server-routes/restore/restore.component.html new file mode 100644 index 000000000..d50f88003 --- /dev/null +++ b/ui/src/app/pages/server-routes/restore/restore.component.html @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/src/app/pages/server-routes/restore/restore.component.module.ts b/ui/src/app/pages/server-routes/restore/restore.component.module.ts new file mode 100644 index 000000000..234569c82 --- /dev/null +++ b/ui/src/app/pages/server-routes/restore/restore.component.module.ts @@ -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 { } \ No newline at end of file diff --git a/ui/src/app/pages/server-routes/restore/restore.component.scss b/ui/src/app/pages/server-routes/restore/restore.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/ui/src/app/pages/server-routes/restore/restore.component.ts b/ui/src/app/pages/server-routes/restore/restore.component.ts new file mode 100644 index 000000000..bf7319201 --- /dev/null +++ b/ui/src/app/pages/server-routes/restore/restore.component.ts @@ -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 { + 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 { + const backupInfo = await this.embassyApi.getBackupInfo({ + logicalname, + password, + }) + this.presentModalSelect(logicalname, password, backupInfo) + } + + async presentModalSelect (logicalname: string, password: string, backupInfo: BackupInfo): Promise { + 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() + } +} diff --git a/ui/src/app/pages/server-routes/server-backup/server-backup.page.html b/ui/src/app/pages/server-routes/server-backup/server-backup.page.html index bb596dd9f..925fd7d1a 100644 --- a/ui/src/app/pages/server-routes/server-backup/server-backup.page.html +++ b/ui/src/app/pages/server-routes/server-backup/server-backup.page.html @@ -1,8 +1,8 @@ - + - + diff --git a/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts b/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts index 94fccd4e3..e5f42902a 100644 --- a/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts +++ b/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts @@ -1,5 +1,5 @@ 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 { GenericInputComponent } from 'src/app/modals/generic-input/generic-input.component' import { MappedPartitionInfo } from 'src/app/util/misc.util' @@ -19,7 +19,6 @@ export class ServerBackupPage { subs: Subscription[] constructor ( - private readonly navCtrl: NavController, private readonly modalCtrl: ModalController, private readonly embassyApi: ApiService, private readonly patch: PatchDbService, @@ -44,11 +43,7 @@ export class ServerBackupPage { this.pkgs.forEach(pkg => pkg.sub.unsubscribe()) } - back () { - this.navCtrl.back() - } - - async presentModal (partition: MappedPartitionInfo): Promise { + async presentModalPassword (partition: MappedPartitionInfo): Promise { 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.' @@ -65,7 +60,7 @@ export class ServerBackupPage { useMask: true, buttonText: 'Create 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', component: GenericInputComponent, diff --git a/ui/src/app/pages/server-routes/server-routing.module.ts b/ui/src/app/pages/server-routes/server-routing.module.ts index d7fdc9856..3e0f51762 100644 --- a/ui/src/app/pages/server-routes/server-routing.module.ts +++ b/ui/src/app/pages/server-routes/server-routing.module.ts @@ -26,6 +26,10 @@ const routes: Routes = [ path: 'preferences', loadChildren: () => import('./preferences/preferences.module').then( m => m.PreferencesPageModule), }, + { + path: 'restore', + loadChildren: () => import('./restore/restore.component.module').then( m => m.RestorePageModule), + }, { path: 'sessions', loadChildren: () => import('./sessions/sessions.module').then( m => m.SessionsPageModule), diff --git a/ui/src/app/pages/server-routes/server-show/server-show.page.html b/ui/src/app/pages/server-routes/server-show/server-show.page.html index be9ab41f3..f661bebd0 100644 --- a/ui/src/app/pages/server-routes/server-show/server-show.page.html +++ b/ui/src/app/pages/server-routes/server-show/server-show.page.html @@ -9,20 +9,6 @@ - Backups - - - -

Create Backup

-

Back up your Embassy and all its services

-

- - Last Backup: {{ patch.data['server-info']['last-backup'] ? (patch.data['server-info']['last-backup'] | date: 'short') : 'never' }} - -

-
-
-
{{ cat.key }} diff --git a/ui/src/app/pages/server-routes/server-show/server-show.page.ts b/ui/src/app/pages/server-routes/server-show/server-show.page.ts index 860dd4f07..1d0416f56 100644 --- a/ui/src/app/pages/server-routes/server-show/server-show.page.ts +++ b/ui/src/app/pages/server-routes/server-show/server-show.page.ts @@ -105,6 +105,22 @@ 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, + }, + { + 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': [ { title: 'About', diff --git a/ui/src/app/services/api/api.fixures.ts b/ui/src/app/services/api/api.fixures.ts index 2214ad8b1..c251cbe2d 100644 --- a/ui/src/app/services/api/api.fixures.ts +++ b/ui/src/app/services/api/api.fixures.ts @@ -990,6 +990,7 @@ export module Mock { }, ], capacity: 1000000000000, + internal: true, }, { logicalname: '/dev/sdb', @@ -1015,6 +1016,7 @@ export module Mock { }, ], capacity: 10000000000, + internal: false, }, ] @@ -1023,10 +1025,17 @@ export module Mock { timestamp: new Date().toISOString(), 'package-backups': { bitcoind: { + title: 'Bitcoin Core', version: '0.21.0', 'os-version': '0.3.0', 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: { 'last-backup': null, status: { - configured: true, + configured: false, main: { - status: PackageMainStatus.Running, - started: new Date().toISOString(), - health: { }, + status: PackageMainStatus.Stopped, }, 'dependency-errors': { }, }, diff --git a/ui/src/app/services/api/api.types.ts b/ui/src/app/services/api/api.types.ts index 6806b5206..f19145bed 100644 --- a/ui/src/app/services/api/api.types.ts +++ b/ui/src/app/services/api/api.types.ts @@ -157,8 +157,8 @@ export module RR { export type SetPackageConfigReq = WithExpire // package.config.set export type SetPackageConfigRes = WithRevision - export type RestorePackageReq = WithExpire<{ id: string, logicalname: string, password: string }> // package.backup.restore - export type RestorePackageRes = WithRevision + export type RestorePackagesReq = WithExpire<{ ids: string[], logicalname: string, password: string }> // package.backup.restore + export type RestorePackagesRes = WithRevision export type ExecutePackageActionReq = { id: string, 'action-id': string, input?: object } // package.action export type ExecutePackageActionRes = ActionResponse @@ -300,6 +300,7 @@ export interface DriveInfo { model: string | null partitions: PartitionInfo[] capacity: number + internal: boolean } export interface PartitionInfo { @@ -319,14 +320,17 @@ export interface BackupInfo { version: string, timestamp: string, 'package-backups': { - [id: string]: { - version: string - 'os-version': string - timestamp: string - } + [id: string]: PackageBackupInfo } } +export interface PackageBackupInfo { + title: string + version: string + 'os-version': string + timestamp: string +} + export interface ServerSpecs { [key: string]: string | number } diff --git a/ui/src/app/services/api/embassy-api.service.ts b/ui/src/app/services/api/embassy-api.service.ts index 458b6b4de..8037c7864 100644 --- a/ui/src/app/services/api/embassy-api.service.ts +++ b/ui/src/app/services/api/embassy-api.service.ts @@ -154,9 +154,9 @@ export abstract class ApiService implements Source, Http { () => this.setPackageConfigRaw(params), )() - protected abstract restorePackageRaw (params: RR.RestorePackageReq): Promise - restorePackage = (params: RR.RestorePackageReq) => this.syncResponse( - () => this.restorePackageRaw(params), + protected abstract restorePackagesRaw (params: RR.RestorePackagesReq): Promise + restorePackages = (params: RR.RestorePackagesReq) => this.syncResponse( + () => this.restorePackagesRaw(params), )() abstract executePackageAction (params: RR.ExecutePackageActionReq): Promise diff --git a/ui/src/app/services/api/embassy-live-api.service.ts b/ui/src/app/services/api/embassy-live-api.service.ts index fda5742ae..40d98c128 100644 --- a/ui/src/app/services/api/embassy-live-api.service.ts +++ b/ui/src/app/services/api/embassy-live-api.service.ts @@ -236,8 +236,8 @@ export class LiveApiService extends ApiService { return this.http.rpcRequest({ method: 'package.config.set', params }) } - async restorePackageRaw (params: RR.RestorePackageReq): Promise { - return this.http.rpcRequest({ method: 'package.restore', params }) + async restorePackagesRaw (params: RR.RestorePackagesReq): Promise { + return this.http.rpcRequest({ method: 'package.backup.restore', params }) } async executePackageAction (params: RR.ExecutePackageActionReq): Promise { diff --git a/ui/src/app/services/api/embassy-mock-api.service.ts b/ui/src/app/services/api/embassy-mock-api.service.ts index 831b75c02..10805e560 100644 --- a/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/ui/src/app/services/api/embassy-mock-api.service.ts @@ -8,6 +8,7 @@ import { parsePropertiesPermissive } from 'src/app/util/properties.util' import { Mock } from './api.fixures' import { HttpService } from '../http.service' import markdown from 'raw-loader!src/assets/markdown/md-sample.md' +import { Operation } from 'fast-json-patch' @Injectable() export class MockApiService extends ApiService { @@ -413,28 +414,38 @@ export class MockApiService extends ApiService { return this.http.rpcRequest>({ method: 'db.patch', params: { patch } }) } - async restorePackageRaw (params: RR.RestorePackageReq): Promise { + async restorePackagesRaw (params: RR.RestorePackagesReq): Promise { await pauseFor(2000) - const path = `/package-data/${params.id}/installed/status/main/status` - const patch = [ - { - op: PatchOp.REPLACE, - path, - value: PackageMainStatus.Restoring, - }, - ] - const res = await this.http.rpcRequest>({ method: 'db.patch', params: { patch } }) - setTimeout(() => { - const patch = [ - { - op: PatchOp.REPLACE, - path, - value: PackageMainStatus.Stopped, - }, - ] - this.http.rpcRequest>({ method: 'db.patch', params: { patch } }) - }, this.revertTime) - return res + const patch: Operation[] = params.ids.map(id => { + + const initialProgress: InstallProgress = { + size: 120, + downloaded: 120, + 'download-complete': true, + validated: 0, + 'validation-complete': false, + unpacked: 0, + 'unpack-complete': false, + } + + const pkg: PackageDataEntry = { + ...Mock.LocalPkgs[id], + state: PackageState.Restoring, + 'install-progress': initialProgress, + installed: undefined, + } + + setTimeout(async () => { + this.updateProgress(id, initialProgress) + }, 2000) + + return { + op: 'add', + path: `/package-data/${id}`, + value: pkg, + } + }) + return this.http.rpcRequest>({ method: 'db.patch', params: { patch } }) } async executePackageAction (params: RR.ExecutePackageActionReq): Promise { diff --git a/ui/src/app/services/patch-db/data-model.ts b/ui/src/app/services/patch-db/data-model.ts index c306c9557..903a6e78b 100644 --- a/ui/src/app/services/patch-db/data-model.ts +++ b/ui/src/app/services/patch-db/data-model.ts @@ -95,6 +95,7 @@ export enum PackageState { Installed = 'installed', Updating = 'updating', Removing = 'removing', + Restoring = 'restoring', } export interface Manifest { @@ -235,7 +236,7 @@ export interface Status { 'dependency-errors': { [id: string]: DependencyError | null } } -export type MainStatus = MainStatusStopped | MainStatusStopping | MainStatusRunning | MainStatusBackingUp | MainStatusRestoring +export type MainStatus = MainStatusStopped | MainStatusStopping | MainStatusRunning | MainStatusBackingUp export interface MainStatusStopped { status: PackageMainStatus.Stopped @@ -256,17 +257,11 @@ export interface MainStatusBackingUp { started: string | null // UTC date string } -export interface MainStatusRestoring { - status: PackageMainStatus.Restoring - running: boolean -} - export enum PackageMainStatus { Running = 'running', Stopping = 'stopping', Stopped = 'stopped', BackingUp = 'backing-up', - Restoring = 'restoring', } export type HealthCheckResult = HealthCheckResultStarting | diff --git a/ui/src/app/services/pkg-status-rendering.service.ts b/ui/src/app/services/pkg-status-rendering.service.ts index 7267dd439..219b91fa1 100644 --- a/ui/src/app/services/pkg-status-rendering.service.ts +++ b/ui/src/app/services/pkg-status-rendering.service.ts @@ -71,12 +71,12 @@ export enum PrimaryStatus { Installing = 'installing', Updating = 'updating', Removing = 'removing', + Restoring = 'restoring', // status Running = 'running', Stopping = 'stopping', Stopped = 'stopped', BackingUp = 'backing-up', - Restoring = 'restoring', // config NeedsConfig = 'needs-config', } @@ -98,10 +98,10 @@ export const PrimaryRendering: { [key: string]: StatusRendering } = { [PrimaryStatus.Installing]: { display: 'Installing', color: 'primary', showDots: true }, [PrimaryStatus.Updating]: { display: 'Updating', color: 'primary', 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.Stopped]: { display: 'Stopped', color: 'dark-shade', showDots: false }, [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.NeedsConfig]: { display: 'Needs Config', color: 'warning' }, } diff --git a/ui/src/global.scss b/ui/src/global.scss index 8148f1e50..598ff6f98 100644 --- a/ui/src/global.scss +++ b/ui/src/global.scss @@ -289,18 +289,9 @@ ion-loading { } .dots { - vertical-align: middle; - margin-left: 8px; -} - -.dots-small { - width: 12px !important; - height: 12px !important; -} - -.dots-medium { - width: 16px !important; - height: 16px !important; + vertical-align: top; + margin-left: 2px; + margin-right: 2px; } h2 {