mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 20:14:49 +00:00
Subnav (#391)
* begin subnav implementation * implement subnav AND angular forms for comparison * unions working-ish, list of enums working * new form approach almost complete * finish new forms approach for action inputs and config * expandable list items and handlebars display * Config animation (#394) * config cammel * config animation Co-authored-by: Drew Ansbacher <drew.ansbacher@spiredigital.com> * improve server settings inputs, still needs work * delete all notifications, styling, and bugs * contracted by default Co-authored-by: Drew Ansbacher <drew.ansbacher@gmail.com> Co-authored-by: Drew Ansbacher <drew.ansbacher@spiredigital.com>
This commit is contained in:
committed by
Aiden McClelland
parent
a43ff976a2
commit
5741cf084f
@@ -880,8 +880,8 @@ export module Mock {
|
||||
'name': 'Testnet',
|
||||
'type': 'boolean',
|
||||
'description': 'determines whether your node is running on testnet or mainnet',
|
||||
'changeWarning': 'Chain will have to resync!',
|
||||
'default': false,
|
||||
'change-warning': 'Chain will have to resync!',
|
||||
'default': true,
|
||||
},
|
||||
'objectList': {
|
||||
'name': 'Object List',
|
||||
@@ -905,8 +905,8 @@ export module Mock {
|
||||
// it just so happens that ValueSpecObject's have the field { spec: ConfigSpec }
|
||||
// see 'unionList' below for a different example.
|
||||
'spec': {
|
||||
'uniqueBy': 'lastName',
|
||||
'displayAs': `I'm {{lastName}}, {{firstName}} {{lastName}}`,
|
||||
'unique-by': 'lastName',
|
||||
'display-as': `I'm {{lastName}}, {{firstName}} {{lastName}}`,
|
||||
'spec': {
|
||||
'firstName': {
|
||||
'name': 'First Name',
|
||||
@@ -927,7 +927,7 @@ export module Mock {
|
||||
'len': 12,
|
||||
},
|
||||
'pattern': '^[a-zA-Z]+$',
|
||||
'patternDescription': 'must contain only letters.',
|
||||
'pattern-description': 'must contain only letters.',
|
||||
'masked': false,
|
||||
'copyable': true,
|
||||
},
|
||||
@@ -938,7 +938,7 @@ export module Mock {
|
||||
'nullable': true,
|
||||
'default': null,
|
||||
'integral': false,
|
||||
'changeWarning': 'User must be at least 18.',
|
||||
'change-warning': 'User must be at least 18.',
|
||||
'range': '[18,*)',
|
||||
},
|
||||
},
|
||||
@@ -949,7 +949,7 @@ export module Mock {
|
||||
'type': 'list',
|
||||
'subtype': 'union',
|
||||
'description': 'This is a sample list of unions',
|
||||
'changeWarning': 'If you change this, things may work.',
|
||||
'change-warning': 'If you change this, things may work.',
|
||||
// a list of union selections. e.g. 'summer', 'winter',...
|
||||
'default': [
|
||||
'summer',
|
||||
@@ -959,7 +959,7 @@ export module Mock {
|
||||
'tag': {
|
||||
'id': 'preference',
|
||||
'name': 'Preferences',
|
||||
'variantNames': {
|
||||
'variant-names': {
|
||||
'summer': 'Summer',
|
||||
'winter': 'Winter',
|
||||
'other': 'Other',
|
||||
@@ -982,7 +982,7 @@ export module Mock {
|
||||
'name': 'Favorite Flower',
|
||||
'type': 'enum',
|
||||
'description': 'Select your favorite flower',
|
||||
'valueNames': {
|
||||
'value-names': {
|
||||
'none': 'Hate Flowers',
|
||||
'red': 'Red',
|
||||
'blue': 'Blue',
|
||||
@@ -1006,13 +1006,13 @@ export module Mock {
|
||||
},
|
||||
},
|
||||
},
|
||||
'uniqueBy': 'preference',
|
||||
'unique-by': 'preference',
|
||||
},
|
||||
},
|
||||
'randomEnum': {
|
||||
'name': 'Random Enum',
|
||||
'type': 'enum',
|
||||
'valueNames': {
|
||||
'value-names': {
|
||||
'null': 'Null',
|
||||
'option1': 'One 1',
|
||||
'option2': 'Two 2',
|
||||
@@ -1020,7 +1020,7 @@ export module Mock {
|
||||
},
|
||||
'default': 'null',
|
||||
'description': 'This is not even real.',
|
||||
'changeWarning': 'Be careful chnaging this!',
|
||||
'change-warning': 'Be careful chnaging this!',
|
||||
'values': [
|
||||
'null',
|
||||
'option1',
|
||||
@@ -1033,7 +1033,7 @@ export module Mock {
|
||||
'type': 'number',
|
||||
'integral': false,
|
||||
'description': 'Your favorite number of all time',
|
||||
'changeWarning': 'Once you set this number, it can never be changed without severe consequences.',
|
||||
'change-warning': 'Once you set this number, it can never be changed without severe consequences.',
|
||||
'nullable': false,
|
||||
'default': 7,
|
||||
'range': '(-100,100]',
|
||||
@@ -1057,19 +1057,15 @@ export module Mock {
|
||||
'rpcsettings': {
|
||||
'name': 'RPC Settings',
|
||||
'type': 'object',
|
||||
'uniqueBy': null,
|
||||
'unique-by': null,
|
||||
'description': 'rpc username and password',
|
||||
'changeWarning': 'Adding RPC users gives them special permissions on your node.',
|
||||
'nullable': false,
|
||||
'nullByDefault': false,
|
||||
'change-warning': 'Adding RPC users gives them special permissions on your node.',
|
||||
'spec': {
|
||||
'laws': {
|
||||
'name': 'Laws',
|
||||
'type': 'object',
|
||||
'uniqueBy': 'law1',
|
||||
'unique-by': 'law1',
|
||||
'description': 'the law of the realm',
|
||||
'nullable': true,
|
||||
'nullByDefault': true,
|
||||
'spec': {
|
||||
'law1': {
|
||||
'name': 'First Law',
|
||||
@@ -1097,7 +1093,7 @@ export module Mock {
|
||||
'range': '[0,2]',
|
||||
'default': [],
|
||||
'spec': {
|
||||
'uniqueBy': null,
|
||||
'unique-by': null,
|
||||
'spec': {
|
||||
'rulemakername': {
|
||||
'name': 'Rulemaker Name',
|
||||
@@ -1118,7 +1114,7 @@ export module Mock {
|
||||
'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])$',
|
||||
'patternDescription': 'may only contain numbers and periods',
|
||||
'pattern-description': 'may only contain numbers and periods',
|
||||
'masked': false,
|
||||
'copyable': true,
|
||||
},
|
||||
@@ -1132,7 +1128,7 @@ export module Mock {
|
||||
'nullable': false,
|
||||
'default': 'defaultrpcusername',
|
||||
'pattern': '^[a-zA-Z]+$',
|
||||
'patternDescription': 'must contain only letters.',
|
||||
'pattern-description': 'must contain only letters.',
|
||||
'masked': false,
|
||||
'copyable': true,
|
||||
},
|
||||
@@ -1153,10 +1149,8 @@ export module Mock {
|
||||
'advanced': {
|
||||
'name': 'Advanced',
|
||||
'type': 'object',
|
||||
'uniqueBy': null,
|
||||
'unique-by': null,
|
||||
'description': 'Advanced settings',
|
||||
'nullable': false,
|
||||
'nullByDefault': false,
|
||||
'spec': {
|
||||
'notifications': {
|
||||
'name': 'Notification Preferences',
|
||||
@@ -1168,7 +1162,7 @@ export module Mock {
|
||||
'email',
|
||||
],
|
||||
'spec': {
|
||||
'valueNames': {
|
||||
'value-names': {
|
||||
'email': 'EEEEmail',
|
||||
'text': 'Texxxt',
|
||||
'call': 'Ccccall',
|
||||
@@ -1189,14 +1183,14 @@ export module Mock {
|
||||
'bitcoinNode': {
|
||||
'name': 'Bitcoin Node Settings',
|
||||
'type': 'union',
|
||||
'uniqueBy': null,
|
||||
'unique-by': null,
|
||||
'description': 'The node settings',
|
||||
'default': 'internal',
|
||||
'changeWarning': 'Careful changing this',
|
||||
'change-warning': 'Careful changing this',
|
||||
'tag': {
|
||||
'id': 'type',
|
||||
'name': 'Type',
|
||||
'variantNames': {
|
||||
'variant-names': {
|
||||
'internal': 'Internal',
|
||||
'external': 'External',
|
||||
},
|
||||
@@ -1220,7 +1214,7 @@ export module Mock {
|
||||
'nullable': false,
|
||||
'default': 'bitcoinnode.com',
|
||||
'pattern': '.*',
|
||||
'patternDescription': 'anything',
|
||||
'pattern-description': 'anything',
|
||||
'masked': false,
|
||||
'copyable': true,
|
||||
},
|
||||
@@ -1249,14 +1243,14 @@ export module Mock {
|
||||
'type': 'list',
|
||||
'subtype': 'string',
|
||||
'description': 'external ip addresses that are authorized to access your Bitcoin node',
|
||||
'changeWarning': 'Any IP you allow here will have RPC access to your Bitcoin node.',
|
||||
'change-warning': 'Any IP you allow here will have RPC access to your Bitcoin node.',
|
||||
'range': '[1,10]',
|
||||
'default': [
|
||||
'192.168.1.1',
|
||||
],
|
||||
'spec': {
|
||||
'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]$))',
|
||||
'patternDescription': 'must be a valid ipv4, ipv6, or domain name',
|
||||
'pattern-description': 'must be a valid ipv4, ipv6, or domain name',
|
||||
},
|
||||
},
|
||||
'rpcauth': {
|
||||
@@ -1271,24 +1265,37 @@ export module Mock {
|
||||
},
|
||||
// actual config
|
||||
config: {
|
||||
testnet: undefined,
|
||||
objectList: undefined,
|
||||
testnet: false,
|
||||
objectList: [
|
||||
{
|
||||
'firstName': 'Admin',
|
||||
'lastName': 'User',
|
||||
'age': 40,
|
||||
},
|
||||
{
|
||||
'firstName': 'Admin2',
|
||||
'lastName': 'User',
|
||||
'age': 40,
|
||||
},
|
||||
],
|
||||
unionList: undefined,
|
||||
randomEnum: 'option1',
|
||||
favoriteNumber: 8,
|
||||
secondaryNumbers: undefined,
|
||||
rpcsettings: {
|
||||
laws: null,
|
||||
laws: {
|
||||
law1: 'The first law',
|
||||
law2: 'The second law',
|
||||
},
|
||||
rpcpass: null,
|
||||
rpcuser: '123',
|
||||
rulemakers: [],
|
||||
},
|
||||
advanced: {
|
||||
notifications: ['call'],
|
||||
notifications: ['email'],
|
||||
},
|
||||
bitcoinNode: undefined,
|
||||
port: 5959,
|
||||
maxconnections: null,
|
||||
rpcallowip: undefined,
|
||||
rpcauth: ['matt: 8273gr8qwoidm1uid91jeh8y23gdio1kskmwejkdnm'],
|
||||
},
|
||||
@@ -1312,7 +1319,6 @@ export module Mock {
|
||||
},
|
||||
bitcoinNode: { type: 'internal' },
|
||||
port: 5959,
|
||||
maxconnections: null,
|
||||
rpcallowip: [],
|
||||
rpcauth: ['matt: 8273gr8qwoidm1uid91jeh8y23gdio1kskmwejkdnm'],
|
||||
}
|
||||
|
||||
@@ -78,6 +78,9 @@ export module RR {
|
||||
export type DeleteNotificationReq = { id: string } // notification.delete
|
||||
export type DeleteNotificationRes = null
|
||||
|
||||
export type DeleteAllNotificationsReq = { } // notification.delete.all
|
||||
export type DeleteAllNotificationsRes = null
|
||||
|
||||
// wifi
|
||||
|
||||
export type AddWifiReq = { // wifi.add
|
||||
|
||||
@@ -86,6 +86,8 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
|
||||
|
||||
abstract deleteNotification (params: RR.DeleteNotificationReq): Promise<RR.DeleteNotificationRes>
|
||||
|
||||
abstract deleteAllNotifications (params: RR.DeleteAllNotificationsReq): Promise<RR.DeleteAllNotificationsRes>
|
||||
|
||||
// wifi
|
||||
|
||||
abstract addWifi (params: RR.AddWifiReq): Promise<RR.AddWifiRes>
|
||||
|
||||
@@ -102,11 +102,15 @@ export class LiveApiService extends ApiService {
|
||||
// notification
|
||||
|
||||
async getNotificationsRaw (params: RR.GetNotificationsReq): Promise<RR.GetNotificationsRes> {
|
||||
return this.http.rpcRequest({ method: 'notifications.list', params })
|
||||
return this.http.rpcRequest({ method: 'notification.list', params })
|
||||
}
|
||||
|
||||
async deleteNotification (params: RR.DeleteNotificationReq): Promise<RR.DeleteNotificationRes> {
|
||||
return this.http.rpcRequest({ method: 'notifications.delete', params })
|
||||
return this.http.rpcRequest({ method: 'notification.delete', params })
|
||||
}
|
||||
|
||||
async deleteAllNotifications (params: RR.DeleteAllNotificationsReq): Promise<RR.DeleteAllNotificationsRes> {
|
||||
return this.http.rpcRequest({ method: 'notification.delete.all', params })
|
||||
}
|
||||
|
||||
// wifi
|
||||
|
||||
@@ -35,6 +35,7 @@ export class MockApiService extends ApiService {
|
||||
}
|
||||
|
||||
async setDbValueRaw (params: RR.SetDBValueReq): Promise<RR.SetDBValueRes> {
|
||||
await pauseFor(2000)
|
||||
return this.http.rpcRequest<WithRevision<null>>({ method: 'db.put.ui', params })
|
||||
}
|
||||
|
||||
@@ -202,6 +203,11 @@ export class MockApiService extends ApiService {
|
||||
return null
|
||||
}
|
||||
|
||||
async deleteAllNotifications (params: RR.DeleteAllNotificationsReq): Promise<RR.DeleteAllNotificationsRes> {
|
||||
await pauseFor(2000)
|
||||
return null
|
||||
}
|
||||
|
||||
// wifi
|
||||
|
||||
async addWifi (params: RR.AddWifiReq): Promise<RR.AddWifiRes> {
|
||||
@@ -484,7 +490,30 @@ export class MockApiService extends ApiService {
|
||||
|
||||
async dryConfigureDependency (params: RR.DryConfigureDependencyReq): Promise<RR.DryConfigureDependencyRes> {
|
||||
await pauseFor(2000)
|
||||
return { }
|
||||
return {
|
||||
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'],
|
||||
}
|
||||
}
|
||||
|
||||
private async updateProgress (id: string, initialProgress: InstallProgress) {
|
||||
@@ -495,9 +524,7 @@ export class MockApiService extends ApiService {
|
||||
]
|
||||
for (let phase of phases) {
|
||||
let i = initialProgress[phase.progress]
|
||||
console.log('Initial i', i)
|
||||
while (i < initialProgress.size) {
|
||||
console.log(i)
|
||||
await pauseFor(1000)
|
||||
i = Math.min(i + 5, initialProgress.size)
|
||||
initialProgress[phase.progress] = i
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { InterfaceDef, Manifest, PackageDataEntry, PackageMainStatus, PackageState } from './patch-db/data-model'
|
||||
import { InterfaceDef, PackageDataEntry, PackageMainStatus, PackageState } from './patch-db/data-model'
|
||||
|
||||
const { start9Marketplace, patchDb, api, mocks } = require('../../../config.json') as UiConfig
|
||||
|
||||
@@ -67,7 +67,6 @@ export class ConfigService {
|
||||
}
|
||||
|
||||
launchableURL (pkg: PackageDataEntry): string {
|
||||
console.log('PKGPKGPKG', pkg)
|
||||
return this.isTor() ? `http://${torUiAddress(pkg)}` : `https://${lanUiAddress(pkg)}`
|
||||
}
|
||||
}
|
||||
|
||||
244
ui/src/app/services/form.service.ts
Normal file
244
ui/src/app/services/form.service.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { AbstractControl, FormArray, FormBuilder, FormControl, FormGroup, ValidationErrors, ValidatorFn, Validators } from '@angular/forms'
|
||||
import { ConfigSpec, isValueSpecListOf, ListValueSpecNumber, ListValueSpecString, ListValueSpecUnion, ValueSpec, ValueSpecEnum, ValueSpecList, ValueSpecNumber, ValueSpecObject, ValueSpecString, ValueSpecUnion } from '../pkg-config/config-types'
|
||||
import { getDefaultString, Range } from '../pkg-config/config-utilities'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class FormService {
|
||||
validationMessages: { [key: string]: { type: string, message: string }[] } = { }
|
||||
|
||||
constructor (
|
||||
private readonly formBuilder: FormBuilder,
|
||||
) { }
|
||||
|
||||
createForm (config: ConfigSpec, current: { [key: string]: any } = { }): FormGroup {
|
||||
return this.getFormGroup(config, [], current)
|
||||
}
|
||||
|
||||
getListItemValidators (spec: ValueSpecList, key: string, index: number) {
|
||||
const listKey = `${key}/${index}`
|
||||
this.validationMessages[listKey] = []
|
||||
if (isValueSpecListOf(spec, 'string')) {
|
||||
return this.stringValidators(listKey, spec.spec)
|
||||
} else if (isValueSpecListOf(spec, 'number')) {
|
||||
return this.numberValidators(listKey, spec.spec)
|
||||
}
|
||||
}
|
||||
|
||||
getUnionObject (spec: ValueSpecUnion | ListValueSpecUnion, selection: string, current?: { [key: string]: any }): FormGroup {
|
||||
const { variants, tag } = spec
|
||||
const { name, description, 'change-warning' : changeWarning } = isFullUnion(spec) ? spec : { ...spec.tag, 'change-warning': undefined }
|
||||
|
||||
const enumSpec: ValueSpecEnum = {
|
||||
type: 'enum',
|
||||
name,
|
||||
description,
|
||||
'change-warning': changeWarning,
|
||||
default: selection,
|
||||
values: Object.keys(variants),
|
||||
'value-names': tag['variant-names'],
|
||||
}
|
||||
return this.getFormGroup({ [spec.tag.id]: enumSpec, ...spec.variants[selection] }, [], current)
|
||||
}
|
||||
|
||||
getFormGroup (config: ConfigSpec, validators: ValidatorFn[] = [], current: { [key: string]: any } = { }): FormGroup {
|
||||
let group = { }
|
||||
Object.entries(config).map(([key, spec]) => {
|
||||
if (spec.type === 'pointer') return
|
||||
group[key] = this.getFormEntry(key, spec, current ? current[key] : { })
|
||||
})
|
||||
return this.formBuilder.group(group, { validators } )
|
||||
}
|
||||
|
||||
getListItem (key: string, index: number, spec: ValueSpecList, entry: any) {
|
||||
const listItemValidators = this.getListItemValidators(spec, key, index)
|
||||
if (isValueSpecListOf(spec, 'string')) {
|
||||
return this.formBuilder.control(entry, listItemValidators)
|
||||
} else if (isValueSpecListOf(spec, 'number')) {
|
||||
return this.formBuilder.control(entry, listItemValidators)
|
||||
} else if (isValueSpecListOf(spec, 'enum')) {
|
||||
return this.formBuilder.control(entry)
|
||||
} else if (isValueSpecListOf(spec, 'object')) {
|
||||
return this.getFormGroup(spec.spec.spec, listItemValidators, entry)
|
||||
} else if (isValueSpecListOf(spec, 'union')) {
|
||||
return this.getUnionObject(spec.spec, spec.spec.default, entry)
|
||||
}
|
||||
}
|
||||
|
||||
private getFormEntry (key: string, spec: ValueSpec, currentValue: any): FormGroup | FormArray | FormControl {
|
||||
this.validationMessages[key] = []
|
||||
let validators: ValidatorFn[]
|
||||
let value: any
|
||||
switch (spec.type) {
|
||||
case 'string':
|
||||
validators = this.stringValidators(key, spec)
|
||||
if (currentValue !== undefined) {
|
||||
value = currentValue
|
||||
} else {
|
||||
value = spec.default ? getDefaultString(spec.default) : null
|
||||
}
|
||||
return this.formBuilder.control(value, validators)
|
||||
case 'number':
|
||||
validators = this.numberValidators(key, spec)
|
||||
if (currentValue !== undefined) {
|
||||
value = currentValue
|
||||
} else {
|
||||
value = spec.default || null
|
||||
}
|
||||
return this.formBuilder.control(value, validators)
|
||||
case 'object':
|
||||
return this.getFormGroup(spec.spec, [], currentValue)
|
||||
case 'list':
|
||||
validators = this.listValidators(key, spec)
|
||||
const mapped = (Array.isArray(currentValue) ? currentValue : spec.default as any[]).map((entry: any, index) => {
|
||||
return this.getListItem(key, index, spec, entry)
|
||||
})
|
||||
return this.formBuilder.array(mapped, validators)
|
||||
case 'union':
|
||||
return this.getUnionObject(spec, currentValue?.[spec.tag.id] || spec.default, currentValue)
|
||||
case 'boolean':
|
||||
case 'enum':
|
||||
value = currentValue === undefined ? spec.default : currentValue
|
||||
return this.formBuilder.control(value)
|
||||
}
|
||||
}
|
||||
|
||||
private stringValidators (key: string, spec: ValueSpecString | ListValueSpecString): ValidatorFn[] {
|
||||
const validators: ValidatorFn[] = []
|
||||
|
||||
if (!(spec as ValueSpecString).nullable) {
|
||||
validators.push(Validators.required)
|
||||
this.validationMessages[key].push({
|
||||
type: 'required',
|
||||
message: 'Cannot be blank.',
|
||||
})
|
||||
}
|
||||
|
||||
if (spec.pattern) {
|
||||
validators.push(Validators.pattern(spec.pattern))
|
||||
this.validationMessages[key].push({
|
||||
type: 'pattern',
|
||||
message: spec['pattern-description'],
|
||||
})
|
||||
}
|
||||
|
||||
return validators
|
||||
}
|
||||
|
||||
private numberValidators (key: string, spec: ValueSpecNumber | ListValueSpecNumber): ValidatorFn[] {
|
||||
const validators: ValidatorFn[] = []
|
||||
|
||||
if (!(spec as ValueSpecNumber).nullable) {
|
||||
validators.push(Validators.required)
|
||||
this.validationMessages[key].push({
|
||||
type: 'required',
|
||||
message: 'Cannot be blank.',
|
||||
})
|
||||
}
|
||||
|
||||
if (spec.integral) {
|
||||
validators.push(isInteger())
|
||||
this.validationMessages[key].push({
|
||||
type: 'numberNotInteger',
|
||||
message: 'Number must be an integer.',
|
||||
})
|
||||
}
|
||||
|
||||
validators.push(numberInRange(spec.range))
|
||||
this.validationMessages[key].push({
|
||||
type: 'numberNotInRange',
|
||||
message: 'Number not in range.',
|
||||
})
|
||||
|
||||
return validators
|
||||
}
|
||||
|
||||
private listValidators (key: string, spec: ValueSpecList): ValidatorFn[] {
|
||||
const validators: ValidatorFn[] = []
|
||||
|
||||
validators.push(listInRange(spec.range))
|
||||
this.validationMessages[key].push({
|
||||
type: 'listNotInRange',
|
||||
message: 'List not in range.',
|
||||
})
|
||||
|
||||
if (!isValueSpecListOf(spec, 'enum')) {
|
||||
validators.push(listUnique(spec))
|
||||
this.validationMessages[key].push({
|
||||
type: 'listNotUnique',
|
||||
message: 'List contains duplicate entries.',
|
||||
})
|
||||
}
|
||||
|
||||
return validators
|
||||
}
|
||||
}
|
||||
|
||||
function isFullUnion (spec: ValueSpecUnion | ListValueSpecUnion): spec is ValueSpecUnion {
|
||||
return !!(spec as ValueSpecUnion).name
|
||||
}
|
||||
|
||||
export function numberInRange (stringRange: string): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
try {
|
||||
Range.from(stringRange).checkIncludes(control.value)
|
||||
return null
|
||||
} catch (e) {
|
||||
return { numberNotInRange: { value: control.value } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isInteger (): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
return control.value == Math.trunc(control.value) ?
|
||||
null :
|
||||
{ numberNotInteger: { value: control.value } }
|
||||
}
|
||||
}
|
||||
|
||||
export function listInRange (stringRange: string): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const range = Range.from(stringRange)
|
||||
const min = range.integralMin()
|
||||
const max = range.integralMax()
|
||||
const length = control.value.length
|
||||
if ((min && length < min) || (max && length > max)) {
|
||||
return { listNotInRange: { value: control.value } }
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function listUnique (spec: ValueSpec): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
for (let idx = 0; idx < control.value.length; idx++) {
|
||||
for (let idx2 = idx + 1; idx2 < control.value.length; idx2++) {
|
||||
if (equals(spec, control.value[idx], control.value[idx2])) {
|
||||
return { listNotUnique: { value: control.value } }
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function equals (spec: ValueSpec, val1: any, val2: any): boolean {
|
||||
switch (spec.type) {
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'boolean':
|
||||
case 'enum':
|
||||
return val1 === val2
|
||||
case 'object':
|
||||
case 'union':
|
||||
// @TODO how to check this
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -348,7 +348,7 @@ export interface DependencyEntry {
|
||||
optional: string | null
|
||||
recommended: boolean
|
||||
description: string | null
|
||||
config: ConfigRuleEntryWithSuggestions[]
|
||||
config: ConfigRuleEntryWithSuggestions[] // @TODO when do we use this?
|
||||
interfaces: any[] // @TODO placeholder
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ function handleInstalledState (status: Status): PkgStatusRendering {
|
||||
|
||||
switch (status.main.status) {
|
||||
case PackageMainStatus.Stopping: return { display: 'Stopping', color: 'dark', showDots: true, feStatus: FEStatus.Stopping }
|
||||
case PackageMainStatus.Stopped: return { display: 'Stopped', color: 'medium', showDots: false, feStatus: FEStatus.Stopped }
|
||||
case PackageMainStatus.Stopped: return { display: 'Stopped', color: 'dark', showDots: false, feStatus: FEStatus.Stopped }
|
||||
case PackageMainStatus.BackingUp: return { display: 'Backing Up', color: 'warning', showDots: true, feStatus: FEStatus.BackingUp }
|
||||
case PackageMainStatus.Restoring: return { display: 'Restoring', color: 'primary', showDots: true, feStatus: FEStatus.Restoring }
|
||||
case PackageMainStatus.Running: return handleRunningState(status.main)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { AppConfigValuePage } from '../modals/app-config-value/app-config-value.page'
|
||||
import { AlertInput, AlertButton } from '@ionic/core'
|
||||
// import { AppConfigValuePage } from '../modals/app-config-value/app-config-value.page'
|
||||
import { ApiService } from './api/embassy/embassy-api.service'
|
||||
import { ConfigSpec } from '../pkg-config/config-types'
|
||||
import { ConfigCursor } from '../pkg-config/config-cursor'
|
||||
import { SSHService } from '../pages/server-routes/security-routes/ssh-keys/ssh.service'
|
||||
import { TrackingModalController } from './tracking-modal-controller.service'
|
||||
import { AlertController, LoadingController } from '@ionic/angular'
|
||||
import { ErrorToastService } from './error-toast.service'
|
||||
// import { ModalController } from '@ionic/angular'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -12,41 +14,110 @@ import { TrackingModalController } from './tracking-modal-controller.service'
|
||||
export class ServerConfigService {
|
||||
|
||||
constructor (
|
||||
private readonly trackingModalCtrl: TrackingModalController,
|
||||
// private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly sshService: SSHService,
|
||||
) { }
|
||||
|
||||
async presentModalValueEdit (key: string, current?: string) {
|
||||
const cursor = new ConfigCursor(serverConfig, { [key]: current }).seekNext(key)
|
||||
async presentAlert (key: string, current?: any): Promise<void> {
|
||||
const spec = serverConfig[key]
|
||||
|
||||
const modal = await this.trackingModalCtrl.create({
|
||||
backdropDismiss: false,
|
||||
component: AppConfigValuePage,
|
||||
presentingElement: await this.trackingModalCtrl.getTop(),
|
||||
componentProps: {
|
||||
cursor,
|
||||
saveFn: this.saveFns[key],
|
||||
let inputs: AlertInput[]
|
||||
let buttons: AlertButton[] = [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
})
|
||||
{
|
||||
text: 'Save',
|
||||
handler: async (data: any) => {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
message: 'Saving...',
|
||||
cssClass: 'loader',
|
||||
})
|
||||
loader.present()
|
||||
|
||||
await modal.present()
|
||||
try {
|
||||
await this.saveFns[key](data)
|
||||
} catch (e) {
|
||||
this.errToast.present(e.message)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
switch (spec.type) {
|
||||
case 'boolean':
|
||||
inputs = [
|
||||
{
|
||||
name: 'enabled',
|
||||
type: 'radio',
|
||||
label: 'Enabled',
|
||||
value: true,
|
||||
checked: current,
|
||||
},
|
||||
{
|
||||
name: 'disabled',
|
||||
type: 'radio',
|
||||
label: 'Disabled',
|
||||
value: false,
|
||||
checked: !current,
|
||||
},
|
||||
]
|
||||
break
|
||||
case 'string':
|
||||
inputs = [
|
||||
{
|
||||
name: key,
|
||||
type: 'textarea',
|
||||
placeholder: 'Enter SSH public key',
|
||||
value: current,
|
||||
},
|
||||
]
|
||||
break
|
||||
}
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: spec.name,
|
||||
message: spec.description,
|
||||
inputs,
|
||||
buttons,
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
// async presentModalForm (key: string, current?: string) {
|
||||
// const modal = await this.modalCtrl.create({
|
||||
// component: AppConfigValuePage,
|
||||
// componentProps: {
|
||||
// cursor,
|
||||
// saveFn: this.saveFns[key],
|
||||
// },
|
||||
// })
|
||||
// await modal.present()
|
||||
// }
|
||||
|
||||
saveFns: { [key: string]: (val: any) => Promise<any> } = {
|
||||
autoCheckUpdates: async (enabled: boolean) => {
|
||||
'auto-check-updates': async (enabled: boolean) => {
|
||||
console.log('SAVING auto check', enabled)
|
||||
return this.embassyApi.setDbValue({ pointer: '/auto-check-updates', value: enabled })
|
||||
},
|
||||
ssh: async (pubkey: string) => {
|
||||
return this.sshService.add(pubkey)
|
||||
},
|
||||
eosMarketplace: async (enabled: boolean) => {
|
||||
'eos-marketplace': async (enabled: boolean) => {
|
||||
return this.embassyApi.setEosMarketplace(enabled)
|
||||
},
|
||||
// packageMarketplace: async (url: string) => {
|
||||
// 'package-marketplace': async (url: string) => {
|
||||
// return this.embassyApi.setPackageMarketplace({ url })
|
||||
// },
|
||||
shareStats: async (enabled: boolean) => {
|
||||
'share-stats': async (enabled: boolean) => {
|
||||
return this.embassyApi.setShareStats({ value: enabled })
|
||||
},
|
||||
// password: async (password: string) => {
|
||||
@@ -55,8 +126,8 @@ export class ServerConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
const serverConfig: ConfigSpec = {
|
||||
autoCheckUpdates: {
|
||||
export const serverConfig: ConfigSpec = {
|
||||
'auto-check-updates': {
|
||||
type: 'boolean',
|
||||
name: 'Auto Check for Updates',
|
||||
description: 'On launch, EmbassyOS will automatically check for updates of itself and your installed services. Updating still requires user approval and action. No updates will ever be performed automatically.',
|
||||
@@ -65,33 +136,33 @@ const serverConfig: ConfigSpec = {
|
||||
ssh: {
|
||||
type: 'string',
|
||||
name: 'SSH Key',
|
||||
description: 'Add SSH keys to your Embassy to gain root access from the command line.',
|
||||
description: 'Enter an SSH public key to authorize root access from the command line.',
|
||||
nullable: false,
|
||||
// @TODO regex for SSH Key
|
||||
// pattern: '',
|
||||
patternDescription: 'Must be a valid SSH key',
|
||||
'pattern-description': 'Must be a valid SSH key',
|
||||
masked: false,
|
||||
copyable: false,
|
||||
},
|
||||
eosMarketplace: {
|
||||
'eos-marketplace': {
|
||||
type: 'boolean',
|
||||
name: 'Tor Only Marketplace',
|
||||
description: `Use Start9's Tor (instead of clearnet) Marketplace.`,
|
||||
changeWarning: 'This will result in higher latency and slower download times.',
|
||||
'change-warning': 'This will result in higher latency and slower download times.',
|
||||
default: false,
|
||||
},
|
||||
// packageMarketplace: {
|
||||
// 'package-marketplace': {
|
||||
// type: 'string',
|
||||
// name: 'Package Marketplace',
|
||||
// description: `Use for alternative embassy marketplace. Leave empty to use start9's marketplace.`,
|
||||
// nullable: true,
|
||||
// // @TODO regex for URL
|
||||
// // pattern: '',
|
||||
// patternDescription: 'Must be a valid URL.',
|
||||
// 'pattern-description': 'Must be a valid URL.',
|
||||
// masked: false,
|
||||
// copyable: false,
|
||||
// },
|
||||
shareStats: {
|
||||
'share-stats': {
|
||||
type: 'boolean',
|
||||
name: 'Share Anonymous Statistics',
|
||||
description: 'Start9 uses this information to identify bugs quickly and improve EmbassyOS. The information is 100% anonymous and transmitted over Tor.',
|
||||
@@ -104,8 +175,8 @@ const serverConfig: ConfigSpec = {
|
||||
// nullable: false,
|
||||
// // @TODO regex for 12 chars
|
||||
// // pattern: '',
|
||||
// patternDescription: 'Must contain at least 12 characters.',
|
||||
// changeWarning: 'If you forget your master password, there is absolutely no way to recover your data. This can result in loss of money! Keep in mind, old backups will still be encrypted by the password used to encrypt them.',
|
||||
// 'pattern-description': 'Must contain at least 12 characters.',
|
||||
// 'change-warning': 'If you forget your master password, there is absolutely no way to recover your data. This can result in loss of money! Keep in mind, old backups will still be encrypted by the password used to encrypt them.',
|
||||
// masked: false,
|
||||
// copyable: false,
|
||||
// },
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ConfigService } from './config.service'
|
||||
import { Emver } from './emver.service'
|
||||
import { MarketplaceService } from '../pages/marketplace-routes/marketplace.service'
|
||||
import { MarketplaceApiService } from './api/marketplace/marketplace-api.service'
|
||||
import { DataModel, PackageDataEntry } from './patch-db/data-model'
|
||||
import { DataModel } from './patch-db/data-model'
|
||||
import { PatchDbService } from './patch-db/patch-db.service'
|
||||
import { filter, take } from 'rxjs/operators'
|
||||
import { isEmptyObject } from '../util/misc.util'
|
||||
@@ -78,7 +78,6 @@ export class StartupAlertsService {
|
||||
let checkRes: any
|
||||
try {
|
||||
checkRes = await c.check()
|
||||
console.log('CHECK RES', checkRes)
|
||||
} catch (e) {
|
||||
console.error(`Exception in ${c.name} check:`, e)
|
||||
return true
|
||||
|
||||
33
ui/src/app/services/sub-nav.service.ts
Normal file
33
ui/src/app/services/sub-nav.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core'
|
||||
import { IonNav } from '@ionic/angular'
|
||||
import { AppConfigComponentMapping } from '../modals/app-config-injectable'
|
||||
import { ConfigCursor } from '../pkg-config/config-cursor'
|
||||
|
||||
export const APP_CONFIG_COMPONENT_MAPPING = new InjectionToken<string>('APP_CONFIG_COMPONENTS')
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SubNavService {
|
||||
path: string[]
|
||||
|
||||
constructor (
|
||||
@Inject(APP_CONFIG_COMPONENT_MAPPING) private readonly appConfigComponentMapping: AppConfigComponentMapping,
|
||||
) { }
|
||||
|
||||
async push (key: string, cursor: ConfigCursor<any>, nav: IonNav) {
|
||||
const component = this.appConfigComponentMapping[cursor.spec().type]
|
||||
this.path.push(key)
|
||||
nav.push(component, { cursor }, { mode: 'ios' })
|
||||
}
|
||||
|
||||
async pop (nav: IonNav) {
|
||||
this.path.pop()
|
||||
nav.pop({ mode: 'ios' })
|
||||
}
|
||||
|
||||
async popTo (index: number, nav: IonNav) {
|
||||
this.path = this.path.slice(0, index + 1)
|
||||
nav.popTo(index, { mode: 'ios' })
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Inject, Injectable, InjectionToken } from '@angular/core'
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { ModalOptions } from '@ionic/core'
|
||||
import { ValueSpec } from '../pkg-config/config-types'
|
||||
import { AppConfigComponentMapping } from '../modals/app-config-injectable'
|
||||
|
||||
export const APP_CONFIG_COMPONENT_MAPPING = new InjectionToken<string>('APP_CONFIG_COMPONENTS')
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class TrackingModalController {
|
||||
private modals: { [modalId: string] : HTMLIonModalElement} = { }
|
||||
|
||||
private readonly $onDismiss$ = new Subject<string>()
|
||||
private readonly $onCreate$ = new Subject<string>()
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
@Inject(APP_CONFIG_COMPONENT_MAPPING) private readonly appConfigComponentMapping: AppConfigComponentMapping,
|
||||
) { }
|
||||
|
||||
async createConfigModal (o: Omit<ModalOptions, 'component'>, type: ValueSpec['type']) {
|
||||
const component = this.appConfigComponentMapping[type]
|
||||
return this.create({ ...o, component })
|
||||
}
|
||||
|
||||
async create (a: ModalOptions): Promise<HTMLIonModalElement> {
|
||||
const modal = await this.modalCtrl.create(a)
|
||||
this.modals[modal.id] = modal
|
||||
this.$onCreate$.next(modal.id)
|
||||
|
||||
modal.onWillDismiss().then(() => {
|
||||
delete this.modals[modal.id]
|
||||
this.$onDismiss$.next(modal.id)
|
||||
})
|
||||
return modal
|
||||
}
|
||||
|
||||
dismissAll (): Promise<boolean[]> {
|
||||
return Promise.all(
|
||||
Object.values(this.modals).map(m => m.dismiss()),
|
||||
)
|
||||
}
|
||||
|
||||
dismiss (val?: any): Promise<boolean> {
|
||||
return this.modalCtrl.dismiss(val)
|
||||
}
|
||||
|
||||
onCreateAny$ (): Observable<string> {
|
||||
return this.$onCreate$.asObservable()
|
||||
}
|
||||
|
||||
onDismissAny$ (): Observable<string> {
|
||||
return this.$onDismiss$.asObservable()
|
||||
}
|
||||
|
||||
async getTop (): Promise<HTMLIonModalElement> {
|
||||
return this.modalCtrl.getTop()
|
||||
}
|
||||
|
||||
get anyModals (): boolean {
|
||||
return Object.keys(this.modals).length !== 0
|
||||
}
|
||||
|
||||
get modalCount (): number {
|
||||
return Object.keys(this.modals).length
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user