mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-04 14:29:45 +00:00
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:
committed by
Aiden McClelland
parent
9499ea8ca9
commit
e53c90f8f0
@@ -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 {}
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
@@ -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>) {}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,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>
|
||||
@@ -0,0 +1,5 @@
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
{{ run.job.target.name }}
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ng-template>
|
||||
</ion-grid>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,3 @@
|
||||
.highlighted {
|
||||
background-color: var(--ion-color-medium-shade);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
{{ 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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
margin-bottom: 6px;
|
||||
font-size: medium;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
{{ 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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
{{ 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>
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user