mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 20:14:49 +00:00
0.2.5 initial commit
Makefile incomplete
This commit is contained in:
15
ui/src/app/modals/app-backup/app-backup.module.ts
Normal file
15
ui/src/app/modals/app-backup/app-backup.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AppBackupPage } from './app-backup.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppBackupPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
],
|
||||
entryComponents: [AppBackupPage],
|
||||
exports: [AppBackupPage],
|
||||
})
|
||||
export class AppBackupPageModule { }
|
||||
50
ui/src/app/modals/app-backup/app-backup.page.html
Normal file
50
ui/src/app/modals/app-backup/app-backup.page.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ type === 'create' ? 'Create Backup' : 'Restore Backup' }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="presentAlertHelp()" color="primary">
|
||||
<ion-icon slot="icon-only" name="help-circle-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
|
||||
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
|
||||
<ion-refresher-content pullingIcon="lines" refreshingSpinner="lines"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<ion-item *ngIf="error" style="margin-bottom: 16px;">
|
||||
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
|
||||
</ion-item>
|
||||
|
||||
<ion-spinner *ngIf="loading" class="center" name="lines" color="warning"></ion-spinner>
|
||||
|
||||
<ng-container *ngIf="!loading">
|
||||
<ion-item *ngIf="allPartitionsMounted">
|
||||
<ion-text *ngIf="type === 'create'" class="ion-text-wrap" color="warning">No partitions available. To begin a backup, insert a storage device into your Embassy.</ion-text>
|
||||
<ion-text *ngIf="type === 'restore'" class="ion-text-wrap" color="warning">No partitions available. Insert the storage device containing the backup you wish to restore.</ion-text>
|
||||
</ion-item>
|
||||
<ion-item-group *ngFor="let d of disks">
|
||||
<ion-item-divider>{{ d.logicalname }} ({{ d.size }})</ion-item-divider>
|
||||
<ion-item-group>
|
||||
<ion-item button [disabled]="p.isMounted" *ngFor="let p of d.partitions" (click)="presentAlert(p)">
|
||||
<ion-icon slot="start" name="save-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>{{ p.label || p.logicalname }}</h2>
|
||||
<p>{{ p.size || 'unknown size' }}</p>
|
||||
<p *ngIf="!p.isMounted"><ion-text color="success">Available</ion-text></p>
|
||||
<p *ngIf="p.isMounted"><ion-text color="danger">Unvailable</ion-text></p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-item-group>
|
||||
</ng-container>
|
||||
|
||||
</ion-content>
|
||||
0
ui/src/app/modals/app-backup/app-backup.page.scss
Normal file
0
ui/src/app/modals/app-backup/app-backup.page.scss
Normal file
206
ui/src/app/modals/app-backup/app-backup.page.ts
Normal file
206
ui/src/app/modals/app-backup/app-backup.page.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController, AlertController, LoadingController, IonicSafeString } from '@ionic/angular'
|
||||
import { AppModel, AppStatus } from 'src/app/models/app-model'
|
||||
import { AppInstalledFull } from 'src/app/models/app-types'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { DiskInfo, DiskPartition } from 'src/app/models/server-model'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
|
||||
@Component({
|
||||
selector: 'app-backup',
|
||||
templateUrl: './app-backup.page.html',
|
||||
styleUrls: ['./app-backup.page.scss'],
|
||||
})
|
||||
export class AppBackupPage {
|
||||
@Input() app: AppInstalledFull
|
||||
@Input() type: 'create' | 'restore'
|
||||
disks: DiskInfo[]
|
||||
loading = true
|
||||
error: string
|
||||
allPartitionsMounted: boolean
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly appModel: AppModel,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
return this.getExternalDisks().then(() => this.loading = false)
|
||||
}
|
||||
|
||||
async getExternalDisks (): Promise<void> {
|
||||
try {
|
||||
this.disks = await this.apiService.getExternalDisks()
|
||||
this.allPartitionsMounted = this.disks.every(d => d.partitions.every(p => p.isMounted))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async doRefresh (event: any) {
|
||||
await Promise.all([
|
||||
this.getExternalDisks(),
|
||||
pauseFor(600),
|
||||
])
|
||||
event.target.complete()
|
||||
}
|
||||
|
||||
async dismiss () {
|
||||
await this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
async presentAlertHelp (): Promise<void> {
|
||||
let alert: HTMLIonAlertElement
|
||||
if (this.type === 'create') {
|
||||
alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: `Backups`,
|
||||
message: `Select a location to back up ${this.app.title}.<br /><br />Internal drives and drives currently backing up other services will not be available.<br /><br />Depending on the amount of data in ${this.app.title}, your first backup may take a while. Since backups are diff-based, the speed of future backups to the same disk will likely be much faster.`,
|
||||
buttons: ['Dismiss'],
|
||||
})
|
||||
} else if (this.type === 'restore') {
|
||||
alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: `Backups`,
|
||||
message: `Select a location containing the backup you wish to restore for ${this.app.title}.<br /><br />Restoring ${this.app.title} will re-sync your service with your previous backup. The speed of the restore process depends on the backup size.`,
|
||||
buttons: ['Dismiss'],
|
||||
})
|
||||
}
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async presentAlert (partition: DiskPartition): Promise<void> {
|
||||
if (this.type === 'create') {
|
||||
this.presentAlertCreateEncrypted(partition)
|
||||
} else {
|
||||
this.presentAlertWarn(partition)
|
||||
}
|
||||
}
|
||||
|
||||
private async presentAlertCreateEncrypted (partition: DiskPartition): Promise<void> {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: `Encrypt Backup`,
|
||||
message: `Enter your master password to create an encrypted backup of ${this.app.title} to "${partition.label || partition.logicalname}".`,
|
||||
inputs: [
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
placeholder: 'Master Password',
|
||||
},
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Create Backup',
|
||||
handler: (data) => {
|
||||
if (!data.password || data.password.length < 12) {
|
||||
alert.message = new IonicSafeString(alert.message + '<br /><br /><ion-text color="danger">Password must be at least 12 characters in length.</ion-text>')
|
||||
return false
|
||||
} else {
|
||||
this.create(partition, data.password)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private async presentAlertWarn (partition: DiskPartition): Promise<void> {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: `Warning`,
|
||||
message: `Restoring ${this.app.title} from "${partition.label || partition.logicalname}" will overwrite its current data.<br /><br />Are you sure you want to continue?`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
}, {
|
||||
text: 'Continue',
|
||||
handler: () => {
|
||||
this.presentAlertRestore(partition)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private async presentAlertRestore (partition: DiskPartition): Promise<void> {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: `Decrypt Backup`,
|
||||
message: `Enter your master password`,
|
||||
inputs: [
|
||||
{
|
||||
name: 'password',
|
||||
type: 'password',
|
||||
placeholder: 'Password',
|
||||
},
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
}, {
|
||||
text: 'Restore',
|
||||
handler: (data) => {
|
||||
this.restore(partition, data.password)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private async restore (partition: DiskPartition, password?: string): Promise<void> {
|
||||
this.error = ''
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader-ontop-of-all',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.apiService.restoreAppBackup(this.app.id, partition.logicalname, password)
|
||||
this.appModel.update({ id: this.app.id, status: AppStatus.RESTORING_BACKUP })
|
||||
await this.dismiss()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
} finally {
|
||||
await loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async create (partition: DiskPartition, password?: string): Promise<void> {
|
||||
this.error = ''
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader-ontop-of-all',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.apiService.createAppBackup(this.app.id, partition.logicalname, password)
|
||||
this.appModel.update({ id: this.app.id, status: AppStatus.CREATING_BACKUP })
|
||||
await this.dismiss()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
} finally {
|
||||
await loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { InjectionToken } from '@angular/core'
|
||||
|
||||
export const APP_CONFIG_COMPONENT_MAPPING = new InjectionToken<string>('APP_CONFIG_COMPONENTS')
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Type } from '@angular/core'
|
||||
import { ValueType } from 'src/app/app-config/config-types'
|
||||
|
||||
export type AppConfigComponentMapping = { [k in ValueType]: Type<any> }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AppConfigObjectPage } from '../app-config-object/app-config-object.page'
|
||||
import { AppConfigListPage } from '../app-config-list/app-config-list.page'
|
||||
import { AppConfigUnionPage } from '../app-config-union/app-config-union.page'
|
||||
import { AppConfigValuePage } from '../app-config-value/app-config-value.page'
|
||||
import { AppConfigComponentMapping } from './modal-injectable-type'
|
||||
|
||||
export const appConfigComponents: AppConfigComponentMapping = {
|
||||
'string': AppConfigValuePage,
|
||||
'number': AppConfigValuePage,
|
||||
'enum': AppConfigValuePage,
|
||||
'boolean': AppConfigValuePage,
|
||||
'list': AppConfigListPage,
|
||||
'object': AppConfigObjectPage,
|
||||
'union': AppConfigUnionPage,
|
||||
'pointer': undefined,
|
||||
}
|
||||
21
ui/src/app/modals/app-config-list/app-config-list.module.ts
Normal file
21
ui/src/app/modals/app-config-list/app-config-list.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AppConfigListPage } from './app-config-list.page'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
import { ConfigHeaderComponentModule } from 'src/app/components/config-header/config-header.component.module'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppConfigListPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SharingModule,
|
||||
FormsModule,
|
||||
ConfigHeaderComponentModule,
|
||||
],
|
||||
entryComponents: [AppConfigListPage],
|
||||
exports: [AppConfigListPage],
|
||||
})
|
||||
export class AppConfigListPageModule { }
|
||||
68
ui/src/app/modals/app-config-list/app-config-list.page.html
Normal file
68
ui/src/app/modals/app-config-list/app-config-list.page.html
Normal file
@@ -0,0 +1,68 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon name="arrow-back"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ spec.name }}</ion-title>
|
||||
<ion-buttons *ngIf="spec.subtype !== 'enum'" slot="end">
|
||||
<ion-button (click)="presentModalValueEdit()">
|
||||
<ion-icon name="add" color="primary"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
|
||||
<config-header [spec]="spec" [error]="error"></config-header>
|
||||
|
||||
<!-- enum list -->
|
||||
<ion-item-group *ngIf="spec.subtype === 'enum'">
|
||||
<ion-item-divider class="borderless"></ion-item-divider>
|
||||
<ion-item-divider>
|
||||
{{ value.length }} selected
|
||||
<span *ngIf="min"> (min: {{ min }})</span>
|
||||
<span *ngIf="max"> (max: {{ max }})</span>
|
||||
<ion-button slot="end" fill="clear" color="primary" (click)="toggleSelectAll()">{{ selectAll ? 'All' : 'None' }}</ion-button>
|
||||
</ion-item-divider>
|
||||
<ion-item *ngFor="let option of options">
|
||||
<ion-label>{{ option.value }}</ion-label>
|
||||
<ion-checkbox slot="end" [(ngModel)]="option.checked" (click)="toggleSelected(option.value)"></ion-checkbox>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
|
||||
<!-- not enum list -->
|
||||
<div *ngIf="spec.subtype !== 'enum'">
|
||||
<ion-item-divider class="borderless"></ion-item-divider>
|
||||
<ion-item-group>
|
||||
<ion-item-divider style="font-size: small; color: var(--ion-color-medium);">
|
||||
{{ value.length }}
|
||||
<span *ngIf="value.length === 1">Entry</span>
|
||||
<span *ngIf="value.length !== 1">Entries</span>
|
||||
<span *ngIf="min"> (min: {{ min }})</span>
|
||||
<span *ngIf="max"> (max: {{ max }})</span>
|
||||
</ion-item-divider>
|
||||
|
||||
<div *ngFor="let v of value; index as i;">
|
||||
<ion-item-sliding>
|
||||
<ion-item button detail="false" (click)="presentModalValueEdit(i)">
|
||||
<ion-icon size="small" slot="start" *ngIf="!annotations.members[i] || (annotations.members[i] | annotationStatus: 'NoChange')" style="margin-right: 15px; color: rgba(0,0,0,0); background: radial-gradient(#2a4e8970, #2a4e8970 35%, transparent 35%, transparent);" name="ellipse"></ion-icon>
|
||||
<ion-icon size="small" slot="start" *ngIf="!annotations.members[i] || (annotations.members[i] | annotationStatus: 'Added')" style="margin-right: 15px; color: rgba(0,0,0,0); background: radial-gradient(#2a4e8970, #2a4e8970 35%, transparent 35%, transparent);" name="ellipse"></ion-icon>
|
||||
<ion-icon size="small" slot="start" *ngIf="annotations.members[i] && (annotations.members[i] | annotationStatus: 'Edited')" style="margin-right: 15px" color="primary" name="ellipse"></ion-icon>
|
||||
<ion-icon size="small" slot="start" *ngIf="annotations.members[i] && (annotations.members[i] | annotationStatus: 'Invalid')" style="margin-right: 15px" color="danger" name="warning-outline"></ion-icon>
|
||||
|
||||
<ion-label>{{ valueString[i] }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item-options side="end">
|
||||
<ion-item-option color="danger" (click)="presentAlertDeleteEntry(i)">
|
||||
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
|
||||
</ion-item-option>
|
||||
</ion-item-options>
|
||||
</ion-item-sliding>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
</div>
|
||||
|
||||
</ion-content>
|
||||
146
ui/src/app/modals/app-config-list/app-config-list.page.ts
Normal file
146
ui/src/app/modals/app-config-list/app-config-list.page.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { AlertController } from '@ionic/angular'
|
||||
import { Annotations, Range } from '../../app-config/config-utilities'
|
||||
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
|
||||
import { ConfigCursor } from 'src/app/app-config/config-cursor'
|
||||
import { ValueSpecList, isValueSpecListOf } from 'src/app/app-config/config-types'
|
||||
import { ModalPresentable } from 'src/app/app-config/modal-presentable'
|
||||
|
||||
@Component({
|
||||
selector: 'app-config-list',
|
||||
templateUrl: './app-config-list.page.html',
|
||||
styleUrls: ['./app-config-list.page.scss'],
|
||||
})
|
||||
export class AppConfigListPage extends ModalPresentable {
|
||||
@Input() cursor: ConfigCursor<'list'>
|
||||
|
||||
spec: ValueSpecList
|
||||
value: string[] | number[] | object[]
|
||||
valueString: string[]
|
||||
annotations: Annotations<'list'>
|
||||
|
||||
// enum only
|
||||
options: { value: string, checked: boolean }[] = []
|
||||
selectAll = true
|
||||
//
|
||||
|
||||
min: number | undefined
|
||||
max: number | undefined
|
||||
|
||||
minMessage: string
|
||||
maxMessage: string
|
||||
|
||||
error: string
|
||||
|
||||
constructor (
|
||||
private readonly alertCtrl: AlertController,
|
||||
trackingModalCtrl: TrackingModalController,
|
||||
) {
|
||||
super(trackingModalCtrl)
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.spec = this.cursor.spec()
|
||||
this.value = this.cursor.config()
|
||||
const range = Range.from(this.spec.range)
|
||||
this.min = range.integralMin()
|
||||
this.max = range.integralMax()
|
||||
this.minMessage = `The minimum number of ${this.cursor.key()} is ${this.min}.`
|
||||
this.maxMessage = `The maximum number of ${this.cursor.key()} is ${this.max}.`
|
||||
// enum list only
|
||||
if (isValueSpecListOf(this.spec, 'enum')) {
|
||||
for (let val of this.spec.spec.values) {
|
||||
this.options.push({
|
||||
value: val,
|
||||
checked: (this.value as string[]).includes(val),
|
||||
})
|
||||
}
|
||||
}
|
||||
this.updateCaches()
|
||||
}
|
||||
|
||||
async dismiss () {
|
||||
return this.dismissModal(this.value)
|
||||
}
|
||||
|
||||
// enum only
|
||||
toggleSelectAll () {
|
||||
if (!isValueSpecListOf(this.spec, 'enum')) { throw new Error('unreachable') }
|
||||
|
||||
this.value.length = 0
|
||||
if (this.selectAll) {
|
||||
for (let v of this.spec.spec.values) {
|
||||
(this.value as string[]).push(v)
|
||||
}
|
||||
for (let option of this.options) {
|
||||
option.checked = true
|
||||
}
|
||||
} else {
|
||||
for (let option of this.options) {
|
||||
option.checked = false
|
||||
}
|
||||
}
|
||||
this.updateCaches()
|
||||
}
|
||||
|
||||
// enum only
|
||||
async toggleSelected (value: string) {
|
||||
const index = (this.value as string[]).indexOf(value)
|
||||
|
||||
// if present, delete
|
||||
if (index > -1) {
|
||||
(this.value as string[]).splice(index, 1)
|
||||
// if not present, add
|
||||
} else {
|
||||
(this.value as string[]).push(value)
|
||||
}
|
||||
|
||||
this.updateCaches()
|
||||
}
|
||||
|
||||
async presentModalValueEdit (index?: number) {
|
||||
const nextCursor = this.cursor.seekNext(index === undefined ? this.value.length : index)
|
||||
nextCursor.createFirstEntryForList()
|
||||
return this.presentModal(nextCursor, () => this.updateCaches())
|
||||
}
|
||||
|
||||
async presentAlertDeleteEntry (key: number) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: 'Caution',
|
||||
message: `Are you sure you want to delete this entry?`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
cssClass: 'alert-danger',
|
||||
handler: () => {
|
||||
if (typeof key === 'number') {
|
||||
(this.value as any[]).splice(key, 1)
|
||||
} else {
|
||||
delete this.value[key]
|
||||
}
|
||||
this.updateCaches()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
asIsOrder () {
|
||||
return 0
|
||||
}
|
||||
|
||||
private updateCaches () {
|
||||
if (isValueSpecListOf(this.spec, 'enum')) {
|
||||
this.selectAll = this.value.length !== this.spec.spec.values.length
|
||||
}
|
||||
this.error = this.cursor.checkInvalid()
|
||||
this.annotations = this.cursor.getAnnotations()
|
||||
this.valueString = (this.value as any[]).map((_, idx) => this.cursor.seekNext(idx).toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AppConfigObjectPage } from './app-config-object.page'
|
||||
import { ObjectConfigComponentModule } from 'src/app/components/object-config/object-config.component.module'
|
||||
import { ConfigHeaderComponentModule } from 'src/app/components/config-header/config-header.component.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppConfigObjectPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
ObjectConfigComponentModule,
|
||||
ConfigHeaderComponentModule,
|
||||
],
|
||||
entryComponents: [AppConfigObjectPage],
|
||||
exports: [AppConfigObjectPage],
|
||||
})
|
||||
export class AppConfigObjectPageModule { }
|
||||
@@ -0,0 +1,27 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon name="arrow-back"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ spec.name }}</ion-title>
|
||||
<ion-buttons *ngIf="spec.nullable" slot="end">
|
||||
<ion-button color="danger" (click)="presentAlertDestroy()">
|
||||
Delete
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
|
||||
<config-header [spec]="spec" [error]="error"></config-header>
|
||||
|
||||
<!-- object -->
|
||||
<ion-item-group>
|
||||
<ion-item-divider></ion-item-divider>
|
||||
<object-config [cursor]="cursor" (onEdit)="handleObjectEdit()"></object-config>
|
||||
</ion-item-group>
|
||||
|
||||
</ion-content>
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController, AlertController } from '@ionic/angular'
|
||||
import { ConfigCursor } from 'src/app/app-config/config-cursor'
|
||||
import { ValueSpecObject } from 'src/app/app-config/config-types'
|
||||
|
||||
@Component({
|
||||
selector: 'app-config-object',
|
||||
templateUrl: './app-config-object.page.html',
|
||||
styleUrls: ['./app-config-object.page.scss'],
|
||||
})
|
||||
export class AppConfigObjectPage {
|
||||
@Input() cursor: ConfigCursor<'object'>
|
||||
spec: ValueSpecObject
|
||||
value: object
|
||||
error: string
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.spec = this.cursor.spec()
|
||||
this.value = this.cursor.config()
|
||||
this.error = this.cursor.checkInvalid()
|
||||
}
|
||||
|
||||
async dismiss (nullify = false) {
|
||||
this.modalCtrl.dismiss(nullify ? null : this.value)
|
||||
}
|
||||
|
||||
async presentAlertDestroy () {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: 'Caution',
|
||||
message: 'Are you sure you want to delete this record?',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
cssClass: 'alert-danger',
|
||||
handler: () => {
|
||||
this.dismiss(true)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
handleObjectEdit () {
|
||||
this.error = this.cursor.checkInvalid()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AppConfigUnionPage } from './app-config-union.page'
|
||||
import { ObjectConfigComponentModule } from 'src/app/components/object-config/object-config.component.module'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ConfigHeaderComponentModule } from 'src/app/components/config-header/config-header.component.module'
|
||||
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppConfigUnionPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
ObjectConfigComponentModule,
|
||||
ConfigHeaderComponentModule,
|
||||
],
|
||||
entryComponents: [AppConfigUnionPage],
|
||||
exports: [AppConfigUnionPage],
|
||||
})
|
||||
export class AppConfigUnionPageModule { }
|
||||
@@ -0,0 +1,31 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon name="arrow-back"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ spec.name }}</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
|
||||
<config-header [spec]="spec" [error]="error"></config-header>
|
||||
|
||||
<!-- union -->
|
||||
<ion-item-group>
|
||||
<ion-item-divider></ion-item-divider>
|
||||
<ion-item>
|
||||
<ion-label>{{ spec.tag.name }}</ion-label>
|
||||
<ion-select slot="end" [interfaceOptions]="setSelectOptions()" placeholder="Select One" [(ngModel)]="value[spec.tag.id]" [selectedText]="spec.tag.variantNames[value[spec.tag.id]]" (ngModelChange)="handleUnionChange()">
|
||||
<ion-select-option *ngFor="let option of spec.variants | keyvalue: asIsOrder" [value]="option.key">
|
||||
{{ spec.tag.variantNames[option.key] }}
|
||||
<span *ngIf="option.key === spec.default"> (default)</span>
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
<object-config [cursor]="cursor" (onEdit)="handleObjectEdit()"></object-config>
|
||||
</ion-item-group>
|
||||
|
||||
</ion-content>
|
||||
58
ui/src/app/modals/app-config-union/app-config-union.page.ts
Normal file
58
ui/src/app/modals/app-config-union/app-config-union.page.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Component, Input, ViewChild } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { ConfigCursor } from 'src/app/app-config/config-cursor'
|
||||
import { ValueSpecUnion } from 'src/app/app-config/config-types'
|
||||
import { ObjectConfigComponent } from 'src/app/components/object-config/object-config.component'
|
||||
import { mapUnionSpec } from '../../app-config/config-utilities'
|
||||
|
||||
@Component({
|
||||
selector: 'app-config-union',
|
||||
templateUrl: './app-config-union.page.html',
|
||||
styleUrls: ['./app-config-union.page.scss'],
|
||||
})
|
||||
export class AppConfigUnionPage {
|
||||
@Input() cursor: ConfigCursor<'union'>
|
||||
|
||||
@ViewChild(ObjectConfigComponent)
|
||||
objectConfig: ObjectConfigComponent
|
||||
|
||||
spec: ValueSpecUnion
|
||||
value: object
|
||||
error: string
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.spec = this.cursor.spec()
|
||||
this.value = this.cursor.config()
|
||||
this.error = this.cursor.checkInvalid()
|
||||
}
|
||||
|
||||
async dismiss () {
|
||||
this.modalCtrl.dismiss(this.value)
|
||||
}
|
||||
|
||||
async handleUnionChange () {
|
||||
this.value = mapUnionSpec(this.spec, this.value)
|
||||
this.objectConfig.annotations = this.objectConfig.cursor.getAnnotations()
|
||||
}
|
||||
|
||||
setSelectOptions () {
|
||||
return {
|
||||
header: this.spec.tag.name,
|
||||
subHeader: this.spec.changeWarning ? 'Warning!' : undefined,
|
||||
message: this.spec.changeWarning ? `${this.spec.changeWarning}` : undefined,
|
||||
cssClass: 'select-change-warning',
|
||||
}
|
||||
}
|
||||
|
||||
handleObjectEdit () {
|
||||
this.error = this.cursor.checkInvalid()
|
||||
}
|
||||
|
||||
asIsOrder () {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AppConfigValuePage } from './app-config-value.page'
|
||||
import { ConfigHeaderComponentModule } from 'src/app/components/config-header/config-header.component.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppConfigValuePage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
ConfigHeaderComponentModule,
|
||||
],
|
||||
entryComponents: [AppConfigValuePage],
|
||||
exports: [AppConfigValuePage],
|
||||
})
|
||||
export class AppConfigValuePageModule { }
|
||||
@@ -0,0 +1,83 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon name="arrow-back"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>
|
||||
{{ spec.name }}
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button [disabled]="!!error" (click)="done()" color="primary">
|
||||
{{ saveFn ? 'Save' : 'Done' }}
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
|
||||
<config-header [spec]="spec" [error]="error"></config-header>
|
||||
|
||||
<ion-item-group>
|
||||
<ion-item-divider>
|
||||
<ion-button *ngIf="spec.type === 'string' && spec.copyable" style="padding-right: 12px;" size="small" slot="end" fill="clear" color="primary" (click)="copy()">
|
||||
<ion-icon slot="icon-only" name="copy-outline" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
<!-- string -->
|
||||
<ion-item *ngIf="spec.type === 'string'">
|
||||
<ion-input [type]="spec.masked && !unmasked ? 'password' : 'text'" placeholder="Enter value" [(ngModel)]="value" (ngModelChange)="handleInput()"></ion-input>
|
||||
<div slot="end">
|
||||
<ion-button *ngIf="spec.masked" fill="clear" [color]="unmasked ? 'danger' : 'primary'" (click)="toggleMask()">
|
||||
<ion-icon slot="icon-only" [name]="unmasked ? 'eye-off-outline' : 'eye-outline'" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button *ngIf="value && spec.nullable" fill="clear" color="medium" (click)="clear()">
|
||||
<ion-icon slot="icon-only" name="close" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</ion-item>
|
||||
<!-- number -->
|
||||
<ion-item *ngIf="spec.type === 'number'">
|
||||
<ion-input type="tel" placeholder="Enter value" [(ngModel)]="value" (ngModelChange)="handleInput()"></ion-input>
|
||||
<span slot="end" *ngIf="spec.units"><ion-text color="medium">{{ spec.units }}</ion-text></span>
|
||||
<ion-button *ngIf="value && spec.nullable" slot="end" fill="clear" color="medium" (click)="clear()">
|
||||
<ion-icon slot="icon-only" name="close" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<!-- boolean -->
|
||||
<ion-item *ngIf="spec.type === 'boolean'">
|
||||
<ion-label>{{ spec.name }}</ion-label>
|
||||
<ion-toggle slot="end" [(ngModel)]="value" (ngModelChange)="edited = true"></ion-toggle>
|
||||
</ion-item>
|
||||
<!-- enum -->
|
||||
<ion-list *ngIf="spec.type === 'enum'">
|
||||
<ion-radio-group [(ngModel)]="value">
|
||||
<ion-item *ngFor="let option of spec.values">
|
||||
<ion-label>{{ spec.valueNames[option] }}</ion-label>
|
||||
<ion-radio slot="start" [value]="option"></ion-radio>
|
||||
</ion-item>
|
||||
</ion-radio-group>
|
||||
</ion-list>
|
||||
<!-- metadata -->
|
||||
<div class="ion-padding-start">
|
||||
<p *ngIf="spec.type === 'string' && spec.patternDescription">
|
||||
<ion-text color="medium">{{ spec.patternDescription }}</ion-text>
|
||||
</p>
|
||||
<p *ngIf="spec.type === 'number' && spec.integral">
|
||||
<ion-text color="medium">{{ integralDescription }}</ion-text>
|
||||
</p>
|
||||
<p *ngIf="rangeDescription">
|
||||
<ion-text color="medium">{{ rangeDescription }}</ion-text>
|
||||
</p>
|
||||
<p *ngIf="spec.default">
|
||||
<ion-text color="medium">
|
||||
<p>Default: {{ defaultDescription }} <ion-icon style="padding-left: 8px;" name="refresh-outline" color="primary" (click)="refreshDefault()"></ion-icon></p>
|
||||
<p *ngIf="spec.type === 'number' && spec.units">Units: {{ spec.units }}</p>
|
||||
</ion-text>
|
||||
</p>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
|
||||
</ion-content>
|
||||
173
ui/src/app/modals/app-config-value/app-config-value.page.ts
Normal file
173
ui/src/app/modals/app-config-value/app-config-value.page.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { getDefaultConfigValue, getDefaultDescription, Range } from 'src/app/app-config/config-utilities'
|
||||
import { AlertController, ToastController } from '@ionic/angular'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
|
||||
import { ConfigCursor } from 'src/app/app-config/config-cursor'
|
||||
import { ValueSpecOf } from 'src/app/app-config/config-types'
|
||||
import { copyToClipboard } from 'src/app/util/web.util'
|
||||
|
||||
@Component({
|
||||
selector: 'app-config-value',
|
||||
templateUrl: 'app-config-value.page.html',
|
||||
styleUrls: ['app-config-value.page.scss'],
|
||||
})
|
||||
export class AppConfigValuePage {
|
||||
@Input() cursor: ConfigCursor<'string' | 'number' | 'boolean' | 'enum'>
|
||||
@Input() saveFn?: (value: string | number | boolean) => Promise<any>
|
||||
|
||||
spec: ValueSpecOf<'string' | 'number' | 'boolean' | 'enum'>
|
||||
value: string | number | boolean | null
|
||||
|
||||
edited: boolean
|
||||
error: string
|
||||
unmasked = false
|
||||
|
||||
defaultDescription: string
|
||||
integralDescription = 'Value must be a whole number.'
|
||||
|
||||
range: Range
|
||||
rangeDescription: string
|
||||
|
||||
constructor (
|
||||
private readonly loader: LoaderService,
|
||||
private readonly trackingModalCtrl: TrackingModalController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly toastCtrl: ToastController,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.spec = this.cursor.spec()
|
||||
this.value = this.cursor.config()
|
||||
this.error = this.cursor.checkInvalid()
|
||||
|
||||
this.defaultDescription = getDefaultDescription(this.spec)
|
||||
if (this.spec.type === 'number') {
|
||||
this.range = Range.from(this.spec.range)
|
||||
this.rangeDescription = this.range.description()
|
||||
}
|
||||
}
|
||||
|
||||
async dismiss () {
|
||||
if (this.edited) {
|
||||
await this.presentAlertUnsaved()
|
||||
} else {
|
||||
await this.trackingModalCtrl.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
async done () {
|
||||
if (!this.validate()) { return }
|
||||
|
||||
if (this.spec.type !== 'boolean') {
|
||||
this.value = this.value || null
|
||||
}
|
||||
if (this.spec.type === 'number' && this.value) {
|
||||
this.value = Number(this.value)
|
||||
}
|
||||
|
||||
if (this.saveFn) {
|
||||
this.loader.displayDuringP(
|
||||
this.saveFn(this.value).catch(e => {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
await this.trackingModalCtrl.dismiss(this.value)
|
||||
}
|
||||
|
||||
refreshDefault () {
|
||||
this.value = getDefaultConfigValue(this.spec) as any
|
||||
this.handleInput()
|
||||
}
|
||||
|
||||
handleInput () {
|
||||
this.error = ''
|
||||
this.edited = true
|
||||
}
|
||||
|
||||
clear () {
|
||||
this.value = null
|
||||
this.edited = true
|
||||
}
|
||||
|
||||
toggleMask () {
|
||||
this.unmasked = !this.unmasked
|
||||
}
|
||||
|
||||
async copy (): Promise<void> {
|
||||
let message = ''
|
||||
await copyToClipboard(String(this.value)).then(success => { message = success ? 'copied to clipboard!' : 'failed to copy'})
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: message,
|
||||
position: 'bottom',
|
||||
duration: 1000,
|
||||
cssClass: 'notification-toast',
|
||||
})
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
private validate (): boolean {
|
||||
if (this.spec.type === 'boolean') return true
|
||||
|
||||
// test blank
|
||||
if (!this.value && !(this.spec as any).nullable) {
|
||||
this.error = 'Value cannot be blank'
|
||||
return false
|
||||
}
|
||||
// test pattern if string
|
||||
if (this.spec.type === 'string' && this.value) {
|
||||
const { pattern, patternDescription } = this.spec
|
||||
if (pattern && !RegExp(pattern).test(this.value as string)) {
|
||||
this.error = patternDescription || `Must match ${pattern}`
|
||||
return false
|
||||
}
|
||||
}
|
||||
// test range if number
|
||||
if (this.spec.type === 'number' && this.value) {
|
||||
if (this.spec.integral && !RegExp(/^[-+]?[0-9]+$/).test(String(this.value))) {
|
||||
this.error = this.integralDescription
|
||||
return false
|
||||
} else if (!this.spec.integral && !RegExp(/^[0-9]*\.?[0-9]+$/).test(String(this.value))) {
|
||||
this.error = 'Value must be a number.'
|
||||
return false
|
||||
} else {
|
||||
try {
|
||||
this.range.checkIncludes(Number(this.value))
|
||||
} catch (e) {
|
||||
console.warn(e) //an invalid spec is not an error
|
||||
this.error = e.message
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private async presentAlertUnsaved () {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: 'Unsaved Changes',
|
||||
message: 'You have unsaved changes. Are you sure you want to leave?',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: `Leave`,
|
||||
cssClass: 'alert-danger',
|
||||
handler: () => {
|
||||
this.trackingModalCtrl.dismiss()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AppReleaseNotesPage } from './app-release-notes.page'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SharingModule,
|
||||
],
|
||||
declarations: [AppReleaseNotesPage],
|
||||
})
|
||||
export class AppReleaseNotesPageModule { }
|
||||
@@ -0,0 +1,14 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title style="font-size: 16px;">{{ version }} Release Notes</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<div [innerHTML]="releaseNotes | markdown"></div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
|
||||
@Component({
|
||||
selector: 'app-release-notes',
|
||||
templateUrl: './app-release-notes.page.html',
|
||||
styleUrls: ['./app-release-notes.page.scss'],
|
||||
})
|
||||
export class AppReleaseNotesPage {
|
||||
@Input() releaseNotes: string
|
||||
@Input() version: string
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
) { }
|
||||
|
||||
dismiss () {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user