Feat/automated backups (#2142)

* initial restructuring

* very cool

* new structure in place

* delete unnecessary T

* down the rabbit hole

* getting better

* dont like it

* nice

* very nice

* sessions select all

* nice

* backup runs

* fix targets and more

* small improvements

* mostly working

* address PR comments

* fix error

* delete issue with merge

* fix checkboxes and add API for deleting backup runs

* better styling for checkboxes

* small button in ssh kpage too

* complete multiple UI launcher

* fix actions

* present error toast too

* fix target forms
This commit is contained in:
Matt Hill
2023-05-09 07:49:20 -06:00
committed by Aiden McClelland
parent 9499ea8ca9
commit e53c90f8f0
116 changed files with 3072 additions and 1530 deletions

View File

@@ -0,0 +1,37 @@
import { NgModule } from '@angular/core'
import { Routes, RouterModule } from '@angular/router'
const routes: Routes = [
{
path: '',
loadChildren: () =>
import('./pages/backups/backups.module').then(m => m.BackupsPageModule),
},
{
path: 'jobs',
loadChildren: () =>
import('./pages/backup-jobs/backup-jobs.module').then(
m => m.BackupJobsPageModule,
),
},
{
path: 'targets',
loadChildren: () =>
import('./pages/backup-targets/backup-targets.module').then(
m => m.BackupTargetsPageModule,
),
},
{
path: 'history',
loadChildren: () =>
import('./pages/backup-history/backup-history.module').then(
m => m.BackupHistoryPageModule,
),
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class BackupsRoutingModule {}

View File

@@ -0,0 +1,60 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="system"></ion-back-button>
</ion-buttons>
<ion-title>Backup Progress</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding with-widgets">
<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.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 === 'backing-up'; 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,48 @@
import {
ChangeDetectionStrategy,
Component,
Pipe,
PipeTransform,
} from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { take } from 'rxjs/operators'
import {
DataModel,
PackageMainStatus,
} from 'src/app/services/patch-db/data-model'
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',
'current-backup',
'backup-progress',
)
constructor(private readonly patch: PatchDB<DataModel>) {}
}
@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: PatchDB<DataModel>) {}
}

View File

@@ -0,0 +1,77 @@
import { Directive, HostListener } from '@angular/core'
import { LoadingController, ModalController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { BackupSelectPage } from 'src/app/pages/backups-routes/modals/backup-select/backup-select.page'
import { TargetSelectPage } from '../modals/target-select/target-select.page'
import {
CifsBackupTarget,
DiskBackupTarget,
} from 'src/app/services/api/api.types'
@Directive({
selector: '[backupCreate]',
})
export class BackupCreateDirective {
serviceIds: string[] = []
constructor(
private readonly loadingCtrl: LoadingController,
private readonly modalCtrl: ModalController,
private readonly embassyApi: ApiService,
) {}
@HostListener('click') onClick() {
this.presentModalTarget()
}
async presentModalTarget() {
const modal = await this.modalCtrl.create({
presentingElement: await this.modalCtrl.getTop(),
component: TargetSelectPage,
componentProps: { type: 'create' },
})
modal.onDidDismiss<CifsBackupTarget | DiskBackupTarget>().then(res => {
if (res.data) {
this.presentModalSelect(res.data.id)
}
})
await modal.present()
}
private async presentModalSelect(targetId: string) {
const modal = await this.modalCtrl.create({
presentingElement: await this.modalCtrl.getTop(),
component: BackupSelectPage,
componentProps: {
btnText: 'Create Backup',
},
})
modal.onWillDismiss().then(res => {
if (res.data) {
this.createBackup(targetId, res.data)
}
})
await modal.present()
}
private async createBackup(
targetId: string,
pkgIds: string[],
): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Beginning backup...',
})
await loader.present()
await this.embassyApi
.createBackup({
'target-id': targetId,
'package-ids': pkgIds,
})
.finally(() => loader.dismiss())
}
}

View File

@@ -0,0 +1,121 @@
import { Directive, HostListener } from '@angular/core'
import {
LoadingController,
ModalController,
NavController,
} from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import {
GenericInputComponent,
GenericInputOptions,
} from 'src/app/modals/generic-input/generic-input.component'
import { BackupInfo, BackupTarget } from 'src/app/services/api/api.types'
import { RecoverSelectPage } from 'src/app/pages/backups-routes/modals/recover-select/recover-select.page'
import * as argon2 from '@start9labs/argon2'
import { TargetSelectPage } from '../modals/target-select/target-select.page'
@Directive({
selector: '[backupRestore]',
})
export class BackupRestoreDirective {
constructor(
private readonly modalCtrl: ModalController,
private readonly navCtrl: NavController,
private readonly embassyApi: ApiService,
private readonly loadingCtrl: LoadingController,
) {}
@HostListener('click') onClick() {
this.presentModalTarget()
}
async presentModalTarget() {
const modal = await this.modalCtrl.create({
presentingElement: await this.modalCtrl.getTop(),
component: TargetSelectPage,
componentProps: { type: 'restore' },
})
modal.onDidDismiss<BackupTarget>().then(res => {
if (res.data) {
this.presentModalPassword(res.data)
}
})
await modal.present()
}
async presentModalPassword(target: BackupTarget): Promise<void> {
const options: GenericInputOptions = {
title: 'Password Required',
message:
'Enter the master password that was used to encrypt this backup. On the next screen, you will select the individual services you want to restore.',
label: 'Master Password',
placeholder: 'Enter master password',
useMask: true,
buttonText: 'Next',
submitFn: async (password: string) => {
const passwordHash = target['embassy-os']?.['password-hash'] || ''
argon2.verify(passwordHash, password)
return this.getBackupInfo(target.id, password)
},
}
const modal = await this.modalCtrl.create({
componentProps: { options },
cssClass: 'alertlike-modal',
presentingElement: await this.modalCtrl.getTop(),
component: GenericInputComponent,
})
modal.onDidDismiss().then(res => {
if (res.data) {
const { value, response } = res.data
this.presentModalSelect(target.id, response, value)
}
})
await modal.present()
}
private async getBackupInfo(
targetId: string,
password: string,
): Promise<BackupInfo> {
const loader = await this.loadingCtrl.create({
message: 'Decrypting drive...',
})
await loader.present()
return this.embassyApi
.getBackupInfo({
'target-id': targetId,
password,
})
.finally(() => loader.dismiss())
}
private async presentModalSelect(
targetId: string,
backupInfo: BackupInfo,
password: string,
): Promise<void> {
const modal = await this.modalCtrl.create({
componentProps: {
targetId,
backupInfo,
password,
},
presentingElement: await this.modalCtrl.getTop(),
component: RecoverSelectPage,
})
modal.onWillDismiss().then(res => {
if (res.role === 'success') {
this.navCtrl.navigateRoot('/services')
}
})
await modal.present()
}
}

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,57 @@
<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>
<ng-container *ngIf="pkgs.length; else empty">
<ion-item-group>
<ion-item-divider>
<ion-buttons slot="end" style="padding-bottom: 6px">
<ion-button fill="clear" (click)="toggleSelectAll()">
<b>{{ selectAll ? 'Select All' : 'Deselect All' }}</b>
</ion-button>
</ion-buttons>
</ion-item-divider>
<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>
</ng-container>
<ng-template #empty>
<h2 class="center">No services installed!</h2>
</ng-template>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="end" class="ion-padding-end">
<ion-button
[disabled]="!hasSelection"
fill="solid"
color="primary"
(click)="done()"
class="enter-click btn-128"
>
{{ btnText }}
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,5 @@
.center {
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,72 @@
import { Component, Input } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { map } from 'rxjs/operators'
import { DataModel, PackageState } from 'src/app/services/patch-db/data-model'
import { PatchDB } from 'patch-db-client'
import { firstValueFrom } from 'rxjs'
@Component({
selector: 'backup-select',
templateUrl: './backup-select.page.html',
styleUrls: ['./backup-select.page.scss'],
})
export class BackupSelectPage {
@Input() btnText!: string
@Input() selectedIds: string[] = []
hasSelection = false
selectAll = false
pkgs: {
id: string
title: string
icon: string
disabled: boolean
checked: boolean
}[] = []
constructor(
private readonly modalCtrl: ModalController,
private readonly patch: PatchDB<DataModel>,
) {}
async ngOnInit() {
this.pkgs = await firstValueFrom(
this.patch.watch$('package-data').pipe(
map(pkgs => {
return Object.values(pkgs)
.map(pkg => {
const { id, title } = pkg.manifest
return {
id,
title,
icon: pkg.icon,
disabled: pkg.state !== PackageState.Installed,
checked: this.selectedIds.includes(id),
}
})
.sort((a, b) =>
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
)
}),
),
)
}
dismiss() {
this.modalCtrl.dismiss()
}
async done() {
const pkgIds = this.pkgs.filter(p => p.checked).map(p => p.id)
this.modalCtrl.dismiss(pkgIds)
}
handleChange() {
this.hasSelection = this.pkgs.some(p => p.checked)
}
toggleSelectAll() {
this.pkgs.forEach(pkg => (pkg.checked = this.selectAll))
this.selectAll = !this.selectAll
}
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { RecoverSelectPage } from './recover-select.page'
import { ToOptionsPipe } from './to-options.pipe'
@NgModule({
declarations: [RecoverSelectPage, ToOptionsPipe],
imports: [CommonModule, IonicModule, FormsModule],
exports: [RecoverSelectPage],
})
export class RecoverSelectPageModule {}

View File

@@ -0,0 +1,61 @@
<ng-container
*ngIf="packageData$ | toOptions : backupInfo['package-backups'] | async as options"
>
<ion-header>
<ion-toolbar>
<ion-title>Select Services to Restore</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 : 'medium' }}</p>
<p *ngIf="!option.installed && !option['newer-eos']">
<ion-text color="success">Ready to restore</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 StartOS.
</ion-text>
</p>
</ion-label>
<ion-checkbox
slot="end"
[(ngModel)]="option.checked"
[disabled]="option.installed || option['newer-eos']"
(ionChange)="handleChange(options)"
></ion-checkbox>
</ion-item>
</ion-item-group>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="end" class="ion-padding-end">
<ion-button
fill="solid"
color="primary"
class="enter-click btn-128"
[disabled]="!hasSelection"
(click)="restore(options)"
>
Restore Selected
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>
</ng-container>

View File

@@ -0,0 +1,66 @@
import { Component, Input } from '@angular/core'
import {
LoadingController,
ModalController,
IonicSafeString,
} from '@ionic/angular'
import { getErrorMessage } from '@start9labs/shared'
import { BackupInfo } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PatchDB } from 'patch-db-client'
import { AppRecoverOption } from './to-options.pipe'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { take } from 'rxjs'
@Component({
selector: 'recover-select',
templateUrl: './recover-select.page.html',
styleUrls: ['./recover-select.page.scss'],
})
export class RecoverSelectPage {
@Input() targetId!: string
@Input() backupInfo!: BackupInfo
@Input() password!: string
@Input() oldPassword?: string
readonly packageData$ = this.patch.watch$('package-data').pipe(take(1))
hasSelection = false
error: string | IonicSafeString = ''
constructor(
private readonly modalCtrl: ModalController,
private readonly loadingCtrl: LoadingController,
private readonly embassyApi: ApiService,
private readonly patch: PatchDB<DataModel>,
) {}
dismiss() {
this.modalCtrl.dismiss()
}
handleChange(options: AppRecoverOption[]) {
this.hasSelection = options.some(o => o.checked)
}
async restore(options: AppRecoverOption[]): Promise<void> {
const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id)
const loader = await this.loadingCtrl.create({
message: 'Initializing...',
})
await loader.present()
try {
await this.embassyApi.restorePackages({
ids,
'target-id': this.targetId,
password: this.password,
})
this.modalCtrl.dismiss(undefined, 'success')
} catch (e: any) {
this.error = getErrorMessage(e)
} finally {
loader.dismiss()
}
}
}

View File

@@ -0,0 +1,50 @@
import { Pipe, PipeTransform } from '@angular/core'
import { Emver } from '@start9labs/shared'
import { PackageBackupInfo } from 'src/app/services/api/api.types'
import { ConfigService } from 'src/app/services/config.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
export interface AppRecoverOption extends PackageBackupInfo {
id: string
checked: boolean
installed: boolean
'newer-eos': boolean
}
@Pipe({
name: 'toOptions',
})
export class ToOptionsPipe implements PipeTransform {
constructor(
private readonly config: ConfigService,
private readonly emver: Emver,
) {}
transform(
packageData$: Observable<Record<string, PackageDataEntry>>,
packageBackups: Record<string, PackageBackupInfo> = {},
): Observable<AppRecoverOption[]> {
return packageData$.pipe(
map(packageData =>
Object.keys(packageBackups)
.map(id => ({
...packageBackups[id],
id,
installed: !!packageData[id],
checked: false,
'newer-eos': this.compare(packageBackups[id]['os-version']),
}))
.sort((a, b) =>
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
),
),
)
}
private compare(version: string): boolean {
// checks to see if backup was made on a newer version of eOS
return this.emver.compare(version, this.config.version) === 1
}
}

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { TargetSelectPage, TargetStatusComponent } from './target-select.page'
import { TargetPipesModule } from '../../pipes/target-pipes.module'
import { TextSpinnerComponentModule } from '@start9labs/shared'
@NgModule({
declarations: [TargetSelectPage, TargetStatusComponent],
imports: [
CommonModule,
IonicModule,
TargetPipesModule,
TextSpinnerComponentModule,
],
exports: [TargetSelectPage],
})
export class TargetSelectPageModule {}

View File

@@ -0,0 +1,55 @@
<ion-header>
<ion-toolbar>
<ion-title
>Select Backup {{ type === 'create' ? 'Target' : 'Source' }}</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>
<!-- loading -->
<text-spinner
*ngIf="loading$ | async; else loaded"
[text]="type === 'create' ? 'Loading Backup Targets' : 'Loading Backup Sources'"
></text-spinner>
<!-- loaded -->
<ng-template #loaded>
<ion-item-group>
<ion-item-divider>Saved Targets</ion-item-divider>
<ion-item
button
*ngFor="let target of targets"
(click)="select(target)"
[disabled]="
(isOneOff && !target.mountable) ||
(type === 'restore' && !target['embassy-os'])
"
>
<ng-container *ngIf="target | getDisplayInfo as displayInfo">
<ion-icon
slot="start"
[name]="displayInfo.icon"
size="large"
></ion-icon>
<ion-label>
<h1 style="font-size: x-large">{{ displayInfo.name }}</h1>
<target-status [type]="type" [target]="target"></target-status>
<p>{{ displayInfo.description }}</p>
<p>{{ displayInfo.path }}</p>
</ion-label>
</ng-container>
</ion-item>
<div *ngIf="!targets.length" class="ion-text-center ion-padding-top">
<h2 class="ion-padding-bottom">No saved targets</h2>
<ion-button (click)="goToTargets()"> Go to Targets </ion-button>
</div>
</ion-item-group>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,72 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { ModalController, NavController } from '@ionic/angular'
import { BehaviorSubject } from 'rxjs'
import { BackupTarget } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from '@start9labs/shared'
import { BackupType } from '../../pages/backup-targets/backup-targets.page'
@Component({
selector: 'target-select',
templateUrl: './target-select.page.html',
styleUrls: ['./target-select.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TargetSelectPage {
@Input() type!: BackupType
@Input() isOneOff = true
targets: BackupTarget[] = []
loading$ = new BehaviorSubject(true)
constructor(
private readonly modalCtrl: ModalController,
private readonly navCtrl: NavController,
private readonly api: ApiService,
private readonly errToast: ErrorToastService,
) {}
async ngOnInit() {
await this.getTargets()
}
dismiss() {
this.modalCtrl.dismiss()
}
select(target: BackupTarget): void {
this.modalCtrl.dismiss(target)
}
goToTargets() {
this.modalCtrl
.dismiss()
.then(() => this.navCtrl.navigateForward(`/backups/targets`))
}
async refresh() {
await this.getTargets()
}
private async getTargets(): Promise<void> {
this.loading$.next(true)
try {
this.targets = (await this.api.getBackupTargets({})).saved
} catch (e: any) {
this.errToast.present(e)
} finally {
this.loading$.next(false)
}
}
}
@Component({
selector: 'target-status',
templateUrl: './target-status.component.html',
styleUrls: ['./target-select.page.scss'],
})
export class TargetStatusComponent {
@Input() type!: BackupType
@Input() target!: BackupTarget
}

View File

@@ -0,0 +1,30 @@
<div class="inline">
<h2 *ngIf="!target.mountable; else mountable">
<ion-icon name="cellular-outline" color="danger"></ion-icon>
Unable to connect
</h2>
<ng-template #mountable>
<h2 *ngIf="type === 'create'; else restore">
<ion-icon name="cloud-outline" color="success"></ion-icon>
{{
(target | hasValidBackup)
? 'Available, contains existing backup'
: 'Available for fresh backup'
}}
</h2>
<ng-template #restore>
<h2 *ngIf="target | hasValidBackup; else noBackup">
<ion-icon name="cloud-done-outline" color="success"></ion-icon>
Embassy backup detected
</h2>
<ng-template #noBackup>
<h2>
<ion-icon name="cloud-offline-outline" color="danger"></ion-icon>
No Embassy backup
</h2>
</ng-template>
</ng-template>
</ng-template>
</div>

View File

@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import {
BackupHistoryPage,
DurationPipe,
HasErrorPipe,
} from './backup-history.page'
import { TargetPipesModule } from '../../pipes/target-pipes.module'
const routes: Routes = [
{
path: '',
component: BackupHistoryPage,
},
]
@NgModule({
declarations: [BackupHistoryPage, DurationPipe, HasErrorPipe],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
TargetPipesModule,
],
})
export class BackupHistoryPageModule {}

View File

@@ -0,0 +1,93 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="/backups"></ion-back-button>
</ion-buttons>
<ion-title>Backup History</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-item-divider>
Past Events
<ion-button
class="ion-padding-start"
color="danger"
strong
size="small"
(click)="deleteSelected()"
[disabled]="empty"
>
Delete Selected
</ion-button>
</ion-item-divider>
<div class="grid-fixed">
<ion-grid class="ion-padding">
<ion-row class="grid-headings">
<ion-col size="3.5" class="inline">
<div class="checkbox" (click)="toggleAll(runs)">
<ion-icon
[name]="empty ? 'square-outline' : count === runs.length ? 'checkbox-outline' : 'remove-circle-outline'"
></ion-icon>
</div>
Started At
</ion-col>
<ion-col size="2">Duration</ion-col>
<ion-col size="1.5">Result</ion-col>
<ion-col size="2.5">Job</ion-col>
<ion-col size="2.5">Target</ion-col>
</ion-row>
<!-- loading -->
<ng-container *ngIf="loading$ | async; else loaded">
<ion-row
*ngFor="let row of ['', '', '']"
class="ion-align-items-center grid-row-border"
>
<ion-col>
<ion-skeleton-text animated></ion-skeleton-text>
</ion-col>
</ion-row>
</ng-container>
<!-- loaded -->
<ng-template #loaded>
<ion-row
*ngFor="let run of runs"
class="ion-align-items-center grid-row-border"
[class.highlighted]="selected[run.id]"
>
<ion-col size="3.5" class="inline">
<div class="checkbox" (click)="toggleChecked(run.id)">
<ion-icon
[name]="selected[run.id] ? 'checkbox-outline' : 'square-outline'"
></ion-icon>
</div>
{{ run['started-at'] | date : 'medium' }}
</ion-col>
<ion-col size="2">
{{ run['started-at']| duration : run['completed-at'] }} Minutes
</ion-col>
<ion-col size="1.5">
<ion-icon
*ngIf="run.report | hasError; else noError"
name="close"
color="danger"
></ion-icon>
<ng-template #noError>
<ion-icon name="checkmark" color="success"></ion-icon>
</ng-template>
<a (click)="presentModalReport(run)">Report</a>
</ion-col>
<ion-col size="2.5">{{ run.job.name || 'No job' }}</ion-col>
<ion-col size="2.5" class="inline">
<ion-icon
[name]="(run.job.target | getDisplayInfo).icon"
size="small"
></ion-icon>
&nbsp; {{ run.job.target.name }}
</ion-col>
</ion-row>
</ng-template>
</ion-grid>
</div>
</ion-content>

View File

@@ -0,0 +1,3 @@
.highlighted {
background-color: var(--ion-color-medium-shade);
}

View File

@@ -0,0 +1,111 @@
import { Component } from '@angular/core'
import { Pipe, PipeTransform } from '@angular/core'
import { BackupReport, BackupRun } from 'src/app/services/api/api.types'
import { LoadingController, ModalController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from '@start9labs/shared'
import { BehaviorSubject } from 'rxjs'
import { BackupReportPage } from 'src/app/modals/backup-report/backup-report.page'
@Component({
selector: 'backup-history',
templateUrl: './backup-history.page.html',
styleUrls: ['./backup-history.page.scss'],
})
export class BackupHistoryPage {
selected: Record<string, boolean> = {}
runs: BackupRun[] = []
loading$ = new BehaviorSubject(true)
constructor(
private readonly modalCtrl: ModalController,
private readonly loadingCtrl: LoadingController,
private readonly errToast: ErrorToastService,
private readonly api: ApiService,
) {}
async ngOnInit() {
try {
this.runs = await this.api.getBackupRuns({})
} catch (e: any) {
this.errToast.present(e)
} finally {
this.loading$.next(false)
}
}
get empty() {
return this.count === 0
}
get count() {
return Object.keys(this.selected).length
}
async presentModalReport(run: BackupRun) {
const modal = await this.modalCtrl.create({
component: BackupReportPage,
componentProps: {
report: run.report,
timestamp: run['completed-at'],
},
})
await modal.present()
}
async toggleChecked(id: string) {
if (this.selected[id]) {
delete this.selected[id]
} else {
this.selected[id] = true
}
}
async toggleAll(runs: BackupRun[]) {
if (this.empty) {
runs.forEach(r => (this.selected[r.id] = true))
} else {
this.selected = {}
}
}
async deleteSelected(): Promise<void> {
const ids = Object.keys(this.selected)
const loader = await this.loadingCtrl.create({
message: 'Deleting...',
})
await loader.present()
try {
await this.api.deleteBackupRuns({ ids })
this.selected = {}
this.runs = this.runs.filter(r => !ids.includes(r.id))
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
}
@Pipe({
name: 'duration',
})
export class DurationPipe implements PipeTransform {
transform(start: string, finish: string): number {
const diffMs = new Date(finish).valueOf() - new Date(start).valueOf()
return diffMs / 100
}
}
@Pipe({
name: 'hasError',
})
export class HasErrorPipe implements PipeTransform {
transform(report: BackupReport): boolean {
const osErr = !!report.server.error
const pkgErr = !!Object.values(report.packages).find(pkg => pkg.error)
return osErr || pkgErr
}
}

View File

@@ -0,0 +1,38 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { BackupJobsPage } from './backup-jobs.page'
import { NewJobPage } from './new-job/new-job.page'
import { EditJobPage } from './edit-job/edit-job.page'
import { JobOptionsComponent } from './job-options/job-options.component'
import { ToHumanCronPipe } from './pipes'
import { FormsModule } from '@angular/forms'
import { TargetSelectPageModule } from '../../modals/target-select/target-select.module'
import { TargetPipesModule } from '../../pipes/target-pipes.module'
const routes: Routes = [
{
path: '',
component: BackupJobsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
FormsModule,
TargetSelectPageModule,
TargetPipesModule,
],
declarations: [
BackupJobsPage,
ToHumanCronPipe,
NewJobPage,
EditJobPage,
JobOptionsComponent,
],
})
export class BackupJobsPageModule {}

View File

@@ -0,0 +1,92 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="/backups"></ion-back-button>
</ion-buttons>
<ion-title>Backup Jobs</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-item-group>
<ion-item>
<ion-label>
<h2>
Scheduling automatic backups is an excellent way to ensure your
Embassy data is safely backed up. Your Embassy will issue a
notification whenever one of your scheduled backups succeeds or fails.
<a [href]="docsUrl" target="_blank" rel="noreferrer">
View instructions
</a>
</h2>
</ion-label>
</ion-item>
<ion-item-divider>
Saved Jobs
<ion-button
class="ion-padding-start"
strong
size="small"
(click)="presentModalCreate()"
>
<ion-icon slot="start" name="add"></ion-icon>
New Job
</ion-button>
</ion-item-divider>
<div class="grid-fixed">
<ion-grid class="ion-padding">
<ion-row class="grid-headings">
<ion-col size="2.5">Name</ion-col>
<ion-col size="2.5">Target</ion-col>
<ion-col size="2">Packages</ion-col>
<ion-col size="3">Schedule</ion-col>
<ion-col size="2"></ion-col>
</ion-row>
<!-- loading -->
<ng-container *ngIf="loading$ | async; else loaded">
<ion-row
*ngFor="let row of ['', '']"
class="ion-align-items-center grid-row-border"
>
<ion-col>
<ion-skeleton-text animated></ion-skeleton-text>
</ion-col>
</ion-row>
</ng-container>
<!-- loaded -->
<ng-template #loaded>
<ion-row
*ngFor="let job of jobs; let i = index"
class="ion-align-items-center grid-row-border"
>
<ion-col size="2.5">{{ job.name }}</ion-col>
<ion-col size="2.5" class="inline">
<ion-icon
[name]="(job.target | getDisplayInfo).icon"
size="small"
></ion-icon>
&nbsp; {{ job.target.name }}
</ion-col>
<ion-col size="2">{{ job['package-ids'].length }} Packages</ion-col>
<ion-col size="3">{{ (job.cron | toHumanCron).message }}</ion-col>
<ion-col size="2">
<ion-buttons style="float: right">
<ion-button size="small" (click)="presentModalUpdate(job)">
<ion-icon name="pencil"></ion-icon>
</ion-button>
<ion-button
size="small"
(click)="presentAlertDelete(job.id, i)"
>
<ion-icon name="trash"></ion-icon>
</ion-button>
</ion-buttons>
</ion-col>
</ion-row>
</ng-template>
</ion-grid>
</div>
</ion-item-group>
</ion-content>

View File

@@ -0,0 +1,121 @@
import { Component } from '@angular/core'
import {
AlertController,
LoadingController,
ModalController,
} from '@ionic/angular'
import { BehaviorSubject } from 'rxjs'
import { BackupJob } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from '@start9labs/shared'
import { EditJobPage } from './edit-job/edit-job.page'
import { NewJobPage } from './new-job/new-job.page'
@Component({
selector: 'backup-jobs',
templateUrl: './backup-jobs.page.html',
styleUrls: ['./backup-jobs.page.scss'],
})
export class BackupJobsPage {
readonly docsUrl =
'https://docs.start9.com/latest/user-manual/backups/backup-jobs'
jobs: BackupJob[] = []
loading$ = new BehaviorSubject(true)
constructor(
private readonly modalCtrl: ModalController,
private readonly alertCtrl: AlertController,
private readonly loadingCtrl: LoadingController,
private readonly errToast: ErrorToastService,
private readonly api: ApiService,
) {}
async ngOnInit() {
try {
this.jobs = await this.api.getBackupJobs({})
} catch (e: any) {
this.errToast.present(e)
} finally {
this.loading$.next(false)
}
}
async presentModalCreate() {
const modal = await this.modalCtrl.create({
presentingElement: await this.modalCtrl.getTop(),
component: NewJobPage,
componentProps: {
count: this.jobs.length + 1,
},
})
modal.onWillDismiss().then(res => {
if (res.data) {
this.jobs.push(res.data)
}
})
await modal.present()
}
async presentModalUpdate(job: BackupJob) {
const modal = await this.modalCtrl.create({
presentingElement: await this.modalCtrl.getTop(),
component: EditJobPage,
componentProps: {
existingJob: job,
},
})
modal.onWillDismiss().then((res: { data?: BackupJob }) => {
if (res.data) {
const { name, target, cron } = res.data
job.name = name
job.target = target
job.cron = cron
job['package-ids'] = res.data['package-ids']
}
})
await modal.present()
}
async presentAlertDelete(id: string, index: number) {
const alert = await this.alertCtrl.create({
header: 'Confirm',
message: 'Delete backup job? This action cannot be undone.',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Delete',
handler: () => {
this.delete(id, index)
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
private async delete(id: string, i: number): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Deleting...',
})
await loader.present()
try {
await this.api.removeBackupTarget({ id })
this.jobs.splice(i, 1)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
}

View File

@@ -0,0 +1,33 @@
<ion-header>
<ion-toolbar>
<ion-title>Edit Job</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 class="ion-padding">
<ion-item-group>
<job-options [job]="job"></job-options>
</ion-item-group>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="end" class="ion-padding-end">
<ion-button
fill="solid"
color="primary"
[disabled]="!job.target || saving"
(click)="save()"
class="enter-click btn-128"
[class.no-click]="saving"
>
Save
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,3 @@
h2 {
font-weight: bold;
}

View File

@@ -0,0 +1,54 @@
import { Component, Input } from '@angular/core'
import { BackupJob } from 'src/app/services/api/api.types'
import { LoadingController, ModalController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from '@start9labs/shared'
import { BackupJobBuilder } from '../job-options/job-options.component'
@Component({
selector: 'edit-job',
templateUrl: './edit-job.page.html',
styleUrls: ['./edit-job.page.scss'],
})
export class EditJobPage {
@Input() existingJob!: BackupJob
job = {} as BackupJobBuilder
saving = false
constructor(
private readonly modalCtrl: ModalController,
private readonly loadingCtrl: LoadingController,
private readonly api: ApiService,
private readonly errToast: ErrorToastService,
) {}
ngOnInit() {
this.job = new BackupJobBuilder(this.existingJob)
}
async dismiss() {
this.modalCtrl.dismiss()
}
async save() {
this.saving = true
const loader = await this.loadingCtrl.create({
message: 'Saving Job',
})
await loader.present()
try {
const job = await this.api.updateBackupJob(
this.job.buildUpdate(this.existingJob.id),
)
this.modalCtrl.dismiss(job)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
this.saving = false
}
}
}

View File

@@ -0,0 +1,34 @@
<div class="ion-padding-start">
<p class="input-label">Job Name</p>
<ion-item color="dark">
<ion-input placeholder="My Backup Job" [(ngModel)]="job.name"></ion-input>
</ion-item>
</div>
<ion-item button (click)="presentModalTarget()">
<ion-label>
<h2>Target</h2>
</ion-label>
<ion-note slot="end" color="success">{{
job.target.type || 'Select target'
}}</ion-note>
</ion-item>
<ion-item button (click)="presentModalPackages()">
<ion-label>
<h2>Packages</h2>
</ion-label>
<ion-note slot="end" color="success">{{
job['package-ids'].length + ' selected'
}}</ion-note>
</ion-item>
<div class="ion-padding-start">
<p class="input-label">Schedule</p>
<ion-item color="dark">
<ion-input placeholder="* * * * *" [(ngModel)]="job.cron"></ion-input>
</ion-item>
<p *ngIf="job.cron | toHumanCron as human" style="padding-left: 6px">
<ion-text [color]="human.color"> {{ human.message }} </ion-text>
</p>
</div>

View File

@@ -0,0 +1,9 @@
h2 {
font-weight: bold;
}
.input-label {
margin-bottom: 6px;
font-size: medium;
font-weight: bold;
}

View File

@@ -0,0 +1,91 @@
import { Component, Input } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { BackupJob, BackupTarget, RR } from 'src/app/services/api/api.types'
import { BackupSelectPage } from '../../../modals/backup-select/backup-select.page'
import { TargetSelectPage } from '../../../modals/target-select/target-select.page'
@Component({
selector: 'job-options',
templateUrl: './job-options.component.html',
styleUrls: ['./job-options.component.scss'],
})
export class JobOptionsComponent {
@Input() job!: BackupJobBuilder
constructor(private readonly modalCtrl: ModalController) {}
async presentModalTarget() {
const modal = await this.modalCtrl.create({
presentingElement: await this.modalCtrl.getTop(),
component: TargetSelectPage,
componentProps: { type: 'create' },
})
modal.onWillDismiss<BackupTarget>().then(res => {
if (res.data) {
this.job.target = res.data
}
})
await modal.present()
}
async presentModalPackages() {
const modal = await this.modalCtrl.create({
presentingElement: await this.modalCtrl.getTop(),
component: BackupSelectPage,
componentProps: {
btnText: 'Done',
selectedIds: this.job['package-ids'],
},
})
modal.onWillDismiss().then(res => {
if (res.data) {
this.job['package-ids'] = res.data
}
})
await modal.present()
}
}
export class BackupJobBuilder {
name: string
target: BackupTarget
cron: string
'package-ids': string[]
now = false
constructor(readonly job: Partial<BackupJob>) {
const { name, target, cron } = job
this.name = name || ''
this.target = target || ({} as BackupTarget)
this.cron = cron || '0 2 * * *'
this['package-ids'] = job['package-ids'] || []
}
buildCreate(): RR.CreateBackupJobReq {
const { name, target, cron, now } = this
return {
name,
'target-id': target.id,
cron,
'package-ids': this['package-ids'],
now,
}
}
buildUpdate(id: string): RR.UpdateBackupJobReq {
const { name, target, cron } = this
return {
id,
name,
'target-id': target.id,
cron,
'package-ids': this['package-ids'],
}
}
}

View File

@@ -0,0 +1,40 @@
<ion-header>
<ion-toolbar>
<ion-title>Create New Job</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 class="ion-padding">
<ion-item-group>
<job-options [job]="job"></job-options>
<ion-item>
<ion-label>
<h2>Also Execute Now</h2>
</ion-label>
<ion-toggle slot="end" [(ngModel)]="job.now"></ion-toggle>
</ion-item>
</ion-item-group>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="end" class="ion-padding-end">
<ion-button
fill="solid"
color="primary"
[disabled]="!job.target || saving"
(click)="save()"
class="enter-click btn-128"
[class.no-click]="saving"
>
Save Job
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,3 @@
h2 {
font-weight: bold;
}

View File

@@ -0,0 +1,54 @@
import { Component, Input } from '@angular/core'
import { LoadingController, ModalController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from '@start9labs/shared'
import { BackupJobBuilder } from '../job-options/job-options.component'
@Component({
selector: 'new-job',
templateUrl: './new-job.page.html',
styleUrls: ['./new-job.page.scss'],
})
export class NewJobPage {
@Input() count!: number
readonly docsUrl =
'https://docs.start9.com/latest/user-manual/backups/backup-jobs'
job = {} as BackupJobBuilder
saving = false
constructor(
private readonly modalCtrl: ModalController,
private readonly loadingCtrl: LoadingController,
private readonly api: ApiService,
private readonly errToast: ErrorToastService,
) {}
ngOnInit() {
this.job = new BackupJobBuilder({ name: `Backup Job ${this.count}` })
}
async dismiss() {
this.modalCtrl.dismiss()
}
async save() {
const loader = await this.loadingCtrl.create({
message: 'Saving Job',
})
await loader.present()
this.saving = true
try {
const job = await this.api.createBackupJob(this.job.buildCreate())
this.modalCtrl.dismiss(job)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
this.saving = false
}
}
}

View File

@@ -0,0 +1,34 @@
import { Pipe, PipeTransform } from '@angular/core'
import cronstrue from 'cronstrue'
@Pipe({
name: 'toHumanCron',
})
export class ToHumanCronPipe implements PipeTransform {
transform(cron: string): { message: string; color: string } {
const toReturn = {
message: '',
color: 'success',
}
try {
const human = cronstrue.toString(cron, {
verbose: true,
throwExceptionOnParseError: true,
})
const zero = Number(cron[0])
const one = Number(cron[1])
if (Number.isNaN(zero) || Number.isNaN(one)) {
throw new Error(
`${human}. Cannot run cron jobs more than once per hour`,
)
}
toReturn.message = human
} catch (e) {
toReturn.message = e as string
toReturn.color = 'danger'
}
return toReturn
}
}

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { BackupTargetsPage } from './backup-targets.page'
import { SkeletonListComponentModule } from 'src/app/components/skeleton-list/skeleton-list.component.module'
import { UnitConversionPipesModule } from '@start9labs/shared'
const routes: Routes = [
{
path: '',
component: BackupTargetsPage,
},
]
@NgModule({
declarations: [BackupTargetsPage],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SkeletonListComponentModule,
UnitConversionPipesModule,
],
})
export class BackupTargetsPageModule {}

View File

@@ -0,0 +1,166 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="/backups"></ion-back-button>
</ion-buttons>
<ion-title>Backup Targets</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-item-group>
<ion-item>
<ion-label>
<h2>
Backup targets are physical or virtual locations for storing encrypted
backups. They can be physical drives plugged into your server, shared
folders on your Local Area Network (LAN), or third party clouds such
as Dropbox or Google Drive.
<a [href]="docsUrl" target="_blank" rel="noreferrer">
View instructions
</a>
</h2>
</ion-label>
</ion-item>
<!-- unknown disks -->
<ion-item-divider>
Unknown Physical Drives
<ion-button
class="ion-padding-start"
strong
size="small"
(click)="refresh()"
>
<ion-icon slot="start" name="refresh"></ion-icon>
Refresh
</ion-button>
</ion-item-divider>
<div class="grid-fixed">
<ion-grid class="ion-padding">
<ion-row class="grid-headings">
<ion-col size="3">Make/Model</ion-col>
<ion-col size="3">Label</ion-col>
<ion-col size="2">Capacity</ion-col>
<ion-col size="2">Used</ion-col>
<ion-col size="2"></ion-col>
</ion-row>
<!-- loading -->
<ion-row
*ngIf="loading$ | async; else loaded"
class="ion-align-items-center grid-row-border"
>
<ion-col>
<ion-skeleton-text animated></ion-skeleton-text>
</ion-col>
</ion-row>
<!-- loaded -->
<ng-template #loaded>
<ion-row
*ngFor="let disk of targets['unknown-disks']; let i = index"
class="ion-align-items-center grid-row-border"
>
<ion-col size="3">
{{ disk.vendor || 'unknown make' }}, {{ disk.model || 'unknown
model' }}
</ion-col>
<ion-col size="3">{{ disk.label }}</ion-col>
<ion-col size="2">{{ disk.capacity | convertBytes }}</ion-col>
<ion-col size="2">
{{ disk.used ? (disk.used | convertBytes) : 'unknown' }}
</ion-col>
<ion-col size="2">
<ion-button
strong
size="small"
style="float: right"
(click)="presentModalAddPhysical(disk, i)"
>
<ion-icon name="add" slot="start"></ion-icon>
Save
</ion-button>
</ion-col>
</ion-row>
<p *ngIf="!targets['unknown-disks'].length">
To add a new physical backup target, connect the drive and click
refresh.
</p>
</ng-template>
</ion-grid>
</div>
<!-- saved targets -->
<ion-item-divider>
Saved Targets
<ion-button
class="ion-padding-start"
strong
size="small"
(click)="presentModalAddRemote()"
>
<ion-icon slot="start" name="add"></ion-icon>
Add Target
</ion-button>
</ion-item-divider>
<div class="grid-fixed">
<ion-grid class="ion-padding">
<ion-row class="grid-headings">
<ion-col size="3">Name</ion-col>
<ion-col>Type</ion-col>
<ion-col>Available</ion-col>
<ion-col size="4">Path</ion-col>
<ion-col size="2"></ion-col>
</ion-row>
<!-- loading -->
<ng-container *ngIf="loading$ | async; else loaded2">
<ion-row
*ngFor="let row of ['', '']"
class="ion-align-items-center grid-row-border"
>
<ion-col>
<ion-skeleton-text animated></ion-skeleton-text>
</ion-col>
</ion-row>
</ng-container>
<!-- loaded -->
<ng-template #loaded2>
<ion-row
*ngFor="let target of targets.saved; let i = index"
class="ion-align-items-center grid-row-border"
>
<ion-col size="3">{{ target.name }}</ion-col>
<ion-col class="inline">
<ion-icon [name]="getIcon(target.type)" size="small"></ion-icon>
&nbsp; {{ target.type | titlecase }}
</ion-col>
<ion-col>
<ion-icon
[name]="target.mountable ? 'checkmark' : 'close'"
[color]="target.mountable ? 'success' : 'danger'"
></ion-icon>
</ion-col>
<ion-col size="4">{{ target.path }}</ion-col>
<ion-col size="2">
<ion-buttons style="float: right">
<ion-button size="small" (click)="presentModalUpdate(target)">
<ion-icon name="pencil"></ion-icon>
</ion-button>
<ion-button
size="small"
(click)="presentAlertDelete(target.id, i)"
>
<ion-icon name="trash"></ion-icon>
</ion-button>
</ion-buttons>
</ion-col>
</ion-row>
<p *ngIf="!targets.saved.length">No saved backup targets.</p>
</ng-template>
</ion-grid>
</div>
</ion-item-group>
</ion-content>

View File

@@ -0,0 +1,250 @@
import { Component } from '@angular/core'
import {
BackupTarget,
BackupTargetType,
DiskBackupTarget,
RR,
UnknownDisk,
} from 'src/app/services/api/api.types'
import {
AlertController,
LoadingController,
ModalController,
} from '@ionic/angular'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from '@start9labs/shared'
import {
CifsSpec,
DropboxSpec,
GoogleDriveSpec,
DiskBackupTargetSpec,
RemoteBackupTargetSpec,
} from '../../types/target-types'
import { BehaviorSubject } from 'rxjs'
export type BackupType = 'create' | 'restore'
@Component({
selector: 'backup-targets',
templateUrl: './backup-targets.page.html',
styleUrls: ['./backup-targets.page.scss'],
})
export class BackupTargetsPage {
readonly docsUrl =
'https://docs.start9.com/latest/user-manual/backups/backup-targets'
targets: RR.GetBackupTargetsRes = {
'unknown-disks': [],
saved: [],
}
loading$ = new BehaviorSubject(true)
constructor(
private readonly modalCtrl: ModalController,
private readonly alertCtrl: AlertController,
private readonly loadingCtrl: LoadingController,
private readonly errToast: ErrorToastService,
private readonly api: ApiService,
) {}
ngOnInit() {
this.getTargets()
}
async presentModalAddPhysical(
disk: UnknownDisk,
index: number,
): Promise<void> {
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: 'New Physical Target',
spec: DiskBackupTargetSpec,
initialValue: {
name: disk.label || disk.logicalname,
},
buttons: [
{
text: 'Save',
handler: (value: Omit<RR.AddDiskBackupTargetReq, 'logicalname'>) =>
this.add('disk', {
logicalname: disk.logicalname,
...value,
}).then(disk => {
this.targets['unknown-disks'].splice(index, 1)
this.targets.saved.push(disk)
}),
isSubmit: true,
},
],
},
})
await modal.present()
}
async presentModalAddRemote(): Promise<void> {
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: 'New Remote Target',
spec: RemoteBackupTargetSpec,
buttons: [
{
text: 'Save',
handler: (
value:
| (RR.AddCifsBackupTargetReq & { type: BackupTargetType })
| (RR.AddCloudBackupTargetReq & { type: BackupTargetType }),
) => this.add(value.type, value),
isSubmit: true,
},
],
},
})
await modal.present()
}
async presentModalUpdate(target: BackupTarget): Promise<void> {
let spec: typeof RemoteBackupTargetSpec = {}
switch (target.type) {
case 'cifs':
spec = CifsSpec
break
case 'cloud':
spec = target.provider === 'dropbox' ? DropboxSpec : GoogleDriveSpec
break
case 'disk':
spec = DiskBackupTargetSpec
break
}
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: 'Update Remote Target',
spec,
initialValue: target,
buttons: [
{
text: 'Save',
handler: (
value:
| RR.UpdateCifsBackupTargetReq
| RR.UpdateCloudBackupTargetReq
| RR.UpdateDiskBackupTargetReq,
) => this.update(target.type, value),
isSubmit: true,
},
],
},
})
await modal.present()
}
async presentAlertDelete(id: string, index: number) {
const alert = await this.alertCtrl.create({
header: 'Confirm',
message: 'Forget backup target? This actions cannot be undone.',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Delete',
handler: () => {
this.delete(id, index)
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
async delete(id: string, index: number): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Removing...',
})
await loader.present()
try {
await this.api.removeBackupTarget({ id })
this.targets.saved.splice(index, 1)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
async refresh() {
this.loading$.next(true)
await this.getTargets()
}
getIcon(type: BackupTargetType) {
switch (type) {
case 'disk':
return 'save-outline'
case 'cifs':
return 'folder-open-outline'
case 'cloud':
return 'cloud-outline'
}
}
private async getTargets(): Promise<void> {
try {
this.targets = await this.api.getBackupTargets({})
} catch (e: any) {
this.errToast.present(e)
} finally {
this.loading$.next(false)
}
}
private async add(
type: BackupTargetType,
value:
| RR.AddCifsBackupTargetReq
| RR.AddCloudBackupTargetReq
| RR.AddDiskBackupTargetReq,
): Promise<BackupTarget> {
const loader = await this.loadingCtrl.create({
message: 'Saving target...',
})
await loader.present()
try {
const res = await this.api.addBackupTarget(type, value)
return res
} finally {
loader.dismiss()
}
}
private async update(
type: BackupTargetType,
value:
| RR.UpdateCifsBackupTargetReq
| RR.UpdateCloudBackupTargetReq
| RR.UpdateDiskBackupTargetReq,
): Promise<BackupTarget> {
const loader = await this.loadingCtrl.create({
message: 'Saving target...',
})
await loader.present()
try {
const res = await this.api.updateBackupTarget(type, value)
return res
} finally {
loader.dismiss()
}
}
}

View File

@@ -0,0 +1,44 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { BackupsPage } from './backups.page'
import { BackupCreateDirective } from '../../directives/backup-create.directive'
import { BackupRestoreDirective } from '../../directives/backup-restore.directive'
import {
BackingUpComponent,
PkgMainStatusPipe,
} from '../../components/backing-up/backing-up.component'
import { BackupSelectPageModule } from '../../modals/backup-select/backup-select.module'
import { RecoverSelectPageModule } from '../../modals/recover-select/recover-select.module'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { InsecureWarningComponentModule } from 'src/app/components/insecure-warning/insecure-warning.module'
import { TargetPipesModule } from '../../pipes/target-pipes.module'
const routes: Routes = [
{
path: '',
component: BackupsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
BackupSelectPageModule,
RecoverSelectPageModule,
BadgeMenuComponentModule,
InsecureWarningComponentModule,
TargetPipesModule,
],
declarations: [
BackupsPage,
BackupCreateDirective,
BackupRestoreDirective,
BackingUpComponent,
PkgMainStatusPipe,
],
})
export class BackupsPageModule {}

View File

@@ -0,0 +1,112 @@
<ion-header>
<ion-toolbar>
<ion-title>Backups</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<insecure-warning *ngIf="!secure"></insecure-warning>
<ion-item-group>
<ion-item-divider>Options</ion-item-divider>
<ion-item button backupCreate>
<ion-icon slot="start" name="add"></ion-icon>
<ion-label>
<h2>Create a Backup</h2>
<p>Create a one-time backup</p>
</ion-label>
</ion-item>
<ion-item button backupRestore>
<ion-icon slot="start" name="color-wand-outline"></ion-icon>
<ion-label>
<h2>Restore From Backup</h2>
<p>Restore services from backup</p>
</ion-label>
</ion-item>
<ion-item button routerLink="jobs">
<ion-icon slot="start" name="hammer-outline"></ion-icon>
<ion-label>
<h2>Jobs</h2>
<p>Manage backup jobs</p>
</ion-label>
</ion-item>
<ion-item button routerLink="targets">
<ion-icon slot="start" name="server-outline"></ion-icon>
<ion-label>
<h2>Targets</h2>
<p>Manage backup targets</p>
</ion-label>
</ion-item>
<ion-item button routerLink="history">
<ion-icon slot="start" name="archive-outline"></ion-icon>
<ion-label>
<h2>History</h2>
<p>View your entire backup history</p>
</ion-label>
</ion-item>
<ion-item-divider>Upcoming Jobs</ion-item-divider>
<div class="grid-fixed">
<ion-grid class="ion-padding">
<ion-row class="grid-headings">
<ion-col size="3">Scheduled</ion-col>
<ion-col size="2.5">Job</ion-col>
<ion-col size="3">Target</ion-col>
<ion-col size="2.5">Packages</ion-col>
</ion-row>
<!-- loaded -->
<ng-container *ngIf="upcoming$ | async as upcoming; else loading;">
<ng-container *ngIf="current$ | async as current">
<ion-row
*ngFor="let upcoming of upcoming"
class="ion-align-items-center grid-row-border"
>
<ion-col size="3">
<ion-text
*ngIf="current.id === upcoming.id; else notRunning"
color="success"
>
Running
</ion-text>
<ng-template #notRunning>
{{ upcoming.next | date : 'MMM d, y, h:mm a' }}
</ng-template>
</ion-col>
<ion-col size="2.5">{{ upcoming.name }}</ion-col>
<ion-col size="3" class="inline">
<ion-icon
[name]="(upcoming.target | getDisplayInfo).icon"
size="small"
></ion-icon>
&nbsp; {{ upcoming.target.name }}
</ion-col>
<ion-col size="2.5">
{{ upcoming['package-ids'].length }} Packages
</ion-col>
</ion-row>
<p *ngIf="!upcoming.length">
You have no active or upcoming backup jobs.
</p>
</ng-container>
</ng-container>
<!-- loading -->
<ng-template #loading>
<ion-row
*ngFor="let row of ['', '']"
class="ion-align-items-center grid-row-border"
>
<ion-col>
<ion-skeleton-text animated></ion-skeleton-text>
</ion-col>
</ion-row>
</ng-template>
</ion-grid>
</div>
</ion-item-group>
</ion-content>

View File

@@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { from, map } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { CronJob } from 'cron'
@Component({
selector: 'backups',
templateUrl: './backups.page.html',
styleUrls: ['./backups.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BackupsPage {
readonly secure = this.config.isSecure()
readonly current$ = this.patch
.watch$('server-info', 'status-info', 'current-backup', 'job')
.pipe(map(job => job || {}))
readonly upcoming$ = from(this.api.getBackupJobs({})).pipe(
map(jobs =>
jobs
.map(job => {
const nextDate = new CronJob(job.cron, () => {}).nextDate()
const next = nextDate.toISO()
const diff = nextDate.diffNow().milliseconds
return {
...job,
next,
diff,
}
})
.sort((a, b) => a.diff - b.diff),
),
)
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly config: ConfigService,
private readonly api: ApiService,
) {}
}

View File

@@ -0,0 +1,42 @@
import { Pipe, PipeTransform } from '@angular/core'
import { BackupTarget } from 'src/app/services/api/api.types'
@Pipe({
name: 'getDisplayInfo',
})
export class GetDisplayInfoPipe implements PipeTransform {
transform(target: BackupTarget): DisplayInfo {
const toReturn: DisplayInfo = {
name: target.name,
path: `Path: ${target.path}`,
description: '',
icon: '',
}
switch (target.type) {
case 'cifs':
toReturn.description = `Network Folder: ${target.hostname}`
toReturn.icon = 'folder-open-outline'
break
case 'disk':
toReturn.description = `Physical Drive: ${
target.vendor || 'Unknown Vendor'
}, ${target.model || 'Unknown Model'}`
toReturn.icon = 'save-outline'
break
case 'cloud':
toReturn.description = `Provider: ${target.provider}`
toReturn.icon = 'cloud-outline'
break
}
return toReturn
}
}
interface DisplayInfo {
name: string
path: string
description: string
icon: string
}

View File

@@ -0,0 +1,15 @@
import { Pipe, PipeTransform } from '@angular/core'
import { BackupTarget } from 'src/app/services/api/api.types'
import { Emver } from '@start9labs/shared'
@Pipe({
name: 'hasValidBackup',
})
export class HasValidBackupPipe implements PipeTransform {
constructor(private readonly emver: Emver) {}
transform(target: BackupTarget): boolean {
const backup = target['embassy-os']
return !!backup && this.emver.compare(backup.version, '0.3.0') !== -1
}
}

View File

@@ -0,0 +1,11 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { HasValidBackupPipe } from './has-valid-backup.pipe'
import { GetDisplayInfoPipe } from './get-display-info.pipe'
@NgModule({
declarations: [HasValidBackupPipe, GetDisplayInfoPipe],
imports: [CommonModule],
exports: [HasValidBackupPipe, GetDisplayInfoPipe],
})
export class TargetPipesModule {}

View File

@@ -0,0 +1,221 @@
import { InputSpec } from 'start-sdk/lib/config/configTypes'
export const DropboxSpec: InputSpec = {
name: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
name: 'Name',
description: 'A friendly name for this Dropbox target',
placeholder: 'My Dropbox',
required: true,
masked: false,
warning: null,
default: null,
},
token: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
name: 'Access Token',
description: 'The secret access token for your custom Dropbox app',
warning: null,
placeholder: null,
required: true,
masked: true,
default: null,
},
path: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
name: 'Path',
description: 'The fully qualified path to the backup directory',
warning: null,
placeholder: 'e.g. /Desktop/my-folder',
required: true,
masked: false,
default: null,
},
}
export const GoogleDriveSpec: InputSpec = {
name: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
name: 'Name',
description: 'A friendly name for this Google Drive target',
warning: null,
placeholder: 'My Google Drive',
required: true,
masked: false,
default: null,
},
key: {
type: 'file',
name: 'Private Key File',
description:
'Your Google Drive service account private key file (.json file)',
warning: null,
required: true,
extensions: ['json'],
},
path: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
name: 'Path',
description: 'The fully qualified path to the backup directory',
placeholder: 'e.g. /Desktop/my-folder',
required: true,
masked: false,
warning: null,
default: null,
},
}
export const CifsSpec: InputSpec = {
name: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
name: 'Name',
description: 'A friendly name for this Network Folder',
warning: null,
placeholder: 'My Network Folder',
required: true,
masked: false,
default: null,
},
hostname: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [
{
regex: '^[a-zA-Z0-9._-]+( [a-zA-Z0-9]+)*$',
description: `Must be a valid hostname. e.g. 'My Computer' OR 'my-computer.local'`,
},
],
name: 'Hostname',
description:
'The hostname of your target device on the Local Area Network.',
warning: null,
required: true,
masked: false,
placeholder: `e.g. 'My Computer' OR 'my-computer.local'`,
default: null,
},
path: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
name: 'Path',
description: `On Windows, this is the fully qualified path to the shared folder, (e.g. /Desktop/my-folder).\n\n On Linux and Mac, this is the literal name of the shared folder (e.g. my-shared-folder).`,
placeholder: 'e.g. my-shared-folder or /Desktop/my-folder',
required: true,
masked: false,
warning: null,
default: null,
},
username: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
name: 'Username',
description: `On Linux, this is the samba username you created when sharing the folder.\n\n On Mac and Windows, this is the username of the user who is sharing the folder.`,
required: true,
masked: false,
warning: null,
placeholder: 'My Network Folder',
default: null,
},
password: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
name: 'Password',
description: `On Linux, this is the samba password you created when sharing the folder.\n\n On Mac and Windows, this is the password of the user who is sharing the folder.`,
required: false,
masked: true,
warning: null,
placeholder: 'My Network Folder',
default: null,
},
}
export const RemoteBackupTargetSpec: InputSpec = {
type: {
type: 'union',
name: 'Target Type',
description: null,
warning: null,
required: true,
variants: {
dropbox: {
name: 'Dropbox',
spec: DropboxSpec,
},
'google-drive': {
name: 'Google Drive',
spec: GoogleDriveSpec,
},
cifs: {
name: 'Network Folder',
spec: CifsSpec,
},
},
default: 'dropbox',
},
}
export const DiskBackupTargetSpec: InputSpec = {
name: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
name: 'Name',
description: 'A friendly name for this physical target',
placeholder: 'My Physical Target',
required: true,
masked: false,
warning: null,
default: null,
},
path: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
name: 'Path',
description: 'The fully qualified path to the backup directory',
placeholder: 'e.g. /Backups/my-folder',
required: true,
masked: false,
warning: null,
default: null,
},
}