mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
0.2.5 initial commit
Makefile incomplete
This commit is contained in:
33
ui/.gitignore
vendored
Normal file
33
ui/.gitignore
vendored
Normal 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
19
ui/README.md
Normal 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
144
ui/angular.json
Normal 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
12
ui/browserslist
Normal 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
40
ui/build-send-beta.sh
Executable 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
38
ui/build-send.sh
Executable 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
117
ui/client-manifest.yaml
Normal 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
5
ui/ionic.config.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Embassy",
|
||||
"integrations": {},
|
||||
"type": "angular"
|
||||
}
|
||||
13885
ui/package-lock.json
generated
Normal file
13885
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
ui/package.json
Normal file
62
ui/package.json
Normal 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
13
ui/postprocess.ts
Normal 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)
|
||||
446
ui/src/app/app-config/config-cursor.ts
Normal file
446
ui/src/app/app-config/config-cursor.ts
Normal 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
|
||||
}
|
||||
}
|
||||
133
ui/src/app/app-config/config-types.ts
Normal file
133
ui/src/app/app-config/config-types.ts
Normal 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 }
|
||||
413
ui/src/app/app-config/config-utilities.ts
Normal file
413
ui/src/app/app-config/config-utilities.ts
Normal 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
|
||||
}
|
||||
27
ui/src/app/app-config/modal-presentable.ts
Normal file
27
ui/src/app/app-config/modal-presentable.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
48
ui/src/app/app-routing.module.ts
Normal file
48
ui/src/app/app-routing.module.ts
Normal 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 { }
|
||||
159
ui/src/app/app.component.html
Normal file
159
ui/src/app/app.component.html
Normal 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>
|
||||
|
||||
13
ui/src/app/app.component.scss
Normal file
13
ui/src/app/app.component.scss
Normal 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
190
ui/src/app/app.component.ts
Normal 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
41
ui/src/app/app.module.ts
Normal 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 { }
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 { }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 { }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -0,0 +1,10 @@
|
||||
.error-message {
|
||||
--background: var(--ion-color-danger);
|
||||
margin: 12px;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.legacy-error-message {
|
||||
margin: 5px;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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}`) }),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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;
|
||||
}
|
||||
128
ui/src/app/components/install-wizard/install-wizard.component.ts
Normal file
128
ui/src/app/components/install-wizard/install-wizard.component.ts
Normal 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 }))
|
||||
}
|
||||
11
ui/src/app/components/install-wizard/loadable.ts
Normal file
11
ui/src/app/components/install-wizard/loadable.ts
Normal 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>
|
||||
}
|
||||
161
ui/src/app/components/install-wizard/prebaked-wizards.ts
Normal file
161
ui/src/app/components/install-wizard/prebaked-wizards.ts
Normal 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
|
||||
7
ui/src/app/components/install-wizard/wizard-types.ts
Normal file
7
ui/src/app/components/install-wizard/wizard-types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type WizardAction =
|
||||
'install'
|
||||
| 'update'
|
||||
| 'downgrade'
|
||||
| 'uninstall'
|
||||
| 'stop'
|
||||
| 'configure'
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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;
|
||||
}
|
||||
101
ui/src/app/components/object-config/object-config.component.ts
Normal file
101
ui/src/app/components/object-config/object-config.component.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<ion-button (click)="navigateBack()">
|
||||
<ion-icon slot="icon-only" name="arrow-back"></ion-icon>
|
||||
</ion-button>
|
||||
@@ -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 { }
|
||||
16
ui/src/app/components/pwa-back-button/pwa-back.component.ts
Normal file
16
ui/src/app/components/pwa-back-button/pwa-back.component.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
1
ui/src/app/components/qr/qr.component.html
Normal file
1
ui/src/app/components/qr/qr.component.html
Normal file
@@ -0,0 +1 @@
|
||||
<qrcode [qrdata]="text" [width]="width" errorCorrectionLevel="L"></qrcode>
|
||||
18
ui/src/app/components/qr/qr.component.module.ts
Normal file
18
ui/src/app/components/qr/qr.component.module.ts
Normal 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 { }
|
||||
0
ui/src/app/components/qr/qr.component.scss
Normal file
0
ui/src/app/components/qr/qr.component.scss
Normal file
16
ui/src/app/components/qr/qr.component.ts
Normal file
16
ui/src/app/components/qr/qr.component.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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
|
||||
}
|
||||
24
ui/src/app/components/status/status.component.html
Normal file
24
ui/src/app/components/status/status.component.html
Normal 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>
|
||||
16
ui/src/app/components/status/status.component.module.ts
Normal file
16
ui/src/app/components/status/status.component.module.ts
Normal 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 { }
|
||||
32
ui/src/app/components/status/status.component.scss
Normal file
32
ui/src/app/components/status/status.component.scss
Normal 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;
|
||||
}
|
||||
56
ui/src/app/components/status/status.component.ts
Normal file
56
ui/src/app/components/status/status.component.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
36
ui/src/app/guards/auth.guard.ts
Normal file
36
ui/src/app/guards/auth.guard.ts
Normal 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
|
||||
}
|
||||
}
|
||||
26
ui/src/app/guards/deactivate.guard.ts
Normal file
26
ui/src/app/guards/deactivate.guard.ts
Normal 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?')
|
||||
}
|
||||
}
|
||||
26
ui/src/app/guards/unauth.guard.ts
Normal file
26
ui/src/app/guards/unauth.guard.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
ui/src/app/modals/app-backup/app-backup.module.ts
Normal file
15
ui/src/app/modals/app-backup/app-backup.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AppBackupPage } from './app-backup.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppBackupPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
],
|
||||
entryComponents: [AppBackupPage],
|
||||
exports: [AppBackupPage],
|
||||
})
|
||||
export class AppBackupPageModule { }
|
||||
50
ui/src/app/modals/app-backup/app-backup.page.html
Normal file
50
ui/src/app/modals/app-backup/app-backup.page.html
Normal file
@@ -0,0 +1,50 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ type === 'create' ? 'Create Backup' : 'Restore Backup' }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="presentAlertHelp()" color="primary">
|
||||
<ion-icon slot="icon-only" name="help-circle-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
|
||||
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
|
||||
<ion-refresher-content pullingIcon="lines" refreshingSpinner="lines"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<ion-item *ngIf="error" style="margin-bottom: 16px;">
|
||||
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
|
||||
</ion-item>
|
||||
|
||||
<ion-spinner *ngIf="loading" class="center" name="lines" color="warning"></ion-spinner>
|
||||
|
||||
<ng-container *ngIf="!loading">
|
||||
<ion-item *ngIf="allPartitionsMounted">
|
||||
<ion-text *ngIf="type === 'create'" class="ion-text-wrap" color="warning">No partitions available. To begin a backup, insert a storage device into your Embassy.</ion-text>
|
||||
<ion-text *ngIf="type === 'restore'" class="ion-text-wrap" color="warning">No partitions available. Insert the storage device containing the backup you wish to restore.</ion-text>
|
||||
</ion-item>
|
||||
<ion-item-group *ngFor="let d of disks">
|
||||
<ion-item-divider>{{ d.logicalname }} ({{ d.size }})</ion-item-divider>
|
||||
<ion-item-group>
|
||||
<ion-item button [disabled]="p.isMounted" *ngFor="let p of d.partitions" (click)="presentAlert(p)">
|
||||
<ion-icon slot="start" name="save-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>{{ p.label || p.logicalname }}</h2>
|
||||
<p>{{ p.size || 'unknown size' }}</p>
|
||||
<p *ngIf="!p.isMounted"><ion-text color="success">Available</ion-text></p>
|
||||
<p *ngIf="p.isMounted"><ion-text color="danger">Unvailable</ion-text></p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-item-group>
|
||||
</ng-container>
|
||||
|
||||
</ion-content>
|
||||
0
ui/src/app/modals/app-backup/app-backup.page.scss
Normal file
0
ui/src/app/modals/app-backup/app-backup.page.scss
Normal file
206
ui/src/app/modals/app-backup/app-backup.page.ts
Normal file
206
ui/src/app/modals/app-backup/app-backup.page.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController, AlertController, LoadingController, IonicSafeString } from '@ionic/angular'
|
||||
import { AppModel, AppStatus } from 'src/app/models/app-model'
|
||||
import { AppInstalledFull } from 'src/app/models/app-types'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { DiskInfo, DiskPartition } from 'src/app/models/server-model'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
|
||||
@Component({
|
||||
selector: 'app-backup',
|
||||
templateUrl: './app-backup.page.html',
|
||||
styleUrls: ['./app-backup.page.scss'],
|
||||
})
|
||||
export class AppBackupPage {
|
||||
@Input() app: AppInstalledFull
|
||||
@Input() type: 'create' | 'restore'
|
||||
disks: DiskInfo[]
|
||||
loading = true
|
||||
error: string
|
||||
allPartitionsMounted: boolean
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly appModel: AppModel,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
return this.getExternalDisks().then(() => this.loading = false)
|
||||
}
|
||||
|
||||
async getExternalDisks (): Promise<void> {
|
||||
try {
|
||||
this.disks = await this.apiService.getExternalDisks()
|
||||
this.allPartitionsMounted = this.disks.every(d => d.partitions.every(p => p.isMounted))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async doRefresh (event: any) {
|
||||
await Promise.all([
|
||||
this.getExternalDisks(),
|
||||
pauseFor(600),
|
||||
])
|
||||
event.target.complete()
|
||||
}
|
||||
|
||||
async dismiss () {
|
||||
await this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
async presentAlertHelp (): Promise<void> {
|
||||
let alert: HTMLIonAlertElement
|
||||
if (this.type === 'create') {
|
||||
alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: `Backups`,
|
||||
message: `Select a location to back up ${this.app.title}.<br /><br />Internal drives and drives currently backing up other services will not be available.<br /><br />Depending on the amount of data in ${this.app.title}, your first backup may take a while. Since backups are diff-based, the speed of future backups to the same disk will likely be much faster.`,
|
||||
buttons: ['Dismiss'],
|
||||
})
|
||||
} else if (this.type === 'restore') {
|
||||
alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: `Backups`,
|
||||
message: `Select a location containing the backup you wish to restore for ${this.app.title}.<br /><br />Restoring ${this.app.title} will re-sync your service with your previous backup. The speed of the restore process depends on the backup size.`,
|
||||
buttons: ['Dismiss'],
|
||||
})
|
||||
}
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async presentAlert (partition: DiskPartition): Promise<void> {
|
||||
if (this.type === 'create') {
|
||||
this.presentAlertCreateEncrypted(partition)
|
||||
} else {
|
||||
this.presentAlertWarn(partition)
|
||||
}
|
||||
}
|
||||
|
||||
private async presentAlertCreateEncrypted (partition: DiskPartition): Promise<void> {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: `Encrypt Backup`,
|
||||
message: `Enter your master password to create an encrypted backup of ${this.app.title} to "${partition.label || partition.logicalname}".`,
|
||||
inputs: [
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
placeholder: 'Master Password',
|
||||
},
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Create Backup',
|
||||
handler: (data) => {
|
||||
if (!data.password || data.password.length < 12) {
|
||||
alert.message = new IonicSafeString(alert.message + '<br /><br /><ion-text color="danger">Password must be at least 12 characters in length.</ion-text>')
|
||||
return false
|
||||
} else {
|
||||
this.create(partition, data.password)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private async presentAlertWarn (partition: DiskPartition): Promise<void> {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: `Warning`,
|
||||
message: `Restoring ${this.app.title} from "${partition.label || partition.logicalname}" will overwrite its current data.<br /><br />Are you sure you want to continue?`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
}, {
|
||||
text: 'Continue',
|
||||
handler: () => {
|
||||
this.presentAlertRestore(partition)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private async presentAlertRestore (partition: DiskPartition): Promise<void> {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: `Decrypt Backup`,
|
||||
message: `Enter your master password`,
|
||||
inputs: [
|
||||
{
|
||||
name: 'password',
|
||||
type: 'password',
|
||||
placeholder: 'Password',
|
||||
},
|
||||
],
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
}, {
|
||||
text: 'Restore',
|
||||
handler: (data) => {
|
||||
this.restore(partition, data.password)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private async restore (partition: DiskPartition, password?: string): Promise<void> {
|
||||
this.error = ''
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader-ontop-of-all',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.apiService.restoreAppBackup(this.app.id, partition.logicalname, password)
|
||||
this.appModel.update({ id: this.app.id, status: AppStatus.RESTORING_BACKUP })
|
||||
await this.dismiss()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
} finally {
|
||||
await loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async create (partition: DiskPartition, password?: string): Promise<void> {
|
||||
this.error = ''
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader-ontop-of-all',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.apiService.createAppBackup(this.app.id, partition.logicalname, password)
|
||||
this.appModel.update({ id: this.app.id, status: AppStatus.CREATING_BACKUP })
|
||||
await this.dismiss()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
} finally {
|
||||
await loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { InjectionToken } from '@angular/core'
|
||||
|
||||
export const APP_CONFIG_COMPONENT_MAPPING = new InjectionToken<string>('APP_CONFIG_COMPONENTS')
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Type } from '@angular/core'
|
||||
import { ValueType } from 'src/app/app-config/config-types'
|
||||
|
||||
export type AppConfigComponentMapping = { [k in ValueType]: Type<any> }
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AppConfigObjectPage } from '../app-config-object/app-config-object.page'
|
||||
import { AppConfigListPage } from '../app-config-list/app-config-list.page'
|
||||
import { AppConfigUnionPage } from '../app-config-union/app-config-union.page'
|
||||
import { AppConfigValuePage } from '../app-config-value/app-config-value.page'
|
||||
import { AppConfigComponentMapping } from './modal-injectable-type'
|
||||
|
||||
export const appConfigComponents: AppConfigComponentMapping = {
|
||||
'string': AppConfigValuePage,
|
||||
'number': AppConfigValuePage,
|
||||
'enum': AppConfigValuePage,
|
||||
'boolean': AppConfigValuePage,
|
||||
'list': AppConfigListPage,
|
||||
'object': AppConfigObjectPage,
|
||||
'union': AppConfigUnionPage,
|
||||
'pointer': undefined,
|
||||
}
|
||||
21
ui/src/app/modals/app-config-list/app-config-list.module.ts
Normal file
21
ui/src/app/modals/app-config-list/app-config-list.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AppConfigListPage } from './app-config-list.page'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
import { ConfigHeaderComponentModule } from 'src/app/components/config-header/config-header.component.module'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppConfigListPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SharingModule,
|
||||
FormsModule,
|
||||
ConfigHeaderComponentModule,
|
||||
],
|
||||
entryComponents: [AppConfigListPage],
|
||||
exports: [AppConfigListPage],
|
||||
})
|
||||
export class AppConfigListPageModule { }
|
||||
68
ui/src/app/modals/app-config-list/app-config-list.page.html
Normal file
68
ui/src/app/modals/app-config-list/app-config-list.page.html
Normal file
@@ -0,0 +1,68 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon name="arrow-back"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ spec.name }}</ion-title>
|
||||
<ion-buttons *ngIf="spec.subtype !== 'enum'" slot="end">
|
||||
<ion-button (click)="presentModalValueEdit()">
|
||||
<ion-icon name="add" color="primary"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
|
||||
<config-header [spec]="spec" [error]="error"></config-header>
|
||||
|
||||
<!-- enum list -->
|
||||
<ion-item-group *ngIf="spec.subtype === 'enum'">
|
||||
<ion-item-divider class="borderless"></ion-item-divider>
|
||||
<ion-item-divider>
|
||||
{{ value.length }} selected
|
||||
<span *ngIf="min"> (min: {{ min }})</span>
|
||||
<span *ngIf="max"> (max: {{ max }})</span>
|
||||
<ion-button slot="end" fill="clear" color="primary" (click)="toggleSelectAll()">{{ selectAll ? 'All' : 'None' }}</ion-button>
|
||||
</ion-item-divider>
|
||||
<ion-item *ngFor="let option of options">
|
||||
<ion-label>{{ option.value }}</ion-label>
|
||||
<ion-checkbox slot="end" [(ngModel)]="option.checked" (click)="toggleSelected(option.value)"></ion-checkbox>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
|
||||
<!-- not enum list -->
|
||||
<div *ngIf="spec.subtype !== 'enum'">
|
||||
<ion-item-divider class="borderless"></ion-item-divider>
|
||||
<ion-item-group>
|
||||
<ion-item-divider style="font-size: small; color: var(--ion-color-medium);">
|
||||
{{ value.length }}
|
||||
<span *ngIf="value.length === 1">Entry</span>
|
||||
<span *ngIf="value.length !== 1">Entries</span>
|
||||
<span *ngIf="min"> (min: {{ min }})</span>
|
||||
<span *ngIf="max"> (max: {{ max }})</span>
|
||||
</ion-item-divider>
|
||||
|
||||
<div *ngFor="let v of value; index as i;">
|
||||
<ion-item-sliding>
|
||||
<ion-item button detail="false" (click)="presentModalValueEdit(i)">
|
||||
<ion-icon size="small" slot="start" *ngIf="!annotations.members[i] || (annotations.members[i] | annotationStatus: 'NoChange')" style="margin-right: 15px; color: rgba(0,0,0,0); background: radial-gradient(#2a4e8970, #2a4e8970 35%, transparent 35%, transparent);" name="ellipse"></ion-icon>
|
||||
<ion-icon size="small" slot="start" *ngIf="!annotations.members[i] || (annotations.members[i] | annotationStatus: 'Added')" style="margin-right: 15px; color: rgba(0,0,0,0); background: radial-gradient(#2a4e8970, #2a4e8970 35%, transparent 35%, transparent);" name="ellipse"></ion-icon>
|
||||
<ion-icon size="small" slot="start" *ngIf="annotations.members[i] && (annotations.members[i] | annotationStatus: 'Edited')" style="margin-right: 15px" color="primary" name="ellipse"></ion-icon>
|
||||
<ion-icon size="small" slot="start" *ngIf="annotations.members[i] && (annotations.members[i] | annotationStatus: 'Invalid')" style="margin-right: 15px" color="danger" name="warning-outline"></ion-icon>
|
||||
|
||||
<ion-label>{{ valueString[i] }}</ion-label>
|
||||
</ion-item>
|
||||
<ion-item-options side="end">
|
||||
<ion-item-option color="danger" (click)="presentAlertDeleteEntry(i)">
|
||||
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
|
||||
</ion-item-option>
|
||||
</ion-item-options>
|
||||
</ion-item-sliding>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
</div>
|
||||
|
||||
</ion-content>
|
||||
146
ui/src/app/modals/app-config-list/app-config-list.page.ts
Normal file
146
ui/src/app/modals/app-config-list/app-config-list.page.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { AlertController } from '@ionic/angular'
|
||||
import { Annotations, Range } from '../../app-config/config-utilities'
|
||||
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
|
||||
import { ConfigCursor } from 'src/app/app-config/config-cursor'
|
||||
import { ValueSpecList, isValueSpecListOf } from 'src/app/app-config/config-types'
|
||||
import { ModalPresentable } from 'src/app/app-config/modal-presentable'
|
||||
|
||||
@Component({
|
||||
selector: 'app-config-list',
|
||||
templateUrl: './app-config-list.page.html',
|
||||
styleUrls: ['./app-config-list.page.scss'],
|
||||
})
|
||||
export class AppConfigListPage extends ModalPresentable {
|
||||
@Input() cursor: ConfigCursor<'list'>
|
||||
|
||||
spec: ValueSpecList
|
||||
value: string[] | number[] | object[]
|
||||
valueString: string[]
|
||||
annotations: Annotations<'list'>
|
||||
|
||||
// enum only
|
||||
options: { value: string, checked: boolean }[] = []
|
||||
selectAll = true
|
||||
//
|
||||
|
||||
min: number | undefined
|
||||
max: number | undefined
|
||||
|
||||
minMessage: string
|
||||
maxMessage: string
|
||||
|
||||
error: string
|
||||
|
||||
constructor (
|
||||
private readonly alertCtrl: AlertController,
|
||||
trackingModalCtrl: TrackingModalController,
|
||||
) {
|
||||
super(trackingModalCtrl)
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.spec = this.cursor.spec()
|
||||
this.value = this.cursor.config()
|
||||
const range = Range.from(this.spec.range)
|
||||
this.min = range.integralMin()
|
||||
this.max = range.integralMax()
|
||||
this.minMessage = `The minimum number of ${this.cursor.key()} is ${this.min}.`
|
||||
this.maxMessage = `The maximum number of ${this.cursor.key()} is ${this.max}.`
|
||||
// enum list only
|
||||
if (isValueSpecListOf(this.spec, 'enum')) {
|
||||
for (let val of this.spec.spec.values) {
|
||||
this.options.push({
|
||||
value: val,
|
||||
checked: (this.value as string[]).includes(val),
|
||||
})
|
||||
}
|
||||
}
|
||||
this.updateCaches()
|
||||
}
|
||||
|
||||
async dismiss () {
|
||||
return this.dismissModal(this.value)
|
||||
}
|
||||
|
||||
// enum only
|
||||
toggleSelectAll () {
|
||||
if (!isValueSpecListOf(this.spec, 'enum')) { throw new Error('unreachable') }
|
||||
|
||||
this.value.length = 0
|
||||
if (this.selectAll) {
|
||||
for (let v of this.spec.spec.values) {
|
||||
(this.value as string[]).push(v)
|
||||
}
|
||||
for (let option of this.options) {
|
||||
option.checked = true
|
||||
}
|
||||
} else {
|
||||
for (let option of this.options) {
|
||||
option.checked = false
|
||||
}
|
||||
}
|
||||
this.updateCaches()
|
||||
}
|
||||
|
||||
// enum only
|
||||
async toggleSelected (value: string) {
|
||||
const index = (this.value as string[]).indexOf(value)
|
||||
|
||||
// if present, delete
|
||||
if (index > -1) {
|
||||
(this.value as string[]).splice(index, 1)
|
||||
// if not present, add
|
||||
} else {
|
||||
(this.value as string[]).push(value)
|
||||
}
|
||||
|
||||
this.updateCaches()
|
||||
}
|
||||
|
||||
async presentModalValueEdit (index?: number) {
|
||||
const nextCursor = this.cursor.seekNext(index === undefined ? this.value.length : index)
|
||||
nextCursor.createFirstEntryForList()
|
||||
return this.presentModal(nextCursor, () => this.updateCaches())
|
||||
}
|
||||
|
||||
async presentAlertDeleteEntry (key: number) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: 'Caution',
|
||||
message: `Are you sure you want to delete this entry?`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
cssClass: 'alert-danger',
|
||||
handler: () => {
|
||||
if (typeof key === 'number') {
|
||||
(this.value as any[]).splice(key, 1)
|
||||
} else {
|
||||
delete this.value[key]
|
||||
}
|
||||
this.updateCaches()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
asIsOrder () {
|
||||
return 0
|
||||
}
|
||||
|
||||
private updateCaches () {
|
||||
if (isValueSpecListOf(this.spec, 'enum')) {
|
||||
this.selectAll = this.value.length !== this.spec.spec.values.length
|
||||
}
|
||||
this.error = this.cursor.checkInvalid()
|
||||
this.annotations = this.cursor.getAnnotations()
|
||||
this.valueString = (this.value as any[]).map((_, idx) => this.cursor.seekNext(idx).toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AppConfigObjectPage } from './app-config-object.page'
|
||||
import { ObjectConfigComponentModule } from 'src/app/components/object-config/object-config.component.module'
|
||||
import { ConfigHeaderComponentModule } from 'src/app/components/config-header/config-header.component.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppConfigObjectPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
ObjectConfigComponentModule,
|
||||
ConfigHeaderComponentModule,
|
||||
],
|
||||
entryComponents: [AppConfigObjectPage],
|
||||
exports: [AppConfigObjectPage],
|
||||
})
|
||||
export class AppConfigObjectPageModule { }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user