mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
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:
@@ -1,7 +1,8 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use clap::ArgMatches;
|
||||
use color_eyre::eyre::eyre;
|
||||
use openssl::pkey::{PKey, Private};
|
||||
use openssl::x509::X509;
|
||||
@@ -18,6 +19,7 @@ use super::PackageBackupReport;
|
||||
use crate::auth::check_password_against_db;
|
||||
use crate::backup::{BackupReport, ServerBackupReport};
|
||||
use crate::context::RpcContext;
|
||||
use crate::db::model::BackupProgress;
|
||||
use crate::db::util::WithRevision;
|
||||
use crate::disk::mount::backup::BackupMountGuard;
|
||||
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))]
|
||||
#[instrument(skip(ctx, old_password, password))]
|
||||
pub async fn backup_all(
|
||||
#[context] ctx: RpcContext,
|
||||
#[arg(rename = "target-id")] target_id: BackupTargetId,
|
||||
#[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,
|
||||
) -> Result<WithRevision<()>, Error> {
|
||||
let mut db = ctx.db.handle();
|
||||
@@ -130,17 +144,27 @@ pub async fn backup_all(
|
||||
old_password.as_ref().unwrap_or(&password),
|
||||
)
|
||||
.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() {
|
||||
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 {
|
||||
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()
|
||||
.status_info()
|
||||
.backing_up();
|
||||
status_model
|
||||
.backup_progress();
|
||||
backup_progress
|
||||
.clone()
|
||||
.lock(&mut db, LockType::Write)
|
||||
.await
|
||||
@@ -207,8 +231,8 @@ pub async fn backup_all(
|
||||
.expect("failed to send notification");
|
||||
}
|
||||
}
|
||||
status_model
|
||||
.put(&mut db, &false)
|
||||
backup_progress
|
||||
.delete(&mut db)
|
||||
.await
|
||||
.expect("failed to change server status");
|
||||
});
|
||||
@@ -218,23 +242,40 @@ pub async fn backup_all(
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(skip(db))]
|
||||
async fn assure_backing_up(db: &mut PatchDbHandle) -> Result<Option<Arc<Revision>>, Error> {
|
||||
#[instrument(skip(db, packages))]
|
||||
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 backing_up = crate::db::DatabaseModel::new()
|
||||
.server_info()
|
||||
.status_info()
|
||||
.backing_up()
|
||||
.backup_progress()
|
||||
.get_mut(&mut tx)
|
||||
.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(
|
||||
eyre!("Server is already backing up!"),
|
||||
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?;
|
||||
Ok(tx.commit(None).await?)
|
||||
}
|
||||
@@ -343,7 +384,7 @@ async fn perform_backup<Db: DbHandle>(
|
||||
backup_guard
|
||||
.metadata
|
||||
.package_backups
|
||||
.insert(package_id, pkg_meta);
|
||||
.insert(package_id.clone(), pkg_meta);
|
||||
}
|
||||
|
||||
main_status_model
|
||||
@@ -355,6 +396,24 @@ async fn perform_backup<Db: DbHandle>(
|
||||
},
|
||||
)
|
||||
.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?;
|
||||
}
|
||||
|
||||
@@ -394,6 +453,5 @@ async fn perform_backup<Db: DbHandle>(
|
||||
.last_backup()
|
||||
.put(&mut db, ×tamp)
|
||||
.await?;
|
||||
|
||||
Ok(backup_report)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ impl Database {
|
||||
.parse()
|
||||
.unwrap(),
|
||||
status_info: ServerStatus {
|
||||
backing_up: false,
|
||||
backup_progress: None,
|
||||
updated: false,
|
||||
update_progress: None,
|
||||
},
|
||||
@@ -99,10 +99,16 @@ pub struct ServerInfo {
|
||||
pub password_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, HasModel)]
|
||||
pub struct BackupProgress {
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize, HasModel)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct ServerStatus {
|
||||
pub backing_up: bool,
|
||||
#[model]
|
||||
pub backup_progress: Option<BTreeMap<PackageId, BackupProgress>>,
|
||||
pub updated: bool,
|
||||
#[model]
|
||||
pub update_progress: Option<UpdateProgress>,
|
||||
|
||||
@@ -143,9 +143,9 @@ pub async fn init(cfg: &RpcContextConfig, product_key: &str) -> Result<(), Error
|
||||
.set(
|
||||
&mut handle,
|
||||
ServerStatus {
|
||||
backing_up: false,
|
||||
updated: false,
|
||||
update_progress: None,
|
||||
backup_progress: None,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -32,7 +32,7 @@ const ICONS = [
|
||||
'desktop-outline',
|
||||
'download-outline',
|
||||
'earth-outline',
|
||||
'ellipsis-horizontal-outline',
|
||||
'ellipsis-horizontal',
|
||||
'eye-off-outline',
|
||||
'eye-outline',
|
||||
'file-tray-stacked-outline',
|
||||
|
||||
@@ -47,11 +47,7 @@
|
||||
</ion-item>
|
||||
<!-- cifs list -->
|
||||
<ng-container *ngFor="let target of backupService.cifs; let i = index">
|
||||
<ion-item
|
||||
button
|
||||
*ngIf="target.entry as cifs"
|
||||
(click)="presentActionCifs(target, i)"
|
||||
>
|
||||
<ion-item button *ngIf="target.entry as cifs" (click)="select(target)">
|
||||
<ion-icon
|
||||
slot="start"
|
||||
name="folder-open-outline"
|
||||
@@ -72,6 +68,13 @@
|
||||
<p>Hostname: {{ cifs.hostname }}</p>
|
||||
<p>Path: {{ cifs.path }}</p>
|
||||
</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>
|
||||
</ng-container>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -89,9 +89,12 @@ export class BackupDrivesComponent {
|
||||
}
|
||||
|
||||
async presentActionCifs(
|
||||
event: Event,
|
||||
target: MappedBackupTarget<CifsBackupTarget>,
|
||||
index: number,
|
||||
): Promise<void> {
|
||||
event.stopPropagation()
|
||||
|
||||
const entry = target.entry as CifsBackupTarget
|
||||
|
||||
const action = await this.actionCtrl.create({
|
||||
@@ -114,17 +117,6 @@ export class BackupDrivesComponent {
|
||||
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)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@
|
||||
fill="clear"
|
||||
(click)="presentAction(entry.key, $event)"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal-outline"></ion-icon>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</ion-content>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ModalController,
|
||||
} from '@ionic/angular'
|
||||
import { ActionSheetButton } from '@ionic/core'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { DestroyService, ErrorToastService } from '@start9labs/shared'
|
||||
import { AbstractMarketplaceService } from '@start9labs/marketplace'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
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 { ConfigService } from '../../../services/config.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 = {
|
||||
id: string | undefined
|
||||
@@ -27,6 +32,7 @@ type Marketplaces = {
|
||||
selector: 'marketplaces',
|
||||
templateUrl: 'marketplaces.page.html',
|
||||
styleUrls: ['marketplaces.page.scss'],
|
||||
providers: [DestroyService],
|
||||
})
|
||||
export class MarketplacesPage {
|
||||
selectedId: string | undefined
|
||||
@@ -42,12 +48,13 @@ export class MarketplacesPage {
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly config: ConfigService,
|
||||
public readonly patch: PatchDbService,
|
||||
private readonly destroy$: DestroyService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.patch
|
||||
.watch$('ui', 'marketplace')
|
||||
.pipe(distinctUntilChanged())
|
||||
.pipe(distinctUntilChanged(), takeUntil(this.destroy$))
|
||||
.subscribe((mp: UIMarketplaceData | undefined) => {
|
||||
let marketplaces: Marketplaces = [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { ServerBackupPage } from './server-backup.page'
|
||||
import { BackingUpComponent } from './backing-up/backing-up.component'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { BackupDrivesComponentModule } from 'src/app/components/backup-drives/backup-drives.component.module'
|
||||
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 = [
|
||||
{
|
||||
@@ -20,7 +23,8 @@ const routes: Routes = [
|
||||
RouterModule.forChild(routes),
|
||||
SharedPipesModule,
|
||||
BackupDrivesComponentModule,
|
||||
BackupSelectPageModule,
|
||||
],
|
||||
declarations: [ServerBackupPage],
|
||||
declarations: [ServerBackupPage, BackingUpComponent, PkgMainStatusPipe],
|
||||
})
|
||||
export class ServerBackupPageModule {}
|
||||
|
||||
@@ -1,55 +1,16 @@
|
||||
<!-- currently backing up -->
|
||||
<backing-up
|
||||
*ngIf="backingUp$ | async; else notBackingUp"
|
||||
style="height: 100%"
|
||||
></backing-up>
|
||||
|
||||
<!-- not backing up -->
|
||||
<ng-container *ngIf="!backingUp">
|
||||
<ng-template #notBackingUp>
|
||||
<backup-drives-header title="Create Backup"></backup-drives-header>
|
||||
<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>
|
||||
</ng-container>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
</ng-template>
|
||||
|
||||
@@ -10,78 +10,73 @@ import {
|
||||
GenericInputOptions,
|
||||
} from 'src/app/modals/generic-input/generic-input.component'
|
||||
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { take } from 'rxjs/operators'
|
||||
import { skip, takeUntil } from 'rxjs/operators'
|
||||
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 {
|
||||
CifsBackupTarget,
|
||||
DiskBackupTarget,
|
||||
} 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({
|
||||
selector: 'server-backup',
|
||||
templateUrl: './server-backup.page.html',
|
||||
styleUrls: ['./server-backup.page.scss'],
|
||||
providers: [DestroyService],
|
||||
})
|
||||
export class ServerBackupPage {
|
||||
backingUp = false
|
||||
pkgs: PkgInfo[] = []
|
||||
subs: Subscription[]
|
||||
target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>
|
||||
serviceIds: string[] = []
|
||||
|
||||
readonly backingUp$ = this.eosService.backingUp$
|
||||
|
||||
constructor(
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly patch: PatchDbService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly destroy$: DestroyService,
|
||||
private readonly eosService: EOSService,
|
||||
private readonly patch: PatchDbService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.subs = [
|
||||
this.patch
|
||||
.watch$('server-info', 'status-info', 'backing-up')
|
||||
.pipe()
|
||||
.subscribe(isBackingUp => {
|
||||
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')
|
||||
}
|
||||
}
|
||||
}),
|
||||
]
|
||||
this.backingUp$
|
||||
.pipe(skip(1), takeUntil(this.destroy$))
|
||||
.subscribe(isBackingUp => {
|
||||
if (!isBackingUp) {
|
||||
this.navCtrl.navigateRoot('/embassy')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subs.forEach(sub => sub.unsubscribe())
|
||||
this.pkgs.forEach(pkg => pkg.sub?.unsubscribe())
|
||||
}
|
||||
|
||||
async presentModalPassword(
|
||||
async presentModalSelect(
|
||||
target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>,
|
||||
): Promise<void> {
|
||||
let message =
|
||||
'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.'
|
||||
}
|
||||
) {
|
||||
this.target = target
|
||||
|
||||
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 = {
|
||||
title: 'Master Password Needed',
|
||||
message,
|
||||
message: 'Enter your master password to encrypt this backup.',
|
||||
label: 'Master Password',
|
||||
placeholder: 'Enter master password',
|
||||
useMask: true,
|
||||
@@ -93,23 +88,20 @@ export class ServerBackupPage {
|
||||
argon2.verify(passwordHash, password)
|
||||
|
||||
// first time backup
|
||||
if (!target.hasValidBackup) {
|
||||
await this.createBackup(target.id, password)
|
||||
if (!this.target.hasValidBackup) {
|
||||
await this.createBackup(password)
|
||||
// existing backup
|
||||
} else {
|
||||
try {
|
||||
const passwordHash =
|
||||
target.entry['embassy-os']?.['password-hash'] || ''
|
||||
this.target.entry['embassy-os']?.['password-hash'] || ''
|
||||
|
||||
argon2.verify(passwordHash, password)
|
||||
} catch {
|
||||
setTimeout(
|
||||
() => this.presentModalOldPassword(target, password),
|
||||
500,
|
||||
)
|
||||
setTimeout(() => this.presentModalOldPassword(password), 500)
|
||||
return
|
||||
}
|
||||
await this.createBackup(target.id, password)
|
||||
await this.createBackup(password)
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -123,10 +115,7 @@ export class ServerBackupPage {
|
||||
await m.present()
|
||||
}
|
||||
|
||||
private async presentModalOldPassword(
|
||||
target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
private async presentModalOldPassword(password: string): Promise<void> {
|
||||
const options: GenericInputOptions = {
|
||||
title: 'Original Password Needed',
|
||||
message:
|
||||
@@ -136,10 +125,11 @@ export class ServerBackupPage {
|
||||
useMask: true,
|
||||
buttonText: 'Create Backup',
|
||||
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)
|
||||
await this.createBackup(target.id, password, oldPassword)
|
||||
await this.createBackup(password, oldPassword)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -153,7 +143,6 @@ export class ServerBackupPage {
|
||||
}
|
||||
|
||||
private async createBackup(
|
||||
id: string,
|
||||
password: string,
|
||||
oldPassword?: string,
|
||||
): Promise<void> {
|
||||
@@ -164,7 +153,8 @@ export class ServerBackupPage {
|
||||
|
||||
try {
|
||||
await this.embassyApi.createBackup({
|
||||
'target-id': id,
|
||||
'target-id': this.target.id,
|
||||
'package-ids': this.serviceIds,
|
||||
'old-password': oldPassword || null,
|
||||
password,
|
||||
})
|
||||
@@ -172,53 +162,4 @@ export class ServerBackupPage {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -52,17 +52,17 @@
|
||||
<ng-container *ngIf="server['status-info'] as statusInfo">
|
||||
<ion-text
|
||||
color="warning"
|
||||
*ngIf="!statusInfo['backing-up'] && !statusInfo['update-progress']"
|
||||
*ngIf="!statusInfo['backup-progress'] && !statusInfo['update-progress']"
|
||||
>
|
||||
Last Backup: {{ server['last-backup'] ?
|
||||
(server['last-backup'] | date: 'short') : 'never' }}
|
||||
</ion-text>
|
||||
<span *ngIf="!!statusInfo['backing-up']" class="inline">
|
||||
<span *ngIf="!!statusInfo['backup-progress']" class="inline">
|
||||
<ion-spinner
|
||||
color="success"
|
||||
style="height: 12px; width: 12px; margin-right: 6px"
|
||||
></ion-spinner>
|
||||
<ion-text color="success"> Backing up </ion-text>
|
||||
<ion-text color="success">Backing up</ion-text>
|
||||
</span>
|
||||
</ng-container>
|
||||
</p>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { MarketplacePkg } from '@start9labs/marketplace'
|
||||
|
||||
export module Mock {
|
||||
export const ServerUpdated: ServerStatusInfo = {
|
||||
'backing-up': false,
|
||||
'backup-progress': null,
|
||||
'update-progress': null,
|
||||
updated: true,
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ export module RR {
|
||||
export type CreateBackupReq = WithExpire<{
|
||||
// backup.create
|
||||
'target-id': string
|
||||
'package-ids': string[]
|
||||
'old-password': string | null
|
||||
password: string
|
||||
}>
|
||||
|
||||
@@ -392,12 +392,13 @@ export class MockApiService extends ApiService {
|
||||
params: RR.CreateBackupReq,
|
||||
): Promise<RR.CreateBackupRes> {
|
||||
await pauseFor(2000)
|
||||
const path = '/server-info/status-info/backing-up'
|
||||
const ids = ['bitcoind', 'lnd']
|
||||
const path = '/server-info/status-info/backup-progress'
|
||||
const ids = params['package-ids']
|
||||
|
||||
setTimeout(async () => {
|
||||
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 = [
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
@@ -409,13 +410,19 @@ export class MockApiService extends ApiService {
|
||||
|
||||
await pauseFor(8000)
|
||||
|
||||
const newPatch = [
|
||||
this.updateMock([
|
||||
{
|
||||
...appPatch[0],
|
||||
value: PackageMainStatus.Stopped,
|
||||
},
|
||||
]
|
||||
this.updateMock(newPatch)
|
||||
])
|
||||
this.updateMock([
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: `${path}/${id}/complete`,
|
||||
value: true,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
await pauseFor(1000)
|
||||
@@ -425,7 +432,7 @@ export class MockApiService extends ApiService {
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path,
|
||||
value: false,
|
||||
value: null,
|
||||
},
|
||||
]
|
||||
this.updateMock(lastPatch)
|
||||
@@ -435,7 +442,12 @@ export class MockApiService extends ApiService {
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path,
|
||||
value: true,
|
||||
value: ids.reduce((acc, val) => {
|
||||
return {
|
||||
...acc,
|
||||
[val]: { complete: false },
|
||||
}
|
||||
}, {}),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export const mockPatchData: DataModel = {
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
'eos-version-compat': '>=0.3.0 <=0.3.0.1',
|
||||
'status-info': {
|
||||
'backing-up': false,
|
||||
'backup-progress': null,
|
||||
updated: false,
|
||||
'update-progress': null,
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MarketplaceEOS } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { Emver } from '@start9labs/shared'
|
||||
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { distinctUntilChanged, map } from 'rxjs/operators'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -15,15 +15,17 @@ export class EOSService {
|
||||
|
||||
readonly updating$ = this.patch.watch$('server-info', 'status-info').pipe(
|
||||
map(status => {
|
||||
return status && (!!status['update-progress'] || status.updated)
|
||||
return !!status['update-progress'] || status.updated
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
|
||||
readonly backingUp$ = this.patch.watch$(
|
||||
'server-info',
|
||||
'status-info',
|
||||
'backing-up',
|
||||
)
|
||||
readonly backingUp$ = this.patch
|
||||
.watch$('server-info', 'status-info', 'backup-progress')
|
||||
.pipe(
|
||||
map(obj => !!obj),
|
||||
distinctUntilChanged(),
|
||||
)
|
||||
|
||||
readonly updatingOrBackingUp$ = combineLatest([
|
||||
this.updating$,
|
||||
|
||||
@@ -59,7 +59,11 @@ export interface ServerInfo {
|
||||
}
|
||||
|
||||
export interface ServerStatusInfo {
|
||||
'backing-up': boolean
|
||||
'backup-progress': null | {
|
||||
[packageId: string]: {
|
||||
complete: boolean
|
||||
}
|
||||
}
|
||||
updated: boolean
|
||||
'update-progress': { size: number | null; downloaded: number } | null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user