mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +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
43
frontend/package-lock.json
generated
43
frontend/package-lock.json
generated
@@ -38,6 +38,8 @@
|
||||
"cbor": "npm:@jprochazk/cbor@^0.4.9",
|
||||
"cbor-web": "^8.1.0",
|
||||
"core-js": "^3.21.1",
|
||||
"cron": "^2.2.0",
|
||||
"cronstrue": "^2.21.0",
|
||||
"dompurify": "^2.3.6",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^6.4.6",
|
||||
@@ -64,6 +66,7 @@
|
||||
"@angular/compiler-cli": "^14.1.0",
|
||||
"@angular/language-service": "^14.1.0",
|
||||
"@ionic/cli": "^6.19.0",
|
||||
"@types/cron": "^2.0.0",
|
||||
"@types/dompurify": "^2.3.3",
|
||||
"@types/estree": "^0.0.51",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
@@ -4077,6 +4080,16 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cron": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cron/-/cron-2.0.0.tgz",
|
||||
"integrity": "sha512-xZM08fqvwIXgghtPVkSPKNgC+JoMQ2OHazEvyTKnNf7aWu1aB6/4lBbQFrb03Td2cUGG7ITzMv3mFYnMu6xRaQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/luxon": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-2.4.0.tgz",
|
||||
@@ -4165,6 +4178,12 @@
|
||||
"integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/luxon": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.2.0.tgz",
|
||||
"integrity": "sha512-lGmaGFoaXHuOLXFvuju2bfvZRqxAqkHPx9Y9IQdQABrinJJshJwfNCKV+u7rR3kJbiqfTF/NhOkcxxAFrObyaA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/marked": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.0.8.tgz",
|
||||
@@ -6042,6 +6061,22 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cron": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-2.2.0.tgz",
|
||||
"integrity": "sha512-GPiI3OgMv83XRtEUc2gUdaLvJhO3XbLN288layOBkDTupg0RK5IECNGpkykIMHg+muVR2bxt29b0xvCAcBrjYQ==",
|
||||
"dependencies": {
|
||||
"luxon": "^3.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/cronstrue": {
|
||||
"version": "2.21.0",
|
||||
"resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.21.0.tgz",
|
||||
"integrity": "sha512-YxabE1ZSHA1zJZMPCTSEbc0u4cRRenjqqTgCwJT7OvkspPSvfYFITuPFtsT+VkBuavJtFv2kJXT+mKSnlUJxfg==",
|
||||
"bin": {
|
||||
"cronstrue": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
||||
@@ -9683,6 +9718,14 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.2.1.tgz",
|
||||
"integrity": "sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/macos-release": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.1.tgz",
|
||||
|
||||
@@ -63,6 +63,8 @@
|
||||
"cbor": "npm:@jprochazk/cbor@^0.4.9",
|
||||
"cbor-web": "^8.1.0",
|
||||
"core-js": "^3.21.1",
|
||||
"cron": "^2.2.0",
|
||||
"cronstrue": "^2.21.0",
|
||||
"dompurify": "^2.3.6",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fuse.js": "^6.4.6",
|
||||
@@ -89,6 +91,7 @@
|
||||
"@angular/compiler-cli": "^14.1.0",
|
||||
"@angular/language-service": "^14.1.0",
|
||||
"@ionic/cli": "^6.19.0",
|
||||
"@types/cron": "^2.0.0",
|
||||
"@types/dompurify": "^2.3.3",
|
||||
"@types/estree": "^0.0.51",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
|
||||
@@ -13,8 +13,4 @@ ion-item {
|
||||
|
||||
.item-has-focus {
|
||||
--background: var(--ion-color-dark-tint) !important;
|
||||
}
|
||||
|
||||
ion-modal {
|
||||
--backdrop-opacity: 0.7;
|
||||
}
|
||||
@@ -249,12 +249,6 @@ ion-toast {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
ion-modal.stack-modal {
|
||||
--box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2);
|
||||
--backdrop-opacity: var(--ion-backdrop-opacity, 0.32);
|
||||
}
|
||||
|
||||
.sc-ion-label-md-s p {
|
||||
line-height: 23px;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,7 @@ import { Pipe, PipeTransform } from '@angular/core'
|
||||
})
|
||||
export class ConvertBytesPipe implements PipeTransform {
|
||||
transform(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1024
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
return convertBytes(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +22,15 @@ export class DurationToSecondsPipe implements PipeTransform {
|
||||
}
|
||||
}
|
||||
|
||||
export function convertBytes(bytes: number) {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
|
||||
const k = 1000
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||
|
||||
const unitsToSeconds: Record<string, number> = {
|
||||
|
||||
@@ -24,11 +24,8 @@ ion-alert {
|
||||
}
|
||||
|
||||
ion-modal {
|
||||
--max-height: 600px;
|
||||
--backdrop-opacity: 0.7;
|
||||
&::part(content) {
|
||||
width: 90% !important;
|
||||
left: 5%;
|
||||
|
||||
border-radius: 6px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.03);
|
||||
box-shadow: 0 32px 64px rgba(0, 0, 0, 0.2);
|
||||
@@ -157,3 +154,9 @@ ion-modal {
|
||||
color: var(--ion-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
color: aqua;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -72,6 +72,15 @@ const routes: Routes = [
|
||||
m => m.DeveloperRoutingModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'backups',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/backups-routes/backups-routing.module').then(
|
||||
m => m.BackupsRoutingModule,
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -22,6 +22,7 @@ import { AppComponent } from './app.component'
|
||||
import { AppRoutingModule } from './app-routing.module'
|
||||
import { OSWelcomePageModule } from './modals/os-welcome/os-welcome.module'
|
||||
import { GenericInputComponentModule } from './modals/generic-input/generic-input.component.module'
|
||||
import { GenericFormPageModule } from './modals/generic-form/generic-form.module'
|
||||
import { MarketplaceModule } from './marketplace.module'
|
||||
import { PreloaderModule } from './app/preloader/preloader.module'
|
||||
import { FooterModule } from './app/footer/footer.module'
|
||||
@@ -53,6 +54,7 @@ import { FormPageModule } from './modals/form/form.module'
|
||||
OSWelcomePageModule,
|
||||
MarkdownModule,
|
||||
GenericInputComponentModule,
|
||||
GenericFormPageModule,
|
||||
MonacoEditorModule,
|
||||
SharedPipesModule,
|
||||
MarketplaceModule,
|
||||
|
||||
@@ -46,6 +46,11 @@ export class MenuComponent {
|
||||
url: '/updates',
|
||||
icon: 'globe-outline',
|
||||
},
|
||||
{
|
||||
title: 'Backups',
|
||||
url: '/backups',
|
||||
icon: 'save-outline',
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
url: '/notifications',
|
||||
|
||||
@@ -6,6 +6,7 @@ const ICONS = [
|
||||
'alert-outline',
|
||||
'alert-circle-outline',
|
||||
'aperture-outline',
|
||||
'archive-outline',
|
||||
'arrow-back',
|
||||
'arrow-forward',
|
||||
'arrow-up',
|
||||
@@ -44,6 +45,7 @@ const ICONS = [
|
||||
'folder-open-outline',
|
||||
'globe-outline',
|
||||
'grid-outline',
|
||||
'hammer-outline',
|
||||
'help-circle-outline',
|
||||
'hammer-outline',
|
||||
'home-outline',
|
||||
@@ -76,6 +78,7 @@ const ICONS = [
|
||||
'repeat-outline',
|
||||
'rocket-outline',
|
||||
'save-outline',
|
||||
'server-outline',
|
||||
'settings-outline',
|
||||
'shield-checkmark-outline',
|
||||
'stop-outline',
|
||||
|
||||
@@ -32,7 +32,6 @@ export class SnekDirective {
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Saving high score...',
|
||||
backdropDismiss: true,
|
||||
})
|
||||
|
||||
await loader.present()
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="system"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{
|
||||
type === 'create' ? 'Create Backup' : 'Restore From Backup'
|
||||
}}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button [disabled]="loading" (click)="refresh()">
|
||||
Refresh
|
||||
<ion-icon slot="end" name="refresh"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
@@ -1,20 +0,0 @@
|
||||
<div class="inline">
|
||||
<h2 *ngIf="type === 'create'; else restore">
|
||||
<ion-icon name="cloud-outline" color="success"></ion-icon>
|
||||
{{
|
||||
hasValidBackup
|
||||
? 'Available, contains existing backup'
|
||||
: 'Available for fresh backup'
|
||||
}}
|
||||
</h2>
|
||||
<ng-template #restore>
|
||||
<h2 *ngIf="hasValidBackup">
|
||||
<ion-icon name="cloud-done-outline" color="success"></ion-icon>
|
||||
StartOS backup detected
|
||||
</h2>
|
||||
<h2 *ngIf="!hasValidBackup">
|
||||
<ion-icon name="cloud-offline-outline" color="danger"></ion-icon>
|
||||
No StartOS backup
|
||||
</h2>
|
||||
</ng-template>
|
||||
</div>
|
||||
@@ -1,172 +0,0 @@
|
||||
<backup-drives-header [type]="type"></backup-drives-header>
|
||||
|
||||
<ion-content class="ion-padding with-widgets">
|
||||
<!-- loading -->
|
||||
<text-spinner
|
||||
*ngIf="loading; else loaded"
|
||||
[text]="loadingText"
|
||||
></text-spinner>
|
||||
|
||||
<!-- loaded -->
|
||||
<ng-template #loaded>
|
||||
<!-- error -->
|
||||
<ion-item *ngIf="loadingError; else noError">
|
||||
<ion-label>
|
||||
<ion-text color="danger">
|
||||
{{ loadingError }}
|
||||
</ion-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ng-template #noError>
|
||||
<ion-item-group>
|
||||
<!-- ** cifs ** -->
|
||||
<ion-item-divider>Network Folders</ion-item-divider>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>
|
||||
{{
|
||||
type === 'create'
|
||||
? 'Backup server to'
|
||||
: 'Restore your services from'
|
||||
}}
|
||||
a folder on another computer that is connected to the same network
|
||||
as your Start9 server. View the
|
||||
<a
|
||||
href="https://docs.start9.com/latest/user-manual/backups/backup-create"
|
||||
target="_blank"
|
||||
noreferrer
|
||||
style="text-decoration: none"
|
||||
>
|
||||
Instructions
|
||||
<ion-icon name="open-outline" size="small"></ion-icon>
|
||||
</a>
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<!-- add new cifs -->
|
||||
<ion-item button detail="false" (click)="presentModalAddCifs()">
|
||||
<ion-icon
|
||||
slot="start"
|
||||
name="add"
|
||||
size="large"
|
||||
color="dark"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<b>Open New</b>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<!-- cifs list -->
|
||||
<ng-container *ngFor="let target of cifs; let i = index">
|
||||
<ion-item
|
||||
button
|
||||
*ngIf="target.entry as cifs"
|
||||
(click)="select(target)"
|
||||
>
|
||||
<ion-icon
|
||||
slot="start"
|
||||
name="folder-open-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<h1>{{ cifs.path.split('/').pop() }}</h1>
|
||||
<ng-container *ngIf="cifs.mountable">
|
||||
<backup-drives-status
|
||||
[type]="type"
|
||||
[hasValidBackup]="target.hasValidBackup"
|
||||
></backup-drives-status>
|
||||
</ng-container>
|
||||
<h2 *ngIf="!cifs.mountable" class="inline">
|
||||
<ion-icon name="cellular-outline" color="danger"></ion-icon>
|
||||
Unable to connect
|
||||
</h2>
|
||||
<p>Hostname: {{ cifs.hostname }}</p>
|
||||
<p>Path: {{ cifs.path }}</p>
|
||||
</ion-label>
|
||||
<ion-note
|
||||
slot="end"
|
||||
class="click-area"
|
||||
(click)="presentActionCifs($event, target, i)"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
|
||||
<br />
|
||||
|
||||
<!-- ** drives ** -->
|
||||
<ion-item-divider>Physical Drives</ion-item-divider>
|
||||
<!-- always -->
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>
|
||||
{{
|
||||
type === 'create'
|
||||
? 'Backup server to'
|
||||
: 'Restore your services from'
|
||||
}}
|
||||
a physical drive that is plugged directly into your Start9 Server.
|
||||
View the
|
||||
<a
|
||||
href="https://docs.start9.com/latest/user-manual/backups/backup-setup/backup-physical"
|
||||
target="_blank"
|
||||
noreferrer
|
||||
style="text-decoration: none"
|
||||
>
|
||||
Instructions
|
||||
<ion-icon name="open-outline" size="small"></ion-icon>
|
||||
</a>
|
||||
.
|
||||
<ion-text color="warning">
|
||||
Warning. Do not use this option if you are using a Raspberry Pi
|
||||
with an external SSD. The Raspberry Pi does not support more
|
||||
than one external drive without additional power and can cause
|
||||
data corruption.
|
||||
</ion-text>
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- no drives -->
|
||||
<div
|
||||
*ngIf="!drives.length; else hasDrives"
|
||||
class="ion-padding-bottom ion-text-center"
|
||||
>
|
||||
<br />
|
||||
<p>
|
||||
No drives detected.
|
||||
<a style="cursor: pointer" (click)="refresh()">
|
||||
Refresh
|
||||
<ion-icon name="refresh"></ion-icon>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<!-- drives detected -->
|
||||
<ng-template #hasDrives>
|
||||
<ion-item
|
||||
button
|
||||
*ngFor="let target of drives"
|
||||
(click)="select(target)"
|
||||
>
|
||||
<ion-icon slot="start" name="save-outline" size="large"></ion-icon>
|
||||
<ng-container *ngIf="target.entry as drive">
|
||||
<ion-label>
|
||||
<h1>{{ drive.label || drive.logicalname }}</h1>
|
||||
<backup-drives-status
|
||||
[type]="type"
|
||||
[hasValidBackup]="target.hasValidBackup"
|
||||
></backup-drives-status>
|
||||
<p>
|
||||
{{ drive.vendor || 'Unknown Vendor' }} -
|
||||
{{ drive.model || 'Unknown Model' }}
|
||||
</p>
|
||||
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
|
||||
</ion-label>
|
||||
</ng-container>
|
||||
</ion-item>
|
||||
</ng-template>
|
||||
</ion-item-group>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
@@ -1,34 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
BackupDrivesComponent,
|
||||
BackupDrivesHeaderComponent,
|
||||
BackupDrivesStatusComponent,
|
||||
} from './backup-drives.component'
|
||||
import {
|
||||
UnitConversionPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
} from '@start9labs/shared'
|
||||
import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
BackupDrivesComponent,
|
||||
BackupDrivesHeaderComponent,
|
||||
BackupDrivesStatusComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
UnitConversionPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
GenericFormPageModule,
|
||||
],
|
||||
exports: [
|
||||
BackupDrivesComponent,
|
||||
BackupDrivesHeaderComponent,
|
||||
BackupDrivesStatusComponent,
|
||||
],
|
||||
})
|
||||
export class BackupDrivesComponentModule {}
|
||||
@@ -1,18 +0,0 @@
|
||||
.click-area {
|
||||
padding: 50px;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: var(--ion-color-medium-tint);
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 27px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.click-area {
|
||||
padding: 18px 0px 10px;
|
||||
}
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { BackupService } from './backup.service'
|
||||
import {
|
||||
CifsBackupTarget,
|
||||
DiskBackupTarget,
|
||||
RR,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import {
|
||||
ActionSheetController,
|
||||
AlertController,
|
||||
LoadingController,
|
||||
ModalController,
|
||||
} from '@ionic/angular'
|
||||
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
|
||||
import { InputSpec } from 'start-sdk/lib/config/configTypes'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'
|
||||
|
||||
type BackupType = 'create' | 'restore'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-drives',
|
||||
templateUrl: './backup-drives.component.html',
|
||||
styleUrls: ['./backup-drives.component.scss'],
|
||||
})
|
||||
export class BackupDrivesComponent {
|
||||
@Input() type!: BackupType
|
||||
@Output() onSelect: EventEmitter<
|
||||
MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>
|
||||
> = new EventEmitter()
|
||||
loadingText = ''
|
||||
|
||||
constructor(
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly actionCtrl: ActionSheetController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly backupService: BackupService,
|
||||
) {}
|
||||
|
||||
get loading() {
|
||||
return this.backupService.loading
|
||||
}
|
||||
|
||||
get loadingError() {
|
||||
return this.backupService.loadingError
|
||||
}
|
||||
|
||||
get drives() {
|
||||
return this.backupService.drives
|
||||
}
|
||||
|
||||
get cifs() {
|
||||
return this.backupService.cifs
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.loadingText =
|
||||
this.type === 'create'
|
||||
? 'Fetching Backup Targets'
|
||||
: 'Fetching Backup Sources'
|
||||
this.backupService.getBackupTargets()
|
||||
}
|
||||
|
||||
select(
|
||||
target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>,
|
||||
): void {
|
||||
if (target.entry.type === 'cifs' && !target.entry.mountable) {
|
||||
const message =
|
||||
'Unable to connect to Network Folder. Ensure (1) target computer is connected to the same LAN as your Start9 Server, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.'
|
||||
this.presentAlertError(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.type === 'restore' && !target.hasValidBackup) {
|
||||
const message = `${
|
||||
target.entry.type === 'cifs' ? 'Network Folder' : 'Drive partition'
|
||||
} does not contain a valid Start9 Server backup.`
|
||||
this.presentAlertError(message)
|
||||
return
|
||||
}
|
||||
|
||||
this.onSelect.emit(target)
|
||||
}
|
||||
|
||||
async presentModalAddCifs(): Promise<void> {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: GenericFormPage,
|
||||
componentProps: {
|
||||
title: 'New Network Folder',
|
||||
spec: CifsSpec,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Connect',
|
||||
handler: (value: RR.AddBackupTargetReq) => {
|
||||
return this.addCifs(value)
|
||||
},
|
||||
isSubmit: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async presentActionCifs(
|
||||
event: Event,
|
||||
target: MappedBackupTarget<CifsBackupTarget>,
|
||||
index: number,
|
||||
): Promise<void> {
|
||||
event.stopPropagation()
|
||||
|
||||
const entry = target.entry as CifsBackupTarget
|
||||
|
||||
const action = await this.actionCtrl.create({
|
||||
header: entry.hostname,
|
||||
subHeader: 'Shared Folder',
|
||||
mode: 'ios',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Forget',
|
||||
icon: 'trash',
|
||||
role: 'destructive',
|
||||
handler: () => {
|
||||
this.deleteCifs(target.id, index)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Edit',
|
||||
icon: 'pencil',
|
||||
handler: () => {
|
||||
this.presentModalEditCifs(target.id, entry, index)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await action.present()
|
||||
}
|
||||
|
||||
private async presentAlertError(message: string): Promise<void> {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Error',
|
||||
message,
|
||||
buttons: ['OK'],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private async addCifs(value: RR.AddBackupTargetReq): Promise<boolean> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Testing connectivity to shared folder...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
const res = await this.embassyApi.addBackupTarget(value)
|
||||
const [id, entry] = Object.entries(res)[0]
|
||||
this.backupService.cifs.unshift({
|
||||
id,
|
||||
hasValidBackup: this.backupService.hasValidBackup(entry),
|
||||
entry,
|
||||
})
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async presentModalEditCifs(
|
||||
id: string,
|
||||
entry: CifsBackupTarget,
|
||||
index: number,
|
||||
): Promise<void> {
|
||||
const { hostname, path, username } = entry
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: GenericFormPage,
|
||||
componentProps: {
|
||||
title: 'Update Shared Folder',
|
||||
spec: CifsSpec,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (value: RR.AddBackupTargetReq) => {
|
||||
return this.editCifs({ id, ...value }, index)
|
||||
},
|
||||
isSubmit: true,
|
||||
},
|
||||
],
|
||||
initialValue: {
|
||||
hostname,
|
||||
path,
|
||||
username,
|
||||
},
|
||||
},
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async editCifs(
|
||||
value: RR.UpdateBackupTargetReq,
|
||||
index: number,
|
||||
): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Testing connectivity to shared folder...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
const res = await this.embassyApi.updateBackupTarget(value)
|
||||
this.backupService.cifs[index].entry = Object.values(res)[0]
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteCifs(id: string, index: number): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Removing...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.removeBackupTarget({ id })
|
||||
this.backupService.cifs.splice(index, 1)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.backupService.getBackupTargets()
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'backup-drives-header',
|
||||
templateUrl: './backup-drives-header.component.html',
|
||||
styleUrls: ['./backup-drives.component.scss'],
|
||||
})
|
||||
export class BackupDrivesHeaderComponent {
|
||||
@Input() type!: BackupType
|
||||
@Output() onClose: EventEmitter<void> = new EventEmitter()
|
||||
|
||||
constructor(private readonly backupService: BackupService) {}
|
||||
|
||||
get loading() {
|
||||
return this.backupService.loading
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.backupService.getBackupTargets()
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'backup-drives-status',
|
||||
templateUrl: './backup-drives-status.component.html',
|
||||
styleUrls: ['./backup-drives.component.scss'],
|
||||
})
|
||||
export class BackupDrivesStatusComponent {
|
||||
@Input() type!: BackupType
|
||||
@Input() hasValidBackup!: boolean
|
||||
}
|
||||
|
||||
const CifsSpec: InputSpec = {
|
||||
hostname: {
|
||||
type: 'text',
|
||||
name: 'Hostname',
|
||||
description:
|
||||
'The hostname of your target device on the Local Area Network.',
|
||||
inputmode: 'text',
|
||||
placeholder: `e.g. 'My Computer' OR 'my-computer.local'`,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
patterns: [],
|
||||
required: true,
|
||||
masked: false,
|
||||
default: null,
|
||||
warning: null,
|
||||
},
|
||||
path: {
|
||||
type: 'text',
|
||||
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).`,
|
||||
inputmode: 'text',
|
||||
placeholder: 'e.g. my-shared-folder or /Desktop/my-folder',
|
||||
patterns: [],
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
required: true,
|
||||
masked: false,
|
||||
default: null,
|
||||
warning: null,
|
||||
},
|
||||
username: {
|
||||
type: 'text',
|
||||
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.`,
|
||||
inputmode: 'text',
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
placeholder: null,
|
||||
patterns: [],
|
||||
required: true,
|
||||
masked: false,
|
||||
default: null,
|
||||
warning: null,
|
||||
},
|
||||
password: {
|
||||
type: 'text',
|
||||
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.`,
|
||||
inputmode: 'text',
|
||||
placeholder: null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
patterns: [],
|
||||
required: false,
|
||||
masked: true,
|
||||
default: null,
|
||||
warning: null,
|
||||
},
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { IonicSafeString } from '@ionic/core'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
BackupTarget,
|
||||
CifsBackupTarget,
|
||||
DiskBackupTarget,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'
|
||||
import { getErrorMessage, Emver } from '@start9labs/shared'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class BackupService {
|
||||
cifs: MappedBackupTarget<CifsBackupTarget>[] = []
|
||||
drives: MappedBackupTarget<DiskBackupTarget>[] = []
|
||||
loading = true
|
||||
loadingError: string | IonicSafeString = ''
|
||||
|
||||
constructor(
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly emver: Emver,
|
||||
) {}
|
||||
|
||||
async getBackupTargets(): Promise<void> {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const targets = await this.embassyApi.getBackupTargets({})
|
||||
// cifs
|
||||
this.cifs = Object.entries(targets)
|
||||
.filter(([_, target]) => target.type === 'cifs')
|
||||
.map(([id, cifs]) => {
|
||||
return {
|
||||
id,
|
||||
hasValidBackup: this.hasValidBackup(cifs),
|
||||
entry: cifs as CifsBackupTarget,
|
||||
}
|
||||
})
|
||||
// drives
|
||||
this.drives = Object.entries(targets)
|
||||
.filter(([_, target]) => target.type === 'disk')
|
||||
.map(([id, drive]) => {
|
||||
return {
|
||||
id,
|
||||
hasValidBackup: this.hasValidBackup(drive),
|
||||
entry: drive as DiskBackupTarget,
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
this.loadingError = getErrorMessage(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
hasValidBackup(target: BackupTarget): boolean {
|
||||
const backup = target['embassy-os']
|
||||
return !!backup && this.emver.compare(backup.version, '0.3.0') !== -1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<ion-item color="warning" class="ion-margin-bottom">
|
||||
<ion-icon slot="start" name="warning-outline"></ion-icon>
|
||||
<ion-label class="warn-label">
|
||||
<h2>You are using unencrypted http</h2>
|
||||
<p>
|
||||
Click the button on the right to switch to https. Your browser may warn
|
||||
you that the page is insecure. You can safely bypass this warning. It will
|
||||
go away after you download and trust your Embassy's certificate
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" color="light" (click)="launchHttps()">
|
||||
Open Https
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
@@ -0,0 +1,8 @@
|
||||
.warn-label {
|
||||
h2 {
|
||||
font-weight: 700;
|
||||
}
|
||||
p {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'insecure-warning',
|
||||
templateUrl: './insecure-warning.component.html',
|
||||
styleUrls: ['./insecure-warning.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class InsecureWarningComponent {
|
||||
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
|
||||
|
||||
launchHttps() {
|
||||
this.document.defaultView?.open(
|
||||
this.document.location.href.replace('http', 'https'),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { InsecureWarningComponent } from './insecure-warning.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [InsecureWarningComponent],
|
||||
imports: [CommonModule, IonicModule],
|
||||
exports: [InsecureWarningComponent],
|
||||
})
|
||||
export class InsecureWarningComponentModule {}
|
||||
@@ -0,0 +1,28 @@
|
||||
<ion-popover
|
||||
#popover
|
||||
(didDismiss)="popover.isOpen = false"
|
||||
mode="ios"
|
||||
type="event"
|
||||
>
|
||||
<ng-template>
|
||||
<ion-content>
|
||||
<ion-item-group>
|
||||
<ng-container *ngFor="let address of addressInfo | uiAddresses">
|
||||
<ion-item-divider>{{ address.name }}</ion-item-divider>
|
||||
<ion-item
|
||||
button
|
||||
detail="false"
|
||||
*ngFor="let address of address.addresses"
|
||||
(click)="launchUI(address)"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>{{ address | addressType }}</h2>
|
||||
<p>{{ address }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline" size="small"></ion-icon>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
</ng-template>
|
||||
</ion-popover>
|
||||
@@ -0,0 +1,3 @@
|
||||
ion-popover {
|
||||
--min-width: 360px;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
Input,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { InstalledPackageInfo } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'launch-menu',
|
||||
templateUrl: 'launch-menu.component.html',
|
||||
styleUrls: ['launch-menu.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LaunchMenuComponent {
|
||||
@ViewChild('popover') popover!: HTMLIonPopoverElement
|
||||
|
||||
@Input()
|
||||
addressInfo!: InstalledPackageInfo['address-info']
|
||||
|
||||
set isOpen(open: boolean) {
|
||||
this.popover.isOpen = open
|
||||
}
|
||||
|
||||
set event(event: Event) {
|
||||
this.popover.event = event
|
||||
}
|
||||
|
||||
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
|
||||
|
||||
launchUI(address: string) {
|
||||
this.document.defaultView?.open(address, '_blank', 'noreferrer')
|
||||
this.popover.isOpen = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { LaunchMenuComponent } from './launch-menu.component'
|
||||
import { UiPipeModule } from 'src/app/pipes/ui/ui.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [LaunchMenuComponent],
|
||||
imports: [CommonModule, IonicModule, UiPipeModule],
|
||||
exports: [LaunchMenuComponent],
|
||||
})
|
||||
export class LaunchMenuComponentModule {}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
FormService,
|
||||
} from 'src/app/services/form.service'
|
||||
import { InputSpec } from 'start-sdk/lib/config/configTypes'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
|
||||
export interface ActionButton {
|
||||
text: string
|
||||
@@ -30,6 +31,7 @@ export class GenericFormPage {
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly formService: FormService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
@@ -51,9 +53,12 @@ export class GenericFormPage {
|
||||
return
|
||||
}
|
||||
|
||||
// @TODO make this more like generic input component dismissal
|
||||
const success = await handler(this.formGroup.value)
|
||||
if (success === true) this.modalCtrl.dismiss()
|
||||
try {
|
||||
const response = await handler(this.formGroup.value)
|
||||
this.modalCtrl.dismiss({ response }, 'success')
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -71,8 +71,8 @@ export class GenericInputComponent {
|
||||
if (!value && this.options.required) return
|
||||
|
||||
try {
|
||||
await this.options.submitFn(value)
|
||||
this.modalCtrl.dismiss(undefined, 'success')
|
||||
const response = await this.options.submitFn(value)
|
||||
this.modalCtrl.dismiss({ response, value }, 'success')
|
||||
} catch (e: any) {
|
||||
this.error = getErrorMessage(e)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from './app-actions.page'
|
||||
import { QRComponentModule } from 'src/app/components/qr/qr.component.module'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module'
|
||||
import { ActionSuccessPageModule } from 'src/app/modals/action-success/action-success.module'
|
||||
|
||||
const routes: Routes = [
|
||||
@@ -26,7 +25,6 @@ const routes: Routes = [
|
||||
RouterModule.forChild(routes),
|
||||
QRComponentModule,
|
||||
SharedPipesModule,
|
||||
GenericFormPageModule,
|
||||
ActionSuccessPageModule,
|
||||
],
|
||||
declarations: [AppActionsPage, AppActionsItemComponent, GroupActionsPipe],
|
||||
|
||||
@@ -74,7 +74,8 @@ export class AppActionsPage {
|
||||
buttons: [
|
||||
{
|
||||
text: 'Execute',
|
||||
handler: (value: any) => this.executeAction(action.id, value),
|
||||
handler: async (value: any) =>
|
||||
this.executeAction(action.id, value),
|
||||
isSubmit: true,
|
||||
},
|
||||
],
|
||||
@@ -97,9 +98,7 @@ export class AppActionsPage {
|
||||
},
|
||||
{
|
||||
text: 'Execute',
|
||||
handler: () => {
|
||||
this.executeAction(action.id)
|
||||
},
|
||||
handler: async () => this.executeAction(action.id),
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
@@ -185,10 +184,10 @@ export class AppActionsPage {
|
||||
})
|
||||
|
||||
setTimeout(() => successModal.present(), 500)
|
||||
return true // needed to dismiss original modal/alert
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
return false // don't dismiss original modal/alert
|
||||
return false
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<ion-spinner
|
||||
*ngIf="pkg.transitioning; else bulb"
|
||||
class="spinner"
|
||||
size="small"
|
||||
color="primary"
|
||||
></ion-spinner>
|
||||
<ng-template #bulb>
|
||||
|
||||
@@ -30,30 +30,9 @@
|
||||
>
|
||||
<ion-icon slot="icon-only" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-popover
|
||||
#popover
|
||||
[isOpen]="isPopoverOpen"
|
||||
(didDismiss)="isPopoverOpen = false"
|
||||
mode="ios"
|
||||
type="event"
|
||||
>
|
||||
<ng-template>
|
||||
<ion-content>
|
||||
<ion-item-group>
|
||||
<ng-container
|
||||
*ngFor="let uiAddress of installed['address-info'] | uiAddresses"
|
||||
>
|
||||
<ion-item-divider>{{ uiAddress.name }}</ion-item-divider>
|
||||
<ion-item button *ngFor="let address of uiAddress.addresses">
|
||||
<ion-label>
|
||||
<h2>{{ address | addressType }}</h2>
|
||||
<p>{{ address }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
</ng-template>
|
||||
</ion-popover>
|
||||
<launch-menu
|
||||
#launchMenu
|
||||
[addressInfo]="installed['address-info']"
|
||||
></launch-menu>
|
||||
</ng-container>
|
||||
</ion-item>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
ion-popover {
|
||||
--min-width: 300px;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Input,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { LaunchMenuComponent } from 'src/app/components/launch-menu/launch-menu.component'
|
||||
import { PackageMainStatus } from 'src/app/services/patch-db/data-model'
|
||||
import { PkgInfo } from 'src/app/util/get-package-info'
|
||||
|
||||
@@ -14,13 +15,11 @@ import { PkgInfo } from 'src/app/util/get-package-info'
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppListPkgComponent {
|
||||
@ViewChild('popover') popover!: HTMLIonPopoverElement
|
||||
@ViewChild('launchMenu') launchMenu!: LaunchMenuComponent
|
||||
|
||||
@Input()
|
||||
pkg!: PkgInfo
|
||||
|
||||
isPopoverOpen = false
|
||||
|
||||
get status(): PackageMainStatus {
|
||||
return (
|
||||
this.pkg.entry.installed?.status.main.status || PackageMainStatus.Stopped
|
||||
@@ -30,7 +29,7 @@ export class AppListPkgComponent {
|
||||
openPopover(e: Event): void {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
this.popover.event = e
|
||||
this.isPopoverOpen = true
|
||||
this.launchMenu.event = e
|
||||
this.launchMenu.isOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AppListIconComponent } from './app-list-icon/app-list-icon.component'
|
||||
import { AppListPkgComponent } from './app-list-pkg/app-list-pkg.component'
|
||||
import { PackageInfoPipe } from './package-info.pipe'
|
||||
import { WidgetListComponentModule } from 'src/app/components/widget-list/widget-list.component.module'
|
||||
import { LaunchMenuComponentModule } from 'src/app/components/launch-menu/launch-menu.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@@ -37,6 +38,7 @@ const routes: Routes = [
|
||||
WidgetListComponentModule,
|
||||
ResponsiveColModule,
|
||||
TickerModule,
|
||||
LaunchMenuComponentModule,
|
||||
],
|
||||
declarations: [
|
||||
AppListPage,
|
||||
|
||||
@@ -23,6 +23,8 @@ import { ToButtonsPipe } from './pipes/to-buttons.pipe'
|
||||
import { ToDependenciesPipe } from './pipes/to-dependencies.pipe'
|
||||
import { ToStatusPipe } from './pipes/to-status.pipe'
|
||||
import { ProgressDataPipe } from './pipes/progress-data.pipe'
|
||||
import { InsecureWarningComponentModule } from 'src/app/components/insecure-warning/insecure-warning.module'
|
||||
import { LaunchMenuComponentModule } from 'src/app/components/launch-menu/launch-menu.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@@ -57,6 +59,8 @@ const routes: Routes = [
|
||||
UiPipeModule,
|
||||
ResponsiveColModule,
|
||||
SharedPipesModule,
|
||||
InsecureWarningComponentModule,
|
||||
LaunchMenuComponentModule,
|
||||
],
|
||||
})
|
||||
export class AppShowPageModule {}
|
||||
|
||||
@@ -48,33 +48,8 @@
|
||||
|
||||
<!-- INSECURE -->
|
||||
<ng-template #insecure>
|
||||
<ion-grid style="max-width: 540px">
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col class="ion-text-center">
|
||||
<h2>
|
||||
<ion-text color="warning">
|
||||
You are using an unencrypted http connection
|
||||
</ion-text>
|
||||
</h2>
|
||||
<p class="ion-padding-bottom">
|
||||
Click the button below to switch to https. Your browser may warn
|
||||
you that the page is insecure. You can safely bypass this
|
||||
warning. It will go away after you
|
||||
<a
|
||||
[routerLink]="['/system', 'lan']"
|
||||
style="color: var(--ion-color-dark)"
|
||||
>
|
||||
download and trust your server's certificate
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<ion-button (click)="launchHttps()">
|
||||
Open https
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
<insecure-warning></insecure-warning>
|
||||
<h2>This page cannot safely be accessed over an insecure connection</h2>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import {
|
||||
@@ -13,9 +13,7 @@ import {
|
||||
import { tap } from 'rxjs/operators'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { getPkgId } from '@start9labs/shared'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { getServerInfo } from 'src/app/util/get-server-info'
|
||||
|
||||
const STATES = [
|
||||
PackageState.Installing,
|
||||
@@ -45,7 +43,6 @@ export class AppShowPage {
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly config: ConfigService,
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
) {}
|
||||
|
||||
isInstalled({ state }: PackageDataEntry): boolean {
|
||||
@@ -63,9 +60,4 @@ export class AppShowPage {
|
||||
showProgress({ state }: PackageDataEntry): boolean {
|
||||
return STATES.includes(state)
|
||||
}
|
||||
|
||||
async launchHttps() {
|
||||
const { 'lan-address': lanAddress } = await getServerInfo(this.patch)
|
||||
window.open(lanAddress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
size="x-large"
|
||||
weight="600"
|
||||
[installProgress]="pkg['install-progress']"
|
||||
[rendering]="PR[status.primary]"
|
||||
[rendering]="rendering"
|
||||
></status>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
@@ -53,12 +53,14 @@
|
||||
*ngIf="addressInfo | hasUi"
|
||||
class="action-button"
|
||||
color="primary"
|
||||
[disabled]="status.primary === 'running'"
|
||||
(click)="launchUi(addressInfo)"
|
||||
[disabled]="status.primary !== 'running'"
|
||||
(click)="openPopover($event)"
|
||||
>
|
||||
<ion-icon slot="start" name="open-outline"></ion-icon>
|
||||
Open UI
|
||||
</ion-button>
|
||||
|
||||
<launch-menu #launchMenu [addressInfo]="addressInfo"></launch-menu>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { UiLauncherService } from 'src/app/services/ui-launcher.service'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
PackageStatus,
|
||||
PrimaryRendering,
|
||||
PrimaryStatus,
|
||||
StatusRendering,
|
||||
} from 'src/app/services/pkg-status-rendering.service'
|
||||
import {
|
||||
AddressInfo,
|
||||
DataModel,
|
||||
InstalledPackageInfo,
|
||||
PackageDataEntry,
|
||||
PackageState,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
@@ -24,6 +28,7 @@ import { DependencyInfo } from '../../pipes/to-dependencies.pipe'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { LaunchMenuComponent } from 'src/app/components/launch-menu/launch-menu.component'
|
||||
|
||||
@Component({
|
||||
selector: 'app-show-status',
|
||||
@@ -32,6 +37,8 @@ import { PatchDB } from 'patch-db-client'
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppShowStatusComponent {
|
||||
@ViewChild('launchMenu') launchMenu!: LaunchMenuComponent
|
||||
|
||||
@Input()
|
||||
pkg!: PackageDataEntry
|
||||
|
||||
@@ -41,8 +48,6 @@ export class AppShowStatusComponent {
|
||||
@Input()
|
||||
dependencies: DependencyInfo[] = []
|
||||
|
||||
PR = PrimaryRendering
|
||||
|
||||
readonly connected$ = this.connectionService.connected$
|
||||
|
||||
constructor(
|
||||
@@ -50,7 +55,6 @@ export class AppShowStatusComponent {
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly launcherService: UiLauncherService,
|
||||
private readonly formDialog: FormDialogService,
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
@@ -80,8 +84,13 @@ export class AppShowStatusComponent {
|
||||
return this.status.primary === PrimaryStatus.Stopped
|
||||
}
|
||||
|
||||
launchUi(addressInfo: InstalledPackageInfo['address-info']): void {
|
||||
this.launcherService.launch(addressInfo)
|
||||
get rendering(): StatusRendering {
|
||||
return PrimaryRendering[this.status.primary]
|
||||
}
|
||||
|
||||
openPopover(e: Event): void {
|
||||
this.launchMenu.event = e
|
||||
this.launchMenu.isOpen = true
|
||||
}
|
||||
|
||||
presentModalConfig(): void {
|
||||
|
||||
@@ -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 {}
|
||||
@@ -35,10 +35,7 @@
|
||||
>
|
||||
<!-- active -->
|
||||
<ion-note
|
||||
*ngIf="
|
||||
pkgStatus === PackageMainStatus.BackingUp;
|
||||
else queued
|
||||
"
|
||||
*ngIf="pkgStatus === 'backing-up'; else queued"
|
||||
class="inline"
|
||||
slot="end"
|
||||
>
|
||||
@@ -22,11 +22,10 @@ export class BackingUpComponent {
|
||||
readonly backupProgress$ = this.patch.watch$(
|
||||
'server-info',
|
||||
'status-info',
|
||||
'current-backup',
|
||||
'backup-progress',
|
||||
)
|
||||
|
||||
PackageMainStatus = PackageMainStatus
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { Directive, HostListener } from '@angular/core'
|
||||
import {
|
||||
LoadingController,
|
||||
ModalController,
|
||||
@@ -9,21 +9,15 @@ import {
|
||||
GenericInputComponent,
|
||||
GenericInputOptions,
|
||||
} from 'src/app/modals/generic-input/generic-input.component'
|
||||
import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'
|
||||
import {
|
||||
BackupInfo,
|
||||
CifsBackupTarget,
|
||||
DiskBackupTarget,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import { AppRecoverSelectPage } from 'src/app/modals/app-recover-select/app-recover-select.page'
|
||||
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'
|
||||
|
||||
@Component({
|
||||
selector: 'restore',
|
||||
templateUrl: './restore.component.html',
|
||||
styleUrls: ['./restore.component.scss'],
|
||||
@Directive({
|
||||
selector: '[backupRestore]',
|
||||
})
|
||||
export class RestorePage {
|
||||
export class BackupRestoreDirective {
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly navCtrl: NavController,
|
||||
@@ -31,9 +25,27 @@ export class RestorePage {
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
) {}
|
||||
|
||||
async presentModalPassword(
|
||||
target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>,
|
||||
): Promise<void> {
|
||||
@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:
|
||||
@@ -43,9 +55,9 @@ export class RestorePage {
|
||||
useMask: true,
|
||||
buttonText: 'Next',
|
||||
submitFn: async (password: string) => {
|
||||
const passwordHash = target.entry['embassy-os']?.['password-hash'] || ''
|
||||
const passwordHash = target['embassy-os']?.['password-hash'] || ''
|
||||
argon2.verify(passwordHash, password)
|
||||
await this.restoreFromBackup(target, password)
|
||||
return this.getBackupInfo(target.id, password)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -56,45 +68,46 @@ export class RestorePage {
|
||||
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 restoreFromBackup(
|
||||
target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>,
|
||||
private async getBackupInfo(
|
||||
targetId: string,
|
||||
password: string,
|
||||
oldPassword?: string,
|
||||
): Promise<void> {
|
||||
): Promise<BackupInfo> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Decrypting drive...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
const backupInfo = await this.embassyApi.getBackupInfo({
|
||||
'target-id': target.id,
|
||||
return this.embassyApi
|
||||
.getBackupInfo({
|
||||
'target-id': targetId,
|
||||
password,
|
||||
})
|
||||
this.presentModalSelect(target.id, backupInfo, password, oldPassword)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
.finally(() => loader.dismiss())
|
||||
}
|
||||
|
||||
private async presentModalSelect(
|
||||
id: string,
|
||||
targetId: string,
|
||||
backupInfo: BackupInfo,
|
||||
password: string,
|
||||
oldPassword?: string,
|
||||
): Promise<void> {
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: {
|
||||
id,
|
||||
targetId,
|
||||
backupInfo,
|
||||
password,
|
||||
oldPassword,
|
||||
},
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: AppRecoverSelectPage,
|
||||
component: RecoverSelectPage,
|
||||
})
|
||||
|
||||
modal.onWillDismiss().then(res => {
|
||||
@@ -47,10 +47,10 @@
|
||||
[disabled]="!hasSelection"
|
||||
fill="solid"
|
||||
color="primary"
|
||||
(click)="dismiss(true)"
|
||||
(click)="done()"
|
||||
class="enter-click btn-128"
|
||||
>
|
||||
Back Up Selected
|
||||
{{ btnText }}
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from '@angular/core'
|
||||
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'
|
||||
@@ -11,6 +11,9 @@ import { firstValueFrom } from 'rxjs'
|
||||
styleUrls: ['./backup-select.page.scss'],
|
||||
})
|
||||
export class BackupSelectPage {
|
||||
@Input() btnText!: string
|
||||
@Input() selectedIds: string[] = []
|
||||
|
||||
hasSelection = false
|
||||
selectAll = false
|
||||
pkgs: {
|
||||
@@ -38,7 +41,7 @@ export class BackupSelectPage {
|
||||
title,
|
||||
icon: pkg.icon,
|
||||
disabled: pkg.state !== PackageState.Installed,
|
||||
checked: pkg.state === PackageState.Installed,
|
||||
checked: this.selectedIds.includes(id),
|
||||
}
|
||||
})
|
||||
.sort((a, b) =>
|
||||
@@ -49,13 +52,13 @@ export class BackupSelectPage {
|
||||
)
|
||||
}
|
||||
|
||||
dismiss(success = false) {
|
||||
if (success) {
|
||||
const ids = this.pkgs.filter(p => p.checked).map(p => p.id)
|
||||
this.modalCtrl.dismiss(ids)
|
||||
} else {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
async done() {
|
||||
const pkgIds = this.pkgs.filter(p => p.checked).map(p => p.id)
|
||||
this.modalCtrl.dismiss(pkgIds)
|
||||
}
|
||||
|
||||
handleChange() {
|
||||
@@ -2,13 +2,12 @@ import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
import { AppRecoverSelectPage } from './app-recover-select.page'
|
||||
import { RecoverSelectPage } from './recover-select.page'
|
||||
import { ToOptionsPipe } from './to-options.pipe'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppRecoverSelectPage, ToOptionsPipe],
|
||||
declarations: [RecoverSelectPage, ToOptionsPipe],
|
||||
imports: [CommonModule, IonicModule, FormsModule],
|
||||
exports: [AppRecoverSelectPage],
|
||||
exports: [RecoverSelectPage],
|
||||
})
|
||||
export class AppRecoverSelectPageModule {}
|
||||
export class RecoverSelectPageModule {}
|
||||
@@ -13,12 +13,12 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { take } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
selector: 'app-recover-select',
|
||||
templateUrl: './app-recover-select.page.html',
|
||||
styleUrls: ['./app-recover-select.page.scss'],
|
||||
selector: 'recover-select',
|
||||
templateUrl: './recover-select.page.html',
|
||||
styleUrls: ['./recover-select.page.scss'],
|
||||
})
|
||||
export class AppRecoverSelectPage {
|
||||
@Input() id!: string
|
||||
export class RecoverSelectPage {
|
||||
@Input() targetId!: string
|
||||
@Input() backupInfo!: BackupInfo
|
||||
@Input() password!: string
|
||||
@Input() oldPassword?: string
|
||||
@@ -53,8 +53,7 @@ export class AppRecoverSelectPage {
|
||||
try {
|
||||
await this.embassyApi.restorePackages({
|
||||
ids,
|
||||
'target-id': this.id,
|
||||
'old-password': this.oldPassword || null,
|
||||
'target-id': this.targetId,
|
||||
password: this.password,
|
||||
})
|
||||
this.modalCtrl.dismiss(undefined, 'success')
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { RouterModule, Routes } from '@angular/router'
|
||||
import { DeveloperMenuPage } from './developer-menu.page'
|
||||
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
|
||||
import { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module'
|
||||
import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module'
|
||||
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
@@ -24,7 +23,6 @@ const routes: Routes = [
|
||||
RouterModule.forChild(routes),
|
||||
BadgeMenuComponentModule,
|
||||
BackupReportPageModule,
|
||||
GenericFormPageModule,
|
||||
FormsModule,
|
||||
MonacoEditorModule,
|
||||
SharedPipesModule,
|
||||
|
||||
@@ -91,7 +91,6 @@ export class NotificationsPage {
|
||||
|
||||
async presentAlertDeleteAll() {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: 'Delete All?',
|
||||
message: 'Are you sure you want to delete all notifications?',
|
||||
buttons: [
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<backup-drives
|
||||
type="restore"
|
||||
class="ion-page"
|
||||
(onSelect)="presentModalPassword($event)"
|
||||
></backup-drives>
|
||||
@@ -1,28 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RestorePage } from './restore.component'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { BackupDrivesComponentModule } from 'src/app/components/backup-drives/backup-drives.component.module'
|
||||
import { AppRecoverSelectPageModule } from 'src/app/modals/app-recover-select/app-recover-select.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: RestorePage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
SharedPipesModule,
|
||||
BackupDrivesComponentModule,
|
||||
AppRecoverSelectPageModule,
|
||||
],
|
||||
declarations: [RestorePage],
|
||||
})
|
||||
export class RestorePageModule {}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { ServerBackupPage } from './server-backup.page'
|
||||
import { BackingUpComponent } from './backing-up/backing-up.component'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { BackupDrivesComponentModule } from 'src/app/components/backup-drives/backup-drives.component.module'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { BackupSelectPageModule } from 'src/app/modals/backup-select/backup-select.module'
|
||||
import { PkgMainStatusPipe } from './backing-up/backing-up.component'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ServerBackupPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
SharedPipesModule,
|
||||
BackupDrivesComponentModule,
|
||||
BackupSelectPageModule,
|
||||
],
|
||||
declarations: [ServerBackupPage, BackingUpComponent, PkgMainStatusPipe],
|
||||
})
|
||||
export class ServerBackupPageModule {}
|
||||
@@ -1,14 +0,0 @@
|
||||
<!-- currently backing up -->
|
||||
<backing-up
|
||||
*ngIf="backingUp$ | async; else notBackingUp"
|
||||
class="ion-page"
|
||||
></backing-up>
|
||||
|
||||
<!-- not backing up -->
|
||||
<ng-template #notBackingUp>
|
||||
<backup-drives
|
||||
type="create"
|
||||
class="ion-page"
|
||||
(onSelect)="presentModalSelect($event)"
|
||||
></backup-drives>
|
||||
</ng-template>
|
||||
@@ -1,173 +0,0 @@
|
||||
import { Component } 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 { PatchDB } from 'patch-db-client'
|
||||
import { skip, takeUntil } from 'rxjs/operators'
|
||||
import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'
|
||||
import * as argon2 from '@start9labs/argon2'
|
||||
import { TuiDestroyService } from '@taiga-ui/cdk'
|
||||
import {
|
||||
CifsBackupTarget,
|
||||
DiskBackupTarget,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import { BackupSelectPage } from 'src/app/modals/backup-select/backup-select.page'
|
||||
import { EOSService } from 'src/app/services/eos.service'
|
||||
import { getServerInfo } from 'src/app/util/get-server-info'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'server-backup',
|
||||
templateUrl: './server-backup.page.html',
|
||||
styleUrls: ['./server-backup.page.scss'],
|
||||
providers: [TuiDestroyService],
|
||||
})
|
||||
export class ServerBackupPage {
|
||||
serviceIds: string[] = []
|
||||
|
||||
readonly backingUp$ = this.eosService.backingUp$
|
||||
|
||||
constructor(
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly destroy$: TuiDestroyService,
|
||||
private readonly eosService: EOSService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.backingUp$
|
||||
.pipe(skip(1), takeUntil(this.destroy$))
|
||||
.subscribe(isBackingUp => {
|
||||
if (!isBackingUp) {
|
||||
this.navCtrl.navigateRoot('/system')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async presentModalSelect(
|
||||
target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>,
|
||||
) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: BackupSelectPage,
|
||||
})
|
||||
|
||||
modal.onWillDismiss().then(res => {
|
||||
if (res.data) {
|
||||
this.serviceIds = res.data
|
||||
this.presentModalPassword(target)
|
||||
}
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async presentModalPassword(
|
||||
target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>,
|
||||
): Promise<void> {
|
||||
const options: GenericInputOptions = {
|
||||
title: 'Master Password Needed',
|
||||
message: 'Enter your master password to encrypt this backup.',
|
||||
label: 'Master Password',
|
||||
placeholder: 'Enter master password',
|
||||
useMask: true,
|
||||
buttonText: 'Create Backup',
|
||||
submitFn: async (password: string) => {
|
||||
// confirm password matches current master password
|
||||
const { 'password-hash': passwordHash } = await getServerInfo(
|
||||
this.patch,
|
||||
)
|
||||
argon2.verify(passwordHash, password)
|
||||
|
||||
// first time backup
|
||||
if (!target.hasValidBackup) {
|
||||
await this.createBackup(target, password)
|
||||
// existing backup
|
||||
} else {
|
||||
try {
|
||||
const passwordHash =
|
||||
target.entry['embassy-os']?.['password-hash'] || ''
|
||||
|
||||
argon2.verify(passwordHash, password)
|
||||
} catch {
|
||||
setTimeout(
|
||||
() => this.presentModalOldPassword(target, password),
|
||||
500,
|
||||
)
|
||||
return
|
||||
}
|
||||
await this.createBackup(target, password)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const m = await this.modalCtrl.create({
|
||||
component: GenericInputComponent,
|
||||
componentProps: { options },
|
||||
cssClass: 'alertlike-modal',
|
||||
})
|
||||
|
||||
await m.present()
|
||||
}
|
||||
|
||||
private async presentModalOldPassword(
|
||||
target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const options: GenericInputOptions = {
|
||||
title: 'Original Password Needed',
|
||||
message:
|
||||
'This backup was created with a different password. Enter the ORIGINAL password that was used to encrypt this backup.',
|
||||
label: 'Original Password',
|
||||
placeholder: 'Enter original password',
|
||||
useMask: true,
|
||||
buttonText: 'Create Backup',
|
||||
submitFn: async (oldPassword: string) => {
|
||||
const passwordHash = target.entry['embassy-os']?.['password-hash'] || ''
|
||||
|
||||
argon2.verify(passwordHash, oldPassword)
|
||||
await this.createBackup(target, password, oldPassword)
|
||||
},
|
||||
}
|
||||
|
||||
const m = await this.modalCtrl.create({
|
||||
component: GenericInputComponent,
|
||||
componentProps: { options },
|
||||
cssClass: 'alertlike-modal',
|
||||
})
|
||||
|
||||
await m.present()
|
||||
}
|
||||
|
||||
private async createBackup(
|
||||
target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>,
|
||||
password: string,
|
||||
oldPassword?: string,
|
||||
): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Beginning backup...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.createBackup({
|
||||
'target-id': target.id,
|
||||
'package-ids': this.serviceIds,
|
||||
'old-password': oldPassword || null,
|
||||
password,
|
||||
})
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,6 @@ const routes: Routes = [
|
||||
m => m.ServerShowPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'backup',
|
||||
loadChildren: () =>
|
||||
import('./server-backup/server-backup.module').then(
|
||||
m => m.ServerBackupPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'lan',
|
||||
loadChildren: () => import('./lan/lan.module').then(m => m.LANPageModule),
|
||||
@@ -46,13 +39,6 @@ const routes: Routes = [
|
||||
m => m.ServerMetricsPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'restore',
|
||||
loadChildren: () =>
|
||||
import('./restore/restore.component.module').then(
|
||||
m => m.RestorePageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'sessions',
|
||||
loadChildren: () =>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ServerShowPage } from './server-show.page'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TextSpinnerComponentModule } from '@start9labs/shared'
|
||||
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 { OSUpdatePageModule } from 'src/app/modals/os-update/os-update.page.module'
|
||||
import { BackupColorPipeModule } from 'src/app/pipes/backup-color/backup-color.module'
|
||||
import { ThemeSwitcherModule } from '../theme-switcher/theme-switcher.module'
|
||||
@@ -28,6 +29,7 @@ const routes: Routes = [
|
||||
OSUpdatePageModule,
|
||||
BackupColorPipeModule,
|
||||
ThemeSwitcherModule,
|
||||
InsecureWarningComponentModule,
|
||||
],
|
||||
declarations: [ServerShowPage],
|
||||
})
|
||||
|
||||
@@ -15,22 +15,7 @@
|
||||
|
||||
<!-- loaded -->
|
||||
<ion-item-group *ngIf="server$ | async as server; else loading">
|
||||
<ion-item *ngIf="!secure" color="warning">
|
||||
<ion-icon slot="start" name="warning-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2 style="font-weight: bold">You are using unencrypted http</h2>
|
||||
<p style="font-weight: 600">
|
||||
Click the button on the right to switch to https. Your browser may
|
||||
warn you that the page is insecure. You can safely bypass this
|
||||
warning. It will go away after you download and trust your server's
|
||||
certificate
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" color="light" (click)="launchHttps()">
|
||||
Open Https
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<insecure-warning *ngIf="!secure"></insecure-warning>
|
||||
|
||||
<div *ngFor="let cat of settings | keyvalue : asIsOrder">
|
||||
<ion-item-divider>
|
||||
@@ -52,25 +37,6 @@
|
||||
<h2>{{ button.title }}</h2>
|
||||
<p *ngIf="button.description">{{ button.description }}</p>
|
||||
|
||||
<!-- "Create Backup" button only -->
|
||||
<p *ngIf="button.title === 'Create Backup'">
|
||||
<ng-container *ngIf="server['status-info'] as statusInfo">
|
||||
<ion-text
|
||||
[color]="server['last-backup'] | backupColor"
|
||||
*ngIf="!statusInfo['backup-progress'] && !statusInfo['update-progress']"
|
||||
>
|
||||
Last Backup: {{ server['last-backup'] ? (server['last-backup'] |
|
||||
date: 'medium') : 'never' }}
|
||||
</ion-text>
|
||||
<span *ngIf="!!statusInfo['backup-progress']" class="inline">
|
||||
<ion-spinner
|
||||
color="success"
|
||||
style="height: 12px; width: 12px; margin-right: 6px"
|
||||
></ion-spinner>
|
||||
<ion-text color="success">Backing up</ion-text>
|
||||
</span>
|
||||
</ng-container>
|
||||
</p>
|
||||
<!-- "Software Update" button only -->
|
||||
<p *ngIf="button.title === 'Software Update'">
|
||||
<ion-text
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { combineLatest, firstValueFrom, map, Observable, of } from 'rxjs'
|
||||
import { firstValueFrom, Observable, of } from 'rxjs'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { EOSService } from 'src/app/services/eos.service'
|
||||
import { ClientStorageService } from 'src/app/services/client-storage.service'
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from 'src/app/modals/generic-input/generic-input.component'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { getServerInfo } from 'src/app/util/get-server-info'
|
||||
|
||||
@Component({
|
||||
selector: 'server-show',
|
||||
@@ -57,11 +56,6 @@ export class ServerShowPage {
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
) {}
|
||||
|
||||
async launchHttps() {
|
||||
const { 'lan-address': lanAddress } = await getServerInfo(this.patch)
|
||||
window.open(lanAddress)
|
||||
}
|
||||
|
||||
addClick(title: string) {
|
||||
switch (title) {
|
||||
case 'Manage':
|
||||
@@ -354,29 +348,6 @@ export class ServerShowPage {
|
||||
}
|
||||
|
||||
settings: ServerSettings = {
|
||||
Backups: [
|
||||
{
|
||||
title: 'Create Backup',
|
||||
description: 'Back up StartOS and service data',
|
||||
icon: 'duplicate-outline',
|
||||
action: () =>
|
||||
this.navCtrl.navigateForward(['backup'], { relativeTo: this.route }),
|
||||
detail: true,
|
||||
disabled$: of(!this.secure),
|
||||
},
|
||||
{
|
||||
title: 'Restore From Backup',
|
||||
description: 'Restore one or more services from backup',
|
||||
icon: 'color-wand-outline',
|
||||
action: () =>
|
||||
this.navCtrl.navigateForward(['restore'], { relativeTo: this.route }),
|
||||
detail: true,
|
||||
disabled$: combineLatest([
|
||||
this.eosService.updatingOrBackingUp$,
|
||||
of(this.secure),
|
||||
]).pipe(map(([updating, secure]) => updating || !secure)),
|
||||
},
|
||||
],
|
||||
Manage: [
|
||||
{
|
||||
title: 'Software Update',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user