0.2.5 initial commit

Makefile incomplete
This commit is contained in:
Aiden McClelland
2020-11-23 13:44:28 -07:00
commit 95d3845906
503 changed files with 53448 additions and 0 deletions

33
ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# Specifies intentionally untracked files to ignore when using Git
# http://git-scm.com/docs/gitignore
*~
*.sw[mnpcod]
.tmp
*.tmp
*.tmp.*
.DS_Store
Thumbs.db
UserInterfaceState.xcuserstate
$RECYCLE.BIN/
start9-ambassador
*.tar.gz
ambassador.tar.gz
*.log
log.txt
npm-debug.log*
postprocess.js
/.idea
/.ionic
/.sass-cache
/.sourcemaps
/.vscode
/.gradle
/dist
/out-tsc
/node_modules
/www

19
ui/README.md Normal file
View File

@@ -0,0 +1,19 @@
# Embassy UI
## Setup Instructions
**Make sure you have git, node, and npm installed**
`npm i -g @ionic/cli`
`git clone https://github.com/Start9Labs/embassy-ui.git`
`cd embassy-ui`
`npm i`
`ionic serve`
## Production Deployment
`ionic build --prod`

144
ui/angular.json Normal file
View File

@@ -0,0 +1,144 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"defaultProject": "app",
"newProjectRoot": "projects",
"projects": {
"app": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"prefix": "app",
"schematics": {},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "www",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"tsConfig": "tsconfig.json",
"assets": [
{
"glob": "**/*",
"input": "src/assets",
"output": "assets"
},
{
"glob": "**/*.svg",
"input": "node_modules/ionicons/dist/ionicons/svg",
"output": "./svg"
}
],
"styles": [
{
"input": "src/theme/variables.scss"
},
{
"input": "src/global.scss"
}
],
"scripts": []
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
}
]
},
"ci": {
"progress": false
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "app:build"
},
"configurations": {
"production": {
"browserTarget": "app:build:production"
},
"ci": {
"progress": false
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "app:build"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"tsconfig.json"
],
"exclude": [
"**/node_modules/**"
]
}
},
"ionic-cordova-build": {
"builder": "@ionic/angular-toolkit:cordova-build",
"options": {
"browserTarget": "app:build"
},
"configurations": {
"production": {
"browserTarget": "app:build:production"
}
}
},
"ionic-cordova-serve": {
"builder": "@ionic/angular-toolkit:cordova-serve",
"options": {
"cordovaBuildTarget": "app:ionic-cordova-build",
"devServerTarget": "app:serve"
},
"configurations": {
"production": {
"cordovaBuildTarget": "app:ionic-cordova-build:production",
"devServerTarget": "app:serve:production"
}
}
}
}
}
},
"cli": {
"defaultCollection": "@ionic/angular-toolkit",
"analytics": false
},
"schematics": {
"@ionic/angular-toolkit:component": {
"styleext": "scss"
},
"@ionic/angular-toolkit:page": {
"styleext": "scss"
}
}
}

12
ui/browserslist Normal file
View File

@@ -0,0 +1,12 @@
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
# You can see what browsers were selected by your queries by running:
# npx browserslist
> 0.5%
last 2 versions
Firefox ESR
not dead
not IE 9-11 # For IE 9-11 support, remove 'not'.

40
ui/build-send-beta.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
set -e
echo "turn off mocks"
echo "$( jq '.useMocks = false' use-mocks.json )" > use-mocks.json
echo "FILTER: rm -rf www"
rm -rf www
echo "FILTER: ionic build"
npm run build-prod
echo "FILTER: cp client-manifest.yaml www"
cp client-manifest.yaml www
echo "FILTER: git hash"
touch git-hash.txt
git log | head -n1 > git-hash.txt
mv git-hash.txt www
echo "FILTER: removing mock icons"
rm -rf www/assets/img/service-icons
echo "FILTER: tar -zcvf ambassador-ui.tar.gz www"
tar -zcvf ambassador-ui.tar.gz www
SHA_SUM=$(sha1sum ambassador-ui.tar.gz)
echo "${SHA_SUM}"
echo "Set version"
VERSION=$(jq ".version" package.json)
echo "${VERSION}"
echo "FILTER: mkdir beta-reg"
ssh root@beta-registry.start9labs.com "mkdir -p /var/www/html/resources/sys/ambassador-ui.tar.gz/${VERSION}"
echo "FILTER: scp ambassador-ui.tar.gz"
scp ambassador-ui.tar.gz root@beta-registry.start9labs.com:/var/www/html/resources/sys/ambassador-ui.tar.gz/${VERSION}/ambassador-ui.tar.gz
echo "FILTER: fin"

38
ui/build-send.sh Executable file
View File

@@ -0,0 +1,38 @@
#!/bin/bash
set -e
#echo "turn off mocks"
#echo "$( jq '.useMocks = false' use-mocks.json )" > use-mocks.json
echo "FILTER: rm -rf www"
rm -rf www
echo "FILTER: ionic build"
npm run build-prod
echo "FILTER: cp client-manifest.yaml www"
cp client-manifest.yaml www
echo "FILTER: git hash"
touch git-hash.txt
git log | head -n1 > git-hash.txt
mv git-hash.txt www
echo "FILTER: ssh + rm -rf /var/www/html/start9-ambassador/"
ssh root@start9-$1.local "rm -rf /var/www/html/start9-ambassador"
echo "FILTER: tar -zcvf ambassador.tar.gz www"
rm -rf start9-ambassador
mv www start9-ambassador
tar -zcvf ambassador.tar.gz start9-ambassador
echo "FILTER: scp ambassador.tar.gz root@start9-def09913.local:/root"
scp ambassador.tar.gz root@start9-$1.local:/root/agent
echo "FILTER: ssh root@start9-$1.local:/root 1"
ssh root@start9-$1.local "cd /root/agent && tar -C /var/www/html/ -xvf ambassador.tar.gz"
echo "FILTER: ssh root@start9-$1.local:/root 2"
ssh root@start9-$1.local "systemctl restart nginx"
echo "FILTER: fin"

117
ui/client-manifest.yaml Normal file
View File

@@ -0,0 +1,117 @@
manifest-version: 0
app-id: start9-ambassador
app-version: 0.2.5
uri-rewrites:
- =/api -> http://{{start9-ambassador}}:5959/authenticate
- /api/ -> http://{{start9-ambassador}}:5959/
main-is: index.html
error-pages:
404: index.html
mime-types:
wasm: application/wasm
bin: application/octet-stream
json: application/json
html: text/html
htm: text/html
shtml: text/html
css: text/css
xml: text/xml
gif: image/gif
jpeg: image/jpeg
jpg: image/jpeg
js: application/javascript
atom: application/atom+xml
rss: application/rss+xml
mml: text/mathml
txt: text/plain
jad: text/vnd.sun.j2me.app-descriptor
wml: text/vnd.wap.wml
htc: text/x-component
png: image/png
tif: image/tiff
tiff: image/tiff
wbmp: image/vnd.wap.wbmp
ico: image/x-icon
jng: image/x-jng
bmp: image/x-ms-bmp
svg: image/svg+xml
svgz: image/svg+xml
webp: image/webp
woff: application/font-woff
jar: application/java-archive
war: application/java-archive
ear: application/java-archive
json: application/json
hqx: application/mac-binhex40
doc: application/msword
pdf: application/pdf
ps: application/postscript
eps: application/postscript
ai: application/postscript
rtf: application/rtf
m3u8: application/vnd.apple.mpegur
xls: application/vnd.ms-exce
eot: application/vnd.ms-fontobjec
ppt: application/vnd.ms-powerpoin
wmlc: application/vnd.wap.wml
kml: application/vnd.google-earth.kml+xm
kmz: application/vnd.google-earth.km
7z: application/x-7z-compresse
cco: application/x-cocoa
jardiff: application/x-java-archive-diff
jnlp: application/x-java-jnlp-file
run: application/x-makesel
pl: application/x-perl
pm: application/x-perl
prc: application/x-pilot
pdb: application/x-pilot
rar: application/x-rar-compressed
rpm: application/x-redhat-package-manager
sea: application/x-sea
swf: application/x-shockwave-flash
sit: application/x-stuffit
tk: application/x-tcl
tcl: application/x-tcl
der: application/x-x509-ca-cert
pem: application/x-x509-ca-cert
crt: application/x-x509-ca-cert
xpi: application/x-xpinstall
xhtml: application/xhtml+xml
xspf: application/xspf+xml
zip: application/zip
bin: application/octet-stream
exe: application/octet-stream
dll: application/octet-stream
deb: application/octet-stream
dmg: application/octet-stream
iso: application/octet-stream
img: application/octet-stream
msi: application/octet-stream
msp: application/octet-stream
msm: application/octet-stream
docx: application/vnd.openxmlformats-officedocument.wordprocessingml.document
xlsx: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
pptx: application/vnd.openxmlformats-officedocument.presentationml.presentation
mid: audio/mid
kar: audio/mid
midi: audio/mid
mp3: audio/mpe
ogg: audio/og
m4a: audio/x-m4
ra: audio/x-realaudio
3gpp: video/3gp
3gp: video/3gp
ts: video/mp2
mp4: video/mp
mpeg: video/mpe
mpg: video/mpe
mov: video/quicktime
webm: video/web
flv: video/x-fl
m4v: video/x-m4
mng: video/x-mn
asx: video/x-ms-asf
asf: video/x-ms-asf
wmv: video/x-ms-wmv
avi: video/x-msvideo
mime-default: text/plain

5
ui/ionic.config.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "Embassy",
"integrations": {},
"type": "angular"
}

13885
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

62
ui/package.json Normal file
View File

@@ -0,0 +1,62 @@
{
"name": "embassy-ui",
"version": "0.2.5",
"description": "GUI for EmbassyOS",
"author": "Start9 Labs",
"homepage": "https://github.com/Start9Labs/embassy-ui",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build-prod": "ng build --prod && tsc postprocess.ts && node postprocess.js",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
"private": true,
"dependencies": {
"@angular/common": "^10.1.6",
"@angular/core": "^10.1.6",
"@angular/forms": "^10.1.6",
"@angular/platform-browser": "^10.1.6",
"@angular/platform-browser-dynamic": "^10.1.6",
"@angular/router": "^10.1.6",
"@ionic/angular": "^5.4.0",
"@ionic/storage": "2.2.0",
"@start9labs/ambassador-sdk": "file:../ambassador-sdk",
"@start9labs/emver": "^0.1.1",
"ajv": "^6.12.6",
"angularx-qrcode": "^10.0.11",
"base32.js": "^0.1.0",
"base64url": "^3.0.1",
"bip39": "^3.0.2",
"bitcoinjs-lib": "^5.2.0",
"compare-versions": "^3.5.0",
"core-js": "^3.4.0",
"handlebars": "^4.7.6",
"json-pointer": "^0.6.1",
"jsonpointerx": "^1.0.30",
"jsontokens": "^3.0.0",
"marked": "^1.2.0",
"rxjs": "^6.6.3",
"uuid": "^8.3.1",
"zone.js": "^0.11.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.1002.0",
"@angular/cli": "^10.1.7",
"@angular/compiler": "^10.1.6",
"@angular/compiler-cli": "^10.1.6",
"@angular/language-service": "^10.1.6",
"@ionic/angular-toolkit": "^2.3.3",
"@ionic/lab": "^3.2.9",
"@types/json-pointer": "^1.0.30",
"@types/marked": "^1.1.0",
"@types/node": "^14.11.10",
"@types/uuid": "^8.0.0",
"node-html-parser": "^1.3.1",
"ts-node": "^9.0.0",
"tslint": "^6.1.0",
"typescript": "^4.0.3"
}
}

13
ui/postprocess.ts Normal file
View File

@@ -0,0 +1,13 @@
import { parse } from 'node-html-parser'
import * as fs from 'fs'
let index = fs.readFileSync('./www/index.html').toString('utf-8')
const root = parse(index)
for (let elem of root.querySelectorAll('link')) {
if (elem.getAttribute('rel') === 'stylesheet') {
const sheet = fs.readFileSync('./www/' + elem.getAttribute('href')).toString('utf-8')
index = index.replace(elem.toString(), '<style>' + sheet + '</style>')
}
}
fs.writeFileSync('./www/index.html', index)

View File

@@ -0,0 +1,446 @@
import {
ValueSpec, ConfigSpec, UniqueBy, ValueSpecOf, ValueType
} from './config-types'
import * as pointer from 'json-pointer'
import * as handlebars from 'handlebars'
import { Annotations, getDefaultObject, getDefaultUnion, listInnerSpec, mapConfigSpec, Range } from './config-utilities'
export class ConfigCursor<T extends ValueType> {
private cachedSpec?: ValueSpecOf<T>
constructor (
private readonly rootSpec: ConfigSpec,
private readonly rootOldConfig: object,
private readonly rootMappedConfig: object = null,
private readonly rootConfig: object = null,
private readonly ptr: string = '',
) {
if (!this.rootOldConfig) {
this.rootOldConfig = getDefaultObject(this.rootSpec)
}
if (!this.rootMappedConfig) {
this.rootMappedConfig = JSON.parse(JSON.stringify(this.rootOldConfig))
mapConfigSpec(this.rootSpec, this.rootMappedConfig)
}
if (!this.rootConfig) {
this.rootConfig = JSON.parse(JSON.stringify(this.rootMappedConfig))
}
}
seek<S extends ValueType> (ptr: string): ConfigCursor<S> {
return new ConfigCursor(
this.rootSpec,
this.rootOldConfig,
this.rootMappedConfig,
this.rootConfig,
pointer.compile(
pointer.parse(this.ptr)
.concat(pointer.parse(ptr)),
),
)
}
seekNext<S extends ValueType> (key: string | number): ConfigCursor<S> {
return this.seek(pointer.compile([`${key}`]))
}
unseek<S extends ValueType> (levels?: number): ConfigCursor<S> {
let ptr: string
if (levels === undefined) {
ptr = ''
} else {
// TODO, delete or make use of, it isn't being used so far
// This is not being used so far
let ptr_arr = pointer.parse(this.ptr)
for (let i = 0; i < levels; i++) {
ptr_arr.pop()
}
ptr = pointer.compile(ptr_arr)
}
return new ConfigCursor(
this.rootSpec,
this.rootOldConfig,
this.rootMappedConfig,
this.rootConfig,
ptr,
)
}
key (): string {
return pointer.parse(this.ptr).pop()
}
oldConfig (): any {
if (pointer.has(this.rootOldConfig, this.ptr)) {
return pointer.get(this.rootOldConfig, this.ptr)
} else {
return undefined
}
}
mappedConfig (): any {
if (pointer.has(this.rootMappedConfig, this.ptr)) {
return pointer.get(this.rootMappedConfig, this.ptr)
} else {
return undefined
}
}
toString (): string {
const spec: ValueSpec = this.spec()
const config = this.config()
switch (spec.type) {
case 'string':
return config
case 'number':
return `${config}${spec.units ? ' ' + spec.units : ''}`
case 'object':
return spec.displayAs ? handlebars.compile(spec.displayAs)(config) : ''
case 'union':
return spec.displayAs ? handlebars.compile(spec.displayAs)(config) : config[spec.tag.id]
case 'pointer':
return 'System Defined'
default:
return ''
}
}
// if (config : T) then (spec : ValueSpecOf<T>)
config (): any {
if (pointer.has(this.rootConfig, this.ptr)) {
return pointer.get(this.rootConfig, this.ptr)
} else {
return undefined
}
}
// if (config : T) then (spec : ValueSpecOf<T>)
spec (): ValueSpecOf<T> {
if (this.cachedSpec) return this.cachedSpec
const parsed = pointer.parse(this.ptr)
// We elevate the rootSpec (ConfigSpec) to a dummy ValueSpecObject
let ret: ValueSpec = {
type: 'object',
spec: this.rootSpec,
nullable: false,
nullByDefault: false,
name: 'Config',
displayAs: 'Config',
uniqueBy: null,
}
let ptr = []
for (let seg of parsed) {
switch (ret.type) {
case 'object':
ret = ret.spec[seg]
break
case 'union':
if (seg === ret.tag.id) {
ret = {
type: 'enum',
default: ret.default,
values: Object.keys(ret.variants),
name: ret.tag.name,
description: ret.tag.description,
valueNames: ret.tag.variantNames,
}
} else {
const cfg = this.unseek().seek(pointer.compile(ptr))
ret = ret.variants[cfg.config()[ret.tag.id]][seg]
}
break
case 'list':
//in essence, for a list we replace the list typed ValueSpecOf with it's internal ListValueSpec, a ValueSpecOf<T> where config @ ptr is of type T[].
// we also append default values to it.
// note also that jsonKey is not used. jsonKey in this case is an index of an array, like 0, 1, etc.
// this implies that every index of a list has an identical inner spec
ret = listInnerSpec(ret)
break
default:
return undefined
}
if (ret === undefined) break
ptr.push(seg)
}
this.cachedSpec = ret as ValueSpecOf<T>
return this.cachedSpec
}
checkInvalid (): string | null { // null if valid
const spec: ValueSpec = this.spec()
const cfg = this.config()
switch (spec.type) {
case 'string':
if (!cfg) {
return spec.nullable ? null : `${spec.name} is missing.`
} else if (typeof cfg === 'string') {
if (!spec.pattern || new RegExp(spec.pattern).test(cfg)) {
return null
} else {
return spec.patternDescription
}
} else {
throw new TypeError(`${this.ptr}: expected string, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
}
case 'number':
if (!cfg) {
return spec.nullable ? null : `${spec.name} is missing.`
} else if (typeof cfg === 'number') {
if (spec.integral && cfg != Math.trunc(cfg)) {
return `${spec.name} must be an integer.`
}
try {
Range.from(spec.range).checkIncludes(cfg)
return null
} catch (e) {
return e.message
}
} else {
throw new TypeError(`${this.ptr}: expected number, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
}
case 'boolean':
if (typeof cfg === 'boolean') {
return null
} else {
throw new TypeError(`${this.ptr}: expected boolean, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
}
case 'enum':
if (typeof cfg === 'string') {
return spec.values.includes(cfg) ? null : `${cfg} is not a valid selection.`
} else {
throw new TypeError(`${this.ptr}: expected string, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
}
case 'list':
if (Array.isArray(cfg)) {
const range = Range.from(spec.range)
const min = range.integralMin()
const max = range.integralMax()
const length = cfg.length
if (min && length < min) {
return spec.subtype === 'enum' ? 'Not enough options selected.' : 'List is too short.'
}
if (max && length > max) {
return spec.subtype === 'enum' ? 'Too many options selected.' : 'List is too long.'
}
for (let idx in cfg) {
let cursor = this.seekNext(idx)
if (cursor.checkInvalid()) {
return `Item #${idx + 1} is invalid. ${cursor.checkInvalid()}`
}
for (let idx2 in cfg) {
if (idx !== idx2 && cursor.equals(this.seekNext(idx2))) {
return `Item #${idx + 1} is not unique.`
}
}
}
return null
} else {
throw new TypeError(`${this.ptr}: expected array, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
}
case 'object':
if (!cfg) {
return spec.nullable ? null : `${spec.name} is missing.`
} else if (typeof cfg === 'object' && !Array.isArray(cfg)) {
for (let idx in spec.spec) {
if (this.seekNext(idx).checkInvalid()) {
return `${spec.spec[idx].name} is invalid.`
}
}
return null
} else {
throw new TypeError(`${this.ptr}: expected object, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
}
case 'pointer':
return null
case 'union':
if (typeof cfg === 'object' && !Array.isArray(cfg)) {
if (typeof cfg[spec.tag.id] === 'string') {
for (let idx in spec.variants[cfg[spec.tag.id]]) {
if (this.seekNext(idx).checkInvalid()) {
return `${spec.variants[cfg[spec.tag.id]][idx].name} is invalid.`
}
}
return null
} else {
throw new TypeError(`${this.ptr}/${spec.tag.id}: expected string, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
}
} else {
throw new TypeError(`${this.ptr}: expected object, got ${Array.isArray(cfg) ? 'array' : typeof cfg}`)
}
}
}
isNew (): boolean {
const oldCfg = this.oldConfig()
const mappedCfg = this.mappedConfig()
if (mappedCfg && oldCfg && typeof mappedCfg === 'object' && typeof oldCfg === 'object') {
for (let key in mappedCfg) {
if (this.seekNext(key).isNew()) return true
}
return false
} else {
return mappedCfg !== oldCfg
}
}
isEdited (): boolean {
const cfg = this.config()
const mappedCfg = this.mappedConfig()
if (cfg && mappedCfg && typeof cfg === 'object' && typeof mappedCfg === 'object') {
const spec = this.spec()
let allKeys
if (spec.type === 'union') {
let unionSpec = spec as ValueSpecOf<'union'>
const labelForSelection = unionSpec.tag.id
allKeys = new Set([...Object.keys(unionSpec.variants[cfg[labelForSelection]])])
} else {
allKeys = new Set([...Object.keys(cfg), ...Object.keys(mappedCfg)])
}
for (let key of allKeys) {
if (this.seekNext(key).isEdited()) return true
}
return false
} else {
return cfg !== mappedCfg
}
}
equals (cursor: ConfigCursor<T>): boolean {
const lhs = this.config()
const rhs = cursor.config()
const spec: ValueSpec = this.spec()
switch (spec.type) {
case 'string':
case 'number':
case 'boolean':
case 'enum':
return lhs === rhs
case 'object':
case 'union':
return isEqual(spec.uniqueBy, this as ConfigCursor<'object' | 'union'>, cursor as ConfigCursor<'object' | 'union'>)
case 'list':
if (lhs.length !== rhs.length) {
return false
}
for (let idx = 0; idx < lhs.length; idx++) {
if (!this.seekNext(`${idx}`).equals(cursor.seekNext(`${idx}`))) {
return false
}
}
return true
default:
return false
}
}
getAnnotations (): Annotations<T> {
const spec: ValueSpec = this.spec()
switch (spec.type) {
case 'object': {
const ret: Annotations<'object'> = {
self: {
invalid: this.checkInvalid(),
edited: this.isEdited(),
added: this.isNew(),
},
members: { },
}
for (let key in spec.spec) {
let annotation: any = this.seekNext(key).getAnnotations()
if ('self' in annotation) {
annotation = annotation.self
}
ret.members[key] = annotation
}
return ret as Annotations<T>
}
case 'union': {
const ret: Annotations<'union'> = {
self: {
invalid: this.checkInvalid(),
edited: this.isEdited(),
added: this.isNew(),
},
members: {
[spec.tag.id]: this.seekNext<'enum'>(spec.tag.id).getAnnotations(),
},
}
for (let key in spec.variants[this.config()[spec.tag.id]]) {
let annotation: any = this.seekNext(key).getAnnotations()
if ('self' in annotation) {
annotation = annotation.self
}
ret.members[key] = annotation
}
return ret as Annotations<T>
}
case 'list': {
const ret: Annotations<'list'> = {
self: {
invalid: this.checkInvalid(),
edited: this.isEdited(),
added: this.isNew(),
},
members: [],
}
for (let key in this.config()) {
let annotation: any = this.seekNext(key).getAnnotations()
if ('self' in annotation) {
annotation = annotation.self
}
ret.members[key] = annotation
}
return ret as Annotations<T>
}
default:
return {
invalid: this.checkInvalid(),
edited: this.isEdited(),
added: this.isNew(),
} as Annotations<T>
}
}
async createFirstEntryForList () {
const spec: ValueSpec = this.spec()
if (spec.type === 'object' && !this.config()) {
pointer.set(this.rootConfig, this.ptr, getDefaultObject(spec.spec))
}
if (spec.type === 'union' && !this.config()) {
pointer.set(this.rootConfig, this.ptr, getDefaultUnion(spec))
}
}
injectModalData (res: { data?: any }): void {
if (res.data !== undefined) {
pointer.set(this.rootConfig, this.ptr, res.data)
}
}
}
function isEqual (uniqueBy: UniqueBy, lhs: ConfigCursor<'object'>, rhs: ConfigCursor<'object'>): boolean {
if (uniqueBy === null) {
return false
} else if (typeof uniqueBy === 'string') {
return lhs.seekNext(uniqueBy).equals(rhs.seekNext(uniqueBy))
} else if ('any' in uniqueBy) {
for (let subSpec of uniqueBy.any) {
if (isEqual(subSpec, lhs, rhs)) {
return true
}
}
return false
} else if ('all' in uniqueBy) {
for (let subSpec of uniqueBy.all) {
if (!isEqual(subSpec, lhs, rhs)) {
return false
}
}
return true
}
}

View File

@@ -0,0 +1,133 @@
export interface ConfigSpec { [key: string]: ValueSpec }
export type ValueType = 'string' | 'number' | 'boolean' | 'enum' | 'list' | 'object' | 'pointer' | 'union'
export type ValueSpec = ValueSpecOf<ValueType>
// core spec types. These types provide the metadata for performing validations
export type ValueSpecOf<T extends ValueType> =
T extends 'string' ? ValueSpecString :
T extends 'number' ? ValueSpecNumber :
T extends 'boolean' ? ValueSpecBoolean :
T extends 'enum' ? ValueSpecEnum :
T extends 'list' ? ValueSpecList :
T extends 'object' ? ValueSpecObject :
T extends 'pointer' ? ValueSpecPointer :
T extends 'union' ? ValueSpecUnion :
never
export interface ValueSpecString extends ListValueSpecString, WithStandalone {
type: 'string'
default?: DefaultString
nullable: boolean
masked: boolean
copyable: boolean
}
export interface ValueSpecNumber extends ListValueSpecNumber, WithStandalone {
type: 'number'
nullable: boolean
default?: number
}
export interface ValueSpecEnum extends ListValueSpecEnum, WithStandalone {
type: 'enum'
default: string
}
export interface ValueSpecBoolean extends WithStandalone {
type: 'boolean'
default: boolean
}
export interface ValueSpecUnion extends ListValueSpecUnion, WithStandalone {
type: 'union'
}
export interface ValueSpecPointer extends WithStandalone {
type: 'pointer'
subtype: 'app' | 'system'
target: 'lan-address' | 'tor-address' | 'config'
'app-id': string
}
export interface ValueSpecObject extends ListValueSpecObject, WithStandalone {
type: 'object'
nullable: boolean
nullByDefault: boolean
}
export interface WithStandalone {
name: string
description?: string
changeWarning?: string
}
// no lists of booleans, lists, pointers
export type ListValueSpecType = 'string' | 'number' | 'enum' | 'object' | 'union'
// represents a spec for the values of a list
export type ListValueSpecOf<T extends ListValueSpecType> =
T extends 'string' ? ListValueSpecString :
T extends 'number' ? ListValueSpecNumber :
T extends 'enum' ? ListValueSpecEnum :
T extends 'object' ? ListValueSpecObject :
T extends 'union' ? ListValueSpecUnion :
never
// represents a spec for a list
export type ValueSpecList = ValueSpecListOf<ListValueSpecType>
export interface ValueSpecListOf<T extends ListValueSpecType> extends WithStandalone {
type: 'list'
subtype: T
spec: ListValueSpecOf<T>
range: string // '[0,1]' (inclusive) OR '[0,*)' (right unbounded), normal math rules
default: string[] | number[] | DefaultString[] | object[]
}
// sometimes the type checker needs just a little bit of help
export function isValueSpecListOf<S extends ListValueSpecType> (t: ValueSpecList, s: S): t is ValueSpecListOf<S> {
return t.subtype === s
}
export interface ListValueSpecString {
pattern?: string
patternDescription?: string
}
export interface ListValueSpecNumber {
range: string
integral: boolean
units?: string
}
export interface ListValueSpecEnum {
values: string[]
valueNames: { [value: string]: string }
}
export interface ListValueSpecObject {
spec: ConfigSpec //this is a mapped type of the config object at this level, replacing the object's values with specs on those values
uniqueBy: UniqueBy //indicates whether duplicates can be permitted in the list
displayAs?: string //this should be a handlebars template which can make use of the entire config which corresponds to 'spec'
}
export type UniqueBy = null | string | { any: UniqueBy[] } | { all: UniqueBy[] }
export interface ListValueSpecUnion {
tag: UnionTagSpec
variants: { [key: string]: ConfigSpec }
displayAs?: string //this may be a handlebars template which can conditionally (on tag.id) make use of each union's entries, or if left blank will display as tag.id
uniqueBy: UniqueBy
default: string //this should be the variantName which one prefers a user to start with by default when creating a new union instance in a list
}
export interface UnionTagSpec {
id: string //The name of the field containing one of the union variants
name: string
description?: string
variantNames: { //the name of each variant
[variant: string]: string
}
}
export type DefaultString = string | { charset: string, len: number }

View File

@@ -0,0 +1,413 @@
import {
ValueSpec, ValueSpecList, DefaultString, ValueSpecUnion, ConfigSpec,
ValueSpecObject, ValueSpecString, ValueSpecEnum, ValueSpecNumber,
ValueSpecBoolean, ValueSpecPointer, ValueSpecOf, ListValueSpecType
} from './config-types'
export interface Annotation {
invalid: string | null
edited: boolean
added: boolean
}
export type Annotations<T extends string> =
T extends 'object' | 'union' ? { self: Annotation, members: { [key: string]: Annotation } } :
T extends 'list' ? { self: Annotation, members: Annotation[] } :
Annotation
export class Range {
min?: number
max?: number
minInclusive: boolean
maxInclusive: boolean
static from (s: string): Range {
const r = new Range()
r.minInclusive = s.startsWith('[')
r.maxInclusive = s.endsWith(']')
const [minStr, maxStr] = s.split(',').map(a => a.trim())
r.min = minStr === '(*' ? undefined : Number(minStr.slice(1))
r.max = maxStr === '*)' ? undefined : Number(maxStr.slice(0, -1))
return r
}
checkIncludes (n: number) {
if (this.hasMin() !== undefined && ((!this.minInclusive && this.min == n || (this.min > n)))) {
throw new Error(`Value must be ${this.minMessage()}.`)
}
if (this.hasMax() && ((!this.maxInclusive && this.max == n || (this.max < n)))) {
throw new Error(`Value must be ${this.maxMessage()}.`)
}
}
hasMin (): boolean {
return this.min !== undefined
}
hasMax (): boolean {
return this.max !== undefined
}
minMessage (): string {
return `greater than${this.minInclusive ? ' or equal to' : ''} ${this.min}`
}
maxMessage (): string {
return `less than${this.maxInclusive ? ' or equal to' : ''} ${this.max}`
}
description (): string {
let message = 'Value can be any number.'
if (this.hasMin() || this.hasMax()) {
message = 'Value must be'
}
if (this.hasMin() && this.hasMax()) {
message = `${message} ${this.minMessage()} AND ${this.maxMessage()}.`
} else if (this.hasMin() && !this.hasMax()) {
message = `${message} ${this.minMessage()}.`
} else if (!this.hasMin() && this.hasMax()) {
message = `${message} ${this.maxMessage()}.`
}
return message
}
integralMin (): number | undefined {
if (this.min) {
const ceil = Math.ceil(this.min)
if (this.minInclusive) {
return ceil
} else {
if (ceil === this.min) {
return ceil + 1
} else {
return ceil
}
}
}
}
integralMax (): number | undefined {
if (this.max) {
const floor = Math.floor(this.max)
if (this.maxInclusive) {
return floor
} else {
if (floor === this.max) {
return floor - 1
} else {
return floor
}
}
}
}
}
// converts a ValueSpecList, i.e. a spec for a list, to its inner ListValueSpec, i.e., a spec for the values within the list.
// We then augment it with the defalt values (e.g. nullable: false) to make a
export function listInnerSpec (listSpec: ValueSpecList): ValueSpecOf<ListValueSpecType> {
return {
type: listSpec.subtype,
nullable: false,
name: listSpec.name,
description: listSpec.description,
changeWarning: listSpec.changeWarning,
...listSpec.spec as any, //listSpec.spec is a ListValueSpecOf listSpec.subtype
}
}
export function mapSpecToConfigValue (spec: ValueSpec, value: any): any {
if (value === undefined) return undefined
switch (spec.type) {
case 'string': return mapStringSpec(value)
case 'number': return mapNumberSpec(value)
case 'boolean': return mapBooleanSpec(spec, value)
case 'enum': return mapEnumSpec(spec, value)
case 'list': return mapListSpec(spec, value)
case 'object': return mapObjectSpec(spec, value)
case 'union': return mapUnionSpec(spec, value)
case 'pointer': return value
}
}
export function mapConfigSpec (configSpec: ConfigSpec, value: any): object {
if (value && typeof value === 'object' && !Array.isArray(value)) {
Object.entries(configSpec).map(([key, val]) => {
value[key] = mapSpecToConfigValue(val, value[key])
if (value[key] === undefined) {
value[key] = getDefaultConfigValue(val)
}
})
return value
} else {
return getDefaultObject(configSpec)
}
}
export function mapObjectSpec (spec: ValueSpecObject, value: any): object {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return mapConfigSpec(spec.spec, value)
} else {
return null
}
}
export function mapUnionSpec (spec: ValueSpecUnion, value: any): object {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const variant = mapEnumSpec({
...spec.tag,
type: 'enum',
default: spec.default,
values: Object.keys(spec.variants),
valueNames: spec.tag.variantNames,
}, value[spec.tag.id])
value = mapConfigSpec(spec.variants[variant], value)
value[spec.tag.id] = variant
return value
} else {
return getDefaultUnion(spec)
}
}
export function mapStringSpec (value: any): string {
if (typeof value === 'string') {
return value
} else {
return null
}
}
export function mapNumberSpec (value: any): number {
if (typeof value === 'number') {
return value
} else {
return null
}
}
export function mapEnumSpec (spec: ValueSpecEnum, value: any): string {
if (typeof value === 'string' && spec.values.includes(value)) {
return value
} else {
return spec.default
}
}
export function mapListSpec (spec: ValueSpecList, value: any): string[] | number[] | object[] {
if (Array.isArray(value)) {
const innerSpec = listInnerSpec(spec)
return value.map(item => mapSpecToConfigValue(innerSpec, item))
} else {
return getDefaultList(spec)
}
}
export function mapBooleanSpec (spec: ValueSpecBoolean, value: any): boolean {
if (typeof value === 'boolean') {
return value
} else {
return spec.default
}
}
export function getDefaultConfigValue (spec: ValueSpec): string | number | object | string[] | number[] | object[] | boolean | null {
switch (spec.type) {
case 'object':
return spec.nullByDefault ? null : getDefaultObject(spec.spec)
case 'union':
return getDefaultUnion(spec)
case 'string':
return spec.default ? getDefaultString(spec.default) : null
case 'number':
return spec.default || null
case 'list':
return getDefaultList(spec)
case 'enum':
case 'boolean':
return spec.default
case 'pointer':
return null
}
}
export function getDefaultObject (spec: ConfigSpec): object {
const obj = { }
Object.entries(spec).map(([key, val]) => {
obj[key] = getDefaultConfigValue(val)
})
return obj
}
export function getDefaultList (spec: ValueSpecList): string[] | number[] | object[] {
if (spec.subtype === 'object') {
const l = (spec.default as any[])
const range = Range.from(spec.range)
while (l.length < range.integralMin()) {
l.push(getDefaultConfigValue(listInnerSpec(spec)))
}
return l as string[] | number[] | object[]
} else {
const l = (spec.default as any[]).map(d => getDefaultConfigValue({ ...listInnerSpec(spec), default: d }))
return l as string[] | number[] | object[]
}
}
export function getDefaultUnion (spec: ValueSpecUnion): object {
return { [spec.tag.id]: spec.default, ...getDefaultObject(spec.variants[spec.default]) }
}
export function getDefaultMapTagKey (defaultSpec: DefaultString = '', value: object): string {
const keySrc = getDefaultString(defaultSpec)
const keys = Object.keys(value)
let key = keySrc
let idx = 1
while (keys.includes(key)) {
key = `${keySrc}-${idx++}`
}
return key
}
export function getDefaultString (defaultSpec: DefaultString): string {
if (typeof defaultSpec === 'string') {
return defaultSpec
} else {
let s = ''
for (let i = 0; i < defaultSpec.len; i++) {
s = s + getRandomCharInSet(defaultSpec.charset)
}
return s
}
}
export function getDefaultDescription (spec: ValueSpec): string {
let toReturn: string | undefined
switch (spec.type) {
case 'string':
if (typeof spec.default === 'string') {
toReturn = spec.default
} else if (typeof spec.default === 'object') {
toReturn = 'random'
}
break
case 'number':
if (typeof spec.default === 'number') {
toReturn = String(spec.default)
}
break
case 'boolean':
toReturn = spec.default === true ? 'True' : 'False'
break
case 'enum':
toReturn = spec.valueNames[spec.default]
break
}
return toReturn || ''
}
// a,g,h,A-Z,,,,-
export function getRandomCharInSet (charset: string): string {
const set = stringToCharSet(charset)
let charIdx = Math.floor(Math.random() * set.len)
for (let range of set.ranges) {
if (range.len > charIdx) {
return String.fromCharCode(range.start.charCodeAt(0) + charIdx)
}
charIdx -= range.len
}
throw new Error('unreachable')
}
function stringToCharSet (charset: string): CharSet {
let set: CharSet = { ranges: [], len: 0 }
let start: string | null = null
let end: string | null = null
let in_range = false
for (let char of charset) {
switch (char) {
case ',':
if (start !== null && end !== null) {
if (start!.charCodeAt(0) > end!.charCodeAt(0)) {
throw new Error('start > end of charset')
}
const len = end.charCodeAt(0) - start.charCodeAt(0) + 1
set.ranges.push({
start,
end,
len,
})
set.len += len
start = null
end = null
in_range = false
} else if (start !== null && !in_range) {
set.len += 1
set.ranges.push({ start, end: start, len: 1 })
start = null
} else if (start !== null && in_range) {
end = ','
} else if (start === null && end === null && !in_range) {
start = ','
} else {
throw new Error('unexpected ","')
}
break
case '-':
if (start === null) {
start = '-'
} else if (!in_range) {
in_range = true
} else if (in_range && end === null) {
end = '-'
} else {
throw new Error('unexpected "-"')
}
break
default:
if (start === null) {
start = char
} else if (in_range && end === null) {
end = char
} else {
throw new Error(`unexpected "${char}"`)
}
}
}
if (start !== null && end !== null) {
if (start!.charCodeAt(0) > end!.charCodeAt(0)) {
throw new Error('start > end of charset')
}
const len = end.charCodeAt(0) - start.charCodeAt(0) + 1
set.ranges.push({
start,
end,
len,
})
set.len += len
} else if (start !== null) {
set.len += 1
set.ranges.push({
start,
end: start,
len: 1,
})
}
return set
}
interface CharSet {
ranges: {
start: string
end: string
len: number
}[]
len: number
}

View File

@@ -0,0 +1,27 @@
import { ConfigCursor } from './config-cursor'
import { TrackingModalController } from '../services/tracking-modal-controller.service'
export class ModalPresentable {
constructor (private readonly trackingModalCtrl: TrackingModalController) { }
async presentModal (cursor: ConfigCursor<any>, callback: () => any) {
const modal = await this.trackingModalCtrl.createConfigModal({
backdropDismiss: false,
presentingElement: await this.trackingModalCtrl.getTop(),
componentProps: {
cursor,
},
}, cursor.spec().type)
modal.onWillDismiss().then(res => {
cursor.injectModalData(res)
callback()
})
await modal.present()
}
dismissModal (a: any) {
return this.trackingModalCtrl.dismiss(a)
}
}

View File

@@ -0,0 +1,48 @@
import { NgModule } from '@angular/core'
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
import { AuthGuard } from './guards/auth.guard'
import { UnauthGuard } from './guards/unauth.guard'
const routes: Routes = [
{
redirectTo: 'services',
pathMatch: 'full',
path: '',
},
{
path: 'authenticate',
canActivate: [UnauthGuard],
pathMatch: 'full',
loadChildren: () => import('./pages/authenticate/authenticate.module').then( m => m.AuthenticatePageModule),
},
{
path: 'embassy',
canActivate: [AuthGuard],
canActivateChild: [AuthGuard],
loadChildren: () => import('./pages/server-routes/server-routing.module').then(m => m.ServerRoutingModule),
},
{
path: 'notifications',
canActivate: [AuthGuard],
canActivateChild: [AuthGuard],
loadChildren: () => import('./pages/notifications/notifications.module').then(m => m.NotificationsPageModule),
},
{
path: 'services',
canActivate: [AuthGuard],
canActivateChild: [AuthGuard],
loadChildren: () => import('./pages/apps-routes/apps-routing.module').then(m => m.AppsRoutingModule),
},
]
@NgModule({
imports: [
RouterModule.forRoot(routes, {
preloadingStrategy: PreloadAllModules,
initialNavigation: false,
useHash: true,
}),
],
exports: [RouterModule],
})
export class AppRoutingModule { }

View File

@@ -0,0 +1,159 @@
<ion-app>
<ion-split-pane (ionSplitPaneVisible)="splitPaneVisible($event)" contentId="main-content">
<ion-menu contentId="main-content" type="overlay">
<ion-header *ngIf="$showMenuContent$ | async" class="menu-style">
<ion-toolbar style="--background: var(--ion-background-color);">
<ion-title *ngIf="serverName$ | async as name">{{ name }}</ion-title>
<ion-title *ngIf="!(serverName$ | async)"><ion-spinner name="dots" color="warning"></ion-spinner></ion-title>
</ion-toolbar>
</ion-header>
<ion-content scroll-y="false" class="menu-style">
<ng-container *ngIf="$showMenuContent$ | async">
<ion-list style="padding: 0px">
<ion-menu-toggle auto-hide="false" *ngFor="let page of appPages; let i = index">
<ion-item
button
(click)="selectedIndex = i"
routerDirection="root"
[routerLink]="[page.url]"
lines="none"
detail="false"
[class.selected]="selectedIndex == i"
>
<ion-icon slot="start" [name]="page.icon"></ion-icon>
<ion-label style="font-family: 'Montserrat';">{{page.title}}</ion-label>
<ion-badge *ngIf="page.url === '/notifications' && (serverBadge$ | async) as s" color="danger" style="margin-right: 3%;" [class.selected-badge]="selectedIndex == i">{{s}}</ion-badge>
</ion-item>
</ion-menu-toggle>
</ion-list>
</ng-container>
<ng-container *ngIf="!($showMenuContent$ | async)">
<ion-card>
<ion-card-header>
<ion-card-title>Welcome</ion-card-title>
</ion-card-header>
<ion-card-content>
<p>This is the private website of your Start9 Embassy device.</p>
<br />
<p>Please authenticate yourself to continue.</p>
</ion-card-content>
</ion-card>
</ng-container>
</ion-content>
<ion-footer style="padding-bottom: 5%; text-align: center;" class="menu-style">
<ion-menu-toggle auto-hide="false">
<ion-item *ngIf="$showMenuContent$ | async" button style="--background:var(--ion-background-color); margin-bottom: 10%;" fill="clear" (click)="presentAlertLogout()">
<ion-icon size="small" slot="start" color="dark" name="log-out-outline"></ion-icon>
<ion-label><ion-text color="danger">Logout</ion-text></ion-label>
</ion-item>
</ion-menu-toggle>
<img style="width: 36%;" src="assets/logo-full.png">
</ion-footer>
</ion-menu>
<ion-router-outlet id="main-content"></ion-router-outlet>
</ion-split-pane>
<section id="preload" style="display: none;">
<!-- 3rd party components -->
<qrcode qrdata="hello"></qrcode>
<img src="assets/img/running-bulb.png"/>
<img src="assets/img/issue-bulb.png"/>
<img src="assets/img/warning-bulb.png"/>
<img src="assets/img/off-bulb.png"/>
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=">
<!-- Ionicons -->
<ion-icon name="add"></ion-icon> <!--0.2.5-->
<ion-icon name="alert-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="arrow-back"></ion-icon> <!--0.2.5-->
<ion-icon name="arrow-forward"></ion-icon> <!--0.2.5-->
<ion-icon name="arrow-up"></ion-icon> <!--0.2.5-->
<ion-icon name="bookmark-outline"></ion-icon>
<ion-icon name="cart-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="chevron-down"></ion-icon> <!--0.2.5-->
<ion-icon name="chevron-up"></ion-icon> <!--0.2.5-->
<ion-icon name="close"></ion-icon> <!--0.2.5-->
<ion-icon name="close-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="code-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="cog-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="color-wand-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="construct-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="copy-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="cube-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="download-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="ellipse"></ion-icon> <!--0.2.5-->
<ion-icon name="eye-off-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="eye-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="file-tray-stacked-outline"></ion-icon>
<ion-icon name="grid-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="help-circle-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="home-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="information-circle-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="list-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="newspaper-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="notifications-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="power"></ion-icon> <!--0.2.5-->
<ion-icon name="pulse"></ion-icon> <!--0.2.5-->
<ion-icon name="qr-code-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="reload-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="refresh-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="save-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="terminal-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="trash-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="warning-outline"></ion-icon> <!--0.2.5-->
<ion-icon name="wifi"></ion-icon> <!--0.2.5-->
<!-- Ionic components -->
<ion-action-sheet></ion-action-sheet>
<ion-alert></ion-alert>
<ion-avatar></ion-avatar>
<ion-badge></ion-badge>
<ion-button></ion-button>
<ion-buttons></ion-buttons>
<ion-card></ion-card>
<ion-card-content></ion-card-content>
<ion-card-header></ion-card-header>
<ion-checkbox></ion-checkbox>
<ion-content></ion-content>
<ion-fab></ion-fab>
<ion-fab-button></ion-fab-button>
<ion-footer></ion-footer>
<ion-grid></ion-grid>
<ion-header></ion-header>
<ion-popover></ion-popover>
<ion-content>
<ion-refresher slot="fixed"></ion-refresher>
<ion-refresher-content pullingContent="lines"></ion-refresher-content>
<ion-infinite-scroll></ion-infinite-scroll>
<ion-infinite-scroll-content loadingSpinner="lines"></ion-infinite-scroll-content>
</ion-content>
<ion-input></ion-input>
<ion-input type="password">getdots</ion-input>
<ion-item></ion-item>
<ion-item-divider></ion-item-divider>
<ion-item-group></ion-item-group>
<ion-item-options></ion-item-options>
<ion-item-sliding></ion-item-sliding>
<ion-label></ion-label>
<ion-list></ion-list>
<ion-loading></ion-loading>
<ion-modal></ion-modal>
<ion-note></ion-note>
<ion-radio></ion-radio> <!--0.2.5-->
<ion-row></ion-row>
<ion-segment></ion-segment>
<ion-segment-button></ion-segment-button>
<ion-select></ion-select>
<ion-select-option></ion-select-option>
<ion-slides></ion-slides> <!--0.2.5-->
<ion-spinner name="dots"></ion-spinner> <!--0.2.5-->
<ion-spinner name="lines"></ion-spinner> <!--0.2.5-->
<ion-text></ion-text>
<ion-textarea></ion-textarea>
<ion-title></ion-title>
<ion-toast></ion-toast>
<ion-toggle></ion-toggle>
<ion-toolbar></ion-toolbar>
<ion-menu-button></ion-menu-button>
</section>
</ion-app>

View File

@@ -0,0 +1,13 @@
.selected {
--background: linear-gradient(120deg, #1e1e1e -1%, var(--ion-color-start9) 96%);
}
.menu-style {
border-style: solid;
border-width: 0px 1px 0px 0px;
border-color: #ff4960;
}
.selected-badge {
background-color: #1e1e1e;
}

190
ui/src/app/app.component.ts Normal file
View File

@@ -0,0 +1,190 @@
import { Component } from '@angular/core'
import { ServerModel, ServerStatus } from './models/server-model'
import { Storage } from '@ionic/storage'
import { SyncDaemon } from './services/sync.service'
import { AuthService, AuthState } from './services/auth.service'
import { ApiService } from './services/api/api.service'
import { Router } from '@angular/router'
import { BehaviorSubject, Observable } from 'rxjs'
import { AppModel } from './models/app-model'
import { filter, take } from 'rxjs/operators'
import { AlertController } from '@ionic/angular'
import { LoaderService } from './services/loader.service'
import { Emver } from './services/emver.service'
import { SplitPaneTracker } from './services/split-pane.service'
import { LoadingOptions } from '@ionic/core'
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
})
export class AppComponent {
isUpdating = false
fullPageMenu = true
$showMenuContent$ = new BehaviorSubject(false)
serverName$ : Observable<string>
serverBadge$: Observable<number>
selectedIndex = 0
appPages = [
{
title: 'Services',
url: '/services/installed',
icon: 'grid-outline',
},
{
title: 'Embassy',
url: '/embassy',
icon: 'cube-outline',
},
{
title: 'Marketplace',
url: '/services/marketplace',
icon: 'cart-outline',
},
{
title: 'Notifications',
url: '/notifications',
icon: 'notifications-outline',
},
]
constructor (
private readonly serverModel: ServerModel,
private readonly syncDaemon: SyncDaemon,
private readonly storage: Storage,
private readonly appModel: AppModel,
private readonly authService: AuthService,
private readonly router: Router,
private readonly api: ApiService,
private readonly alertCtrl: AlertController,
private readonly loader: LoaderService,
private readonly emver: Emver,
readonly splitPane: SplitPaneTracker,
) {
// set dark theme
document.body.classList.toggle('dark', true)
this.serverName$ = this.serverModel.watch().name
this.serverBadge$ = this.serverModel.watch().badge
this.init()
}
async init () {
let fromFresh = true
await this.storage.ready()
await this.authService.restoreCache()
await this.emver.init()
this.authService.listen({
[AuthState.VERIFIED]: async () => {
console.log('verified')
this.api.authenticatedRequestsEnabled = true
await this.serverModel.restoreCache()
await this.appModel.restoreCache()
this.syncDaemon.start()
this.$showMenuContent$.next(true)
if (fromFresh) {
this.router.initialNavigation()
fromFresh = false
}
},
[AuthState.UNVERIFIED]: () => {
console.log('unverified')
this.api.authenticatedRequestsEnabled = false
this.serverModel.clear()
this.appModel.clear()
this.syncDaemon.stop()
this.storage.clear()
this.router.navigate(['/authenticate'], { replaceUrl: true })
this.$showMenuContent$.next(false)
if (fromFresh) {
this.router.initialNavigation()
fromFresh = false
}
},
})
this.serverModel.watch().status.subscribe(s => {
this.isUpdating = (s === ServerStatus.UPDATING)
})
this.router.events.pipe(filter(e => !!(e as any).urlAfterRedirects)).subscribe((e: any) => {
const appPageIndex = this.appPages.findIndex(
appPage => (e.urlAfterRedirects || e.url || '').startsWith(appPage.url),
)
if (appPageIndex > -1) this.selectedIndex = appPageIndex
// TODO: while this works, it is dangerous and impractical.
if (e.urlAfterRedirects !== '/embassy' && e.urlAfterRedirects !== '/authenticate' && this.isUpdating) {
this.router.navigateByUrl('/embassy')
}
})
this.api.watch401$().subscribe(() => {
this.authService.setAuthStateUnverified()
return this.api.postLogout()
})
}
async presentAlertLogout () {
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: 'Caution',
message: 'Are you sure you want to logout?',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Logout',
cssClass: 'alert-danger',
handler: () => {
this.logout()
},
},
],
})
await alert.present()
}
private async logout () {
this.serverName$.pipe(take(1)).subscribe(name => {
this.loader.of(LoadingSpinner(`Logging out ${name || ''}...`))
.displayDuringP(this.api.postLogout())
.then(() => this.authService.setAuthStateUnverified())
.catch(e => this.setError(e))
})
}
async setError (e: Error) {
console.error(e)
await this.presentError(e.message)
}
async presentError (e: string) {
const alert = await this.alertCtrl.create({
backdropDismiss: true,
message: `Exception on logout: ${e}`,
buttons: [
{
text: 'Dismiss',
role: 'cancel',
},
],
})
await alert.present()
}
splitPaneVisible (e) {
this.splitPane.$menuFixedOpenOnLeft$.next(e.detail.visible)
}
}
const LoadingSpinner: (m?: string) => LoadingOptions = (m) => {
const toMergeIn = m ? { message: m } : { }
return {
spinner: 'lines',
cssClass: 'loader',
...toMergeIn,
} as LoadingOptions
}

41
ui/src/app/app.module.ts Normal file
View File

@@ -0,0 +1,41 @@
import { NgModule, CUSTOM_ELEMENTS_SCHEMA, Type } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { RouteReuseStrategy } from '@angular/router'
import { IonicModule, IonicRouteStrategy } from '@ionic/angular'
import { IonicStorageModule } from '@ionic/storage'
import { HttpClientModule } from '@angular/common/http'
import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module'
import { ApiService } from './services/api/api.service'
import { ApiServiceFactory } from './services/api/api.service.factory'
import { AppModel } from './models/app-model'
import { HttpService } from './services/http.service'
import { ServerModel } from './models/server-model'
import { ConfigService } from './services/config.service'
import { QRCodeModule } from 'angularx-qrcode'
import { APP_CONFIG_COMPONENT_MAPPING } from './modals/app-config-injectable/modal-injectable-token'
import { appConfigComponents } from './modals/app-config-injectable/modal-injectable-value';
@NgModule({
declarations: [AppComponent],
entryComponents: [],
imports: [
HttpClientModule,
BrowserModule,
IonicModule.forRoot(),
AppRoutingModule,
IonicStorageModule.forRoot(),
QRCodeModule,
],
providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
{ provide: ApiService, useFactory: ApiServiceFactory, deps: [ConfigService, HttpService, AppModel, ServerModel] },
{ provide: APP_CONFIG_COMPONENT_MAPPING, useValue: appConfigComponents },
],
bootstrap: [AppComponent],
schemas: [ CUSTOM_ELEMENTS_SCHEMA ],
})
export class AppModule { }

View File

@@ -0,0 +1,12 @@
<div style="position: relative; margin-right: 1vh;">
<ion-icon
*ngIf="(badge$ | async) && !(menuFixedOpen$ | async)"
size="medium"
color="dark"
[class.ios-badge]="isIos"
[class.md-badge]="!isIos"
name="alert-outline"
>
</ion-icon>
<ion-menu-button color="dark"></ion-menu-button>
</div>

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { BadgeMenuComponent } from './badge-menu.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
BadgeMenuComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [BadgeMenuComponent],
})
export class BadgeMenuComponentModule { }

View File

@@ -0,0 +1,17 @@
.ios-badge {
background-color: var(--ion-color-start9);
position: absolute;
top: 1px;
left: 62%;
border-radius: 5px;
z-index: 1;
}
.md-badge {
background-color: var(--ion-color-start9);
position: absolute;
top: -2px;
left: 56%;
border-radius: 5px;
z-index: 1;
}

View File

@@ -0,0 +1,27 @@
import { Component } from '@angular/core'
import { ServerModel } from '../../models/server-model'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { SplitPaneTracker } from 'src/app/services/split-pane.service'
import { isPlatform } from '@ionic/angular'
@Component({
selector: 'badge-menu-button',
templateUrl: './badge-menu.component.html',
styleUrls: ['./badge-menu.component.scss'],
})
export class BadgeMenuComponent {
badge$: Observable<boolean>
menuFixedOpen$: Observable<boolean>
isIos: boolean
constructor (
private readonly serverModel: ServerModel,
private readonly splitPane: SplitPaneTracker,
) {
this.menuFixedOpen$ = this.splitPane.$menuFixedOpenOnLeft$.asObservable()
this.badge$ = this.serverModel.watch().badge.pipe(map(i => i > 0))
this.isIos = isPlatform('ios')
}
}

View File

@@ -0,0 +1,24 @@
<ion-item-group>
<!-- error -->
<ng-container *ngIf="error">
<ion-item>
<ion-icon slot="start" name="warning-outline" color="danger" size="small"></ion-icon>
<ion-label><ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text></ion-label>
</ion-item>
<ion-item-divider *ngIf="spec.description || spec.changeWarning"></ion-item-divider>
</ng-container>
<!-- description -->
<ion-item *ngIf="spec.description">
<ion-label class="ion-text-wrap">
<p><ion-text color="dark">Description</ion-text></p>
<p>{{ spec.description }}</p>
</ion-label>
</ion-item>
<!-- warning -->
<ion-item *ngIf="spec.changeWarning">
<ion-label class="ion-text-wrap">
<p><ion-text color="warning">Warning!</ion-text></p>
<p>{{ spec.changeWarning }}</p>
</ion-label>
</ion-item>
</ion-item-group>

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { ConfigHeaderComponent } from './config-header.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
ConfigHeaderComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [ConfigHeaderComponent],
})
export class ConfigHeaderComponentModule { }

View File

@@ -0,0 +1,12 @@
import { Component, Input } from '@angular/core'
import { ValueSpec } from 'src/app/app-config/config-types'
@Component({
selector: 'config-header',
templateUrl: './config-header.component.html',
styleUrls: ['./config-header.component.scss'],
})
export class ConfigHeaderComponent {
@Input() spec: ValueSpec
@Input() error: string
}

View File

@@ -0,0 +1,14 @@
<div *ngFor="let dep of dependenciesToDisplay; let index = i">
<marketplace-dependency-item *ngIf="depType === 'available'" style="width: 100%"
[dep]="dep"
[hostApp]="hostApp"
[$loading$]="$loading$"
>
</marketplace-dependency-item>
<installed-dependency-item *ngIf="depType === 'installed'" style="width: 100%"
[dep]="dep"
[hostApp]="hostApp"
[$loading$]="$loading$"
>
</installed-dependency-item>
</div>

View File

@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { DependencyListComponent } from './dependency-list.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { InformationPopoverComponentModule } from '../information-popover/information-popover.component.module'
import { StatusComponentModule } from '../status/status.component.module'
import { InstalledDependencyItemComponentModule } from './installed-dependency-item/installed-dependency-item.component.module'
import { MarketplaceDependencyItemComponentModule } from './marketplace-dependency-item/marketplace-dependency-item.component.module'
@NgModule({
declarations: [
DependencyListComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
InformationPopoverComponentModule,
StatusComponentModule,
InstalledDependencyItemComponentModule,
MarketplaceDependencyItemComponentModule,
],
exports: [DependencyListComponent],
})
export class DependencyListComponentModule { }

View File

@@ -0,0 +1,30 @@
import { Component, Input } from '@angular/core'
import { BehaviorSubject } from 'rxjs'
import { AppDependency, BaseApp, isOptional } from 'src/app/models/app-types'
@Component({
selector: 'dependency-list',
templateUrl: './dependency-list.component.html',
styleUrls: ['./dependency-list.component.scss'],
})
export class DependencyListComponent {
@Input() depType: 'installed' | 'available' = 'available'
@Input() hostApp: BaseApp
@Input() dependencies: AppDependency[]
dependenciesToDisplay: AppDependency[]
@Input() $loading$: BehaviorSubject<boolean> = new BehaviorSubject(true)
constructor () { }
ngOnChanges () {
this.dependenciesToDisplay = this.dependencies.filter(dep =>
this.depType === 'available' ? !isOptional(dep) : true,
)
}
ngOnInit () {
this.dependenciesToDisplay = this.dependencies.filter(dep =>
this.depType === 'available' ? !isOptional(dep) : true,
)
}
}

View File

@@ -0,0 +1,33 @@
<ng-container *ngIf="{ loading: $loading$ | async, disabled: installing || ($loading$ | async) } as l" >
<ion-item
class="dependency"
lines="none"
>
<ion-avatar style="position: relative; height: 5vh; width: 5vh; margin: 0px;" slot="start">
<div *ngIf="!l.loading" class="badge" [style]="badgeStyle"></div>
<img [src]="dep.iconURL | iconParse" />
</ion-avatar>
<ion-label class="ion-text-wrap" style="padding: 1vh; padding-left: 2vh">
<h4 style="font-family: 'Montserrat'">{{ dep.title }}</h4>
<p style="font-size: small">{{ dep.versionSpec }}</p>
<p *ngIf="!l.loading" style="padding-top: 2px; position: relative; font-style: italic; font-size: smaller"><ion-text [color]="color">{{statusText}}</ion-text></p>
<p *ngIf="l.loading" style="padding-top: 2px; position: relative; font-style: italic; font-size: smaller"><ion-text color="medium">Refreshing</ion-text></p>
</ion-label>
<ion-button size="small" (click)="action()" *ngIf="!installing && !l.loading" color="primary" fill="outline" style="font-size: x-small">
{{actionText}}
</ion-button>
<div slot="end" *ngIf='installing || (l.loading)' >
<div *ngIf='installing && !(l.loading)' class="spinner">
<ion-spinner [color]="color" style="height: 3vh; width: 3vh" name="dots"></ion-spinner>
</div>
<div *ngIf='(l.loading)' class="spinner">
<ion-spinner [color]="medium" style="height: 3vh; width: 3vh" name="lines"></ion-spinner>
</div>
</div>
</ion-item>
</ng-container>

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { InstalledDependencyItemComponent } from './installed-dependency-item.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { InformationPopoverComponentModule } from '../../information-popover/information-popover.component.module'
import { StatusComponentModule } from '../../status/status.component.module'
@NgModule({
declarations: [InstalledDependencyItemComponent],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
InformationPopoverComponentModule,
StatusComponentModule,
],
exports: [InstalledDependencyItemComponent],
})
export class InstalledDependencyItemComponentModule { }

View File

@@ -0,0 +1,30 @@
.spinner {
background: rgba(0,0,0,0);
border-radius: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 5px;
}
.badge {
position: absolute; width: 2.5vh;
height: 2.5vh;
border-radius: 50px;
left: -1vh;
top: -1vh;
}
.xSmallText {
font-size: x-small !important;
}
.mediumText {
font-size: medium !important;
}
.opacityUp {
opacity: 0.75;
}

View File

@@ -0,0 +1,113 @@
import { Component, Input, OnInit } from '@angular/core'
import { NavigationExtras } from '@angular/router'
import { AlertController, NavController } from '@ionic/angular'
import { BehaviorSubject, Observable } from 'rxjs'
import { AppStatus } from 'src/app/models/app-model'
import { AppDependency, BaseApp, DependencyViolationSeverity, getInstalledViolationSeverity, getViolationSeverity, isInstalling, isMisconfigured, isMissing, isNotRunning, isVersionMismatch } from 'src/app/models/app-types'
import { Recommendation } from '../../recommendation-button/recommendation-button.component'
@Component({
selector: 'installed-dependency-item',
templateUrl: './installed-dependency-item.component.html',
styleUrls: ['./installed-dependency-item.component.scss'],
})
export class InstalledDependencyItemComponent implements OnInit {
@Input() dep: AppDependency
@Input() hostApp: BaseApp
@Input() $loading$: BehaviorSubject<boolean>
isLoading$: Observable<boolean>
color: string
installing = false
badgeStyle: string
violationSeverity: DependencyViolationSeverity
statusText: string
actionText: string
action: () => Promise<any>
constructor (private readonly navCtrl: NavController, private readonly alertCtrl: AlertController) { }
ngOnInit () {
this.violationSeverity = getInstalledViolationSeverity(this.dep)
const { color, statusText, installing, actionText, action } = this.getValues()
this.color = color
this.statusText = statusText
this.installing = installing
this.actionText = actionText
this.action = action
this.badgeStyle = `background: radial-gradient(var(--ion-color-${this.color}) 40%, transparent)`
}
isDanger () {
// installed dep violations are either REQUIRED or NONE, by getInstalledViolationSeverity above.
return [DependencyViolationSeverity.REQUIRED].includes(this.violationSeverity)
}
getValues (): { color: string, statusText: string, installing: boolean, actionText: string, action: () => Promise<any> } {
if (isInstalling(this.dep)) return { color: 'primary', statusText: 'Installing', installing: true, actionText: undefined, action: () => this.view() }
if (!this.isDanger()) return { color: 'success', statusText: 'Satisfied', installing: false, actionText: 'View', action: () => this.view() }
if (isMissing(this.dep)) return { color: 'warning', statusText: 'Not Installed', installing: false, actionText: 'Install', action: () => this.install() }
if (isVersionMismatch(this.dep)) return { color: 'warning', statusText: 'Incompatible Version Installed', installing: false, actionText: 'Update', action: () => this.install() }
if (isMisconfigured(this.dep)) return { color: 'warning', statusText: 'Incompatible Config', installing: false, actionText: 'Configure', action: () => this.configure() }
if (isNotRunning(this.dep)) return { color: 'warning', statusText: 'Not Running', installing: false, actionText: 'View', action: () => this.view() }
return { color: 'success', statusText: 'Satisfied', installing: false, actionText: 'View', action: () => this.view() }
}
async view () {
return this.navCtrl.navigateForward(`/services/installed/${this.dep.id}`)
}
async install () {
const verb = 'requires'
const description = `${this.hostApp.title} ${verb} an install of ${this.dep.title} satisfying ${this.dep.versionSpec}.`
const whyDependency = this.dep.description
const installationRecommendation: Recommendation = {
iconURL: this.hostApp.iconURL,
appId: this.hostApp.id,
description,
title: this.hostApp.title,
versionSpec: this.dep.versionSpec,
whyDependency,
}
const navigationExtras: NavigationExtras = {
state: { installationRecommendation },
}
return this.navCtrl.navigateForward(`/services/marketplace/${this.dep.id}`, navigationExtras)
}
async configure () {
if (this.dep.violation.name !== 'incompatible-config') return
const configViolationDesc = this.dep.violation.ruleViolations
const configViolationFormatted =
`<ul>${configViolationDesc.map(d => `<li>${d}</li>`).join('\n')}</ul>`
const configRecommendation: Recommendation = {
iconURL: this.hostApp.iconURL,
appId: this.hostApp.id,
description: configViolationFormatted,
title: this.hostApp.title,
}
const navigationExtras: NavigationExtras = {
state: { configRecommendation },
}
return this.navCtrl.navigateForward(`/services/installed/${this.dep.id}/config`, navigationExtras)
}
async presentAlertDescription() {
const description = `<p>${this.dep.description}<\p>`
const alert = await this.alertCtrl.create({
backdropDismiss: true,
message: description,
})
await alert.present()
}
}

View File

@@ -0,0 +1,45 @@
<ng-container *ngIf="{ loading: $loading$ | async, disabled: installing || ($loading$ | async) } as l" >
<ion-item
class="dependency"
style="--border-color: var(--ion-color-medium-shade)"
[lines]="presentAlertDescription ? 'inset' : 'full'"
>
<ion-avatar style="position: relative; height: 5vh; width: 5vh; margin: 0px;" slot="start">
<div *ngIf="!l.loading" class="badge" [style]="badgeStyle"></div>
<img [src]="dep.iconURL | iconParse" />
</ion-avatar>
<ion-label class="ion-text-wrap" style="padding: 1vh; padding-left: 2vh">
<h4 style="font-family: 'Montserrat'">{{ dep.title }}
<span *ngIf="recommended" style="font-family: 'Open Sans'; font-size: small; color: var(--ion-color-medium)">(recommended)</span>
</h4>
<p style="font-size: small">{{ dep.versionSpec }}</p>
<p *ngIf="!l.loading" style="padding-top: 2px; position: relative; font-style: italic; font-size: smaller"><ion-text [color]="color">{{statusText}}</ion-text></p>
<p *ngIf="l.loading" style="padding-top: 2px; position: relative; font-style: italic; font-size: smaller"><ion-text color="medium">Refreshing</ion-text></p>
</ion-label>
<ion-button size="small" (click)="presentAlertDescription=!presentAlertDescription" [disabled]="l.loading" color="medium" fill="clear" style="margin: 14px; font-size: small">
<ion-icon *ngIf="!presentAlertDescription" name="chevron-down"></ion-icon>
<ion-icon *ngIf="presentAlertDescription" name="chevron-up"></ion-icon>
</ion-button>
<ion-button size="small" (click)="toInstall()" *ngIf="!installing && !l.loading" color="primary" fill="outline" style="font-size: small">
{{actionText}}
</ion-button>
<div slot="end" *ngIf='installing || (l.loading)' style="margin: 0" >
<div *ngIf='installing && !(l.loading)' class="spinner">
<ion-spinner [color]="color" style="height: 3vh; width: 3vh" name="dots"></ion-spinner>
</div>
<div *ngIf='(l.loading)' class="spinner">
<ion-spinner [color]="medium" style="height: 3vh; width: 3vh" name="lines"></ion-spinner>
</div>
</div>
</ion-item>
<ion-item style="margin-bottom: 10px"*ngIf="presentAlertDescription" lines="none">
<div style="font-size: small; color: var(--ion-color-medium)" [innerHtml]="descriptionText"></div>
</ion-item>
<div style="height: 8px"></div>
</ng-container>

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { MarketplaceDependencyItemComponent } from './marketplace-dependency-item.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { InformationPopoverComponentModule } from '../../information-popover/information-popover.component.module'
import { StatusComponentModule } from '../../status/status.component.module'
@NgModule({
declarations: [MarketplaceDependencyItemComponent],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
InformationPopoverComponentModule,
StatusComponentModule,
],
exports: [MarketplaceDependencyItemComponent],
})
export class MarketplaceDependencyItemComponentModule { }

View File

@@ -0,0 +1,35 @@
.spinner {
background: rgba(0,0,0,0);
border-radius: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 14px;
}
.badge {
position: absolute; width: 2.5vh;
height: 2.5vh;
border-radius: 50px;
left: -1vh;
top: -1vh;
}
.xSmallText {
font-size: x-small !important;
}
.mediumText {
font-size: medium !important;
}
.opacityUp {
opacity: 0.75;
}
.dependency {
--padding-start: 20px;
--padding-end: 2px;
}

View File

@@ -0,0 +1,88 @@
import { Component, Input, OnInit } from '@angular/core'
import { NavigationExtras } from '@angular/router'
import { NavController } from '@ionic/angular'
import { BehaviorSubject, Observable } from 'rxjs'
import { AppDependency, BaseApp, DependencyViolationSeverity, getViolationSeverity, isOptional, isMissing, isInstalling, isRecommended, isVersionMismatch } from 'src/app/models/app-types'
import { Recommendation } from '../../recommendation-button/recommendation-button.component'
@Component({
selector: 'marketplace-dependency-item',
templateUrl: './marketplace-dependency-item.component.html',
styleUrls: ['./marketplace-dependency-item.component.scss'],
})
export class MarketplaceDependencyItemComponent implements OnInit {
@Input() dep: AppDependency
@Input() hostApp: BaseApp
@Input() $loading$: BehaviorSubject<boolean>
presentAlertDescription = false
isLoading$: Observable<boolean>
color: string
installing = false
recommended = false
badgeStyle: string
violationSeverity: DependencyViolationSeverity
statusText: string
actionText: 'View' | 'Get'
descriptionText: string
constructor (
private readonly navCtrl: NavController,
) { }
ngOnInit () {
this.violationSeverity = getViolationSeverity(this.dep)
if (isOptional(this.dep)) throw new Error('Do not display optional deps, satisfied or otherwise, on the AAL')
const { actionText, color, statusText, installing } = this.getValues()
this.color = color
this.statusText = statusText
this.installing = installing
this.recommended = isRecommended(this.dep)
this.actionText = actionText
this.badgeStyle = `background: radial-gradient(var(--ion-color-${this.color}) 40%, transparent)`
this.descriptionText = `<p>${this.dep.description}<\p>`
if (this.recommended) {
this.descriptionText = this.descriptionText + `<p>This service is not required: ${this.dep.optional}<\p>`
}
}
isDanger (): boolean {
return [DependencyViolationSeverity.REQUIRED, DependencyViolationSeverity.RECOMMENDED].includes(this.violationSeverity)
}
getValues (): { color: string, statusText: string, installing: boolean, actionText: 'View' | 'Get' } {
if (isInstalling(this.dep)) return { color: 'primary', statusText: 'Installing', installing: true, actionText: undefined }
if (!this.isDanger()) return { color: 'success', statusText: 'Satisfied', installing: false, actionText: 'View' }
if (isMissing(this.dep)) return { color: 'warning', statusText: 'Not Installed', installing: false, actionText: 'Get' }
if (isVersionMismatch(this.dep)) return { color: 'warning', statusText: 'Incompatible Version Installed', installing: false, actionText: 'Get' }
return { color: 'success', statusText: 'Satisfied', installing: false, actionText: 'View' }
}
async toInstall () {
if (this.actionText === 'View') return this.navCtrl.navigateForward(`/services/marketplace/${this.dep.id}`)
const verb = this.violationSeverity === DependencyViolationSeverity.REQUIRED ? 'requires' : 'recommends'
const description = `${this.hostApp.title} ${verb} an install of ${this.dep.title} satisfying ${this.dep.versionSpec}.`
const whyDependency = this.dep.description
const installationRecommendation: Recommendation = {
iconURL: this.hostApp.iconURL,
appId: this.hostApp.id,
description,
title: this.hostApp.title,
versionSpec: this.dep.versionSpec,
whyDependency,
}
const navigationExtras: NavigationExtras = {
state: { installationRecommendation },
}
return this.navCtrl.navigateForward(`/services/marketplace/${this.dep.id}`, navigationExtras)
}
}

View File

@@ -0,0 +1,6 @@
<ion-item lines="none" *ngIf="$error$ | async as error" class="notifier-item" style="margin-top: 12px">
<ion-label class="ion-text-wrap" color="danger"><p>{{ error }}</p></ion-label>
<ion-button *ngIf="dismissable" size="small" slot="end" fill="outline" color="danger" (click)="clear()">
<ion-icon style="height: 12px; width: 12px;" name="close"></ion-icon>
</ion-button>
</ion-item>

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { ErrorMessageComponent } from './error-message.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
ErrorMessageComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [ErrorMessageComponent],
})
export class ErrorMessageComponentModule { }

View File

@@ -0,0 +1,10 @@
.error-message {
--background: var(--ion-color-danger);
margin: 12px;
border-radius: 3px;
font-weight: bold;
}
.legacy-error-message {
margin: 5px;
}

View File

@@ -0,0 +1,19 @@
import { Component, Input, OnInit } from '@angular/core'
import { BehaviorSubject } from 'rxjs'
@Component({
selector: 'error-message',
templateUrl: './error-message.component.html',
styleUrls: ['./error-message.component.scss'],
})
export class ErrorMessageComponent implements OnInit {
@Input() $error$: BehaviorSubject<string | undefined> = new BehaviorSubject(undefined)
@Input() dismissable = true
constructor () { }
ngOnInit () { }
clear () {
this.$error$.next(undefined)
}
}

View File

@@ -0,0 +1,11 @@
<div style="padding: 15px;
border-style: solid;
border-width: 1px;
background: var(--ion-color-light);
border-radius: 10px;
border-color: var(--ion-color-warning);
color: white;
font-size: small;
"
[innerHTML]="unsafeInformation">
</div>

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { InformationPopoverComponent } from './information-popover.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
InformationPopoverComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [InformationPopoverComponent],
})
export class InformationPopoverComponentModule { }

View File

@@ -0,0 +1,18 @@
import { Component, Input, OnInit, ViewEncapsulation } from '@angular/core'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
@Component({
selector: 'app-information-popover',
templateUrl: './information-popover.component.html',
styleUrls: ['./information-popover.component.scss'],
encapsulation: ViewEncapsulation.None,
})
export class InformationPopoverComponent implements OnInit {
@Input() title: string
@Input() information: string
unsafeInformation: SafeHtml
constructor (private sanitizer: DomSanitizer) { }
ngOnInit () {
this.unsafeInformation = this.sanitizer.bypassSecurityTrustHtml(this.information)
}
}

View File

@@ -0,0 +1,17 @@
<div *ngIf="!($loading$ | async) && !params.skipCompletionDialogue" class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label [color]="$color$ | async" style="font-size: xx-large; font-weight: bold;">
{{successText}}
</ion-label>
</div>
<div class="long-message">
{{summary}}
</div>
</div>
</div>
<div *ngIf="$loading$ | async" class="center-spinner">
<ion-spinner color="warning" name="lines"></ion-spinner>
<ion-label class="long-message">{{label}}</ion-label>
</div>

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { CompleteComponent } from './complete.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
CompleteComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [CompleteComponent],
})
export class CompleteComponentModule { }

View File

@@ -0,0 +1,82 @@
import { Component, Input, OnInit } from '@angular/core'
import { BehaviorSubject, from, Subject } from 'rxjs'
import { takeUntil } from 'rxjs/operators'
import { markAsLoadingDuring$ } from 'src/app/services/loader.service'
import { capitalizeFirstLetter } from 'src/app/util/misc.util'
import { Colorable, Loadable } from '../loadable'
import { WizardAction } from '../wizard-types'
@Component({
selector: 'complete',
templateUrl: './complete.component.html',
styleUrls: ['../install-wizard.component.scss'],
})
export class CompleteComponent implements OnInit, Loadable, Colorable {
@Input() params: {
action: WizardAction
verb: string //loader verb: '*stopping* ...'
title: string
executeAction: () => Promise<any>
skipCompletionDialogue?: boolean
}
@Input() finished: (info: { error?: Error, cancelled?: true, final?: true }) => Promise<any>
$loading$ = new BehaviorSubject(false)
$color$ = new BehaviorSubject('medium')
$cancel$ = new Subject<void>()
label: string
summary: string
successText: string
load () {
markAsLoadingDuring$(this.$loading$, from(this.params.executeAction())).pipe(takeUntil(this.$cancel$)).subscribe(
{ error: e => this.finished({ error: new Error(`${this.params.action} failed: ${e.message || e}`) }),
complete: () => this.params.skipCompletionDialogue && this.finished( { final: true} ),
},
)
}
constructor () { }
ngOnInit () {
switch (this.params.action) {
case 'install':
this.summary = `Installation of ${this.params.title} is now in progress. You will receive a notification when the installation has completed.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('primary')
this.successText = 'In Progress'
break
case 'downgrade':
this.summary = `Downgrade for ${this.params.title} is now in progress. You will receive a notification when the downgrade has completed.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('primary')
this.successText = 'In Progress'
break
case 'update':
this.summary = `Update for ${this.params.title} is now in progress. You will receive a notification when the update has completed.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('primary')
this.successText = 'In Progress'
break
case 'uninstall':
this.summary = `${capitalizeFirstLetter(this.params.title)} has been successfully uninstalled.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('success')
this.successText = 'Success'
break
case 'stop':
this.summary = `${capitalizeFirstLetter(this.params.title)} has been successfully stopped.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('success')
this.successText = 'Success'
break
case 'configure':
this.summary = `New config for ${this.params.title} has been successfully saved.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('success')
this.successText = 'Success'
break
}
}
}

View File

@@ -0,0 +1,31 @@
<div class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label [color]="$color$ | async" style="font-size: xx-large; font-weight: bold;">
{{label}}
</ion-label>
</div>
<div class="long-message">
{{longMessage}}
</div>
<div style="margin: 25px 0px;">
<ion-item
style="--ion-item-background: rgb(0,0,0,0); margin-top: 5px"
*ngFor="let dep of dependencyViolations"
>
<ion-avatar style="position: relative; height: 4vh; width: 4vh" slot="start">
<div class="badge" [style]="dep.badgeStyle"></div>
<img [src]="dep.iconURL | iconParse" />
</ion-avatar>
<ion-label>
<h5>{{dep.title}}</h5>
<ion-text color="medium" style="font-size: smaller">{{dep.versionSpec}}</ion-text>
</ion-label>
<ion-text [color]="dep.color" style="font-size: smaller; font-style: italic; margin-right: 5px;">{{dep.violation}}</ion-text>
<status *ngIf="dep.isInstalling" [appStatus]="'INSTALLING'" size="italics-small"></status>
</ion-item>
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { DependenciesComponent } from './dependencies.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { StatusComponentModule } from '../../status/status.component.module'
@NgModule({
declarations: [
DependenciesComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
StatusComponentModule,
],
exports: [DependenciesComponent],
})
export class DependenciesComponentModule { }

View File

@@ -0,0 +1,127 @@
import { Component, Input, OnInit } from '@angular/core'
import { PopoverController } from '@ionic/angular'
import { BehaviorSubject, Subject } from 'rxjs'
import { AppStatus } from 'src/app/models/app-model'
import { AppDependency, DependencyViolationSeverity, getViolationSeverity } from 'src/app/models/app-types'
import { displayEmver } from 'src/app/pipes/emver.pipe'
import { InformationPopoverComponent } from '../../information-popover/information-popover.component'
import { Colorable, Loadable } from '../loadable'
import { WizardAction } from '../wizard-types'
@Component({
selector: 'dependencies',
templateUrl: './dependencies.component.html',
styleUrls: ['../install-wizard.component.scss'],
})
export class DependenciesComponent implements OnInit, Loadable, Colorable {
@Input() params: {
action: WizardAction,
title: string,
version: string,
serviceRequirements: AppDependency[]
}
filteredServiceRequirements: AppDependency[]
$loading$ = new BehaviorSubject(false)
$cancel$ = new Subject<void>()
longMessage: string
dependencyViolations: {
iconURL: string
title: string,
versionSpec: string,
violation: string,
color: string,
badgeStyle: string
}[]
label: string
$color$ = new BehaviorSubject('medium')
constructor (private readonly popoverController: PopoverController) { }
load () {
this.$color$.next(this.$color$.getValue())
}
ngOnInit () {
this.filteredServiceRequirements = this.params.serviceRequirements.filter(dep => {
return [DependencyViolationSeverity.REQUIRED, DependencyViolationSeverity.RECOMMENDED].includes(getViolationSeverity(dep))
})
.filter(dep => ['incompatible-version', 'missing'].includes(dep.violation.name))
this.dependencyViolations = this.filteredServiceRequirements
.map(dep => ({
iconURL: dep.iconURL,
title: dep.title,
versionSpec: (dep.violation && dep.violation.name === 'incompatible-config' && 'reconfigure') || dep.versionSpec,
isInstalling: dep.violation && dep.violation.name === 'incompatible-status' && dep.violation.status === AppStatus.INSTALLING,
violation: renderViolation(dep),
color: 'medium',
badgeStyle: `background: radial-gradient(var(--ion-color-warning) 40%, transparent)`,
}))
this.setSeverityAttributes()
}
setSeverityAttributes () {
switch (getWorstViolationSeverity(this.filteredServiceRequirements)){
case DependencyViolationSeverity.REQUIRED:
this.longMessage = `${this.params.title} requires the installation of other services. Don't worry, you'll be able to install these requirements later.`
this.label = 'Notice'
this.$color$.next('dark')
break
case DependencyViolationSeverity.RECOMMENDED:
this.longMessage = `${this.params.title} recommends the installation of other services. Don't worry, you'll be able to install these requirements later.`
this.label = 'Notice'
this.$color$.next('dark')
break
default:
this.longMessage = `All installation requirements for ${this.params.title} version ${displayEmver(this.params.version)} are met.`
this.$color$.next('success')
this.label = `Ready`
}
}
async presentPopover (ev: any, information: string) {
const popover = await this.popoverController.create({
component: InformationPopoverComponent,
event: ev,
translucent: false,
showBackdrop: true,
backdropDismiss: true,
componentProps: {
information,
},
})
return popover.present()
}
}
function renderViolation1 (dep: AppDependency): string {
const severity = getViolationSeverity(dep)
switch (severity){
case DependencyViolationSeverity.REQUIRED: return 'mandatory'
case DependencyViolationSeverity.RECOMMENDED: return 'recommended'
case DependencyViolationSeverity.OPTIONAL: return 'optional'
case DependencyViolationSeverity.NONE: return 'none'
}
}
function renderViolation (dep: AppDependency): string {
const severity = renderViolation1(dep)
if (severity === 'none') return ''
switch (dep.violation.name){
case 'missing': return `${severity}`
case 'incompatible-version': return `${severity}`
case 'incompatible-config': return ``
case 'incompatible-status': return ''
default: return ''
}
}
function getWorstViolationSeverity (rs: AppDependency[]) : DependencyViolationSeverity {
if (!rs) return DependencyViolationSeverity.NONE
return rs.map(getViolationSeverity).sort( (a, b) => b - a )[0] || DependencyViolationSeverity.NONE
}

View File

@@ -0,0 +1,42 @@
<div>
<div *ngIf="!($loading$ | async)" class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label color="warning" style="font-size: xx-large; font-weight: bold;"
*ngIf="hasDependentViolation">
WARNING
</ion-label>
<ion-label color="success" style="font-size: x-large; font-weight: bold; text-transform: capitalize;"
*ngIf="!hasDependentViolation">
READY
</ion-label>
</div>
<div *ngIf="longMessage" class="long-message" >
{{longMessage}}
</div>
<div style="margin: 25px 0px;">
<div style="border-width: 0px 0px 1px 0px; font-size: unset; text-align: left; font-weight: bold; margin-left: 13px; border-style: solid; border-color: var(--ion-color-light-tint);"
*ngIf="hasDependentViolation"
>
<ion-text color="warning">Will Stop</ion-text>
</div>
<ion-item
style="--ion-item-background: rgb(0,0,0,0); margin-top: 5px"
*ngFor="let dep of dependentBreakages"
>
<ion-avatar style="position: relative; height: 4vh; width: 4vh" slot="start">
<img [src]="dep.iconURL | iconParse" />
</ion-avatar>
<ion-label>
<h5>{{dep.title}}</h5>
</ion-label>
</ion-item>
</div>
</div>
</div>
<div *ngIf="$loading$ | async" class="center-spinner">
<ion-spinner color="warning" name="lines"></ion-spinner>
<ion-label class="long-message">Checking for installed services which depend on {{params.title}}...</ion-label>
</div>
</div>

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { DependentsComponent } from './dependents.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { InformationPopoverComponentModule } from '../../information-popover/information-popover.component.module'
@NgModule({
declarations: [
DependentsComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
InformationPopoverComponentModule,
],
exports: [DependentsComponent],
})
export class DependentsComponentModule { }

View File

@@ -0,0 +1,59 @@
import { Component, Input, OnInit } from '@angular/core'
import { BehaviorSubject, from, Subject } from 'rxjs'
import { takeUntil, tap } from 'rxjs/operators'
import { DependentBreakage } from 'src/app/models/app-types'
import { markAsLoadingDuring$ } from 'src/app/services/loader.service'
import { capitalizeFirstLetter } from 'src/app/util/misc.util'
import { Colorable, Loadable } from '../loadable'
import { WizardAction } from '../wizard-types'
@Component({
selector: 'dependents',
templateUrl: './dependents.component.html',
styleUrls: ['../install-wizard.component.scss'],
})
export class DependentsComponent implements OnInit, Loadable, Colorable {
@Input() params: {
title: string,
action: WizardAction, //Are you sure you want to *uninstall*...,
verb: string, // *Uninstalling* will cause problems...
fetchBreakages: () => Promise<DependentBreakage[]>,
skipConfirmationDialogue?: boolean
}
@Input() finished: (info: { error?: Error, cancelled?: true, final?: true }) => Promise<any>
dependentBreakages: DependentBreakage[]
hasDependentViolation: boolean
longMessage: string | null = null
$color$ = new BehaviorSubject('medium') // this will display disabled while loading
$loading$ = new BehaviorSubject(false)
$cancel$ = new Subject<void>()
constructor () { }
ngOnInit () { }
load () {
this.$color$.next('medium')
markAsLoadingDuring$(this.$loading$, from(this.params.fetchBreakages())).pipe(
takeUntil(this.$cancel$),
tap(breakages => this.dependentBreakages = breakages || []),
).subscribe(
{
complete: () => {
this.hasDependentViolation = this.dependentBreakages && this.dependentBreakages.length > 0
if (this.hasDependentViolation) {
this.longMessage = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title} will cause the following services to STOP running. Starting them again will require additional actions.`
this.$color$.next('warning')
} else if (this.params.skipConfirmationDialogue) {
this.finished({ })
} else {
this.longMessage = `No other services installed on your Embassy will be affected by this action.`
this.$color$.next('success')
}
},
error: (e: Error) => this.finished({ error: new Error(`Fetching dependent service information failed: ${e.message || e}`) }),
},
)
}
}

View File

@@ -0,0 +1,52 @@
<ion-header style="height: 12vh">
<ion-toolbar>
<ion-label class="toolbar-label text-ellipses">
<h1 class="toolbar-title">{{ params.toolbar.title }}</h1>
<h3 style="font-size: large; font-style: italic">{{params.toolbar.action}} <ion-text style="font-size: medium;" color="medium">{{ params.toolbar.version | displayEmver }}</ion-text></h3>
</ion-label>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-slides *ngIf="!($error$ | async)" id="slide-show" style="--bullet-background: white" pager="false">
<ion-slide *ngFor="let slide of params.slideDefinitions">
<dependencies #components *ngIf="slide.selector === 'dependencies'" [params]="slide.params"></dependencies>
<dependents #components *ngIf="slide.selector === 'dependents'" [params]="slide.params" [finished]="finished"></dependents>
<complete #components *ngIf="slide.selector === 'complete'" [params]="slide.params" [finished]="finished"></complete>
</ion-slide>
</ion-slides>
<div *ngIf="$error$ | async as error" class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label color="danger" style="font-size: xx-large; font-weight: bold;">
Error
</ion-label>
</div>
<div class="long-message">
{{error}}
</div>
</div>
</div>
</ion-content>
<ion-footer>
<ion-toolbar style="padding: 8px;">
<ng-container *ngIf="!($error$ | async)">
<ion-button slot="start" *ngIf="($anythingLoading$ | async) && currentSlideDef.cancelButton.whileLoading as cancel" (click)="finished({ cancelled: true })" class="toolbar-button" fill="outline" color="medium">
<ion-text *ngIf="cancel.text as t">{{t}}</ion-text>
<ion-icon *ngIf="!cancel.text" name="close-outline"></ion-icon>
</ion-button>
<ion-button slot="start" *ngIf="!($anythingLoading$ | async) && currentSlideDef.cancelButton.afterLoading as cancel" (click)="finished({ cancelled: true })" class="toolbar-button" fill="outline" color="medium">
<ion-text *ngIf="cancel.text as t">{{t}}</ion-text>
<ion-icon *ngIf="!cancel.text" name="close-outline"></ion-icon>
</ion-button>
<ion-button slot="end" *ngIf="currentSlideDef.nextButton as nextButton" (click)="finished({})" [disabled]="$anythingLoading$ | async" fill="outline" class="toolbar-button" color="primary"><ion-text [class.smaller-text]="nextButton.length > 16">{{nextButton}}</ion-text></ion-button>
<ion-button slot="end" *ngIf="currentSlideDef.finishButton as finishButton" (click)="finished({ final: true })" [disabled]="$anythingLoading$ | async" fill="outline" class="toolbar-button" color="primary"><ion-text [class.smaller-text]="finishButton.length > 16">{{finishButton}}</ion-text></ion-button>
</ng-container>
<ng-container *ngIf="$error$ | async">
<ion-button slot="start" (click)="finished({ final: true })" style="text-transform: capitalize; font-weight: bolder;" color="danger">Dismiss</ion-button>
</ng-container>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { InstallWizardComponent } from './install-wizard.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { DependenciesComponentModule } from './dependencies/dependencies.component.module'
import { DependentsComponentModule } from './dependents/dependents.component.module'
import { CompleteComponentModule } from './complete/complete.component.module'
@NgModule({
declarations: [
InstallWizardComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
DependenciesComponentModule,
DependentsComponentModule,
CompleteComponentModule,
],
exports: [InstallWizardComponent],
})
export class InstallWizardComponentModule { }

View File

@@ -0,0 +1,79 @@
.toolbar-label {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
color: white;
padding: 8px 0px 8px 15px;
}
.toolbar-title {
font-size: x-large;
text-transform: capitalize;
border-style: solid;
border-width: 0px 0px 1px 0px;
border-color: #404040;
font-family: 'Montserrat';
}
.center-spinner {
min-height: 40vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
color:white;
}
.slide-content {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
color:white;
min-height: 40vh
}
.status-label {
font-size: xx-large;
font-weight: bold;
}
.long-message {
margin-left: 5%;
margin-right: 5%;
padding: 10px;
font-size: small;
border-width: 0px 0px 1px 0px;
border-color: #393b40;
}
@media (min-width:500px) {
.long-message {
margin-left: 5%;
margin-right: 5%;
padding: 10px;
font-size: medium;
border-width: 0px 0px 1px 0px;
border-color: #393b40;
}
}
.toolbar-button {
text-transform: capitalize;
font-weight: bolder;
}
.smaller-text {
font-size: 14px;
}
.badge {
position: absolute;
width: 2vh;
height: 2vh;
border-radius: 50px;
left: -0.75vh;
top: -0.75vh;
}

View File

@@ -0,0 +1,128 @@
import { Component, Input, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'
import { IonContent, IonSlides, ModalController } from '@ionic/angular'
import { BehaviorSubject, combineLatest, Subscription } from 'rxjs'
import { map } from 'rxjs/operators'
import { Cleanup } from 'src/app/util/cleanup'
import { capitalizeFirstLetter } from 'src/app/util/misc.util'
import { CompleteComponent } from './complete/complete.component'
import { DependenciesComponent } from './dependencies/dependencies.component'
import { DependentsComponent } from './dependents/dependents.component'
import { Colorable, Loadable } from './loadable'
import { WizardAction } from './wizard-types'
@Component({
selector: 'install-wizard',
templateUrl: './install-wizard.component.html',
styleUrls: ['./install-wizard.component.scss'],
})
export class InstallWizardComponent extends Cleanup implements OnInit {
@Input() params: {
// defines the slideshow in the html
slideDefinitions: SlideDefinition[]
toolbar: TopbarParams
}
// containers
@ViewChild(IonContent) contentContainer: IonContent
@ViewChild(IonSlides) slideContainer: IonSlides
//don't use this, use slideComponents instead.
@ViewChildren('components')
public slideComponentsQL: QueryList<Loadable & Colorable>
//don't use this, use currentSlide instead.
slideIndex = 0
get slideComponents (): (Loadable & Colorable)[] {
return this.slideComponentsQL.toArray()
}
get currentSlide (): (Loadable & Colorable) {
return this.slideComponents[this.slideIndex]
}
get currentSlideDef (): SlideDefinition {
return this.params.slideDefinitions[this.slideIndex]
}
$anythingLoading$: BehaviorSubject<boolean> = new BehaviorSubject(true)
$currentColor$: BehaviorSubject<string> = new BehaviorSubject('medium')
$error$ = new BehaviorSubject(undefined)
constructor (private readonly modalController: ModalController) { super() }
ngOnInit () { }
ngAfterViewInit () {
this.currentSlide.load()
this.slideContainer.update()
this.slideContainer.lockSwipes(true)
}
ionViewDidEnter () {
this.cleanup(
combineLatest(this.slideComponents.map(component => component.$loading$)).pipe(
map(loadings => !loadings.every(p => !p)),
).subscribe(this.$anythingLoading$),
combineLatest(this.slideComponents.map(component => component.$color$)).pipe(
map(colors => colors[this.slideIndex]),
).subscribe(this.$currentColor$),
)
}
finished = (info: { error?: Error, cancelled?: true, final?: true }) => {
if (info.cancelled) this.currentSlide.$cancel$.next()
if (info.final || info.cancelled) return this.modalController.dismiss(info)
if (info.error) return this.$error$.next(capitalizeFirstLetter(info.error.message))
this.slide()
}
private async slide () {
if (this.slideComponents[this.slideIndex + 1] === undefined) { return this.finished({ final: true }) }
this.slideIndex += 1
await this.slideContainer.lockSwipes(false)
await Promise.all([this.contentContainer.scrollToTop(), this.slideContainer.slideNext()])
await this.slideContainer.lockSwipes(true)
this.currentSlide.load()
}
}
export interface SlideCommon {
selector: string
cancelButton: {
// indicates the existence of a cancel button, and whether to have text or an icon 'x' by default.
afterLoading?: { text?: string },
whileLoading?: { text?: string }
}
nextButton?: string,
finishButton?: string
}
export type SlideDefinition = SlideCommon & (
{
selector: 'dependencies',
params: DependenciesComponent['params']
} | {
selector: 'dependents',
params: DependentsComponent['params']
} | {
selector: 'complete',
params: CompleteComponent['params']
}
)
export type TopbarParams = { action: WizardAction, title: string, version: string }
export async function wizardModal (
modalController: ModalController, params: InstallWizardComponent['params'],
): Promise<{ cancelled?: true, final?: true, modal: HTMLIonModalElement }> {
const modal = await modalController.create({
backdropDismiss: false,
cssClass: 'wizard-modal',
component: InstallWizardComponent,
componentProps: { params },
})
await modal.present()
return modal.onWillDismiss().then(({ data }) => ({ ...data, modal }))
}

View File

@@ -0,0 +1,11 @@
import { BehaviorSubject, Subject } from 'rxjs'
export interface Loadable {
load: () => void
$loading$: BehaviorSubject<boolean> //will be true during load function
$cancel$: Subject<void> //will cancel load function
}
export interface Colorable {
$color$: BehaviorSubject<string>
}

View File

@@ -0,0 +1,161 @@
import { Injectable } from '@angular/core'
import { AppModel, AppStatus } from 'src/app/models/app-model'
import { AppDependency, DependentBreakage, AppInstalledPreview } from '../../models/app-types'
import { ApiService } from '../../services/api/api.service'
import { InstallWizardComponent, SlideDefinition, TopbarParams } from './install-wizard.component'
@Injectable({ providedIn: 'root' })
export class WizardBaker {
constructor (private readonly apiService: ApiService, private readonly appModel: AppModel) { }
install (values: {
id: string, title: string, version: string, serviceRequirements: AppDependency[]
}): InstallWizardComponent['params'] {
const { id, title, version, serviceRequirements } = values
validate(id, exists, 'missing id')
validate(title, exists, 'missing title')
validate(version, exists, 'missing version')
validate(serviceRequirements, t => !!t && Array.isArray(t), 'missing serviceRequirements')
const action = 'install'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ selector: 'dependencies', cancelButton: { afterLoading: { text: 'Cancel' } }, nextButton: 'Install', params: {
action, title, version, serviceRequirements,
}},
{ selector: 'complete', finishButton: 'Dismiss', cancelButton: { whileLoading: { } }, params: {
action, verb: 'beginning installation for', title, executeAction: () => this.apiService.installApp(id, version).then(app => {
this.appModel.add({ ...app, status: AppStatus.INSTALLING })
}),
}},
]
return { toolbar, slideDefinitions }
}
update (values: {
id: string, title: string, version: string, serviceRequirements: AppDependency[]
}): InstallWizardComponent['params'] {
const { id, title, version, serviceRequirements } = values
validate(id, exists, 'missing id')
validate(title, exists, 'missing title')
validate(version, exists, 'missing version')
validate(serviceRequirements, t => !!t && Array.isArray(t), 'missing serviceRequirements')
const action = 'update'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ selector: 'dependencies', cancelButton: { afterLoading: { text: 'Cancel' } }, nextButton: 'Update', params: {
action, title, version, serviceRequirements,
}},
{ selector: 'dependents', cancelButton: { whileLoading: { }, afterLoading: { text: 'Cancel' } }, nextButton: 'Update Anyways', params: {
skipConfirmationDialogue: true, action, verb: 'updating', title, fetchBreakages: () => this.apiService.installApp(id, version, true).then( ({ breakages }) => breakages ),
}},
{ selector: 'complete', finishButton: 'Dismiss', cancelButton: { whileLoading: { } }, params: {
action, verb: 'beginning update for', title, executeAction: () => this.apiService.installApp(id, version).then(app => {
this.appModel.update({ id: app.id, status: AppStatus.INSTALLING })
}),
}},
]
return { toolbar, slideDefinitions }
}
downgrade (values: {
id: string, title: string, version: string, serviceRequirements: AppDependency[]
}): InstallWizardComponent['params'] {
const { id, title, version, serviceRequirements } = values
validate(id, exists, 'missing id')
validate(title, exists, 'missing title')
validate(version, exists, 'missing version')
validate(serviceRequirements, t => !!t && Array.isArray(t), 'missing serviceRequirements')
const action = 'downgrade'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ selector: 'dependencies', cancelButton: { afterLoading: { text: 'Cancel' } }, nextButton: 'Downgrade', params: {
action, title, version, serviceRequirements,
}},
{ selector: 'dependents', cancelButton: { whileLoading: { }, afterLoading: { text: 'Cancel' } }, nextButton: 'Downgrade Anyways', params: {
skipConfirmationDialogue: true, action, verb: 'downgrading', title, fetchBreakages: () => this.apiService.installApp(id, version, true).then( ({ breakages }) => breakages ),
}},
{ selector: 'complete', finishButton: 'Dismiss', cancelButton: { whileLoading: { } }, params: {
action, verb: 'beginning downgrade for', title, executeAction: () => this.apiService.installApp(id, version).then(app => {
this.appModel.update({ id: app.id, status: AppStatus.INSTALLING })
}),
}},
]
return { toolbar, slideDefinitions }
}
uninstall (values: {
id: string, title: string, version: string
}): InstallWizardComponent['params'] {
const { id, title, version } = values
validate(id, exists, 'missing id')
validate(title, exists, 'missing title')
validate(version, exists, 'missing version')
const action = 'uninstall'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ selector: 'dependents', cancelButton: { whileLoading: { }, afterLoading: { text: 'Cancel' } }, nextButton: 'Uninstall', params: {
action, verb: 'uninstalling', title, fetchBreakages: () => this.apiService.uninstallApp(id, true).then( ({ breakages }) => breakages ),
}},
{ selector: 'complete', finishButton: 'Dismiss', cancelButton: { whileLoading: { } }, params: {
action, verb: 'uninstalling', title, executeAction: () => this.apiService.uninstallApp(id).then(() => this.appModel.delete(id)),
}},
]
return { toolbar, slideDefinitions }
}
stop (values: {
breakages: DependentBreakage[], id: string, title: string, version: string
}): InstallWizardComponent['params'] {
const { breakages, title, version } = values
validate(breakages, t => !!t && Array.isArray(t), 'missing breakages')
validate(title, exists, 'missing title')
validate(version, exists, 'missing version')
const action = 'stop'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ selector: 'dependents', cancelButton: { afterLoading: { text: 'Cancel' } }, nextButton: 'Stop Anyways', params: {
action, verb: 'stopping', title, fetchBreakages: () => Promise.resolve(breakages),
}},
]
return { toolbar, slideDefinitions }
}
configure (values: {
breakages: DependentBreakage[], app: AppInstalledPreview
}): InstallWizardComponent['params'] {
const { breakages, app } = values
const { title, versionInstalled: version } = app
const action = 'configure'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ selector: 'dependents', cancelButton: { afterLoading: { text: 'Cancel' } }, nextButton: 'Save Config Anyways', params: {
action, verb: 'saving config for', title, fetchBreakages: () => Promise.resolve(breakages),
}},
]
return { toolbar, slideDefinitions }
}
}
function validate<T> (t: T, test: (t: T) => Boolean, desc: string) {
if (!test(t)) {
console.error('failed validation', desc, t)
throw new Error(desc)
}
}
const exists = t => !!t

View File

@@ -0,0 +1,7 @@
export type WizardAction =
'install'
| 'update'
| 'downgrade'
| 'uninstall'
| 'stop'
| 'configure'

View File

@@ -0,0 +1,16 @@
<ion-item button (click)="handleClick()">
<ion-icon size="small" slot="start" *ngIf="anno | annotationStatus: 'Added'" style="margin-right: 15px; color: rgba(0,0,0,0); background: radial-gradient(#2a4e8970, #2a4e8970 35%, transparent 35%, transparent);" name="ellipse"></ion-icon>
<ion-icon size="small" slot="start" *ngIf="anno | annotationStatus: 'NoChange'" style="margin-right: 15px; color: rgba(0,0,0,0); background: radial-gradient(#2a4e8970, #2a4e8970 35%, transparent 35%, transparent);" name="ellipse"></ion-icon>
<ion-icon size="small" slot="start" *ngIf="anno | annotationStatus: 'Edited'" style="margin-right: 15px" color="primary" name="ellipse"></ion-icon>
<ion-icon size="small" slot="start" *ngIf="anno | annotationStatus: 'Invalid'" style="margin-right: 15px" color="danger" name="warning-outline"></ion-icon>
<div class="organizer">
<ion-label class="ion-text-wrap">
<ion-text color="medium">{{ spec.name }}</ion-text>
<ion-text class="new-tag" *ngIf="anno | annotationStatus: 'Added'">(new)</ion-text>
</ion-label>
</div>
<ion-note *ngIf="displayValue" style="font-size: small;" [class.bold]="anno | annotationStatus: 'Edited'" slot="end">{{ displayValue }}</ion-note>
</ion-item>

View File

@@ -0,0 +1,10 @@
<div *ngFor="let keyval of (spec.type === 'object' ? spec.spec : spec.variants[value[spec.tag.id]]) | keyvalue: asIsOrder">
<object-config-item
[key]="keyval.key"
[spec]="keyval.value"
[value]="value[keyval.key]"
[anno]="annotations.members[keyval.key]"
(onClick)="handleClick(keyval.key)"
[class.add-margin]="keyval.key === 'advanced'"
></object-config-item>
</div>

View File

@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { ObjectConfigComponent, ObjectConfigItemComponent } from './object-config.component'
import { IonicModule } from '@ionic/angular'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
ObjectConfigComponent,
ObjectConfigItemComponent,
],
imports: [
CommonModule,
FormsModule,
IonicModule,
SharingModule,
],
exports: [
ObjectConfigComponent,
ObjectConfigItemComponent,
],
})
export class ObjectConfigComponentModule { }

View File

@@ -0,0 +1,43 @@
.add-margin {
margin: 0 16px;
}
.help-button {
position: relative;
bottom: 7px;
right: 9px;
}
.new-tag {
padding: 0px 5px;
font-weight: bold;
font-size: smaller;
color: #cecece;
font-style: italic;
}
.status-icon{
// width: 2%;
margin-right: 12px;
}
.bright {
color: white !important;
}
.bold {
font-weight: bold;
}
.invalid {
color: var(--ion-color-danger) !important;
}
.organizer {
display: flex;
align-items: center;
}
.name {
text-decoration: underline;
}

View File

@@ -0,0 +1,101 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { Annotation, Annotations } from '../../app-config/config-utilities'
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
import { ConfigCursor } from 'src/app/app-config/config-cursor'
import { ModalPresentable } from 'src/app/app-config/modal-presentable'
import { ValueSpecOf, ValueSpec } from 'src/app/app-config/config-types'
import { MaskPipe } from 'src/app/pipes/mask.pipe'
@Component({
selector: 'object-config',
templateUrl: './object-config.component.html',
styleUrls: ['./object-config.component.scss'],
})
export class ObjectConfigComponent extends ModalPresentable {
@Input() cursor: ConfigCursor<'object' | 'union'>
@Output() onEdit = new EventEmitter<boolean>()
spec: ValueSpecOf<'object' | 'union'>
value: object
annotations: Annotations<'object' | 'union'>
constructor (
trackingModalCtrl: TrackingModalController,
) {
super(trackingModalCtrl)
}
ngOnInit () {
this.spec = this.cursor.spec()
this.value = this.cursor.config()
this.annotations = this.cursor.getAnnotations()
}
async handleClick (key: string) {
const nextCursor = this.cursor.seekNext(key)
nextCursor.createFirstEntryForList()
await this.presentModal(nextCursor, () => {
this.onEdit.emit(true)
this.annotations = this.cursor.getAnnotations()
})
}
asIsOrder () {
return 0
}
}
@Component({
selector: 'object-config-item',
templateUrl: './object-config-item.component.html',
styleUrls: ['./object-config.component.scss'],
})
export class ObjectConfigItemComponent {
@Input() key: string
@Input() spec: ValueSpec
@Input() value: string | number
@Input() anno: Annotation
@Output() onClick = new EventEmitter<boolean>()
maskPipe: MaskPipe = new MaskPipe()
displayValue?: string | number | boolean
ngOnChanges () {
switch (this.spec.type) {
case 'string':
if (this.value) {
if (this.spec.masked) {
this.displayValue = this.maskPipe.transform(this.value as string, 4)
} else {
this.displayValue = this.value
}
} else {
this.displayValue = '-'
}
break
case 'boolean':
this.displayValue = String(this.value)
break
case 'number':
this.displayValue = this.value || '-'
if (this.displayValue && this.spec.units) {
this.displayValue = `${this.displayValue} ${this.spec.units}`
}
break
case 'enum':
this.displayValue = this.spec.valueNames[this.value]
break
case 'pointer':
this.displayValue = 'System Defined'
break
default:
return
}
}
async handleClick (): Promise<void> {
if (this.spec.type === 'pointer') return
this.onClick.emit(true)
}
}

View File

@@ -0,0 +1,3 @@
<ion-button (click)="navigateBack()">
<ion-icon slot="icon-only" name="arrow-back"></ion-icon>
</ion-button>

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { PwaBackComponent } from './pwa-back.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
PwaBackComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [PwaBackComponent],
})
export class PwaBackComponentModule { }

View File

@@ -0,0 +1,16 @@
import { Component } from '@angular/core'
import { PwaBackService } from 'src/app/services/pwa-back.service'
@Component({
selector: 'pwa-back-button',
templateUrl: './pwa-back.component.html',
styleUrls: ['./pwa-back.component.scss'],
})
export class PwaBackComponent {
constructor (
private readonly pwaBack: PwaBackService,
) { }
navigateBack () {
return this.pwaBack.back()
}
}

View File

@@ -0,0 +1 @@
<qrcode [qrdata]="text" [width]="width" errorCorrectionLevel="L"></qrcode>

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { QRComponent } from './qr.component'
import { IonicModule } from '@ionic/angular'
import { QRCodeModule } from 'angularx-qrcode'
@NgModule({
declarations: [
QRComponent,
],
imports: [
CommonModule,
IonicModule,
QRCodeModule,
],
exports: [QRComponent],
})
export class QRComponentModule { }

View File

@@ -0,0 +1,16 @@
import { Component, Input } from '@angular/core'
import { isPlatform } from '@ionic/angular'
@Component({
selector: 'qr',
templateUrl: './qr.component.html',
styleUrls: ['./qr.component.scss'],
})
export class QRComponent {
@Input() text: string
width: number
ngOnInit () {
this.width = isPlatform('ios') || isPlatform('android') ? 320 : 420
}
}

View File

@@ -0,0 +1,9 @@
<ion-fab id="recommendation" vertical="top" horizontal="end" slot="fixed">
<ion-fab-button [disabled]="disabled" style="border-style: solid;
border-radius: 100px;
border-color: #FFEB3B;
border-width: medium;
box-shadow: 0 0 10px white;" size="small" (click)="presentPopover($event)">
<img [src]="rec.iconURL | iconParse" />
</ion-fab-button>
</ion-fab>

View File

@@ -0,0 +1,20 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { RecommendationButtonComponent } from './recommendation-button.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
RecommendationButtonComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [RecommendationButtonComponent],
})
export class RecommendationButtonComponentModule { }

View File

@@ -0,0 +1,66 @@
import { Component, Input, OnInit } from '@angular/core'
import { Router } from '@angular/router'
import { PopoverController } from '@ionic/angular'
import { filter, take } from 'rxjs/operators'
import { Cleanup } from 'src/app/util/cleanup'
import { capitalizeFirstLetter } from 'src/app/util/misc.util'
import { InformationPopoverComponent } from '../information-popover/information-popover.component'
@Component({
selector: 'recommendation-button',
templateUrl: './recommendation-button.component.html',
styleUrls: ['./recommendation-button.component.scss'],
})
export class RecommendationButtonComponent extends Cleanup implements OnInit {
@Input() rec: Recommendation
@Input() raise?: { id: string }
constructor (private readonly router: Router, private readonly popoverController: PopoverController) {
super()
}
ngOnInit () {
if (!this.raise) return
const mainContent = document.getElementsByTagName('ion-app')[0]
const recButton = document.getElementById(this.raise.id)
mainContent.appendChild(recButton)
this.router.events.pipe(filter(e => !!(e as any).urlAfterRedirects, take(1))).subscribe((e: any) => {
recButton.remove()
})
}
disabled = false
async presentPopover (ev: any) {
const popover = await this.popoverController.create({
component: InformationPopoverComponent,
event: ev,
translucent: false,
showBackdrop: true,
backdropDismiss: true,
componentProps: {
information: `
<div style="font-size: medium; font-style: italic; margin: 5px 0px;">
${capitalizeFirstLetter(this.rec.title)} Installation Recommendations
</div>
<div>
${this.rec.description}
</div>`,
},
})
popover.onWillDismiss().then(() => {
this.disabled = false
})
this.disabled = true
return await popover.present()
}
}
export type Recommendation = {
title: string
appId: string
iconURL: string,
description: string,
versionSpec?: string
whyDependency?: string
}

View File

@@ -0,0 +1,24 @@
<p *ngIf="size === 'small'" style="margin: 0 0 4px 0;">
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots dots-small" name="dots" [color]="color"></ion-spinner>
</p>
<h3 *ngIf="size === 'italics-small'" style="margin: 0 0 4px 0;">
<ion-text [style]="style" style="font-size: small; font-style: italic; text-transform: lowercase;" [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots dots-small" name="dots" [color]="color"></ion-spinner>
</h3>
<h3 *ngIf="size === 'medium'">
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots dots-medium" name="dots" [color]="color"></ion-spinner>
</h3>
<h1 *ngIf="size === 'large'">
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots" name="dots" [color]="color"></ion-spinner>
</h1>
<h1 style="font-size: 18px; font-weight: 500" *ngIf="size === 'bold-large'">
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots" name="dots" [color]="color"></ion-spinner>
</h1>

View File

@@ -0,0 +1,16 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { StatusComponent } from './status.component'
import { IonicModule } from '@ionic/angular'
@NgModule({
declarations: [
StatusComponent,
],
imports: [
CommonModule,
IonicModule,
],
exports: [StatusComponent],
})
export class StatusComponentModule { }

View File

@@ -0,0 +1,32 @@
.icon-small {
width: auto;
height: 14px;
padding-left: 6px;
}
.icon-medium {
width: auto;
height: 18px;
padding-left: 8px;
}
.icon-large {
width: auto;
height: 24px;
padding-left: 12px;
}
.dots {
vertical-align: middle;
margin-left: 8px;
}
.dots-small {
width: 12px !important;
height: 12px !important;
}
.dots-medium {
width: 16px !important;
height: 16px !important;
}

View File

@@ -0,0 +1,56 @@
import { Component, Input } from '@angular/core'
import { AppStatus } from 'src/app/models/app-model'
import { ServerStatus } from 'src/app/models/server-model'
import { ServerStatusRendering, AppStatusRendering } from '../../util/status-rendering'
@Component({
selector: 'status',
templateUrl: './status.component.html',
styleUrls: ['./status.component.scss'],
})
export class StatusComponent {
@Input() appStatus?: AppStatus
@Input() serverStatus?: ServerStatus
@Input() size: 'small' | 'medium' | 'large' | 'italics-small' | 'bold-large' = 'large'
@Input() text: string = ''
color: string
display: string
showDots: boolean
style = ''
ngOnChanges () {
if (this.serverStatus) {
this.handleServerStatus()
} else if (this.appStatus) {
this.handleAppStatus()
}
}
handleServerStatus () {
let res = ServerStatusRendering[this.serverStatus]
if (!res) {
console.warn(`Received invalid server status from the server: `, this.serverStatus)
res = ServerStatusRendering[ServerStatus.UNKNOWN]
}
const { display, color, showDots } = res
this.display = display
this.color = color
this.showDots = showDots
}
handleAppStatus () {
let res = AppStatusRendering[this.appStatus]
if (!res) {
console.warn(`Received invalid app status from the server: `, this.appStatus)
res = AppStatusRendering[AppStatus.UNKNOWN]
}
const { display, color, showDots, style } = res
this.display = display + this.text
this.color = color
this.showDots = showDots
this.style = style
}
}

View File

@@ -0,0 +1,36 @@
import { Injectable } from '@angular/core'
import { CanActivate, Router, CanActivateChild } from '@angular/router'
import { AuthState, AuthService } from '../services/auth.service'
@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate, CanActivateChild {
constructor (
private readonly authService: AuthService,
private readonly router: Router,
) { }
canActivate (): boolean {
return this.runCheck()
}
canActivateChild (): boolean {
return this.runCheck()
}
private runCheck (): boolean {
const state = this.authService.peek()
switch (state){
case AuthState.VERIFIED: return true
case AuthState.UNVERIFIED: return this.toAuthenticate()
case AuthState.INITIALIZING: return this.toAuthenticate()
}
}
private toAuthenticate () {
this.router.navigate(['/authenticate'], { replaceUrl: true })
return false
}
}

View File

@@ -0,0 +1,26 @@
import { Injectable, Directive } from '@angular/core'
import { CanDeactivate } from '@angular/router'
import { HostListener } from '@angular/core'
@Directive()
export abstract class PageCanDeactivate {
abstract canDeactivate (): boolean
@HostListener('window:beforeunload', ['$event'])
unloadNotification (e: any) {
console.log(e)
if (!this.canDeactivate()) {
e.returnValue = true
}
}
}
@Injectable({
providedIn: 'root',
})
export class CanDeactivateGuard implements CanDeactivate<PageCanDeactivate> {
canDeactivate (page: PageCanDeactivate): boolean {
return page.canDeactivate() || confirm('You have unsaved changes. Are you sure you want to leave the page?')
}
}

View File

@@ -0,0 +1,26 @@
import { Injectable } from '@angular/core'
import { CanActivate, Router } from '@angular/router'
import { AuthService, AuthState } from '../services/auth.service'
@Injectable({
providedIn: 'root',
})
export class UnauthGuard implements CanActivate {
constructor (
private readonly authService: AuthService,
private readonly router: Router,
) { }
canActivate (): boolean {
const state = this.authService.peek()
switch (state){
case AuthState.VERIFIED: {
this.router.navigateByUrl('')
return false
}
case AuthState.UNVERIFIED: return true
case AuthState.INITIALIZING: return true
}
}
}

View File

@@ -0,0 +1,15 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { AppBackupPage } from './app-backup.page'
@NgModule({
declarations: [AppBackupPage],
imports: [
CommonModule,
IonicModule,
],
entryComponents: [AppBackupPage],
exports: [AppBackupPage],
})
export class AppBackupPageModule { }

View File

@@ -0,0 +1,50 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="dismiss()">
<ion-icon name="close"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>{{ type === 'create' ? 'Create Backup' : 'Restore Backup' }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="presentAlertHelp()" color="primary">
<ion-icon slot="icon-only" name="help-circle-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingIcon="lines" refreshingSpinner="lines"></ion-refresher-content>
</ion-refresher>
<ion-item *ngIf="error" style="margin-bottom: 16px;">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<ion-spinner *ngIf="loading" class="center" name="lines" color="warning"></ion-spinner>
<ng-container *ngIf="!loading">
<ion-item *ngIf="allPartitionsMounted">
<ion-text *ngIf="type === 'create'" class="ion-text-wrap" color="warning">No partitions available. To begin a backup, insert a storage device into your Embassy.</ion-text>
<ion-text *ngIf="type === 'restore'" class="ion-text-wrap" color="warning">No partitions available. Insert the storage device containing the backup you wish to restore.</ion-text>
</ion-item>
<ion-item-group *ngFor="let d of disks">
<ion-item-divider>{{ d.logicalname }} ({{ d.size }})</ion-item-divider>
<ion-item-group>
<ion-item button [disabled]="p.isMounted" *ngFor="let p of d.partitions" (click)="presentAlert(p)">
<ion-icon slot="start" name="save-outline"></ion-icon>
<ion-label>
<h2>{{ p.label || p.logicalname }}</h2>
<p>{{ p.size || 'unknown size' }}</p>
<p *ngIf="!p.isMounted"><ion-text color="success">Available</ion-text></p>
<p *ngIf="p.isMounted"><ion-text color="danger">Unvailable</ion-text></p>
</ion-label>
</ion-item>
</ion-item-group>
</ion-item-group>
</ng-container>
</ion-content>

View File

@@ -0,0 +1,206 @@
import { Component, Input } from '@angular/core'
import { ModalController, AlertController, LoadingController, IonicSafeString } from '@ionic/angular'
import { AppModel, AppStatus } from 'src/app/models/app-model'
import { AppInstalledFull } from 'src/app/models/app-types'
import { ApiService } from 'src/app/services/api/api.service'
import { DiskInfo, DiskPartition } from 'src/app/models/server-model'
import { pauseFor } from 'src/app/util/misc.util'
@Component({
selector: 'app-backup',
templateUrl: './app-backup.page.html',
styleUrls: ['./app-backup.page.scss'],
})
export class AppBackupPage {
@Input() app: AppInstalledFull
@Input() type: 'create' | 'restore'
disks: DiskInfo[]
loading = true
error: string
allPartitionsMounted: boolean
constructor (
private readonly modalCtrl: ModalController,
private readonly alertCtrl: AlertController,
private readonly loadingCtrl: LoadingController,
private readonly apiService: ApiService,
private readonly appModel: AppModel,
) { }
ngOnInit () {
return this.getExternalDisks().then(() => this.loading = false)
}
async getExternalDisks (): Promise<void> {
try {
this.disks = await this.apiService.getExternalDisks()
this.allPartitionsMounted = this.disks.every(d => d.partitions.every(p => p.isMounted))
} catch (e) {
console.error(e)
this.error = e.message
}
}
async doRefresh (event: any) {
await Promise.all([
this.getExternalDisks(),
pauseFor(600),
])
event.target.complete()
}
async dismiss () {
await this.modalCtrl.dismiss()
}
async presentAlertHelp (): Promise<void> {
let alert: HTMLIonAlertElement
if (this.type === 'create') {
alert = await this.alertCtrl.create({
backdropDismiss: false,
header: `Backups`,
message: `Select a location to back up ${this.app.title}.<br /><br />Internal drives and drives currently backing up other services will not be available.<br /><br />Depending on the amount of data in ${this.app.title}, your first backup may take a while. Since backups are diff-based, the speed of future backups to the same disk will likely be much faster.`,
buttons: ['Dismiss'],
})
} else if (this.type === 'restore') {
alert = await this.alertCtrl.create({
backdropDismiss: false,
header: `Backups`,
message: `Select a location containing the backup you wish to restore for ${this.app.title}.<br /><br />Restoring ${this.app.title} will re-sync your service with your previous backup. The speed of the restore process depends on the backup size.`,
buttons: ['Dismiss'],
})
}
await alert.present()
}
async presentAlert (partition: DiskPartition): Promise<void> {
if (this.type === 'create') {
this.presentAlertCreateEncrypted(partition)
} else {
this.presentAlertWarn(partition)
}
}
private async presentAlertCreateEncrypted (partition: DiskPartition): Promise<void> {
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: `Encrypt Backup`,
message: `Enter your master password to create an encrypted backup of ${this.app.title} to "${partition.label || partition.logicalname}".`,
inputs: [
{
name: 'password',
label: 'Password',
type: 'password',
placeholder: 'Master Password',
},
],
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Create Backup',
handler: (data) => {
if (!data.password || data.password.length < 12) {
alert.message = new IonicSafeString(alert.message + '<br /><br /><ion-text color="danger">Password must be at least 12 characters in length.</ion-text>')
return false
} else {
this.create(partition, data.password)
}
},
},
],
})
await alert.present()
}
private async presentAlertWarn (partition: DiskPartition): Promise<void> {
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: `Warning`,
message: `Restoring ${this.app.title} from "${partition.label || partition.logicalname}" will overwrite its current data.<br /><br />Are you sure you want to continue?`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
}, {
text: 'Continue',
handler: () => {
this.presentAlertRestore(partition)
},
},
],
})
await alert.present()
}
private async presentAlertRestore (partition: DiskPartition): Promise<void> {
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: `Decrypt Backup`,
message: `Enter your master password`,
inputs: [
{
name: 'password',
type: 'password',
placeholder: 'Password',
},
],
buttons: [
{
text: 'Cancel',
role: 'cancel',
}, {
text: 'Restore',
handler: (data) => {
this.restore(partition, data.password)
},
},
],
})
await alert.present()
}
private async restore (partition: DiskPartition, password?: string): Promise<void> {
this.error = ''
const loader = await this.loadingCtrl.create({
spinner: 'lines',
cssClass: 'loader-ontop-of-all',
})
await loader.present()
try {
await this.apiService.restoreAppBackup(this.app.id, partition.logicalname, password)
this.appModel.update({ id: this.app.id, status: AppStatus.RESTORING_BACKUP })
await this.dismiss()
} catch (e) {
console.error(e)
this.error = e.message
} finally {
await loader.dismiss()
}
}
private async create (partition: DiskPartition, password?: string): Promise<void> {
this.error = ''
const loader = await this.loadingCtrl.create({
spinner: 'lines',
cssClass: 'loader-ontop-of-all',
})
await loader.present()
try {
await this.apiService.createAppBackup(this.app.id, partition.logicalname, password)
this.appModel.update({ id: this.app.id, status: AppStatus.CREATING_BACKUP })
await this.dismiss()
} catch (e) {
console.error(e)
this.error = e.message
} finally {
await loader.dismiss()
}
}
}

View File

@@ -0,0 +1,3 @@
import { InjectionToken } from '@angular/core'
export const APP_CONFIG_COMPONENT_MAPPING = new InjectionToken<string>('APP_CONFIG_COMPONENTS')

View File

@@ -0,0 +1,4 @@
import { Type } from '@angular/core'
import { ValueType } from 'src/app/app-config/config-types'
export type AppConfigComponentMapping = { [k in ValueType]: Type<any> }

View File

@@ -0,0 +1,16 @@
import { AppConfigObjectPage } from '../app-config-object/app-config-object.page'
import { AppConfigListPage } from '../app-config-list/app-config-list.page'
import { AppConfigUnionPage } from '../app-config-union/app-config-union.page'
import { AppConfigValuePage } from '../app-config-value/app-config-value.page'
import { AppConfigComponentMapping } from './modal-injectable-type'
export const appConfigComponents: AppConfigComponentMapping = {
'string': AppConfigValuePage,
'number': AppConfigValuePage,
'enum': AppConfigValuePage,
'boolean': AppConfigValuePage,
'list': AppConfigListPage,
'object': AppConfigObjectPage,
'union': AppConfigUnionPage,
'pointer': undefined,
}

View File

@@ -0,0 +1,21 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { AppConfigListPage } from './app-config-list.page'
import { SharingModule } from 'src/app/modules/sharing.module'
import { ConfigHeaderComponentModule } from 'src/app/components/config-header/config-header.component.module'
import { FormsModule } from '@angular/forms'
@NgModule({
declarations: [AppConfigListPage],
imports: [
CommonModule,
IonicModule,
SharingModule,
FormsModule,
ConfigHeaderComponentModule,
],
entryComponents: [AppConfigListPage],
exports: [AppConfigListPage],
})
export class AppConfigListPageModule { }

View File

@@ -0,0 +1,68 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="dismiss()">
<ion-icon name="arrow-back"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>{{ spec.name }}</ion-title>
<ion-buttons *ngIf="spec.subtype !== 'enum'" slot="end">
<ion-button (click)="presentModalValueEdit()">
<ion-icon name="add" color="primary"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<config-header [spec]="spec" [error]="error"></config-header>
<!-- enum list -->
<ion-item-group *ngIf="spec.subtype === 'enum'">
<ion-item-divider class="borderless"></ion-item-divider>
<ion-item-divider>
{{ value.length }} selected
<span *ngIf="min">&nbsp;(min: {{ min }})</span>
<span *ngIf="max">&nbsp;(max: {{ max }})</span>
<ion-button slot="end" fill="clear" color="primary" (click)="toggleSelectAll()">{{ selectAll ? 'All' : 'None' }}</ion-button>
</ion-item-divider>
<ion-item *ngFor="let option of options">
<ion-label>{{ option.value }}</ion-label>
<ion-checkbox slot="end" [(ngModel)]="option.checked" (click)="toggleSelected(option.value)"></ion-checkbox>
</ion-item>
</ion-item-group>
<!-- not enum list -->
<div *ngIf="spec.subtype !== 'enum'">
<ion-item-divider class="borderless"></ion-item-divider>
<ion-item-group>
<ion-item-divider style="font-size: small; color: var(--ion-color-medium);">
{{ value.length }}&nbsp;
<span *ngIf="value.length === 1">Entry</span>
<span *ngIf="value.length !== 1">Entries</span>
<span *ngIf="min">&nbsp;(min: {{ min }})</span>
<span *ngIf="max">&nbsp;(max: {{ max }})</span>
</ion-item-divider>
<div *ngFor="let v of value; index as i;">
<ion-item-sliding>
<ion-item button detail="false" (click)="presentModalValueEdit(i)">
<ion-icon size="small" slot="start" *ngIf="!annotations.members[i] || (annotations.members[i] | annotationStatus: 'NoChange')" style="margin-right: 15px; color: rgba(0,0,0,0); background: radial-gradient(#2a4e8970, #2a4e8970 35%, transparent 35%, transparent);" name="ellipse"></ion-icon>
<ion-icon size="small" slot="start" *ngIf="!annotations.members[i] || (annotations.members[i] | annotationStatus: 'Added')" style="margin-right: 15px; color: rgba(0,0,0,0); background: radial-gradient(#2a4e8970, #2a4e8970 35%, transparent 35%, transparent);" name="ellipse"></ion-icon>
<ion-icon size="small" slot="start" *ngIf="annotations.members[i] && (annotations.members[i] | annotationStatus: 'Edited')" style="margin-right: 15px" color="primary" name="ellipse"></ion-icon>
<ion-icon size="small" slot="start" *ngIf="annotations.members[i] && (annotations.members[i] | annotationStatus: 'Invalid')" style="margin-right: 15px" color="danger" name="warning-outline"></ion-icon>
<ion-label>{{ valueString[i] }}</ion-label>
</ion-item>
<ion-item-options side="end">
<ion-item-option color="danger" (click)="presentAlertDeleteEntry(i)">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
</ion-item-option>
</ion-item-options>
</ion-item-sliding>
</div>
</ion-item-group>
</div>
</ion-content>

View File

@@ -0,0 +1,146 @@
import { Component, Input } from '@angular/core'
import { AlertController } from '@ionic/angular'
import { Annotations, Range } from '../../app-config/config-utilities'
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
import { ConfigCursor } from 'src/app/app-config/config-cursor'
import { ValueSpecList, isValueSpecListOf } from 'src/app/app-config/config-types'
import { ModalPresentable } from 'src/app/app-config/modal-presentable'
@Component({
selector: 'app-config-list',
templateUrl: './app-config-list.page.html',
styleUrls: ['./app-config-list.page.scss'],
})
export class AppConfigListPage extends ModalPresentable {
@Input() cursor: ConfigCursor<'list'>
spec: ValueSpecList
value: string[] | number[] | object[]
valueString: string[]
annotations: Annotations<'list'>
// enum only
options: { value: string, checked: boolean }[] = []
selectAll = true
//
min: number | undefined
max: number | undefined
minMessage: string
maxMessage: string
error: string
constructor (
private readonly alertCtrl: AlertController,
trackingModalCtrl: TrackingModalController,
) {
super(trackingModalCtrl)
}
ngOnInit () {
this.spec = this.cursor.spec()
this.value = this.cursor.config()
const range = Range.from(this.spec.range)
this.min = range.integralMin()
this.max = range.integralMax()
this.minMessage = `The minimum number of ${this.cursor.key()} is ${this.min}.`
this.maxMessage = `The maximum number of ${this.cursor.key()} is ${this.max}.`
// enum list only
if (isValueSpecListOf(this.spec, 'enum')) {
for (let val of this.spec.spec.values) {
this.options.push({
value: val,
checked: (this.value as string[]).includes(val),
})
}
}
this.updateCaches()
}
async dismiss () {
return this.dismissModal(this.value)
}
// enum only
toggleSelectAll () {
if (!isValueSpecListOf(this.spec, 'enum')) { throw new Error('unreachable') }
this.value.length = 0
if (this.selectAll) {
for (let v of this.spec.spec.values) {
(this.value as string[]).push(v)
}
for (let option of this.options) {
option.checked = true
}
} else {
for (let option of this.options) {
option.checked = false
}
}
this.updateCaches()
}
// enum only
async toggleSelected (value: string) {
const index = (this.value as string[]).indexOf(value)
// if present, delete
if (index > -1) {
(this.value as string[]).splice(index, 1)
// if not present, add
} else {
(this.value as string[]).push(value)
}
this.updateCaches()
}
async presentModalValueEdit (index?: number) {
const nextCursor = this.cursor.seekNext(index === undefined ? this.value.length : index)
nextCursor.createFirstEntryForList()
return this.presentModal(nextCursor, () => this.updateCaches())
}
async presentAlertDeleteEntry (key: number) {
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: 'Caution',
message: `Are you sure you want to delete this entry?`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Delete',
cssClass: 'alert-danger',
handler: () => {
if (typeof key === 'number') {
(this.value as any[]).splice(key, 1)
} else {
delete this.value[key]
}
this.updateCaches()
},
},
],
})
await alert.present()
}
asIsOrder () {
return 0
}
private updateCaches () {
if (isValueSpecListOf(this.spec, 'enum')) {
this.selectAll = this.value.length !== this.spec.spec.values.length
}
this.error = this.cursor.checkInvalid()
this.annotations = this.cursor.getAnnotations()
this.valueString = (this.value as any[]).map((_, idx) => this.cursor.seekNext(idx).toString())
}
}

View File

@@ -0,0 +1,19 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { AppConfigObjectPage } from './app-config-object.page'
import { ObjectConfigComponentModule } from 'src/app/components/object-config/object-config.component.module'
import { ConfigHeaderComponentModule } from 'src/app/components/config-header/config-header.component.module'
@NgModule({
declarations: [AppConfigObjectPage],
imports: [
CommonModule,
IonicModule,
ObjectConfigComponentModule,
ConfigHeaderComponentModule,
],
entryComponents: [AppConfigObjectPage],
exports: [AppConfigObjectPage],
})
export class AppConfigObjectPageModule { }

Some files were not shown because too many files have changed in this diff Show More