rework cutofing processing (#716)

* rework cutofing processing

* fix default generation bug

* dont hard code all dependent ids to 'hello'

* fix recommendation display and bug with health cehck not updating

* fix key name

* fix dependency error updates and retain order on backup

* fix health check display
This commit is contained in:
Matt Hill
2021-10-21 16:49:47 -06:00
committed by Aiden McClelland
parent c6e379bffa
commit 65a4b8ab84
21 changed files with 1691 additions and 1459 deletions

1814
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,9 +24,8 @@
"@start9labs/emver": "0.1.5", "@start9labs/emver": "0.1.5",
"ajv": "^6.12.6", "ajv": "^6.12.6",
"core-js": "^3.17.2", "core-js": "^3.17.2",
"fast-json-patch": "^3.1.0",
"fuse.js": "^6.4.6", "fuse.js": "^6.4.6",
"json-pointer": "^0.6.1",
"jsonpointerx": "^1.1.4",
"marked": "3.0.2", "marked": "3.0.2",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"ng-qrcode": "^5.0.0", "ng-qrcode": "^5.0.0",

View File

@@ -30,5 +30,8 @@
<p *ngIf="control.hasError('listNotUnique')"> <p *ngIf="control.hasError('listNotUnique')">
{{ control.errors.listNotUnique.value }} {{ control.errors.listNotUnique.value }}
</p> </p>
<p *ngIf="control.hasError('listItemIssue')">
{{ control.errors.listItemIssue.value }}
</p>
</ng-container> </ng-container>
</div> </div>

View File

@@ -1,11 +1,16 @@
<ion-button *ngIf="data.spec.description" class="help-button" fill="clear" size="small" (click)="presentAlertDescription()"> <ion-button *ngIf="data.spec.description" class="slot-start" fill="clear" size="small" (click)="presentAlertDescription()">
<ion-icon name="help-circle-outline"></ion-icon> <ion-icon name="help-circle-outline"></ion-icon>
</ion-button> </ion-button>
<!-- this is a button for css purposes only -->
<ion-button *ngIf="data.invalid" class="slot-start" fill="clear" size="small" color="danger">
<ion-icon name="warning-outline"></ion-icon>
</ion-button>
<span>{{ data.spec.name }}</span> <span>{{ data.spec.name }}</span>
<ion-text color="success" *ngIf="data.isNew">&nbsp;(New)</ion-text> <ion-text color="success" *ngIf="data.new">&nbsp;(New)</ion-text>
<ion-text color="warning" *ngIf="data.isEdited">&nbsp;(Edited)</ion-text> <ion-text color="warning" *ngIf="data.edited">&nbsp;(Edited)</ion-text>
<span *ngIf="(['string', 'number'] | includes : data.spec.type) && !$any(data.spec).nullable">&nbsp;*</span> <span *ngIf="(['string', 'number'] | includes : data.spec.type) && !$any(data.spec).nullable">&nbsp;*</span>

View File

@@ -26,8 +26,8 @@
<h4 class="input-label"> <h4 class="input-label">
<form-label [data]="{ <form-label [data]="{
spec: spec, spec: spec,
isNew: current && current[entry.key] === undefined, new: current && current[entry.key] === undefined,
isEdited: entry.value.dirty edited: entry.value.dirty
}"></form-label> }"></form-label>
</h4> </h4>
<!-- string or number --> <!-- string or number -->
@@ -73,8 +73,8 @@
<ion-item-divider> <ion-item-divider>
<form-label [data]="{ <form-label [data]="{
spec: spec, spec: spec,
isNew: current && current[entry.key] === undefined, new: current && current[entry.key] === undefined,
isEdited: entry.value.dirty edited: entry.value.dirty
}"></form-label> }"></form-label>
</ion-item-divider> </ion-item-divider>
<!-- body --> <!-- body -->
@@ -97,8 +97,8 @@
<ion-item-divider> <ion-item-divider>
<form-label [data]="{ <form-label [data]="{
spec: spec, spec: spec,
isNew: current && current[entry.key] === undefined, new: current && current[entry.key] === undefined,
isEdited: entry.value.dirty edited: entry.value.dirty
}"></form-label> }"></form-label>
<ion-button fill="clear" color="primary" slot="end" (click)="addListItemWrapper(entry.key, spec)"> <ion-button fill="clear" color="primary" slot="end" (click)="addListItemWrapper(entry.key, spec)">
<ion-icon slot="start" name="add"></ion-icon> <ion-icon slot="start" name="add"></ion-icon>
@@ -117,8 +117,9 @@
<ion-item button (click)="toggleExpand(entry.key, i)"> <ion-item button (click)="toggleExpand(entry.key, i)">
<form-label [data]="{ <form-label [data]="{
spec: $any({ name: objectListInfo[entry.key][i].displayAs || 'Entry ' + (i + 1) }), spec: $any({ name: objectListInfo[entry.key][i].displayAs || 'Entry ' + (i + 1) }),
isNew: false, new: false,
isEdited: abstractControl.dirty edited: abstractControl.dirty,
invalid: abstractControl.invalid
}"></form-label> }"></form-label>
<ion-icon <ion-icon
slot="end" slot="end"
@@ -192,13 +193,13 @@
<p class="input-label"> <p class="input-label">
<form-label [data]="{ <form-label [data]="{
spec: spec, spec: spec,
isNew: current && current[entry.key] === undefined, new: current && current[entry.key] === undefined,
isEdited: entry.value.dirty edited: entry.value.dirty
}"></form-label> }"></form-label>
</p> </p>
<!-- list --> <!-- list -->
<ion-item button detail="false" color="dark" (click)="presentModalEnumList(entry.key, $any(spec), formArr.value)"> <ion-item button detail="false" color="dark" (click)="presentModalEnumList(entry.key, $any(spec), formArr.value)">
<ion-label> <ion-label style="white-space: nowrap !important;">
<h2>{{ getEnumListDisplay(formArr.value, $any(spec.spec)) }}</h2> <h2>{{ getEnumListDisplay(formArr.value, $any(spec.spec)) }}</h2>
</ion-label> </ion-label>
<ion-button slot="end" fill="clear" color="light"> <ion-button slot="end" fill="clear" color="light">
@@ -211,7 +212,7 @@
*ngIf="formGroup.get(entry.key).errors" *ngIf="formGroup.get(entry.key).errors"
[control]="$any(formGroup.get(entry.key))" [control]="$any(formGroup.get(entry.key))"
[spec]="spec" [spec]="spec"
> >
</form-error> </form-error>
</ng-container> </ng-container>
</div> </div>

View File

@@ -1,4 +1,4 @@
.help-button { .slot-start {
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
} }

View File

@@ -1,12 +1,12 @@
import { Component, Input, Output, SimpleChange, EventEmitter } from '@angular/core' import { Component, Input, Output, SimpleChange, EventEmitter } from '@angular/core'
import { AbstractControl, AbstractFormGroupDirective, FormArray, FormGroup } from '@angular/forms' import { AbstractFormGroupDirective, FormArray, FormGroup } from '@angular/forms'
import { AlertButton, AlertController, IonicSafeString, ModalController } from '@ionic/angular' import { AlertButton, AlertController, IonicSafeString, ModalController } from '@ionic/angular'
import { ConfigSpec, ListValueSpecOf, ListValueSpecString, ValueSpec, ValueSpecBoolean, ValueSpecEnum, ValueSpecList, ValueSpecListOf, ValueSpecNumber, ValueSpecString, ValueSpecUnion } from 'src/app/pkg-config/config-types' import { ConfigSpec, ListValueSpecOf, ValueSpec, ValueSpecBoolean, ValueSpecList, ValueSpecListOf, ValueSpecUnion } from 'src/app/pkg-config/config-types'
import { FormService } from 'src/app/services/form.service' import { FormService } from 'src/app/services/form.service'
import { Range } from 'src/app/pkg-config/config-utilities' import { Range } from 'src/app/pkg-config/config-utilities'
import { EnumListPage } from 'src/app/modals/enum-list/enum-list.page' import { EnumListPage } from 'src/app/modals/enum-list/enum-list.page'
const Mustache = require('mustache')
import { pauseFor } from 'src/app/util/misc.util' import { pauseFor } from 'src/app/util/misc.util'
const Mustache = require('mustache')
@Component({ @Component({
selector: 'form-object', selector: 'form-object',
@@ -76,6 +76,7 @@ export class FormObjectComponent {
addListItem (key: string, markDirty = true, val?: string): void { addListItem (key: string, markDirty = true, val?: string): void {
const arr = this.formGroup.get(key) as FormArray const arr = this.formGroup.get(key) as FormArray
if (markDirty) arr.markAsDirty() if (markDirty) arr.markAsDirty()
// @TODO why are these commented out?
// const validators = this.formService.getListItemValidators(this.objectSpec[key] as ValueSpecList, key, arr.length) // const validators = this.formService.getListItemValidators(this.objectSpec[key] as ValueSpecList, key, arr.length)
// arr.push(new FormControl(value, validators)) // arr.push(new FormControl(value, validators))
const listSpec = this.objectSpec[key] as ValueSpecList const listSpec = this.objectSpec[key] as ValueSpecList
@@ -225,8 +226,9 @@ export class FormObjectComponent {
interface HeaderData { interface HeaderData {
spec: ValueSpec spec: ValueSpec
isEdited: boolean edited: boolean
isNew: boolean new: boolean
invalid?: boolean
} }
@Component({ @Component({

View File

@@ -38,33 +38,23 @@
</ion-label> </ion-label>
</ion-item> </ion-item>
<ng-container *ngIf="rec && showRec"> <!-- auto-config -->
<ion-item class="rec-item"> <ion-item lines="none" *ngIf="dependentInfo" class="rec-item" style="margin-bottom: 48px;">
<ion-label> <ion-label>
<h2 style="display: flex; align-items: center;"> <h2 style="display: flex; align-items: center;">
<ion-icon size="small" style="margin: 4px" slot="start" color="primary" slot="start" name="ellipse"></ion-icon> <img style="width: 18px; margin: 4px;" [src]="pkg['static-files'].icon" [alt]="pkg.manifest.title"/>
<ion-thumbnail style="width: 3vh; height: 3vh; margin: 0px 2px 0px 5px;" slot="start"> <ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: 18px;">{{ pkg.manifest.title }}</ion-text>
<img [src]="rec.dependentIcon" [alt]="rec.dependentTitle"/> </h2>
</ion-thumbnail> <p>
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: smaller;">{{ rec.dependentTitle }}</ion-text> <ion-text color="dark">
</h2> {{ pkg.manifest.title }} has been modified to satisfy {{ dependentInfo.title }}.
<div style="margin: 7px 5px;"> <br />
<p style="font-size: small; color: var(--ion-color-medium)"> {{ pkg.manifest.title }} config has been modified to satisfy {{ rec.dependentTitle }}. <br />
<ion-text color="dark">To accept the changes, click Save” above.</ion-text> To accept the modifications, click "Save".
</p> </ion-text>
<a style="font-size: small" *ngIf="!openRec" (click)="openRec = true">More Info</a> </p>
<ng-container *ngIf="openRec"> </ion-label>
<p style="margin-top: 10px; color: var(--ion-color-medium); font-size: small" [innerHTML]="rec.description"></p> </ion-item>
<a style="font-size: x-small; font-style: italic;" (click)="openRec = false">hide</a>
</ng-container>
<ion-button style="position: absolute; right: 0; top: 0" color="primary" fill="clear" (click)="dismissRec()">
<ion-icon name="close"></ion-icon>
</ion-button>
</div>
</ion-label>
</ion-item>
<ion-item-divider></ion-item-divider>
</ng-container>
<!-- no config --> <!-- no config -->
<ion-item *ngIf="!hasConfig"> <ion-item *ngIf="!hasConfig">
@@ -78,7 +68,7 @@
<form-object <form-object
[objectSpec]="configSpec" [objectSpec]="configSpec"
[formGroup]="configForm" [formGroup]="configForm"
[current]="current" [current]="configForm.value"
[showEdited]="true" [showEdited]="true"
></form-object> ></form-object>
</form> </form>

View File

@@ -1,7 +1,7 @@
import { Component, Input, ViewChild } from '@angular/core' import { Component, Input, ViewChild } from '@angular/core'
import { AlertController, ModalController, IonContent, LoadingController, IonicSafeString } from '@ionic/angular' import { AlertController, ModalController, IonContent, LoadingController, IonicSafeString } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { isEmptyObject, isObject, Recommendation } from 'src/app/util/misc.util' import { DependentInfo, isEmptyObject } from 'src/app/util/misc.util'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component' import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards' import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { ConfigSpec } from 'src/app/pkg-config/config-types' import { ConfigSpec } from 'src/app/pkg-config/config-types'
@@ -10,6 +10,7 @@ import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ErrorToastService, getErrorMessage } from 'src/app/services/error-toast.service' import { ErrorToastService, getErrorMessage } from 'src/app/services/error-toast.service'
import { FormGroup } from '@angular/forms' import { FormGroup } from '@angular/forms'
import { convertValuesRecursive, FormService } from 'src/app/services/form.service' import { convertValuesRecursive, FormService } from 'src/app/services/form.service'
import { compare, Operation } from 'fast-json-patch'
@Component({ @Component({
selector: 'app-config', selector: 'app-config',
@@ -19,16 +20,14 @@ import { convertValuesRecursive, FormService } from 'src/app/services/form.servi
export class AppConfigPage { export class AppConfigPage {
@ViewChild(IonContent) content: IonContent @ViewChild(IonContent) content: IonContent
@Input() pkgId: string @Input() pkgId: string
@Input() rec: Recommendation | null = null @Input() dependentInfo?: DependentInfo
pkg: PackageDataEntry pkg: PackageDataEntry
loadingText: string | undefined loadingText: string | undefined
configSpec: ConfigSpec configSpec: ConfigSpec
configForm: FormGroup configForm: FormGroup
current: object original: object
hasConfig = false hasConfig = false
saving = false saving = false
showRec = true
openRec = false
loadingError: string | IonicSafeString loadingError: string | IonicSafeString
constructor ( constructor (
@@ -49,23 +48,32 @@ export class AppConfigPage {
if (!this.hasConfig) return if (!this.hasConfig) return
try { try {
let oldConfig: object let oldConfig: object
let newConfig: object let newConfig: object
let spec: ConfigSpec let spec: ConfigSpec
if (this.rec) { let patch: Operation[]
this.loadingText = `Setting properties to accommodate ${this.rec.dependentTitle}` if (this.dependentInfo) {
const { 'old-config': oc, 'new-config': nc, spec: s } = await this.embassyApi.dryConfigureDependency({ 'dependency-id': this.pkgId, 'dependent-id': this.rec.dependentId }) this.loadingText = `Setting properties to accommodate ${this.dependentInfo.title}`
const { 'old-config': oc, 'new-config': nc, spec: s } = await this.embassyApi.dryConfigureDependency({ 'dependency-id': this.pkgId, 'dependent-id': this.dependentInfo.id })
oldConfig = oc oldConfig = oc
newConfig = nc newConfig = nc
spec = s spec = s
patch = compare(oldConfig, newConfig)
} else { } else {
this.loadingText = 'Loading Config' this.loadingText = 'Loading Config'
const { config: oc, spec: s } = await this.embassyApi.getPackageConfig({ id: this.pkgId }) const { config: c, spec: s } = await this.embassyApi.getPackageConfig({ id: this.pkgId })
oldConfig = oc oldConfig = c
spec = s spec = s
} }
this.setConfig(spec, oldConfig, newConfig)
this.original = oldConfig
this.configSpec = spec
this.configForm = this.formService.createForm(spec, newConfig || oldConfig)
this.configForm.markAllAsTouched()
if (patch) {
this.markDirty(patch)
}
} catch (e) { } catch (e) {
this.loadingError = getErrorMessage(e) this.loadingError = getErrorMessage(e)
} finally { } finally {
@@ -79,11 +87,8 @@ export class AppConfigPage {
resetDefaults () { resetDefaults () {
this.configForm = this.formService.createForm(this.configSpec) this.configForm = this.formService.createForm(this.configSpec)
this.alterConfigRecursive(this.configForm, this.current) const patch = compare(this.original, this.configForm.value)
} this.markDirty(patch)
dismissRec () {
this.showRec = false
} }
async dismiss () { async dismiss () {
@@ -143,33 +148,20 @@ export class AppConfigPage {
} }
} }
private setConfig (spec: ConfigSpec, config: object, depConfig?: object) { private markDirty (patch: Operation[]) {
this.configSpec = spec patch.forEach(op => {
this.current = config const arrPath = op.path.substring(1)
this.configForm = this.formService.createForm(spec, { ...config, ...depConfig }) .split('/')
this.configForm.markAllAsTouched() .map(node => {
const num = Number(node)
return isNaN(num) ? node : num
})
if (depConfig) { if (op.op !== 'remove') this.configForm.get(arrPath).markAsDirty()
this.alterConfigRecursive(this.configForm, depConfig)
}
}
private alterConfigRecursive (group: FormGroup, config: object) { if (typeof arrPath[arrPath.length - 1] === 'number') {
Object.keys(config).forEach(key => { const prevPath = arrPath.slice(0, arrPath.length - 1)
const next = group.get(key) this.configForm.get(prevPath).markAsDirty()
if (!next) throw new Error('Dependency config not compatible with service version. Please contact support')
const newVal = config[key]
// check if val is an object
if (isObject(newVal)) {
this.alterConfigRecursive(next as FormGroup, newVal)
} else {
let val1 = group.get(key).value
let val2 = config[key]
if (Array.isArray(newVal)) {
val1 = JSON.stringify(val1)
val2 = JSON.stringify(val2)
}
if (val1 != val2) next.markAsDirty()
} }
}) })
} }

View File

@@ -1,7 +1,6 @@
import { Component, Input } from '@angular/core' import { Component, Input } from '@angular/core'
import { ModalController } from '@ionic/angular' import { ModalController } from '@ionic/angular'
import { ValueSpecListOf } from '../../pkg-config/config-types' import { ValueSpecListOf } from '../../pkg-config/config-types'
// import { Range } from '../../pkg-config/config-utilities'
@Component({ @Component({
selector: 'enum-list', selector: 'enum-list',
@@ -13,12 +12,6 @@ export class EnumListPage {
@Input() spec: ValueSpecListOf<'enum'> @Input() spec: ValueSpecListOf<'enum'>
@Input() current: string[] @Input() current: string[]
options: { [option: string]: boolean } = { } options: { [option: string]: boolean } = { }
// min: number | undefined
// max: number | undefined
// minMessage: string
// maxMessage: string
selectAll = true selectAll = true
constructor ( constructor (
@@ -26,12 +19,6 @@ export class EnumListPage {
) { } ) { }
ngOnInit () { ngOnInit () {
// const range = Range.from(this.spec.range)
// this.min = range.integralMin()
// this.max = range.integralMax()
// this.minMessage = `The minimum number of ${this.key} is ${this.min}.`
// this.maxMessage = `The maximum number of ${this.key} is ${this.max}.`
for (let val of this.spec.spec.values) { for (let val of this.spec.spec.values) {
this.options[val] = this.current.includes(val) this.options[val] = this.current.includes(val)
} }

View File

@@ -9,7 +9,7 @@ import { QRComponent } from 'src/app/components/qr/qr.component'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { PackageMainStatus } from 'src/app/services/patch-db/data-model' import { PackageMainStatus } from 'src/app/services/patch-db/data-model'
import { ErrorToastService } from 'src/app/services/error-toast.service' import { ErrorToastService } from 'src/app/services/error-toast.service'
import * as JsonPointer from 'json-pointer' import { getValueByPointer } from 'fast-json-patch'
@Component({ @Component({
selector: 'app-properties', selector: 'app-properties',
@@ -49,7 +49,7 @@ export class AppPropertiesPage {
.subscribe(queryParams => { .subscribe(queryParams => {
if (queryParams['pointer'] === this.pointer) return if (queryParams['pointer'] === this.pointer) return
this.pointer = queryParams['pointer'] this.pointer = queryParams['pointer']
this.node = JsonPointer.get(this.properties, this.pointer || '') this.node = getValueByPointer(this.properties, this.pointer || '')
}), }),
this.patch.watch$('package-data', this.pkgId, 'installed', 'status', 'main', 'status') this.patch.watch$('package-data', this.pkgId, 'installed', 'status', 'main', 'status')
.subscribe(status => { .subscribe(status => {
@@ -119,7 +119,7 @@ export class AppPropertiesPage {
this.loading = true this.loading = true
try { try {
this.properties = await this.embassyApi.getPackageProperties({ id: this.pkgId }) this.properties = await this.embassyApi.getPackageProperties({ id: this.pkgId })
this.node = JsonPointer.get(this.properties, this.pointer || '') this.node = getValueByPointer(this.properties, this.pointer || '')
} catch (e) { } catch (e) {
this.errToast.present(e) this.errToast.present(e)
} finally { } finally {

View File

@@ -100,7 +100,6 @@
<ion-text [color]="!!dep.errorText ? 'warning' : 'success'">{{ dep.errorText || 'satisfied' }}</ion-text> <ion-text [color]="!!dep.errorText ? 'warning' : 'success'">{{ dep.errorText || 'satisfied' }}</ion-text>
</p> </p>
</ion-label> </ion-label>
<ion-spinner *ngIf="dep.spinnerColor" slot="end" [color]="dep.spinnerColor" style="height: 3vh; width: 3vh"></ion-spinner>
<ion-button *ngIf="dep.actionText" slot="end" fill="clear"> <ion-button *ngIf="dep.actionText" slot="end" fill="clear">
{{ dep.actionText }} {{ dep.actionText }}
<ion-icon slot="end" name="arrow-forward"></ion-icon> <ion-icon slot="end" name="arrow-forward"></ion-icon>

View File

@@ -2,13 +2,13 @@ import { Component, ViewChild } from '@angular/core'
import { AlertController, NavController, ModalController, IonContent, LoadingController } from '@ionic/angular' import { AlertController, NavController, ModalController, IonContent, LoadingController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ActivatedRoute, NavigationExtras } from '@angular/router' import { ActivatedRoute, NavigationExtras } from '@angular/router'
import { exists, isEmptyObject, Recommendation } from 'src/app/util/misc.util' import { DependentInfo, exists, isEmptyObject } from 'src/app/util/misc.util'
import { Subscription } from 'rxjs' import { combineLatest, Subscription } from 'rxjs'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component' import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards' import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { DependencyErrorConfigUnsatisfied, DependencyErrorType, HealthCheckResult, HealthResult, PackageDataEntry, PackageMainStatus, PackageState } from 'src/app/services/patch-db/data-model' import { DependencyError, DependencyErrorType, HealthCheckResult, HealthResult, PackageDataEntry, PackageMainStatus, PackageState } from 'src/app/services/patch-db/data-model'
import { DependencyStatus, HealthStatus, PrimaryRendering, PrimaryStatus, renderPkgStatus } from 'src/app/services/pkg-status-rendering.service' import { DependencyStatus, HealthStatus, PrimaryRendering, PrimaryStatus, renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
import { ConnectionFailure, ConnectionService } from 'src/app/services/connection.service' import { ConnectionFailure, ConnectionService } from 'src/app/services/connection.service'
import { ErrorToastService } from 'src/app/services/error-toast.service' import { ErrorToastService } from 'src/app/services/error-toast.service'
@@ -83,34 +83,18 @@ export class AppShowPage {
}), }),
// 2 // 2
this.patch.watch$('package-data', this.pkgId, 'installed', 'current-dependencies') combineLatest([
this.patch.watch$('package-data', this.pkgId, 'installed', 'current-dependencies'),
this.patch.watch$('package-data', this.pkgId, 'installed', 'status', 'dependency-errors'),
])
.pipe( .pipe(
filter(obj => exists(obj)), filter(([currentDeps, depErrors]) => exists(currentDeps) && exists(depErrors)),
) )
.subscribe(currentDeps => { .subscribe(([currentDeps, depErrors]) => {
// remove deleted this.dependencies = Object.keys(currentDeps)
this.dependencies.forEach((dep, i) => { .filter(id => !!this.pkg.manifest.dependencies[id])
if (!currentDeps[dep.id]) { .map(id => {
dep.sub.unsubscribe() return this.setDepValues(id, depErrors)
this.dependencies.splice(i, 1)
}
})
// subscribe
Object.keys(currentDeps)
.filter(id => {
const inManifest = !!this.pkg.manifest.dependencies[id]
const exists = this.dependencies.find(d => d.id === id)
return inManifest && !exists
})
.forEach(id => {
const version = this.pkg.manifest.dependencies[id].version
const dep = { id, version } as DependencyInfo
dep.sub = this.patch.watch$('package-data', id)
.subscribe(localDep => {
this.setDepValues(dep, localDep)
})
this.dependencies.push(dep)
}) })
}), }),
@@ -142,9 +126,6 @@ export class AppShowPage {
ngOnDestroy () { ngOnDestroy () {
this.subs.forEach(sub => sub.unsubscribe()) this.subs.forEach(sub => sub.unsubscribe())
this.dependencies.forEach(dep => {
dep.sub.unsubscribe()
})
} }
launchUi (): void { launchUi (): void {
@@ -213,7 +194,7 @@ export class AppShowPage {
} }
} }
async presentModalConfig (props: { pkgId: string, rec?: Recommendation }): Promise<void> { async presentModalConfig (props: { pkgId: string, dependentInfo?: DependentInfo }): Promise<void> {
const modal = await this.modalCtrl.create({ const modal = await this.modalCtrl.create({
component: AppConfigPage, component: AppConfigPage,
componentProps: props, componentProps: props,
@@ -221,36 +202,27 @@ export class AppShowPage {
await modal.present() await modal.present()
} }
private setDepValues (dep: DependencyInfo, localDep: PackageDataEntry | undefined): void { private setDepValues (id: string, errors: { [id: string]: DependencyError }): DependencyInfo {
let errorText = '' let errorText = ''
let spinnerColor = ''
let actionText = 'View' let actionText = 'View'
let action: () => any = () => this.navCtrl.navigateForward(`/services/${dep.id}`) let action: () => any = () => this.navCtrl.navigateForward(`/services/${id}`)
const error = this.pkg.installed.status['dependency-errors'][dep.id] const error = errors[id]
if (error) { if (error) {
// health checks failed // health checks failed
if ([DependencyErrorType.InterfaceHealthChecksFailed, DependencyErrorType.HealthChecksFailed].includes(error.type)) { if ([DependencyErrorType.InterfaceHealthChecksFailed, DependencyErrorType.HealthChecksFailed].includes(error.type)) {
errorText = 'Health check failed' errorText = 'Health check failed'
// not fully installed (same as !localDep?.installed) // not installed
} else if (error.type === DependencyErrorType.NotInstalled) { } else if (error.type === DependencyErrorType.NotInstalled) {
if (localDep) { errorText = 'Not installed'
errorText = localDep.state // 'Installing' | 'Removing' actionText = 'Install'
} else { action = () => this.fixDep('install', id)
errorText = 'Not installed'
actionText = 'Install'
action = () => this.fixDep('install', dep.id)
}
// incorrect version // incorrect version
} else if (error.type === DependencyErrorType.IncorrectVersion) { } else if (error.type === DependencyErrorType.IncorrectVersion) {
if (localDep) { errorText = 'Incorrect version'
errorText = localDep.state // 'Updating' | 'Removing' actionText = 'Update'
} else { action = () => this.fixDep('update', id)
errorText = 'Incorrect version'
actionText = 'Update'
action = () => this.fixDep('update', dep.id)
}
// not running // not running
} else if (error.type === DependencyErrorType.NotRunning) { } else if (error.type === DependencyErrorType.NotRunning) {
errorText = 'Not running' errorText = 'Not running'
@@ -259,60 +231,50 @@ export class AppShowPage {
} else if (error.type === DependencyErrorType.ConfigUnsatisfied) { } else if (error.type === DependencyErrorType.ConfigUnsatisfied) {
errorText = 'Config not satisfied' errorText = 'Config not satisfied'
actionText = 'Auto config' actionText = 'Auto config'
action = () => this.fixDep('configure', dep.id) action = () => this.fixDep('configure', id)
} else if (error.type === DependencyErrorType.Transitive) { } else if (error.type === DependencyErrorType.Transitive) {
errorText = 'Dependency has a dependency issue' errorText = 'Dependency has a dependency issue'
} }
if (localDep && localDep.state !== PackageState.Installed) {
spinnerColor = localDep.state === PackageState.Removing ? 'danger' : 'primary'
}
} }
const depInfo = this.pkg.installed['dependency-info'][dep.id] const depInfo = this.pkg.installed['dependency-info'][id]
Object.assign(dep, { return {
id,
version: this.pkg.manifest.dependencies[id].version,
title: depInfo.manifest.title, title: depInfo.manifest.title,
icon: depInfo.icon, icon: depInfo.icon,
errorText, errorText,
actionText, actionText,
spinnerColor,
action, action,
}) }
} }
private async installDep (depId: string): Promise<void> { private async installDep (depId: string): Promise<void> {
const title = this.pkg.installed['dependency-info'][depId].manifest.title
const version = this.pkg.manifest.dependencies[depId].version const version = this.pkg.manifest.dependencies[depId].version
const dependentTitle = this.pkg.manifest.title
const installRec: Recommendation = { const dependentInfo: DependentInfo = {
dependentId: this.pkgId, id: this.pkgId,
dependentTitle, title: this.pkg.manifest.title,
dependentIcon: this.pkg['static-files'].icon,
version, version,
description: `${dependentTitle} requires an install of ${title} satisfying ${version}.`,
} }
const navigationExtras: NavigationExtras = { const navigationExtras: NavigationExtras = {
state: { installRec }, state: { dependentInfo },
} }
await this.navCtrl.navigateForward(`/marketplace/${depId}`, navigationExtras) await this.navCtrl.navigateForward(`/marketplace/${depId}`, navigationExtras)
} }
private async configureDep (depId: string): Promise<void> { private async configureDep (dependencyId: string): Promise<void> {
const configRecommendation: Recommendation = { const dependentInfo: DependentInfo = {
dependentId: this.pkgId, id: this.pkgId,
dependentTitle: this.pkg.manifest.title, title: this.pkg.manifest.title,
dependentIcon: this.pkg['static-files'].icon,
description: (this.pkg.installed.status['dependency-errors'][depId] as DependencyErrorConfigUnsatisfied).error,
}
const params = {
pkgId: depId,
rec: configRecommendation,
} }
await this.presentModalConfig(params) await this.presentModalConfig({
pkgId: dependencyId,
dependentInfo,
})
} }
private async presentAlertStart (message: string): Promise<void> { private async presentAlertStart (message: string): Promise<void> {
@@ -434,10 +396,8 @@ interface DependencyInfo {
icon: string icon: string
version: string version: string
errorText: string errorText: string
spinnerColor: string
actionText: string actionText: string
action: () => any action: () => any
sub: Subscription
} }
interface Button { interface Button {

View File

@@ -72,23 +72,21 @@
</ion-row> </ion-row>
</ion-grid> </ion-grid>
<!-- recommendation --> <!-- auto-config -->
<ion-item *ngIf="rec && showRec" class="rec-item"> <ion-item lines="none" *ngIf="dependentInfo" class="rec-item">
<ion-label> <ion-label>
<h2 style="display: flex; align-items: center;"> <h2 style="display: flex; align-items: center;">
<ion-thumbnail style="height: 3vh; width: 3vh; margin: 5px" slot="start"> <ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: 18px;">{{ pkg.manifest.title }}</ion-text>
<img [src]="rec.dependentIcon" [alt]="rec.dependentTitle"/>
</ion-thumbnail>
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: smaller;">{{ rec.dependentTitle }}</ion-text>
</h2> </h2>
<div style="margin: 7px 5px;"> <p>
<p style="color: var(--ion-color-dark); font-size: small">{{ rec.description }}</p> <ion-text color="dark">
<p *ngIf="pkg.manifest.version | satisfiesEmver: rec.version" class="recommendation-text">{{ pkg.manifest.title }} version {{ pkg.manifest.version | displayEmver }} is compatible.</p> {{ dependentInfo.title }} requires an install of {{ pkg.manifest.title }} satisfying {{ dependentInfo.version }}.
<p *ngIf="!(pkg.manifest.version | satisfiesEmver: rec.version)" class="recommendation-text recommendation-error">{{ pkg.manifest.title }} version {{ pkg.manifest.version | displayEmver }} is NOT compatible.</p> <br />
<ion-button style="position: absolute; right: 0; top: 0" fill="clear" (click)="dismissRec()"> <br />
<ion-icon name="close"></ion-icon> <span *ngIf="pkg.manifest.version | satisfiesEmver: dependentInfo.version" class="recommendation-text">{{ pkg.manifest.title }} version {{ pkg.manifest.version | displayEmver }} is compatible.</span>
</ion-button> <span *ngIf="!(pkg.manifest.version | satisfiesEmver: dependentInfo.version)" class="recommendation-text recommendation-error">{{ pkg.manifest.title }} version {{ pkg.manifest.version | displayEmver }} is NOT compatible.</span>
</div> </ion-text>
</p>
</ion-label> </ion-label>
</ion-item> </ion-item>

View File

@@ -5,7 +5,7 @@ import { wizardModal } from 'src/app/components/install-wizard/install-wizard.co
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards' import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { Emver } from 'src/app/services/emver.service' import { Emver } from 'src/app/services/emver.service'
import { displayEmver } from 'src/app/pipes/emver.pipe' import { displayEmver } from 'src/app/pipes/emver.pipe'
import { pauseFor, Recommendation } from 'src/app/util/misc.util' import { DependentInfo, pauseFor } from 'src/app/util/misc.util'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service' import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ErrorToastService } from 'src/app/services/error-toast.service' import { ErrorToastService } from 'src/app/services/error-toast.service'
import { PackageDataEntry, PackageState } from 'src/app/services/patch-db/data-model' import { PackageDataEntry, PackageState } from 'src/app/services/patch-db/data-model'
@@ -27,8 +27,7 @@ export class MarketplaceShowPage {
pkg: MarketplacePkg pkg: MarketplacePkg
localPkg: PackageDataEntry localPkg: PackageDataEntry
PackageState = PackageState PackageState = PackageState
rec: Recommendation | null = null dependentInfo: DependentInfo
showRec = true
subs: Subscription[] = [] subs: Subscription[] = []
constructor ( constructor (
@@ -47,7 +46,7 @@ export class MarketplaceShowPage {
async ngOnInit () { async ngOnInit () {
this.pkgId = this.route.snapshot.paramMap.get('pkgId') this.pkgId = this.route.snapshot.paramMap.get('pkgId')
this.rec = history.state && history.state.installRec as Recommendation this.dependentInfo = history.state && history.state.dependentInfo as DependentInfo
this.subs = [ this.subs = [
this.patch.watch$('package-data', this.pkgId) this.patch.watch$('package-data', this.pkgId)
@@ -171,10 +170,6 @@ export class MarketplaceShowPage {
this.navCtrl.back() this.navCtrl.back()
} }
dismissRec () {
this.showRec = false
}
private async getPkg (version?: string): Promise<void> { private async getPkg (version?: string): Promise<void> {
this.loading = true this.loading = true
try { try {

View File

@@ -84,7 +84,7 @@ export class ServerBackupPage {
take(1), take(1),
) )
.subscribe(pkgs => { .subscribe(pkgs => {
const pkgArr = Object.values(pkgs) const pkgArr = Object.keys(pkgs).sort().map(key => pkgs[key])
const activeIndex = pkgArr.findIndex(pkg => pkg.installed.status.main.status === PackageMainStatus.BackingUp) const activeIndex = pkgArr.findIndex(pkg => pkg.installed.status.main.status === PackageMainStatus.BackingUp)
this.pkgs = pkgArr.map((pkg, i) => { this.pkgs = pkgArr.map((pkg, i) => {

View File

@@ -1,5 +1,6 @@
import { DependencyErrorType, DockerIoFormat, Manifest, PackageDataEntry, PackageMainStatus, PackageState } from 'src/app/services/patch-db/data-model' import { DependencyErrorType, DockerIoFormat, Manifest, PackageDataEntry, PackageMainStatus, PackageState } from 'src/app/services/patch-db/data-model'
import { Log, MarketplacePkg, Metric, NotificationLevel, RR, ServerNotifications } from './api.types' import { Log, MarketplacePkg, Metric, NotificationLevel, RR, ServerNotifications } from './api.types'
import { Operation } from 'fast-json-patch'
export module Mock { export module Mock {
@@ -1081,405 +1082,21 @@ export module Mock {
}, },
} as any // @TODO why is this necessary? } as any // @TODO why is this necessary?
export const PackageConfig: RR.GetPackageConfigRes = { export const ConfigSpec: RR.GetPackageConfigRes['spec'] = {
// config spec 'testnet': {
spec: { 'name': 'Testnet',
'testnet': { 'type': 'boolean',
'name': 'Testnet', 'description': 'determines whether your node is running on testnet or mainnet',
'type': 'boolean', 'warning': 'Chain will have to resync!',
'description': 'determines whether your node is running on testnet or mainnet', 'default': true,
'warning': 'Chain will have to resync!',
'default': true,
},
'object-list': {
'name': 'Object List',
'type': 'list',
'subtype': 'object',
'description': 'This is a list of objects, like users or something',
'range': '[0,4]',
'default': [
{
'first-name': 'Admin',
'last-name': 'User',
'age': 40,
},
{
'first-name': 'Admin2',
'last-name': 'User',
'age': 40,
},
],
// the outer spec here, at the list level, says that what's inside (the inner spec) pertains to its inner elements.
// it just so happens that ValueSpecObject's have the field { spec: ConfigSpec }
// see 'union-list' below for a different example.
'spec': {
'unique-by': 'last-name',
'display-as': `I'm {{last-name}}, {{first-name}} {{last-name}}`,
'spec': {
'first-name': {
'name': 'First Name',
'type': 'string',
'description': 'User first name',
'nullable': true,
'default': null,
'masked': false,
'copyable': false,
},
'last-name': {
'name': 'Last Name',
'type': 'string',
'description': 'User first name',
'nullable': true,
'default': {
'charset': 'a-g,2-9',
'len': 12,
},
'pattern': '^[a-zA-Z]+$',
'pattern-description': 'must contain only letters.',
'masked': false,
'copyable': true,
},
'age': {
'name': 'Age',
'type': 'number',
'description': 'The age of the user',
'nullable': true,
'default': null,
'integral': false,
'warning': 'User must be at least 18.',
'range': '[18,*)',
},
},
},
},
'union-list': {
'name': 'Union List',
'type': 'list',
'subtype': 'union',
'description': 'This is a sample list of unions',
'warning': 'If you change this, things may work.',
// a list of union selections. e.g. 'summer', 'winter',...
'default': [
'summer',
],
'range': '[0, 2]',
'spec': {
'tag': {
'id': 'preference',
'name': 'Preferences',
'variant-names': {
'summer': 'Summer',
'winter': 'Winter',
'other': 'Other',
},
},
// this default is used to make a union selection when a new list element is first created
'default': 'summer',
'variants': {
'summer': {
'favorite-tree': {
'name': 'Favorite Tree',
'type': 'string',
'nullable': false,
'description': 'What is your favorite tree?',
'default': 'Maple',
'masked': false,
'copyable': false,
},
'favorite-flower': {
'name': 'Favorite Flower',
'type': 'enum',
'description': 'Select your favorite flower',
'value-names': {
'none': 'Hate Flowers',
'red': 'Red',
'blue': 'Blue',
'purple': 'Purple',
},
'values': [
'none',
'red',
'blue',
'purple',
],
'default': 'none',
},
},
'winter': {
'like-snow': {
'name': 'Like Snow?',
'type': 'boolean',
'description': 'Do you like snow or not?',
'default': true,
},
},
},
'unique-by': 'preference',
},
},
'random-enum': {
'name': 'Random Enum',
'type': 'enum',
'value-names': {
'null': 'Null',
'option1': 'One 1',
'option2': 'Two 2',
'option3': 'Three 3',
},
'default': 'null',
'description': 'This is not even real.',
'warning': 'Be careful changing this!',
'values': [
'null',
'option1',
'option2',
'option3',
],
},
'favorite-number': {
'name': 'Favorite Number',
'type': 'number',
'integral': false,
'description': 'Your favorite number of all time',
'warning': 'Once you set this number, it can never be changed without severe consequences.',
'nullable': true,
'default': 7,
'range': '(-100,100]',
'units': 'BTC',
},
'unlucky-numbers': {
'name': 'Unlucky Numbers',
'type': 'list',
'subtype': 'number',
'description': 'Numbers that you like but are not your top favorite.',
'spec': {
'integral': false,
'range': '[-100,200)',
},
'range': '[0,10]',
'default': [
2,
3,
],
},
'rpcsettings': {
'name': 'RPC Settings',
'type': 'object',
'unique-by': null,
'description': 'rpc username and password',
'warning': 'Adding RPC users gives them special permissions on your node.',
'spec': {
'laws': {
'name': 'Laws',
'type': 'object',
'unique-by': 'law1',
'description': 'the law of the realm',
'spec': {
'law1': {
'name': 'First Law',
'type': 'string',
'description': 'the first law',
'nullable': true,
'masked': false,
'copyable': true,
},
'law2': {
'name': 'Second Law',
'type': 'string',
'description': 'the second law',
'nullable': true,
'masked': false,
'copyable': true,
},
},
},
'rulemakers': {
'name': 'Rule Makers',
'type': 'list',
'subtype': 'object',
'description': 'the people who make the rules',
'range': '[0,2]',
'default': [],
'spec': {
'unique-by': null,
'spec': {
'rulemakername': {
'name': 'Rulemaker Name',
'type': 'string',
'description': 'the name of the rule maker',
'nullable': false,
'default': {
'charset': 'a-g,2-9',
'len': 12,
},
'masked': false,
'copyable': false,
},
'rulemakerip': {
'name': 'Rulemaker IP',
'type': 'string',
'description': 'the ip of the rule maker',
'nullable': false,
'default': '192.168.1.0',
'pattern': '^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$',
'pattern-description': 'may only contain numbers and periods',
'masked': false,
'copyable': true,
},
},
},
},
'rpcuser': {
'name': 'RPC Username',
'type': 'string',
'description': 'rpc username',
'nullable': false,
'default': 'defaultrpcusername',
'pattern': '^[a-zA-Z]+$',
'pattern-description': 'must contain only letters.',
'masked': false,
'copyable': true,
},
'rpcpass': {
'name': 'RPC User Password',
'type': 'string',
'description': 'rpc password',
'nullable': false,
'default': {
'charset': 'a-z,A-Z,2-9',
'len': 20,
},
'masked': true,
'copyable': true,
},
},
},
'advanced': {
'name': 'Advanced',
'type': 'object',
'unique-by': null,
'description': 'Advanced settings',
'spec': {
'notifications': {
'name': 'Notification Preferences',
'type': 'list',
'subtype': 'enum',
'description': 'how you want to be notified',
'range': '[1,3]',
'default': [
'email',
],
'spec': {
'value-names': {
'email': 'EEEEmail',
'text': 'Texxxt',
'call': 'Ccccall',
'push': 'PuuuusH',
'webhook': 'WebHooookkeee',
},
'values': [
'email',
'text',
'call',
'push',
'webhook',
],
},
},
},
},
'bitcoin-node': {
'name': 'Bitcoin Node Settings',
'type': 'union',
'unique-by': null,
'description': 'The node settings',
'default': 'internal',
'warning': 'Careful changing this',
'tag': {
'id': 'type',
'name': 'Type',
'variant-names': {
'internal': 'Internal',
'external': 'External',
},
},
'variants': {
'internal': {
'lan-address': {
'name': 'LAN Address',
'type': 'pointer',
'subtype': 'app',
'target': 'lan-address',
'app-id': 'bitcoind',
'description': 'the lan address',
},
},
'external': {
'public-domain': {
'name': 'Public Domain',
'type': 'string',
'description': 'the public address of the node',
'nullable': false,
'default': 'bitcoinnode.com',
'pattern': '.*',
'pattern-description': 'anything',
'masked': false,
'copyable': true,
},
},
},
},
'port': {
'name': 'Port',
'type': 'number',
'integral': true,
'description': 'the default port for your Bitcoin node. default: 8333, testnet: 18333, regtest: 18444',
'nullable': false,
'default': 8333,
'range': '(0, 9998]',
},
'favorite-slogan': {
'name': 'Favorite Slogan',
'type': 'string',
'description': 'You most favorite slogan in the whole world, used for paying you.',
'nullable': true,
'masked': true,
'copyable': true,
},
'rpcallowip': {
'name': 'RPC Allowed IPs',
'type': 'list',
'subtype': 'string',
'description': 'external ip addresses that are authorized to access your Bitcoin node',
'warning': 'Any IP you allow here will have RPC access to your Bitcoin node.',
'range': '[1,10]',
'default': [
'192.168.1.1',
],
'spec': {
'masked': false,
'copyable': false,
'pattern': '((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|((^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$)|(^[a-z2-7]{16}\\.onion$)|(^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$))',
'pattern-description': 'must be a valid ipv4, ipv6, or domain name',
},
},
'rpcauth': {
'name': 'RPC Auth',
'type': 'list',
'subtype': 'string',
'description': 'api keys that are authorized to access your Bitcoin node.',
'range': '[0,*)',
'default': [],
'spec': {
'masked': false,
'copyable': false,
},
},
}, },
// actual config 'object-list': {
config: { 'name': 'Object List',
testnet: false, 'type': 'list',
'object-list': [ 'subtype': 'object',
'description': 'This is a list of objects, like users or something',
'range': '[0,4]',
'default': [
{ {
'first-name': 'Admin', 'first-name': 'Admin',
'last-name': 'User', 'last-name': 'User',
@@ -1491,51 +1108,462 @@ export module Mock {
'age': 40, 'age': 40,
}, },
], ],
'union-list': undefined, // the outer spec here, at the list level, says that what's inside (the inner spec) pertains to its inner elements.
'random-enum': 'option1', // it just so happens that ValueSpecObject's have the field { spec: ConfigSpec }
'favorite-number': null, // see 'union-list' below for a different example.
'secondary-numbers': undefined, 'spec': {
rpcsettings: { 'unique-by': 'last-name',
laws: { 'display-as': `I'm {{last-name}}, {{first-name}} {{last-name}}`,
law1: 'The first law', 'spec': {
law2: 'The second law', 'first-name': {
'name': 'First Name',
'type': 'string',
'description': 'User first name',
'nullable': true,
'default': null,
'masked': false,
'copyable': false,
},
'last-name': {
'name': 'Last Name',
'type': 'string',
'description': 'User first name',
'nullable': true,
'default': {
'charset': 'a-g,2-9',
'len': 12,
},
'pattern': '^[a-zA-Z]+$',
'pattern-description': 'must contain only letters.',
'masked': false,
'copyable': true,
},
'age': {
'name': 'Age',
'type': 'number',
'description': 'The age of the user',
'nullable': true,
'default': null,
'integral': false,
'warning': 'User must be at least 18.',
'range': '[18,*)',
},
}, },
rpcpass: null,
rpcuser: '123',
rulemakers: [],
}, },
advanced: { },
notifications: ['email'], 'union-list': {
'name': 'Union List',
'type': 'list',
'subtype': 'union',
'description': 'This is a sample list of unions',
'warning': 'If you change this, things may work.',
// a list of union selections. e.g. 'summer', 'winter',...
'default': [
'summer',
],
'range': '[0, 2]',
'spec': {
'tag': {
'id': 'preference',
'name': 'Preferences',
'variant-names': {
'summer': 'Summer',
'winter': 'Winter',
'other': 'Other',
},
},
// this default is used to make a union selection when a new list element is first created
'default': 'summer',
'variants': {
'summer': {
'favorite-tree': {
'name': 'Favorite Tree',
'type': 'string',
'nullable': false,
'description': 'What is your favorite tree?',
'default': 'Maple',
'masked': false,
'copyable': false,
},
'favorite-flower': {
'name': 'Favorite Flower',
'type': 'enum',
'description': 'Select your favorite flower',
'value-names': {
'none': 'Hate Flowers',
'red': 'Red',
'blue': 'Blue',
'purple': 'Purple',
},
'values': [
'none',
'red',
'blue',
'purple',
],
'default': 'none',
},
},
'winter': {
'like-snow': {
'name': 'Like Snow?',
'type': 'boolean',
'description': 'Do you like snow or not?',
'default': true,
},
},
},
'unique-by': 'preference',
},
},
'random-enum': {
'name': 'Random Enum',
'type': 'enum',
'value-names': {
'null': 'Null',
'option1': 'One 1',
'option2': 'Two 2',
'option3': 'Three 3',
},
'default': 'null',
'description': 'This is not even real.',
'warning': 'Be careful changing this!',
'values': [
'null',
'option1',
'option2',
'option3',
],
},
'favorite-number': {
'name': 'Favorite Number',
'type': 'number',
'integral': false,
'description': 'Your favorite number of all time',
'warning': 'Once you set this number, it can never be changed without severe consequences.',
'nullable': true,
'default': 7,
'range': '(-100,100]',
'units': 'BTC',
},
'unlucky-numbers': {
'name': 'Unlucky Numbers',
'type': 'list',
'subtype': 'number',
'description': 'Numbers that you like but are not your top favorite.',
'spec': {
'integral': false,
'range': '[-100,200)',
},
'range': '[0,10]',
'default': [
2,
3,
],
},
'rpcsettings': {
'name': 'RPC Settings',
'type': 'object',
'unique-by': null,
'description': 'rpc username and password',
'warning': 'Adding RPC users gives them special permissions on your node.',
'spec': {
'laws': {
'name': 'Laws',
'type': 'object',
'unique-by': 'law1',
'description': 'the law of the realm',
'spec': {
'law1': {
'name': 'First Law',
'type': 'string',
'description': 'the first law',
'nullable': true,
'masked': false,
'copyable': true,
},
'law2': {
'name': 'Second Law',
'type': 'string',
'description': 'the second law',
'nullable': true,
'masked': false,
'copyable': true,
},
},
},
'rulemakers': {
'name': 'Rule Makers',
'type': 'list',
'subtype': 'object',
'description': 'the people who make the rules',
'range': '[0,2]',
'default': [],
'spec': {
'unique-by': null,
'spec': {
'rulemakername': {
'name': 'Rulemaker Name',
'type': 'string',
'description': 'the name of the rule maker',
'nullable': false,
'default': {
'charset': 'a-g,2-9',
'len': 12,
},
'masked': false,
'copyable': false,
},
'rulemakerip': {
'name': 'Rulemaker IP',
'type': 'string',
'description': 'the ip of the rule maker',
'nullable': false,
'default': '192.168.1.0',
'pattern': '^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$',
'pattern-description': 'may only contain numbers and periods',
'masked': false,
'copyable': true,
},
},
},
},
'rpcuser': {
'name': 'RPC Username',
'type': 'string',
'description': 'rpc username',
'nullable': false,
'default': 'defaultrpcusername',
'pattern': '^[a-zA-Z]+$',
'pattern-description': 'must contain only letters.',
'masked': false,
'copyable': true,
},
'rpcpass': {
'name': 'RPC User Password',
'type': 'string',
'description': 'rpc password',
'nullable': false,
'default': {
'charset': 'a-z,A-Z,2-9',
'len': 20,
},
'masked': true,
'copyable': true,
},
},
},
'advanced': {
'name': 'Advanced',
'type': 'object',
'unique-by': null,
'description': 'Advanced settings',
'spec': {
'notifications': {
'name': 'Notification Preferences',
'type': 'list',
'subtype': 'enum',
'description': 'how you want to be notified',
'range': '[1,3]',
'default': [
'email',
],
'spec': {
'value-names': {
'email': 'EEEEmail',
'text': 'Texxxt',
'call': 'Ccccall',
'push': 'PuuuusH',
'webhook': 'WebHooookkeee',
},
'values': [
'email',
'text',
'call',
'push',
'webhook',
],
},
},
},
},
'bitcoin-node': {
'name': 'Bitcoin Node Settings',
'type': 'union',
'unique-by': null,
'description': 'The node settings',
'default': 'internal',
'warning': 'Careful changing this',
'tag': {
'id': 'type',
'name': 'Type',
'variant-names': {
'internal': 'Internal',
'external': 'External',
},
},
'variants': {
'internal': {
'lan-address': {
'name': 'LAN Address',
'type': 'pointer',
'subtype': 'app',
'target': 'lan-address',
'app-id': 'bitcoind',
'description': 'the lan address',
},
},
'external': {
'public-domain': {
'name': 'Public Domain',
'type': 'string',
'description': 'the public address of the node',
'nullable': false,
'default': 'bitcoinnode.com',
'pattern': '.*',
'pattern-description': 'anything',
'masked': false,
'copyable': true,
},
},
},
},
'port': {
'name': 'Port',
'type': 'number',
'integral': true,
'description': 'the default port for your Bitcoin node. default: 8333, testnet: 18333, regtest: 18444',
'nullable': false,
'default': 8333,
'range': '(0, 9998]',
},
'favorite-slogan': {
'name': 'Favorite Slogan',
'type': 'string',
'description': 'You most favorite slogan in the whole world, used for paying you.',
'nullable': true,
'masked': true,
'copyable': true,
},
'rpcallowip': {
'name': 'RPC Allowed IPs',
'type': 'list',
'subtype': 'string',
'description': 'external ip addresses that are authorized to access your Bitcoin node',
'warning': 'Any IP you allow here will have RPC access to your Bitcoin node.',
'range': '[1,10]',
'default': [
'192.168.1.1',
],
'spec': {
'masked': false,
'copyable': false,
'pattern': '((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|((^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]).){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$)|(^[a-z2-7]{16}\\.onion$)|(^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$))',
'pattern-description': 'must be a valid ipv4, ipv6, or domain name',
},
},
'rpcauth': {
'name': 'RPC Auth',
'type': 'list',
'subtype': 'string',
'description': 'api keys that are authorized to access your Bitcoin node.',
'range': '[0,*)',
'default': [],
'spec': {
'masked': false,
'copyable': false,
}, },
'bitcoin-node': undefined,
port: 5959,
rpcallowip: undefined,
rpcauth: ['matt: 8273gr8qwoidm1uid91jeh8y23gdio1kskmwejkdnm'],
}, },
} }
export const mockCupsDependentConfig = { export const MockConfig = {
'random-enum': 'option1',
testnet: false, testnet: false,
'favorite-number': 8, 'object-list': [
'secondary-numbers': [13, 58, 20], {
'object-list': [], 'first-name': 'First',
'union-list': [], 'last-name': 'Last',
'age': 30,
},
{
'first-name': 'First2',
'last-name': 'Last2',
'age': 40,
},
],
'union-list': undefined,
'random-enum': 'option1',
'favorite-number': null,
rpcsettings: { rpcsettings: {
laws: null, laws: {
law1: 'The first law',
law2: 'The second law',
},
rpcpass: null, rpcpass: null,
rpcuser: '123', rpcuser: '123',
rulemakers: [], rulemakers: [],
}, },
advanced: { advanced: {
notifications: [], notifications: ['email', 'text'],
}, },
'bitcoin-Node': { type: 'internal' }, 'bitcoin-node': undefined,
port: 5959, port: 5959,
rpcallowip: [], rpcallowip: undefined,
rpcauth: ['matt: 8273gr8qwoidm1uid91jeh8y23gdio1kskmwejkdnm'], rpcauth: ['matt: 8273gr8qwoidm1uid91jeh8y23gdio1kskmwejkdnm'],
} }
export const MockDependencyConfig = {
testnet: true,
'object-list': [
{
'first-name': 'First',
'last-name': 'Last',
'age': 30,
},
{
'first-name': 'First2',
'last-name': 'Last2',
'age': 40,
},
{
'first-name': 'First3',
'last-name': 'Last3',
'age': 60,
},
],
'union-list': undefined,
'random-enum': 'option2',
'favorite-number': null,
rpcsettings: {
laws: {
law1: 'The first law Amended',
law2: 'The second law',
},
rpcpass: null,
rpcuser: '123',
rulemakers: [],
},
advanced: {
notifications: ['email', 'text', 'push'],
},
'bitcoin-node': undefined,
port: 20,
rpcallowip: undefined,
rpcauth: ['matt: 8273gr8qwoidm1uid91jeh8y23gdio1kskmwejkdnm'],
}
export const Patch: Operation[] = [
{
op: 'replace',
path: '/testnet',
value: true,
},
{
op: 'add',
path: '/advanced/notifications/1',
value: 'text',
},
]
export const bitcoind: PackageDataEntry = { export const bitcoind: PackageDataEntry = {
state: PackageState.Installed, state: PackageState.Installed,
'static-files': { 'static-files': {

View File

@@ -3,7 +3,7 @@ import { pauseFor } from '../../util/misc.util'
import { ApiService } from './embassy-api.service' import { ApiService } from './embassy-api.service'
import { PatchOp } from 'patch-db-client' import { PatchOp } from 'patch-db-client'
import { DependencyErrorType, InstallProgress, PackageDataEntry, PackageMainStatus, PackageState, ServerStatus } from 'src/app/services/patch-db/data-model' import { DependencyErrorType, InstallProgress, PackageDataEntry, PackageMainStatus, PackageState, ServerStatus } from 'src/app/services/patch-db/data-model'
import { RR, WithRevision } from './api.types' import { Log, RR, WithRevision } from './api.types'
import { parsePropertiesPermissive } from 'src/app/util/properties.util' import { parsePropertiesPermissive } from 'src/app/util/properties.util'
import { Mock } from './api.fixures' import { Mock } from './api.fixures'
import { HttpService } from '../http.service' import { HttpService } from '../http.service'
@@ -75,7 +75,7 @@ export class MockApiService extends ApiService {
async getServerLogs (params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes> { async getServerLogs (params: RR.GetServerLogsReq): Promise<RR.GetServerLogsRes> {
await pauseFor(2000) await pauseFor(2000)
let entries let entries: Log[]
if (Math.random() < .2) { if (Math.random() < .2) {
entries = Mock.ServerLogs entries = Mock.ServerLogs
} else { } else {
@@ -390,7 +390,10 @@ export class MockApiService extends ApiService {
async getPackageConfig (params: RR.GetPackageConfigReq): Promise<RR.GetPackageConfigRes> { async getPackageConfig (params: RR.GetPackageConfigReq): Promise<RR.GetPackageConfigRes> {
await pauseFor(2000) await pauseFor(2000)
return Mock.PackageConfig return {
config: Mock.MockConfig,
spec: Mock.ConfigSpec,
}
} }
async drySetPackageConfig (params: RR.DrySetPackageConfigReq): Promise<RR.DrySetPackageConfigRes> { async drySetPackageConfig (params: RR.DrySetPackageConfigReq): Promise<RR.DrySetPackageConfigRes> {
@@ -542,32 +545,9 @@ export class MockApiService extends ApiService {
async dryConfigureDependency (params: RR.DryConfigureDependencyReq): Promise<RR.DryConfigureDependencyRes> { async dryConfigureDependency (params: RR.DryConfigureDependencyReq): Promise<RR.DryConfigureDependencyRes> {
await pauseFor(2000) await pauseFor(2000)
return { return {
'old-config': Mock.PackageConfig.config, 'old-config': Mock.MockConfig,
spec: Mock.PackageConfig.spec, 'new-config': Mock.MockDependencyConfig,
'new-config': { spec: Mock.ConfigSpec,
testnet: true,
// objectList: [],
// unionList: [],
randomEnum: 'option2',
favoriteNumber: 9,
secondaryNumbers: [2, 3, 5, 6],
rpcsettings: {
laws: {
law1: 'The 1st law',
law2: 'The 2nd law',
},
rpcpass: null,
rpcuser: '123',
rulemakers: [],
},
advanced: {
notifications: ['call', 'text'],
},
// bitcoinNode: undefined,
port: 22,
// rpcallowip: undefined,
// rpcauth: ['matt: 8273gr8qwoidm1uid91jeh8y23gdio1kskmwejkdnm'],
},
} }
} }

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { AbstractControl, FormArray, FormBuilder, FormControl, FormGroup, ValidationErrors, ValidatorFn, Validators } from '@angular/forms' import { AbstractControl, FormArray, FormBuilder, FormControl, FormGroup, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'
import { ConfigSpec, isValueSpecListOf, ListValueSpecNumber, ListValueSpecObject, ListValueSpecString, ListValueSpecUnion, UniqueBy, ValueSpec, ValueSpecEnum, ValueSpecList, ValueSpecListOf, ValueSpecNumber, ValueSpecObject, ValueSpecString, ValueSpecUnion } from '../pkg-config/config-types' import { ConfigSpec, isValueSpecListOf, ListValueSpecNumber, ListValueSpecObject, ListValueSpecOf, ListValueSpecString, ListValueSpecUnion, UniqueBy, ValueSpec, ValueSpecEnum, ValueSpecList, ValueSpecNumber, ValueSpecObject, ValueSpecString, ValueSpecUnion } from '../pkg-config/config-types'
import { getDefaultString, Range } from '../pkg-config/config-utilities' import { getDefaultString, Range } from '../pkg-config/config-utilities'
const Mustache = require('mustache') const Mustache = require('mustache')
@@ -13,8 +13,8 @@ export class FormService {
private readonly formBuilder: FormBuilder, private readonly formBuilder: FormBuilder,
) { } ) { }
createForm (config: ConfigSpec, current: { [key: string]: any } = { }): FormGroup { createForm (spec: ConfigSpec, current: { [key: string]: any } = { }): FormGroup {
return this.getFormGroup(config, [], current) return this.getFormGroup(spec, [], current)
} }
getUnionObject (spec: ValueSpecUnion | ListValueSpecUnion, selection: string, current?: { [key: string]: any }): FormGroup { getUnionObject (spec: ValueSpecUnion | ListValueSpecUnion, selection: string, current?: { [key: string]: any }): FormGroup {
@@ -60,12 +60,12 @@ export class FormService {
let group = { } let group = { }
Object.entries(config).map(([key, spec]) => { Object.entries(config).map(([key, spec]) => {
if (spec.type === 'pointer') return if (spec.type === 'pointer') return
group[key] = this.getFormEntry(key, spec, current ? current[key] : { }) group[key] = this.getFormEntry(spec, current ? current[key] : undefined)
}) })
return this.formBuilder.group(group, { validators } ) return this.formBuilder.group(group, { validators } )
} }
private getFormEntry (key: string, spec: ValueSpec, currentValue: any): FormGroup | FormArray | FormControl { private getFormEntry (spec: ValueSpec, currentValue?: any): FormGroup | FormArray | FormControl {
let validators: ValidatorFn[] let validators: ValidatorFn[]
let value: any let value: any
switch (spec.type) { switch (spec.type) {
@@ -89,7 +89,7 @@ export class FormService {
return this.getFormGroup(spec.spec, [], currentValue) return this.getFormGroup(spec.spec, [], currentValue)
case 'list': case 'list':
validators = this.listValidators(spec) validators = this.listValidators(spec)
const mapped = (Array.isArray(currentValue) ? currentValue : spec.default as any[]).map((entry: any, index) => { const mapped = (Array.isArray(currentValue) ? currentValue : spec.default as any[]).map(entry => {
return this.getListItem(spec, entry) return this.getListItem(spec, entry)
}) })
return this.formBuilder.array(mapped, validators) return this.formBuilder.array(mapped, validators)
@@ -139,6 +139,8 @@ export class FormService {
validators.push(listInRange(spec.range)) validators.push(listInRange(spec.range))
validators.push(listItemIssue())
if (!isValueSpecListOf(spec, 'enum')) { if (!isValueSpecListOf(spec, 'enum')) {
validators.push(listUnique(spec)) validators.push(listUnique(spec))
} }
@@ -181,29 +183,40 @@ export function isInteger (): ValidatorFn {
} }
export function listInRange (stringRange: string): ValidatorFn { export function listInRange (stringRange: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => { return (control: FormArray): ValidationErrors | null => {
try { try {
Range.from(stringRange).checkIncludes(control.value.length) Range.from(stringRange).checkIncludes(control.value.length)
return null return null
} catch (e) { } catch (e) {
return { numberNotInRange: { value: `List must be ${e.message}` } } return { listNotInRange: { value: `List must be ${e.message}` } }
}
}
}
export function listItemIssue (): ValidatorFn {
return (parentControl: FormArray): ValidationErrors | null => {
const problemChild = parentControl.controls.find(c => c.invalid)
if (problemChild) {
return { listItemIssue: { value: 'Invalid entries' } }
} else {
return null
} }
} }
} }
export function listUnique (spec: ValueSpecList): ValidatorFn { export function listUnique (spec: ValueSpecList): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => { return (control: FormArray): ValidationErrors | null => {
for (let idx = 0; idx < control.value.length; idx++) { const list = control.value
for (let idx2 = idx + 1; idx2 < control.value.length; idx2++) { for (let idx = 0; idx < list.length; idx++) {
if (listItemEquals(spec, control.value[idx], control.value[idx2])) { for (let idx2 = idx + 1; idx2 < list.length; idx2++) {
if (listItemEquals(spec, list[idx], list[idx2])) {
let display1: string let display1: string
let display2: string let display2: string
let uniqueMessage = isObjectOrUnion(spec.spec) ? uniqueByMessageWrapper(spec.spec['unique-by'], spec.spec, control.value[idx]) : '' let uniqueMessage = isObjectOrUnion(spec.spec) ? uniqueByMessageWrapper(spec.spec['unique-by'], spec.spec, list[idx]) : ''
if (isObjectOrUnion(spec.spec) && spec.spec['display-as']) { if (isObjectOrUnion(spec.spec) && spec.spec['display-as']) {
display1 = `"${(Mustache as any).render(spec.spec['display-as'], control.value[idx])}"` display1 = `"${(Mustache as any).render(spec.spec['display-as'], list[idx])}"`
display2 = `"${(Mustache as any).render(spec.spec['display-as'], control.value[idx2])}"` display2 = `"${(Mustache as any).render(spec.spec['display-as'], list[idx2])}"`
} else { } else {
display1 = `Entry ${idx + 1}` display1 = `Entry ${idx + 1}`
display2 = `Entry ${idx2 + 1}` display2 = `Entry ${idx2 + 1}`
@@ -367,7 +380,7 @@ function uniqueByMessage (uniqueBy: UniqueBy, configSpec: ConfigSpec, outermost
return outermost || subSpecs.filter(ss => ss).length === 1 ? ret : '(' + ret + ')' return outermost || subSpecs.filter(ss => ss).length === 1 ? ret : '(' + ret + ')'
} }
function isObjectOrUnion (spec: any): spec is ListValueSpecObject | ListValueSpecUnion { function isObjectOrUnion (spec: ListValueSpecOf<any>): spec is ListValueSpecObject | ListValueSpecUnion {
// only lists of objects and unions have unique-by // only lists of objects and unions have unique-by
return spec['unique-by'] !== undefined return spec['unique-by'] !== undefined
} }

View File

@@ -5,11 +5,9 @@ import { DriveInfo, PartitionInfo } from '../services/api/api.types'
export type Omit<ObjectType, KeysType extends keyof ObjectType> = Pick<ObjectType, Exclude<keyof ObjectType, KeysType>> export type Omit<ObjectType, KeysType extends keyof ObjectType> = Pick<ObjectType, Exclude<keyof ObjectType, KeysType>>
export type PromiseRes<T> = { result: 'resolve', value: T } | { result: 'reject', value: Error } export type PromiseRes<T> = { result: 'resolve', value: T } | { result: 'reject', value: Error }
export type Recommendation = { export interface DependentInfo {
dependentId: string id: string
dependentTitle: string title: string
dependentIcon: string,
description: string
version?: string version?: string
} }

View File

@@ -1,5 +1,5 @@
import * as Ajv from 'ajv' import * as Ajv from 'ajv'
import { JsonPointer } from 'jsonpointerx' import { applyOperation } from 'fast-json-patch'
const ajv = new Ajv({ jsonPointers: true, allErrors: true, nullable: true }) const ajv = new Ajv({ jsonPointers: true, allErrors: true, nullable: true })
const ajvWithDefaults = new Ajv({ jsonPointers: true, allErrors: true, useDefaults: true, nullable: true, removeAdditional: 'failing' }) const ajvWithDefaults = new Ajv({ jsonPointers: true, allErrors: true, useDefaults: true, nullable: true, removeAdditional: 'failing' })
@@ -88,7 +88,7 @@ function parsePropertiesV2Permissive (properties: PackagePropertiesV2, errorCall
for (let err of schemaV2Compiled.errors) { for (let err of schemaV2Compiled.errors) {
errorCallback(new Error(`/data/${idx}${err.dataPath}: ${err.message}`)) errorCallback(new Error(`/data/${idx}${err.dataPath}: ${err.message}`))
if (err.dataPath) { if (err.dataPath) {
JsonPointer.set(value, err.dataPath, undefined) applyOperation(value, { op: 'replace', path: err.dataPath, value: undefined })
} }
} }
if (!schemaV2CompiledWithDefaults(value)) { if (!schemaV2CompiledWithDefaults(value)) {