Feat/automated backups (#2142)

* initial restructuring

* very cool

* new structure in place

* delete unnecessary T

* down the rabbit hole

* getting better

* dont like it

* nice

* very nice

* sessions select all

* nice

* backup runs

* fix targets and more

* small improvements

* mostly working

* address PR comments

* fix error

* delete issue with merge

* fix checkboxes and add API for deleting backup runs

* better styling for checkboxes

* small button in ssh kpage too

* complete multiple UI launcher

* fix actions

* present error toast too

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

View File

@@ -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",

View File

@@ -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",

View File

@@ -13,8 +13,4 @@ ion-item {
.item-has-focus {
--background: var(--ion-color-dark-tint) !important;
}
ion-modal {
--backdrop-opacity: 0.7;
}

View File

@@ -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;
}

View File

@@ -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> = {

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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,

View File

@@ -46,6 +46,11 @@ export class MenuComponent {
url: '/updates',
icon: 'globe-outline',
},
{
title: 'Backups',
url: '/backups',
icon: 'save-outline',
},
{
title: 'Notifications',
url: '/notifications',

View File

@@ -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',

View File

@@ -32,7 +32,6 @@ export class SnekDirective {
const loader = await this.loadingCtrl.create({
message: 'Saving high score...',
backdropDismiss: true,
})
await loader.present()

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {}

View File

@@ -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;
}
}

View File

@@ -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,
},
}

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -0,0 +1,8 @@
.warn-label {
h2 {
font-weight: 700;
}
p {
font-weight: 600;
}
}

View File

@@ -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'),
)
}
}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
ion-popover {
--min-width: 360px;
}

View File

@@ -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
}
}

View File

@@ -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 {}

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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],

View File

@@ -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()
}

View File

@@ -10,7 +10,6 @@
<ion-spinner
*ngIf="pkg.transitioning; else bulb"
class="spinner"
size="small"
color="primary"
></ion-spinner>
<ng-template #bulb>

View File

@@ -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>

View File

@@ -1,3 +0,0 @@
ion-popover {
--min-width: 300px;
}

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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)
}
}

View File

@@ -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>

View File

@@ -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 {

View File

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

View File

@@ -35,10 +35,7 @@
>
<!-- active -->
<ion-note
*ngIf="
pkgStatus === PackageMainStatus.BackingUp;
else queued
"
*ngIf="pkgStatus === 'backing-up'; else queued"
class="inline"
slot="end"
>

View File

@@ -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>) {}
}

View File

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

View File

@@ -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 => {

View File

@@ -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>

View File

@@ -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() {

View File

@@ -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 {}

View File

@@ -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')

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,

View File

@@ -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: [

View File

@@ -1,5 +0,0 @@
<backup-drives
type="restore"
class="ion-page"
(onSelect)="presentModalPassword($event)"
></backup-drives>

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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()
}
}
}

View File

@@ -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: () =>

View File

@@ -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],
})

View File

@@ -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

View File

@@ -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