selective backups and better drive selection interface (#1576)

* selective backups and better drive selection interface

* fix disabled checkbox and backup drives menu styling

* feat: package-ids

* only show services that are backing up on backup page

* refactor for performace and cleanliness

Co-authored-by: Matt Hill <matthill@Matt-M1.start9.dev>
Co-authored-by: Lucy Cifferello <12953208+elvece@users.noreply.github.com>
Co-authored-by: J M <mogulslayer@gmail.com>
This commit is contained in:
Matt Hill
2022-06-28 12:14:26 -06:00
committed by GitHub
parent 753f395b8d
commit 2c5aa84fe7
25 changed files with 460 additions and 220 deletions

View File

@@ -1,7 +1,8 @@
use std::collections::BTreeMap; use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc; use std::sync::Arc;
use chrono::Utc; use chrono::Utc;
use clap::ArgMatches;
use color_eyre::eyre::eyre; use color_eyre::eyre::eyre;
use openssl::pkey::{PKey, Private}; use openssl::pkey::{PKey, Private};
use openssl::x509::X509; use openssl::x509::X509;
@@ -18,6 +19,7 @@ use super::PackageBackupReport;
use crate::auth::check_password_against_db; use crate::auth::check_password_against_db;
use crate::backup::{BackupReport, ServerBackupReport}; use crate::backup::{BackupReport, ServerBackupReport};
use crate::context::RpcContext; use crate::context::RpcContext;
use crate::db::model::BackupProgress;
use crate::db::util::WithRevision; use crate::db::util::WithRevision;
use crate::disk::mount::backup::BackupMountGuard; use crate::disk::mount::backup::BackupMountGuard;
use crate::disk::mount::filesystem::ReadWrite; use crate::disk::mount::filesystem::ReadWrite;
@@ -112,12 +114,24 @@ impl Serialize for OsBackup {
} }
} }
fn parse_comma_separated(arg: &str, _: &ArgMatches<'_>) -> Result<BTreeSet<PackageId>, Error> {
arg.split(',')
.map(|s| s.trim().parse().map_err(Error::from))
.collect()
}
#[command(rename = "create", display(display_none))] #[command(rename = "create", display(display_none))]
#[instrument(skip(ctx, old_password, password))] #[instrument(skip(ctx, old_password, password))]
pub async fn backup_all( pub async fn backup_all(
#[context] ctx: RpcContext, #[context] ctx: RpcContext,
#[arg(rename = "target-id")] target_id: BackupTargetId, #[arg(rename = "target-id")] target_id: BackupTargetId,
#[arg(rename = "old-password", long = "old-password")] old_password: Option<String>, #[arg(rename = "old-password", long = "old-password")] old_password: Option<String>,
#[arg(
rename = "package-ids",
long = "package-ids",
parse(parse_comma_separated)
)]
service_ids: Option<BTreeSet<PackageId>>,
#[arg] password: String, #[arg] password: String,
) -> Result<WithRevision<()>, Error> { ) -> Result<WithRevision<()>, Error> {
let mut db = ctx.db.handle(); let mut db = ctx.db.handle();
@@ -130,17 +144,27 @@ pub async fn backup_all(
old_password.as_ref().unwrap_or(&password), old_password.as_ref().unwrap_or(&password),
) )
.await?; .await?;
let all_packages = crate::db::DatabaseModel::new()
.package_data()
.get(&mut db, false)
.await?
.0
.keys()
.into_iter()
.cloned()
.collect();
let service_ids = service_ids.unwrap_or(all_packages);
if old_password.is_some() { if old_password.is_some() {
backup_guard.change_password(&password)?; backup_guard.change_password(&password)?;
} }
let revision = assure_backing_up(&mut db).await?; let revision = assure_backing_up(&mut db, &service_ids).await?;
tokio::task::spawn(async move { tokio::task::spawn(async move {
let backup_res = perform_backup(&ctx, &mut db, backup_guard).await; let backup_res = perform_backup(&ctx, &mut db, backup_guard).await;
let status_model = crate::db::DatabaseModel::new() let backup_progress = crate::db::DatabaseModel::new()
.server_info() .server_info()
.status_info() .status_info()
.backing_up(); .backup_progress();
status_model backup_progress
.clone() .clone()
.lock(&mut db, LockType::Write) .lock(&mut db, LockType::Write)
.await .await
@@ -207,8 +231,8 @@ pub async fn backup_all(
.expect("failed to send notification"); .expect("failed to send notification");
} }
} }
status_model backup_progress
.put(&mut db, &false) .delete(&mut db)
.await .await
.expect("failed to change server status"); .expect("failed to change server status");
}); });
@@ -218,23 +242,40 @@ pub async fn backup_all(
}) })
} }
#[instrument(skip(db))] #[instrument(skip(db, packages))]
async fn assure_backing_up(db: &mut PatchDbHandle) -> Result<Option<Arc<Revision>>, Error> { async fn assure_backing_up(
db: &mut PatchDbHandle,
packages: impl IntoIterator<Item = &PackageId>,
) -> Result<Option<Arc<Revision>>, Error> {
let mut tx = db.begin().await?; let mut tx = db.begin().await?;
let mut backing_up = crate::db::DatabaseModel::new() let mut backing_up = crate::db::DatabaseModel::new()
.server_info() .server_info()
.status_info() .status_info()
.backing_up() .backup_progress()
.get_mut(&mut tx) .get_mut(&mut tx)
.await?; .await?;
if *backing_up { if backing_up
.iter()
.flat_map(|x| x.values())
.fold(false, |acc, x| {
if !x.complete {
return true;
}
acc
})
{
return Err(Error::new( return Err(Error::new(
eyre!("Server is already backing up!"), eyre!("Server is already backing up!"),
crate::ErrorKind::InvalidRequest, crate::ErrorKind::InvalidRequest,
)); ));
} }
*backing_up = true; *backing_up = Some(
packages
.into_iter()
.map(|x| (x.clone(), BackupProgress { complete: false }))
.collect(),
);
backing_up.save(&mut tx).await?; backing_up.save(&mut tx).await?;
Ok(tx.commit(None).await?) Ok(tx.commit(None).await?)
} }
@@ -343,7 +384,7 @@ async fn perform_backup<Db: DbHandle>(
backup_guard backup_guard
.metadata .metadata
.package_backups .package_backups
.insert(package_id, pkg_meta); .insert(package_id.clone(), pkg_meta);
} }
main_status_model main_status_model
@@ -355,6 +396,24 @@ async fn perform_backup<Db: DbHandle>(
}, },
) )
.await?; .await?;
let mut backup_progress: BTreeMap<_, _> = (crate::db::DatabaseModel::new()
.server_info()
.status_info()
.backup_progress()
.get(&mut tx, true)
.await?
.into_owned())
.unwrap_or_default();
if let Some(mut backup_progress) = backup_progress.get_mut(&package_id) {
(*backup_progress).complete = true;
}
crate::db::DatabaseModel::new()
.server_info()
.status_info()
.backup_progress()
.put(&mut tx, &backup_progress)
.await?;
tx.save().await?; tx.save().await?;
} }
@@ -394,6 +453,5 @@ async fn perform_backup<Db: DbHandle>(
.last_backup() .last_backup()
.put(&mut db, &timestamp) .put(&mut db, &timestamp)
.await?; .await?;
Ok(backup_report) Ok(backup_report)
} }

View File

@@ -51,7 +51,7 @@ impl Database {
.parse() .parse()
.unwrap(), .unwrap(),
status_info: ServerStatus { status_info: ServerStatus {
backing_up: false, backup_progress: None,
updated: false, updated: false,
update_progress: None, update_progress: None,
}, },
@@ -99,10 +99,16 @@ pub struct ServerInfo {
pub password_hash: String, pub password_hash: String,
} }
#[derive(Debug, Default, Deserialize, Serialize, HasModel)]
pub struct BackupProgress {
pub complete: bool,
}
#[derive(Debug, Default, Deserialize, Serialize, HasModel)] #[derive(Debug, Default, Deserialize, Serialize, HasModel)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub struct ServerStatus { pub struct ServerStatus {
pub backing_up: bool, #[model]
pub backup_progress: Option<BTreeMap<PackageId, BackupProgress>>,
pub updated: bool, pub updated: bool,
#[model] #[model]
pub update_progress: Option<UpdateProgress>, pub update_progress: Option<UpdateProgress>,

View File

@@ -143,9 +143,9 @@ pub async fn init(cfg: &RpcContextConfig, product_key: &str) -> Result<(), Error
.set( .set(
&mut handle, &mut handle,
ServerStatus { ServerStatus {
backing_up: false,
updated: false, updated: false,
update_progress: None, update_progress: None,
backup_progress: None,
}, },
) )
.await?; .await?;

View File

@@ -32,7 +32,7 @@ const ICONS = [
'desktop-outline', 'desktop-outline',
'download-outline', 'download-outline',
'earth-outline', 'earth-outline',
'ellipsis-horizontal-outline', 'ellipsis-horizontal',
'eye-off-outline', 'eye-off-outline',
'eye-outline', 'eye-outline',
'file-tray-stacked-outline', 'file-tray-stacked-outline',

View File

@@ -47,11 +47,7 @@
</ion-item> </ion-item>
<!-- cifs list --> <!-- cifs list -->
<ng-container *ngFor="let target of backupService.cifs; let i = index"> <ng-container *ngFor="let target of backupService.cifs; let i = index">
<ion-item <ion-item button *ngIf="target.entry as cifs" (click)="select(target)">
button
*ngIf="target.entry as cifs"
(click)="presentActionCifs(target, i)"
>
<ion-icon <ion-icon
slot="start" slot="start"
name="folder-open-outline" name="folder-open-outline"
@@ -72,6 +68,13 @@
<p>Hostname: {{ cifs.hostname }}</p> <p>Hostname: {{ cifs.hostname }}</p>
<p>Path: {{ cifs.path }}</p> <p>Path: {{ cifs.path }}</p>
</ion-label> </ion-label>
<ion-note
slot="end"
class="click-area"
(click)="presentActionCifs($event, target, i)"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</ion-note>
</ion-item> </ion-item>
</ng-container> </ng-container>

View File

@@ -0,0 +1,18 @@
.click-area {
padding: 50px;
&:hover {
background-color: var(--ion-color-medium-tint);
}
ion-icon {
font-size: 27px;
}
}
@media (max-width: 1000px) {
.click-area {
padding: 18px 0px 10px;
}
}

View File

@@ -89,9 +89,12 @@ export class BackupDrivesComponent {
} }
async presentActionCifs( async presentActionCifs(
event: Event,
target: MappedBackupTarget<CifsBackupTarget>, target: MappedBackupTarget<CifsBackupTarget>,
index: number, index: number,
): Promise<void> { ): Promise<void> {
event.stopPropagation()
const entry = target.entry as CifsBackupTarget const entry = target.entry as CifsBackupTarget
const action = await this.actionCtrl.create({ const action = await this.actionCtrl.create({
@@ -114,17 +117,6 @@ export class BackupDrivesComponent {
this.presentModalEditCifs(target.id, entry, index) this.presentModalEditCifs(target.id, entry, index)
}, },
}, },
{
text:
this.type === 'create' ? 'Create Backup' : 'Restore From Backup',
icon:
this.type === 'create'
? 'cloud-upload-outline'
: 'cloud-download-outline',
handler: () => {
this.select(target)
},
},
], ],
}) })

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { BackupSelectPage } from './backup-select.page'
import { FormsModule } from '@angular/forms'
@NgModule({
declarations: [BackupSelectPage],
imports: [CommonModule, IonicModule, FormsModule],
exports: [BackupSelectPage],
})
export class BackupSelectPageModule {}

View File

@@ -0,0 +1,45 @@
<ion-header>
<ion-toolbar>
<ion-title>Select Services to Back Up</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 pkg of pkgs">
<ion-avatar slot="start">
<img alt="" [src]="pkg.icon" />
</ion-avatar>
<ion-label>
<h2>{{ pkg.title }}</h2>
</ion-label>
<ion-checkbox
slot="end"
[(ngModel)]="pkg.checked"
(ionChange)="handleChange()"
[disabled]="pkg.disabled"
></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="solid"
color="primary"
(click)="dismiss(true)"
class="enter-click btn-128"
>
Back Up Selected
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,61 @@
import { Component } from '@angular/core'
import { ModalController, IonicSafeString } from '@ionic/angular'
import { map, take } from 'rxjs/operators'
import { PackageState } from 'src/app/services/patch-db/data-model'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
@Component({
selector: 'backup-select',
templateUrl: './backup-select.page.html',
styleUrls: ['./backup-select.page.scss'],
})
export class BackupSelectPage {
hasSelection = false
error: string | IonicSafeString = ''
pkgs: {
id: string
title: string
icon: string
disabled: boolean
checked: boolean
}[] = []
constructor(
private readonly modalCtrl: ModalController,
private readonly patch: PatchDbService,
) {}
ngOnInit() {
this.patch
.watch$('package-data')
.pipe(
map(pkgs => {
return Object.values(pkgs).map(pkg => {
const { id, title } = pkg.manifest
return {
id,
title,
icon: pkg['static-files'].icon,
disabled: pkg.state !== PackageState.Installed,
checked: pkg.state === PackageState.Installed,
}
})
}),
take(1),
)
.subscribe(pkgs => (this.pkgs = pkgs))
}
dismiss(success = false) {
if (success) {
const ids = this.pkgs.filter(p => p.checked).map(p => p.id)
this.modalCtrl.dismiss(ids)
} else {
this.modalCtrl.dismiss()
}
}
handleChange() {
this.hasSelection = this.pkgs.some(p => p.checked)
}
}

View File

@@ -31,7 +31,7 @@
fill="clear" fill="clear"
(click)="presentAction(entry.key, $event)" (click)="presentAction(entry.key, $event)"
> >
<ion-icon name="ellipsis-horizontal-outline"></ion-icon> <ion-icon name="ellipsis-horizontal"></ion-icon>
</ion-button> </ion-button>
</ion-item> </ion-item>
</ion-content> </ion-content>

View File

@@ -5,7 +5,7 @@ import {
ModalController, ModalController,
} from '@ionic/angular' } from '@ionic/angular'
import { ActionSheetButton } from '@ionic/core' import { ActionSheetButton } from '@ionic/core'
import { ErrorToastService } from '@start9labs/shared' import { DestroyService, ErrorToastService } from '@start9labs/shared'
import { AbstractMarketplaceService } from '@start9labs/marketplace' import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ValueSpecObject } from 'src/app/pkg-config/config-types' import { ValueSpecObject } from 'src/app/pkg-config/config-types'
@@ -15,7 +15,12 @@ import { v4 } from 'uuid'
import { UIMarketplaceData } from '../../../services/patch-db/data-model' import { UIMarketplaceData } from '../../../services/patch-db/data-model'
import { ConfigService } from '../../../services/config.service' import { ConfigService } from '../../../services/config.service'
import { MarketplaceService } from 'src/app/services/marketplace.service' import { MarketplaceService } from 'src/app/services/marketplace.service'
import { distinctUntilChanged, finalize, first } from 'rxjs/operators' import {
distinctUntilChanged,
finalize,
first,
takeUntil,
} from 'rxjs/operators'
type Marketplaces = { type Marketplaces = {
id: string | undefined id: string | undefined
@@ -27,6 +32,7 @@ type Marketplaces = {
selector: 'marketplaces', selector: 'marketplaces',
templateUrl: 'marketplaces.page.html', templateUrl: 'marketplaces.page.html',
styleUrls: ['marketplaces.page.scss'], styleUrls: ['marketplaces.page.scss'],
providers: [DestroyService],
}) })
export class MarketplacesPage { export class MarketplacesPage {
selectedId: string | undefined selectedId: string | undefined
@@ -42,12 +48,13 @@ export class MarketplacesPage {
private readonly marketplaceService: MarketplaceService, private readonly marketplaceService: MarketplaceService,
private readonly config: ConfigService, private readonly config: ConfigService,
public readonly patch: PatchDbService, public readonly patch: PatchDbService,
private readonly destroy$: DestroyService,
) {} ) {}
ngOnInit() { ngOnInit() {
this.patch this.patch
.watch$('ui', 'marketplace') .watch$('ui', 'marketplace')
.pipe(distinctUntilChanged()) .pipe(distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe((mp: UIMarketplaceData | undefined) => { .subscribe((mp: UIMarketplaceData | undefined) => {
let marketplaces: Marketplaces = [ let marketplaces: Marketplaces = [
{ {

View File

@@ -0,0 +1,63 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="embassy"></ion-back-button>
</ion-buttons>
<ion-title>Backup Progress</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-grid *ngIf="pkgs$ | async as pkgs">
<ion-row *ngIf="backupProgress$ | async as backupProgress">
<ion-col>
<ion-item-group>
<ng-container *ngFor="let pkg of pkgs | keyvalue">
<ion-item *ngIf="backupProgress[pkg.key] as pkgProgress">
<ion-avatar slot="start">
<img [src]="pkg.value['static-files'].icon" />
</ion-avatar>
<ion-label> {{ pkg.value.manifest.title }} </ion-label>
<!-- complete -->
<ion-note
*ngIf="pkgProgress.complete; else incomplete"
class="inline"
slot="end"
>
<ion-icon name="checkmark" color="success"></ion-icon>
&nbsp;
<ion-text color="success">Complete</ion-text>
</ion-note>
<!-- incomplete -->
<ng-template #incomplete>
<ng-container
*ngIf="pkg.key | pkgMainStatus | async as pkgStatus"
>
<!-- active -->
<ion-note
*ngIf="
pkgStatus === PackageMainStatus.BackingUp;
else queued
"
class="inline"
slot="end"
>
<ion-spinner
color="dark"
style="height: 12px; width: 12px; margin-right: 6px"
></ion-spinner>
<ion-text color="dark">Backing up</ion-text>
</ion-note>
<!-- queued -->
<ng-template #queued>
<ion-note slot="end">Waiting...</ion-note>
</ng-template>
</ng-container>
</ng-template>
</ion-item>
</ng-container>
</ion-item-group>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,50 @@
import {
ChangeDetectionStrategy,
Component,
Pipe,
PipeTransform,
} from '@angular/core'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { take } from 'rxjs/operators'
import { PackageMainStatus } from 'src/app/services/patch-db/data-model'
import { EOSService } from 'src/app/services/eos.service'
import { Observable } from 'rxjs'
@Component({
selector: 'backing-up',
templateUrl: './backing-up.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BackingUpComponent {
readonly pkgs$ = this.patch.watch$('package-data').pipe(take(1))
readonly backupProgress$ = this.patch.watch$(
'server-info',
'status-info',
'backup-progress',
)
PackageMainStatus = PackageMainStatus
constructor(
public readonly eosService: EOSService,
public readonly patch: PatchDbService,
) {}
}
@Pipe({
name: 'pkgMainStatus',
})
export class PkgMainStatusPipe implements PipeTransform {
transform(pkgId: string): Observable<PackageMainStatus> {
return this.patch.watch$(
'package-data',
pkgId,
'installed',
'status',
'main',
'status',
)
}
constructor(private readonly patch: PatchDbService) {}
}

View File

@@ -2,9 +2,12 @@ import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular' import { IonicModule } from '@ionic/angular'
import { ServerBackupPage } from './server-backup.page' import { ServerBackupPage } from './server-backup.page'
import { BackingUpComponent } from './backing-up/backing-up.component'
import { RouterModule, Routes } from '@angular/router' import { RouterModule, Routes } from '@angular/router'
import { BackupDrivesComponentModule } from 'src/app/components/backup-drives/backup-drives.component.module' import { BackupDrivesComponentModule } from 'src/app/components/backup-drives/backup-drives.component.module'
import { SharedPipesModule } from '@start9labs/shared' import { SharedPipesModule } from '@start9labs/shared'
import { BackupSelectPageModule } from 'src/app/modals/backup-select/backup-select.module'
import { PkgMainStatusPipe } from './backing-up/backing-up.component'
const routes: Routes = [ const routes: Routes = [
{ {
@@ -20,7 +23,8 @@ const routes: Routes = [
RouterModule.forChild(routes), RouterModule.forChild(routes),
SharedPipesModule, SharedPipesModule,
BackupDrivesComponentModule, BackupDrivesComponentModule,
BackupSelectPageModule,
], ],
declarations: [ServerBackupPage], declarations: [ServerBackupPage, BackingUpComponent, PkgMainStatusPipe],
}) })
export class ServerBackupPageModule {} export class ServerBackupPageModule {}

View File

@@ -1,55 +1,16 @@
<!-- currently backing up -->
<backing-up
*ngIf="backingUp$ | async; else notBackingUp"
style="height: 100%"
></backing-up>
<!-- not backing up --> <!-- not backing up -->
<ng-container *ngIf="!backingUp"> <ng-template #notBackingUp>
<backup-drives-header title="Create Backup"></backup-drives-header> <backup-drives-header title="Create Backup"></backup-drives-header>
<ion-content class="ion-padding"> <ion-content class="ion-padding">
<backup-drives type="create" (onSelect)="presentModalPassword($event)"></backup-drives> <backup-drives
type="create"
(onSelect)="presentModalSelect($event)"
></backup-drives>
</ion-content> </ion-content>
</ng-container> </ng-template>
<!-- currently backing up -->
<ng-container *ngIf="backingUp">
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="embassy"></ion-back-button>
</ion-buttons>
<ion-title>Backup Progress</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-grid>
<ion-row>
<ion-col>
<ion-item-group>
<ion-item *ngFor="let pkg of pkgs">
<ion-avatar slot="start">
<img [src]="pkg.entry['static-files'].icon" />
</ion-avatar>
<ion-label>
{{ pkg.entry.manifest.title }}
</ion-label>
<!-- complete -->
<ion-note *ngIf="pkg.complete" class="inline" slot="end">
<ion-icon name="checkmark" color="success"></ion-icon>
&nbsp;
<ion-text color="success">Complete</ion-text>
</ion-note>
<!-- active -->
<ion-note *ngIf="pkg.active" class="inline" slot="end">
<ion-spinner color="dark" style="height: 12px; width: 12px; margin-right: 6px;"></ion-spinner>
<ion-text color="dark">Backing up</ion-text>
</ion-note>
<!-- queued -->
<ion-note *ngIf="!pkg.complete && !pkg.active" slot="end">
Waiting...
</ion-note>
</ion-item>
</ion-item-group>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>
</ng-container>

View File

@@ -10,78 +10,73 @@ import {
GenericInputOptions, GenericInputOptions,
} from 'src/app/modals/generic-input/generic-input.component' } from 'src/app/modals/generic-input/generic-input.component'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { Subscription } from 'rxjs' import { skip, takeUntil } from 'rxjs/operators'
import { take } from 'rxjs/operators'
import { MappedBackupTarget } from 'src/app/types/mapped-backup-target' import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'
import {
PackageDataEntry,
PackageMainStatus,
} from 'src/app/services/patch-db/data-model'
import * as argon2 from '@start9labs/argon2' import * as argon2 from '@start9labs/argon2'
import { import {
CifsBackupTarget, CifsBackupTarget,
DiskBackupTarget, DiskBackupTarget,
} from 'src/app/services/api/api.types' } from 'src/app/services/api/api.types'
import { BackupSelectPage } from 'src/app/modals/backup-select/backup-select.page'
import { EOSService } from 'src/app/services/eos.service'
import { DestroyService } from '@start9labs/shared'
@Component({ @Component({
selector: 'server-backup', selector: 'server-backup',
templateUrl: './server-backup.page.html', templateUrl: './server-backup.page.html',
styleUrls: ['./server-backup.page.scss'], styleUrls: ['./server-backup.page.scss'],
providers: [DestroyService],
}) })
export class ServerBackupPage { export class ServerBackupPage {
backingUp = false target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>
pkgs: PkgInfo[] = [] serviceIds: string[] = []
subs: Subscription[]
readonly backingUp$ = this.eosService.backingUp$
constructor( constructor(
private readonly loadingCtrl: LoadingController, private readonly loadingCtrl: LoadingController,
private readonly modalCtrl: ModalController, private readonly modalCtrl: ModalController,
private readonly embassyApi: ApiService, private readonly embassyApi: ApiService,
private readonly patch: PatchDbService,
private readonly navCtrl: NavController, private readonly navCtrl: NavController,
private readonly destroy$: DestroyService,
private readonly eosService: EOSService,
private readonly patch: PatchDbService,
) {} ) {}
ngOnInit() { ngOnInit() {
this.subs = [ this.backingUp$
this.patch .pipe(skip(1), takeUntil(this.destroy$))
.watch$('server-info', 'status-info', 'backing-up') .subscribe(isBackingUp => {
.pipe() if (!isBackingUp) {
.subscribe(isBackingUp => { this.navCtrl.navigateRoot('/embassy')
if (isBackingUp) { }
if (!this.backingUp) { })
this.backingUp = true
this.subscribeToBackup()
}
} else {
if (this.backingUp) {
this.backingUp = false
this.pkgs.forEach(pkg => pkg.sub?.unsubscribe())
this.navCtrl.navigateRoot('/embassy')
}
}
}),
]
} }
ngOnDestroy() { async presentModalSelect(
this.subs.forEach(sub => sub.unsubscribe())
this.pkgs.forEach(pkg => pkg.sub?.unsubscribe())
}
async presentModalPassword(
target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>, target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>,
): Promise<void> { ) {
let message = this.target = target
'Enter your master password to create an encrypted backup of your Embassy and all its services.'
if (!target.hasValidBackup) {
message =
message +
' Since this is a fresh backup, it could take a while. Future backups will likely be much faster.'
}
const modal = await this.modalCtrl.create({
presentingElement: await this.modalCtrl.getTop(),
component: BackupSelectPage,
})
modal.onWillDismiss().then(res => {
if (res.data) {
this.serviceIds = res.data
this.presentModalPassword()
}
})
await modal.present()
}
async presentModalPassword(): Promise<void> {
const options: GenericInputOptions = { const options: GenericInputOptions = {
title: 'Master Password Needed', title: 'Master Password Needed',
message, message: 'Enter your master password to encrypt this backup.',
label: 'Master Password', label: 'Master Password',
placeholder: 'Enter master password', placeholder: 'Enter master password',
useMask: true, useMask: true,
@@ -93,23 +88,20 @@ export class ServerBackupPage {
argon2.verify(passwordHash, password) argon2.verify(passwordHash, password)
// first time backup // first time backup
if (!target.hasValidBackup) { if (!this.target.hasValidBackup) {
await this.createBackup(target.id, password) await this.createBackup(password)
// existing backup // existing backup
} else { } else {
try { try {
const passwordHash = const passwordHash =
target.entry['embassy-os']?.['password-hash'] || '' this.target.entry['embassy-os']?.['password-hash'] || ''
argon2.verify(passwordHash, password) argon2.verify(passwordHash, password)
} catch { } catch {
setTimeout( setTimeout(() => this.presentModalOldPassword(password), 500)
() => this.presentModalOldPassword(target, password),
500,
)
return return
} }
await this.createBackup(target.id, password) await this.createBackup(password)
} }
}, },
} }
@@ -123,10 +115,7 @@ export class ServerBackupPage {
await m.present() await m.present()
} }
private async presentModalOldPassword( private async presentModalOldPassword(password: string): Promise<void> {
target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>,
password: string,
): Promise<void> {
const options: GenericInputOptions = { const options: GenericInputOptions = {
title: 'Original Password Needed', title: 'Original Password Needed',
message: message:
@@ -136,10 +125,11 @@ export class ServerBackupPage {
useMask: true, useMask: true,
buttonText: 'Create Backup', buttonText: 'Create Backup',
submitFn: async (oldPassword: string) => { submitFn: async (oldPassword: string) => {
const passwordHash = target.entry['embassy-os']?.['password-hash'] || '' const passwordHash =
this.target.entry['embassy-os']?.['password-hash'] || ''
argon2.verify(passwordHash, oldPassword) argon2.verify(passwordHash, oldPassword)
await this.createBackup(target.id, password, oldPassword) await this.createBackup(password, oldPassword)
}, },
} }
@@ -153,7 +143,6 @@ export class ServerBackupPage {
} }
private async createBackup( private async createBackup(
id: string,
password: string, password: string,
oldPassword?: string, oldPassword?: string,
): Promise<void> { ): Promise<void> {
@@ -164,7 +153,8 @@ export class ServerBackupPage {
try { try {
await this.embassyApi.createBackup({ await this.embassyApi.createBackup({
'target-id': id, 'target-id': this.target.id,
'package-ids': this.serviceIds,
'old-password': oldPassword || null, 'old-password': oldPassword || null,
password, password,
}) })
@@ -172,53 +162,4 @@ export class ServerBackupPage {
loader.dismiss() loader.dismiss()
} }
} }
private subscribeToBackup() {
this.patch
.watch$('package-data')
.pipe(take(1))
.subscribe(pkgs => {
const pkgArr = Object.keys(pkgs)
.sort()
.map(key => pkgs[key])
const activeIndex = pkgArr.findIndex(
pkg =>
pkg.installed?.status.main.status === PackageMainStatus.BackingUp,
)
this.pkgs = pkgArr.map((pkg, i) => ({
entry: pkg,
active: i === activeIndex,
complete: i < activeIndex,
}))
// subscribe to pkg
this.pkgs.forEach(pkg => {
pkg.sub = this.patch
.watch$(
'package-data',
pkg.entry.manifest.id,
'installed',
'status',
'main',
'status',
)
.subscribe(status => {
if (status === PackageMainStatus.BackingUp) {
pkg.active = true
} else if (pkg.active) {
pkg.active = false
pkg.complete = true
}
})
})
})
}
}
interface PkgInfo {
entry: PackageDataEntry
active: boolean
complete: boolean
sub?: Subscription
} }

View File

@@ -52,17 +52,17 @@
<ng-container *ngIf="server['status-info'] as statusInfo"> <ng-container *ngIf="server['status-info'] as statusInfo">
<ion-text <ion-text
color="warning" color="warning"
*ngIf="!statusInfo['backing-up'] && !statusInfo['update-progress']" *ngIf="!statusInfo['backup-progress'] && !statusInfo['update-progress']"
> >
Last Backup: {{ server['last-backup'] ? Last Backup: {{ server['last-backup'] ?
(server['last-backup'] | date: 'short') : 'never' }} (server['last-backup'] | date: 'short') : 'never' }}
</ion-text> </ion-text>
<span *ngIf="!!statusInfo['backing-up']" class="inline"> <span *ngIf="!!statusInfo['backup-progress']" class="inline">
<ion-spinner <ion-spinner
color="success" color="success"
style="height: 12px; width: 12px; margin-right: 6px" style="height: 12px; width: 12px; margin-right: 6px"
></ion-spinner> ></ion-spinner>
<ion-text color="success"> Backing up </ion-text> <ion-text color="success">Backing up</ion-text>
</span> </span>
</ng-container> </ng-container>
</p> </p>

View File

@@ -20,7 +20,7 @@ import { MarketplacePkg } from '@start9labs/marketplace'
export module Mock { export module Mock {
export const ServerUpdated: ServerStatusInfo = { export const ServerUpdated: ServerStatusInfo = {
'backing-up': false, 'backup-progress': null,
'update-progress': null, 'update-progress': null,
updated: true, updated: true,
} }

View File

@@ -148,6 +148,7 @@ export module RR {
export type CreateBackupReq = WithExpire<{ export type CreateBackupReq = WithExpire<{
// backup.create // backup.create
'target-id': string 'target-id': string
'package-ids': string[]
'old-password': string | null 'old-password': string | null
password: string password: string
}> }>

View File

@@ -392,12 +392,13 @@ export class MockApiService extends ApiService {
params: RR.CreateBackupReq, params: RR.CreateBackupReq,
): Promise<RR.CreateBackupRes> { ): Promise<RR.CreateBackupRes> {
await pauseFor(2000) await pauseFor(2000)
const path = '/server-info/status-info/backing-up' const path = '/server-info/status-info/backup-progress'
const ids = ['bitcoind', 'lnd'] const ids = params['package-ids']
setTimeout(async () => { setTimeout(async () => {
for (let i = 0; i < ids.length; i++) { for (let i = 0; i < ids.length; i++) {
const appPath = `/package-data/${ids[i]}/installed/status/main/status` const id = ids[i]
const appPath = `/package-data/${id}/installed/status/main/status`
const appPatch = [ const appPatch = [
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
@@ -409,13 +410,19 @@ export class MockApiService extends ApiService {
await pauseFor(8000) await pauseFor(8000)
const newPatch = [ this.updateMock([
{ {
...appPatch[0], ...appPatch[0],
value: PackageMainStatus.Stopped, value: PackageMainStatus.Stopped,
}, },
] ])
this.updateMock(newPatch) this.updateMock([
{
op: PatchOp.REPLACE,
path: `${path}/${id}/complete`,
value: true,
},
])
} }
await pauseFor(1000) await pauseFor(1000)
@@ -425,7 +432,7 @@ export class MockApiService extends ApiService {
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path, path,
value: false, value: null,
}, },
] ]
this.updateMock(lastPatch) this.updateMock(lastPatch)
@@ -435,7 +442,12 @@ export class MockApiService extends ApiService {
{ {
op: PatchOp.REPLACE, op: PatchOp.REPLACE,
path, path,
value: true, value: ids.reduce((acc, val) => {
return {
...acc,
[val]: { complete: false },
}
}, {}),
}, },
] ]

View File

@@ -27,7 +27,7 @@ export const mockPatchData: DataModel = {
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
'eos-version-compat': '>=0.3.0 <=0.3.0.1', 'eos-version-compat': '>=0.3.0 <=0.3.0.1',
'status-info': { 'status-info': {
'backing-up': false, 'backup-progress': null,
updated: false, updated: false,
'update-progress': null, 'update-progress': null,
}, },

View File

@@ -4,7 +4,7 @@ import { MarketplaceEOS } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { Emver } from '@start9labs/shared' import { Emver } from '@start9labs/shared'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { map } from 'rxjs/operators' import { distinctUntilChanged, map } from 'rxjs/operators'
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -15,15 +15,17 @@ export class EOSService {
readonly updating$ = this.patch.watch$('server-info', 'status-info').pipe( readonly updating$ = this.patch.watch$('server-info', 'status-info').pipe(
map(status => { map(status => {
return status && (!!status['update-progress'] || status.updated) return !!status['update-progress'] || status.updated
}), }),
distinctUntilChanged(),
) )
readonly backingUp$ = this.patch.watch$( readonly backingUp$ = this.patch
'server-info', .watch$('server-info', 'status-info', 'backup-progress')
'status-info', .pipe(
'backing-up', map(obj => !!obj),
) distinctUntilChanged(),
)
readonly updatingOrBackingUp$ = combineLatest([ readonly updatingOrBackingUp$ = combineLatest([
this.updating$, this.updating$,

View File

@@ -59,7 +59,11 @@ export interface ServerInfo {
} }
export interface ServerStatusInfo { export interface ServerStatusInfo {
'backing-up': boolean 'backup-progress': null | {
[packageId: string]: {
complete: boolean
}
}
updated: boolean updated: boolean
'update-progress': { size: number | null; downloaded: number } | null 'update-progress': { size: number | null; downloaded: number } | null
} }