mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
New recovery flow
This commit is contained in:
committed by
Aiden McClelland
parent
221d99bfee
commit
17d0f9e533
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button (click)="close()">
|
||||
<ion-icon [name]="type === 'backup' ? 'arrow-back' : 'close'"></ion-icon>
|
||||
</ion-button>
|
||||
<pwa-back-button></pwa-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ title }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
|
||||
@@ -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<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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,6 @@
|
||||
[style.font-weight]="weight"
|
||||
>
|
||||
{{ 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>
|
||||
</p>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export class EnumListPage {
|
||||
this.selectAll = !this.selectAll
|
||||
}
|
||||
|
||||
async toggleSelected (key: string) {
|
||||
toggleSelected (key: string) {
|
||||
this.options[key] = !this.options[key]
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export class GenericInputComponent {
|
||||
@Input() useMask = false
|
||||
@Input() value = ''
|
||||
@Input() loadingText = ''
|
||||
@Input() submitFn: (value: string, loader?: HTMLIonLoadingElement) => Promise<any>
|
||||
@Input() submitFn: (value: string) => Promise<any>
|
||||
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)
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -7,21 +7,11 @@
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
<ion-item-group *ngIf="pkg">
|
||||
|
||||
<!-- ** standard actions ** -->
|
||||
<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
|
||||
[action]="{
|
||||
name: 'Uninstall',
|
||||
|
||||
@@ -3,13 +3,12 @@ import { ActivatedRoute } from '@angular/router'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { AlertController, IonContent, LoadingController, ModalController, NavController } from '@ionic/angular'
|
||||
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 { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
|
||||
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 { 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 () {
|
||||
const { id, title, version, alerts } = this.pkg.manifest
|
||||
const data = await wizardModal(
|
||||
|
||||
@@ -155,7 +155,7 @@ export class AppListPage {
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Execute',
|
||||
text: 'Delete',
|
||||
handler: () => {
|
||||
execute()
|
||||
},
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
</ion-item>
|
||||
<!-- ** installed ** -->
|
||||
<ng-container *ngIf="pkg.state === PackageState.Installed">
|
||||
<!-- ** !restoring/backing-up ** -->
|
||||
<ng-container *ngIf="!([PS.BackingUp, PS.Restoring] | includes : statuses.primary)">
|
||||
<!-- ** !backing-up ** -->
|
||||
<ng-container *ngIf="statuses.primary !== PS.BackingUp">
|
||||
<!-- ** health checks ** -->
|
||||
<ng-container *ngIf="statuses.primary === PS.Running && !(healthChecks | empty)">
|
||||
<ion-item-divider>Health Checks</ion-item-divider>
|
||||
@@ -126,8 +126,8 @@
|
||||
</ng-container>
|
||||
</ion-item-group>
|
||||
|
||||
<!-- ** installing or updating ** -->
|
||||
<div *ngIf="([PackageState.Installing, PackageState.Updating] | includes : pkg.state) && installProgress" style="padding: 16px;">
|
||||
<!-- ** installing, updating, restoring ** -->
|
||||
<div *ngIf="([PackageState.Installing, PackageState.Updating, PackageState.Restoring] | includes : pkg.state) && installProgress" style="padding: 16px;">
|
||||
<p>Downloading: {{ installProgress.downloadProgress }}%</p>
|
||||
<ion-progress-bar
|
||||
[color]="pkg['install-progress']['download-complete'] ? 'success' : 'secondary'"
|
||||
|
||||
@@ -369,7 +369,7 @@ export class AppShowPage {
|
||||
{
|
||||
action: () => 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',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
69
ui/src/app/pages/server-routes/restore/restore.component.ts
Normal file
69
ui/src/app/pages/server-routes/restore/restore.component.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
<!-- not backing up -->
|
||||
<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">
|
||||
<backup-drives type="backup" (onSelect)="presentModal($event)"></backup-drives>
|
||||
<backup-drives type="backup" (onSelect)="presentModalPassword($event)"></backup-drives>
|
||||
</ion-content>
|
||||
</ng-container>
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
async presentModalPassword (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.'
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -9,20 +9,6 @@
|
||||
|
||||
<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()">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': { },
|
||||
},
|
||||
|
||||
@@ -157,8 +157,8 @@ export module RR {
|
||||
export type SetPackageConfigReq = WithExpire<DrySetPackageConfigReq> // package.config.set
|
||||
export type SetPackageConfigRes = WithRevision<null>
|
||||
|
||||
export type RestorePackageReq = WithExpire<{ id: string, logicalname: string, password: string }> // package.backup.restore
|
||||
export type RestorePackageRes = WithRevision<null>
|
||||
export type RestorePackagesReq = WithExpire<{ ids: string[], logicalname: string, password: string }> // package.backup.restore
|
||||
export type RestorePackagesRes = WithRevision<null>
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -154,9 +154,9 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
|
||||
() => this.setPackageConfigRaw(params),
|
||||
)()
|
||||
|
||||
protected abstract restorePackageRaw (params: RR.RestorePackageReq): Promise<RR.RestorePackageRes>
|
||||
restorePackage = (params: RR.RestorePackageReq) => this.syncResponse(
|
||||
() => this.restorePackageRaw(params),
|
||||
protected abstract restorePackagesRaw (params: RR.RestorePackagesReq): Promise<RR.RestorePackagesRes>
|
||||
restorePackages = (params: RR.RestorePackagesReq) => this.syncResponse(
|
||||
() => this.restorePackagesRaw(params),
|
||||
)()
|
||||
|
||||
abstract executePackageAction (params: RR.ExecutePackageActionReq): Promise<RR.ExecutePackageActionRes>
|
||||
|
||||
@@ -236,8 +236,8 @@ export class LiveApiService extends ApiService {
|
||||
return this.http.rpcRequest({ method: 'package.config.set', params })
|
||||
}
|
||||
|
||||
async restorePackageRaw (params: RR.RestorePackageReq): Promise <RR.RestorePackageRes> {
|
||||
return this.http.rpcRequest({ method: 'package.restore', params })
|
||||
async restorePackagesRaw (params: RR.RestorePackagesReq): Promise <RR.RestorePackagesRes> {
|
||||
return this.http.rpcRequest({ method: 'package.backup.restore', params })
|
||||
}
|
||||
|
||||
async executePackageAction (params: RR.ExecutePackageActionReq): Promise <RR.ExecutePackageActionRes> {
|
||||
|
||||
@@ -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<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)
|
||||
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<WithRevision<null>>({ method: 'db.patch', params: { patch } })
|
||||
setTimeout(() => {
|
||||
const patch = [
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path,
|
||||
value: PackageMainStatus.Stopped,
|
||||
},
|
||||
]
|
||||
this.http.rpcRequest<WithRevision<null>>({ 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<WithRevision<null>>({ method: 'db.patch', params: { patch } })
|
||||
}
|
||||
|
||||
async executePackageAction (params: RR.ExecutePackageActionReq): Promise<RR.ExecutePackageActionRes> {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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' },
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user