Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/major

This commit is contained in:
Matt Hill
2024-08-08 10:52:49 -06:00
765 changed files with 43858 additions and 19423 deletions

View File

@@ -69,7 +69,8 @@
"with": "projects/ui/src/environments/environment.prod.ts"
}
],
"outputHashing": "all"
"outputHashing": "all",
"extractLicenses": false
},
"development": {
"buildOptimizer": false,
@@ -261,6 +262,8 @@
],
"styles": [
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less",
"projects/shared/styles/taiga.scss",
"projects/shared/styles/shared.scss",
"projects/setup-wizard/src/styles.scss"
],
"scripts": []

411
web/package-lock.json generated
View File

@@ -1,12 +1,13 @@
{
"name": "startos-ui",
"version": "0.3.5.2",
"version": "0.3.6-alpha.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "startos-ui",
"version": "0.3.5.2",
"version": "0.3.6-alpha.3",
"license": "MIT",
"dependencies": {
"@angular/animations": "^17.3.1",
"@angular/cdk": "^17.3.1",
@@ -20,8 +21,9 @@
"@angular/router": "^17.3.1",
"@angular/service-worker": "^17.3.1",
"@materia-ui/ngx-monaco-editor": "^6.0.0",
"@start9labs/argon2": "^0.1.0",
"@start9labs/emver": "^0.1.5",
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"@start9labs/argon2": "^0.2.2",
"@start9labs/start-sdk": "file:../sdk/dist",
"@taiga-ui/addon-charts": "4.0.0-rc.7",
"@taiga-ui/addon-commerce": "4.0.0-rc.7",
@@ -38,6 +40,7 @@
"@tinkoff/ng-dompurify": "4.0.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
"cbor": "npm:@jprochazk/cbor@^0.4.9",
"cbor-web": "^8.1.0",
"core-js": "^3.21.1",
@@ -49,6 +52,7 @@
"jose": "^4.9.0",
"js-yaml": "^4.1.0",
"marked": "^4.0.0",
"mime": "^4.0.3",
"monaco-editor": "^0.33.0",
"mustache": "^4.2.0",
"ng-qrcode": "^17.0.0",
@@ -57,7 +61,7 @@
"pbkdf2": "^3.1.2",
"rxjs": "^7.5.6",
"swiper": "^8.2.4",
"ts-matches": "^5.2.1",
"ts-matches": "^5.5.1",
"tslib": "^2.6.3",
"uuid": "^8.3.2",
"zone.js": "^0.14.2"
@@ -374,12 +378,11 @@
}
},
"../patch-db/client/node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"version": "3.0.2",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
"fill-range": "^7.0.1"
},
"engines": {
"node": ">=8"
@@ -612,10 +615,9 @@
}
},
"../patch-db/client/node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"version": "7.0.1",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
@@ -814,9 +816,8 @@
},
"../patch-db/client/node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
@@ -1496,10 +1497,9 @@
}
},
"../patch-db/client/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"version": "5.7.1",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver"
}
@@ -1665,9 +1665,8 @@
},
"../patch-db/client/node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
@@ -1984,20 +1983,29 @@
},
"../sdk/dist": {
"name": "@start9labs/start-sdk",
"version": "0.4.0-rev0.lib0.rc8.beta10",
"version": "0.3.6-alpha6",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^2.2.5",
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"isomorphic-fetch": "^3.0.0",
"ts-matches": "^5.4.1"
"lodash.merge": "^4.6.2",
"mime": "^4.0.3",
"ts-matches": "^5.5.1",
"yaml": "^2.2.2"
},
"devDependencies": {
"@iarna/toml": "^2.2.5",
"@types/jest": "^29.4.0",
"@types/lodash.merge": "^4.6.2",
"copyfiles": "^2.4.1",
"jest": "^29.4.3",
"peggy": "^3.0.2",
"prettier": "^3.2.5",
"ts-jest": "^29.0.5",
"ts-node": "^10.9.1",
"ts-pegjs": "^4.2.1",
"tsx": "^4.7.1",
"typescript": "^5.0.4",
"yaml": "^2.2.2"
@@ -2277,9 +2285,8 @@
},
"node_modules/@angular-devkit/build-angular/node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"dev": true
"dev": true,
"license": "0BSD"
},
"node_modules/@angular-devkit/build-angular/node_modules/webpack": {
"version": "5.90.3",
@@ -4559,8 +4566,7 @@
},
"node_modules/@maskito/angular": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-3.0.0.tgz",
"integrity": "sha512-OqVWCItRhSzbHBfEh79Rg5eHEru6b5p7xzUlZ19JY93Wo/4xhA+d4G8P0j+QJIDl+TIuD+BbB0h6DHtS41GzUw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "2.6.3"
@@ -4573,14 +4579,12 @@
},
"node_modules/@maskito/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@maskito/core/-/core-3.0.0.tgz",
"integrity": "sha512-g7zeYPMlpMczrq4Huf+Bpdm3Emy/GO0NUXXnQnUiCjlAoKQl+86cLyP5Hbf4HGcNl/J9SoEGEA4uoW6uUc/yLw==",
"license": "Apache-2.0",
"peer": true
},
"node_modules/@maskito/kit": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-3.0.0.tgz",
"integrity": "sha512-aXRlDBjeNox/+D7hbXtnM9INGml1QUIXhrnScrCsbqgg7550mt/ivh4PrxL7oazq/BH7HhvS4olJCF5TPEti1g==",
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"@maskito/core": "^3.0.0"
@@ -4588,8 +4592,7 @@
},
"node_modules/@maskito/phone": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@maskito/phone/-/phone-3.0.0.tgz",
"integrity": "sha512-v5ky83geF2PReW8h6MfkeT1Zx02iUlib/KJdmdS2ojMeawbnVqKdNKffL+JNlZJdZtglLR8eCxnGLjp+E75TIQ==",
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"@maskito/core": "^3.0.0",
@@ -4610,8 +4613,7 @@
},
"node_modules/@ng-web-apis/common": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@ng-web-apis/common/-/common-4.2.0.tgz",
"integrity": "sha512-xOadrY1yhmFl5aOB1AyQlwfa7DIXMATdRCld36DKyg+ebPrWaFBeGueAgwspxGaDGoAHcC8xM/erRq0B/qkmbw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
@@ -4624,8 +4626,7 @@
},
"node_modules/@ng-web-apis/intersection-observer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@ng-web-apis/intersection-observer/-/intersection-observer-4.2.0.tgz",
"integrity": "sha512-OAaMtrRUbqvtIu4eFvbo4DIIG6Yw1q+2wirW+4HXqoM84/szhQq/W4iFTTjpDXRy2VNg+PJmMSHUAYWpHSIP5g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
@@ -4637,8 +4638,7 @@
},
"node_modules/@ng-web-apis/mutation-observer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@ng-web-apis/mutation-observer/-/mutation-observer-4.2.0.tgz",
"integrity": "sha512-kz5zYI7UcDGs5DFrlmKsDoCuWaCDbh7kJlztsXdM26H+frYP893jSj8GSSeAang2G7ljv+silIv+QDTX2pJ3dA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
@@ -4650,8 +4650,7 @@
},
"node_modules/@ng-web-apis/resize-observer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@ng-web-apis/resize-observer/-/resize-observer-4.2.0.tgz",
"integrity": "sha512-WVupnbN/41a9fO0O8VuqXc7iT5OAUrayP5y/cLXpuAvymKocJQDys/glqVzuT4I7Dvi7eTWU6CfVePJusDD0Tg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
@@ -4676,9 +4675,31 @@
"webpack": "^5.54.0"
}
},
"node_modules/@noble/curves": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.5.0.tgz",
"integrity": "sha512-J5EKamIHnKPyClwVrzmaf5wSdQXgdHcPZIZLu3bwnbeCx8/7NPK5q2ZBWF+5FvYGByjiQQsJYX6jfgB2wDPn3A==",
"dependencies": {
"@noble/hashes": "1.4.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz",
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -4690,7 +4711,7 @@
},
"node_modules/@nodelib/fs.stat": {
"version": "2.0.5",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -4698,7 +4719,7 @@
},
"node_modules/@nodelib/fs.walk": {
"version": "1.2.8",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -5110,11 +5131,9 @@
}
},
"node_modules/@start9labs/argon2": {
"version": "0.1.0"
},
"node_modules/@start9labs/emver": {
"version": "0.1.5",
"license": "MIT"
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@start9labs/argon2/-/argon2-0.2.2.tgz",
"integrity": "sha512-OEJYDIicwwWg0NgG3d2GSO2Qs65B0LY9dIrlXFIJZJ1mo9vcDIU0kC2Yp8dg4XMt2U16ncsgru98s9I+y5Yuaw=="
},
"node_modules/@start9labs/start-sdk": {
"resolved": "../sdk/dist",
@@ -5122,8 +5141,7 @@
},
"node_modules/@taiga-ui/addon-charts": {
"version": "4.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-4.0.0-rc.7.tgz",
"integrity": "sha512-0saSw6lFsHTPcRa/G9jAYI4M9CgtkG9eo4ZGPiQqapAILRDs0beF7/3Vrhi+sTaJUAx4hhzRJYdO/W3Iqa+irA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.3"
},
@@ -5138,8 +5156,7 @@
},
"node_modules/@taiga-ui/addon-commerce": {
"version": "4.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.0.0-rc.7.tgz",
"integrity": "sha512-N4gotOyFdvLUBIYkJw/7dmCAq4uqYAl7AenkH8vcouYGfz0jPzW1hhuDxZtmB3dIyTcJdZFdLYkK3574gh2qcA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.3"
},
@@ -5161,8 +5178,7 @@
},
"node_modules/@taiga-ui/addon-mobile": {
"version": "4.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-mobile/-/addon-mobile-4.0.0-rc.7.tgz",
"integrity": "sha512-M9FvZ/DWCR0czA+eHxTigfJYuQTWBuW7zqR8rl7TSW7ibluS6GgYBlrLcqUK/qozSDwmx3vA6iaEt1xkbqLLkg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.3"
},
@@ -5180,8 +5196,7 @@
},
"node_modules/@taiga-ui/cdk": {
"version": "4.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.0.0-rc.7.tgz",
"integrity": "sha512-09d5ePA1kTxL8T3o0HTQouSnqM+fuBqPfL7Fckdmebo6Dqm1MWeO6QRa+aFoYmiz1d/kvmDXDBaKJZp51S54FA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.6.3"
},
@@ -5208,8 +5223,7 @@
},
"node_modules/@taiga-ui/core": {
"version": "4.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.0.0-rc.7.tgz",
"integrity": "sha512-59F0U6ZGl7aGA4UQfL8KE8BAFCk3lD9XRshdSk6EatEZITxp7a1XrnKKZOuYYuWMYkKEchHJxPR8g71PlChgAA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.3"
},
@@ -5231,8 +5245,7 @@
},
"node_modules/@taiga-ui/event-plugins": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@taiga-ui/event-plugins/-/event-plugins-4.0.1.tgz",
"integrity": "sha512-qy9AMUVakgZ1e2H7G6dSHtBccveHCi/SG5EhNNjMjhMGkSywIDn+HLJ9kCtDmrJDRtFAPgZ3J0C/UwrsEzCvdQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.3.0"
},
@@ -5244,8 +5257,7 @@
},
"node_modules/@taiga-ui/experimental": {
"version": "4.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@taiga-ui/experimental/-/experimental-4.0.0-rc.7.tgz",
"integrity": "sha512-lbefiQz/zyAamjd4HhRH/AhobCGbFRW2YhmwTEjcBDJOupCm0wkZuV3WO1EiSwpPyu4rh82Z4kcL8X9xhQQWvQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.3"
},
@@ -5262,8 +5274,7 @@
},
"node_modules/@taiga-ui/i18n": {
"version": "4.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.0.0-rc.7.tgz",
"integrity": "sha512-ma/iL0izpM9oO4kD8ATiJUSRzc7iPy2QKRfAOKpP02Wkd1DuDeTFd/Wc+JdMJQBb1adPh73LeQDyatkqxHsOPw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.6.3"
@@ -5276,16 +5287,14 @@
},
"node_modules/@taiga-ui/icons": {
"version": "4.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-4.0.0-rc.7.tgz",
"integrity": "sha512-sT6tRJJgmEYzI0JJ/Xmje7kQx6TnQ7qJPdfRX68deEnn7sq/GuSEDZoDvCOAyNiPUmUn2OIIYTI54yMtZQv2zQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.3.0"
}
},
"node_modules/@taiga-ui/kit": {
"version": "4.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.0.0-rc.7.tgz",
"integrity": "sha512-sHlXxVixmFm79Z3WPjfNN4nzs8YtLArXHXIruI/cRgppuei9yrRWNZ5lue7kV5jByuyhu7k7aiBzpqRLZ0h+TQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.3"
},
@@ -5311,8 +5320,7 @@
},
"node_modules/@taiga-ui/layout": {
"version": "4.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.0.0-rc.7.tgz",
"integrity": "sha512-GkMDBZmJK0uVY1F1BSkcZjzYdmJngjGxJYNsB8jEVkxqCQsXgjYP07/dJTx/G0TtdVft1y8f/zK3CpeFYHvy1w==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.3"
},
@@ -5328,8 +5336,7 @@
},
"node_modules/@taiga-ui/legacy": {
"version": "4.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@taiga-ui/legacy/-/legacy-4.0.0-rc.7.tgz",
"integrity": "sha512-/aVVdWQCcHqhTMZqhOT5T9odSaY6yv1VPy3yPp5n2+ScBmbrSjNqUS0q7Mqwb6OtyeiqOgOmJCrBUup3sQu6Yw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.3"
},
@@ -5339,8 +5346,7 @@
},
"node_modules/@taiga-ui/polymorpheus": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/@taiga-ui/polymorpheus/-/polymorpheus-4.6.4.tgz",
"integrity": "sha512-rlMNWfhImLaMEDWXU1TG7a+YYkVA528poq7lNQ8d+61HyXvbjgs/WAeJQwdabjeLxTUyDozSBJVwSwU69tYprg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.6.3"
@@ -5352,8 +5358,6 @@
},
"node_modules/@taiga-ui/styles": {
"version": "4.0.0-rc.7",
"resolved": "https://registry.npmjs.org/@taiga-ui/styles/-/styles-4.0.0-rc.7.tgz",
"integrity": "sha512-00Dp/cE7DJrNwRBpsSd5n4p99Sw1id4dfc36JoKtEV94KaohVdzsm1gRAqeEIu3aJ7pTl5nzCzB4MYOGE0kc6g==",
"peerDependencies": {
"@taiga-ui/cdk": "^4.0.0-rc.7",
"@taiga-ui/core": "^4.0.0-rc.7",
@@ -5373,33 +5377,6 @@
"dompurify": ">= 2.3.0"
}
},
"node_modules/@ts-morph/common": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.24.0.tgz",
"integrity": "sha512-c1xMmNHWpNselmpIqursHeOHHBTIsJLbB+NuovbTTRCNiTLEr/U9dbJ8qy0jd/O2x5pc3seWuOUN5R2IoOTp8A==",
"optional": true,
"dependencies": {
"fast-glob": "^3.3.2",
"minimatch": "^9.0.4",
"mkdirp": "^3.0.1",
"path-browserify": "^1.0.1"
}
},
"node_modules/@ts-morph/common/node_modules/mkdirp": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
"optional": true,
"bin": {
"mkdirp": "dist/cjs/src/bin.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.9",
"dev": true,
@@ -5563,12 +5540,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/minimatch": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz",
"integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==",
"optional": true
},
"node_modules/@types/mustache": {
"version": "4.2.5",
"dev": true,
@@ -6071,38 +6042,11 @@
"version": "2.0.1",
"license": "Python-2.0"
},
"node_modules/array-differ": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/array-differ/-/array-differ-3.0.0.tgz",
"integrity": "sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"dev": true,
"license": "MIT"
},
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/arrify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz",
"integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/autoprefixer": {
"version": "10.4.19",
"dev": true,
@@ -6403,9 +6347,8 @@
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
@@ -6840,12 +6783,6 @@
"node": ">=0.10.0"
}
},
"node_modules/code-block-writer": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.1.tgz",
"integrity": "sha512-c5or4P6erEA69TxaxTNcHUNcIn+oyxSRTOWV+pSYF+z4epXqNvwvJ70XPGjPNgue83oAFAPBRQYwpAJ/Hpe/Sg==",
"optional": true
},
"node_modules/color-convert": {
"version": "1.9.3",
"dev": true,
@@ -6943,7 +6880,7 @@
},
"node_modules/concat-map": {
"version": "0.0.1",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/connect-history-api-fallback": {
@@ -7911,9 +7848,8 @@
},
"node_modules/express": {
"version": "4.19.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz",
"integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -7997,7 +7933,7 @@
},
"node_modules/fast-glob": {
"version": "3.3.2",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -8021,7 +7957,7 @@
},
"node_modules/fastq": {
"version": "1.17.1",
"devOptional": true,
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -8054,9 +7990,8 @@
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
@@ -9201,9 +9136,8 @@
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
@@ -9523,6 +9457,19 @@
"webpack": "^5.0.0"
}
},
"node_modules/less/node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"dev": true,
"optional": true,
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/less/node_modules/source-map": {
"version": "0.6.1",
"dev": true,
@@ -9534,8 +9481,7 @@
},
"node_modules/libphonenumber-js": {
"version": "1.11.5",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.5.tgz",
"integrity": "sha512-TwHR5BZxGRODtAfz03szucAkjT5OArXr+94SMtAM2pYXIlQNVMrxvb6uSCbnaJJV6QXEyICk7+l6QPgn72WHhg==",
"license": "MIT",
"peer": true
},
"node_modules/license-webpack-plugin": {
@@ -10141,7 +10087,7 @@
},
"node_modules/merge2": {
"version": "1.4.1",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -10157,7 +10103,7 @@
},
"node_modules/micromatch": {
"version": "4.0.5",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.2",
@@ -10169,7 +10115,7 @@
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -10179,14 +10125,17 @@
}
},
"node_modules/mime": {
"version": "1.6.0",
"dev": true,
"license": "MIT",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.4.tgz",
"integrity": "sha512-v8yqInVjhXyqP6+Kw4fV3ZzeMRqEW6FotRsKXjRS5VMTNIuXsdRoAvklpoRgSqXm6o9VNH4/C0mgedko9DdLsQ==",
"funding": [
"https://github.com/sponsors/broofa"
],
"bin": {
"mime": "cli.js"
"mime": "bin/cli.js"
},
"engines": {
"node": ">=4"
"node": ">=16"
}
},
"node_modules/mime-db": {
@@ -10245,9 +10194,8 @@
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -10475,47 +10423,6 @@
"multicast-dns": "cli.js"
}
},
"node_modules/multimatch": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/multimatch/-/multimatch-5.0.0.tgz",
"integrity": "sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==",
"optional": true,
"dependencies": {
"@types/minimatch": "^3.0.3",
"array-differ": "^3.0.0",
"array-union": "^2.1.0",
"arrify": "^2.0.1",
"minimatch": "^3.0.4"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/multimatch/node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"optional": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/multimatch/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"optional": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/mustache": {
"version": "4.2.0",
"license": "MIT",
@@ -10595,44 +10502,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/ng-morph": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/ng-morph/-/ng-morph-4.5.4.tgz",
"integrity": "sha512-g7asHMUZR05bOw/kX5nIVEFs8SDcuH0t5WjdMuVBlJEvl0mY1gDlz1LoIhGlzcbRleMg9n1J0PdrrmYJchWJ9g==",
"optional": true,
"dependencies": {
"jsonc-parser": "3.3.1",
"minimatch": "10.0.1",
"multimatch": "5.0.0",
"ts-morph": "23.0.0"
},
"peerDependencies": {
"@angular-devkit/core": ">=16.0.0",
"@angular-devkit/schematics": ">=16.0.0",
"tslib": "^2.6.3"
}
},
"node_modules/ng-morph/node_modules/jsonc-parser": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
"integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
"optional": true
},
"node_modules/ng-morph/node_modules/minimatch": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz",
"integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==",
"optional": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ng-packagr": {
"version": "17.3.0",
"dev": true,
@@ -11478,12 +11347,6 @@
"resolved": "../patch-db/client",
"link": true
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"optional": true
},
"node_modules/path-exists": {
"version": "4.0.0",
"license": "MIT",
@@ -12054,7 +11917,7 @@
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"devOptional": true,
"dev": true,
"funding": [
{
"type": "github",
@@ -12435,7 +12298,7 @@
},
"node_modules/reusify": {
"version": "1.0.4",
"devOptional": true,
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -12554,7 +12417,7 @@
},
"node_modules/run-parallel": {
"version": "1.2.0",
"devOptional": true,
"dev": true,
"funding": [
{
"type": "github",
@@ -12776,6 +12639,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/send/node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"dev": true,
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"dev": true,
@@ -13335,9 +13210,8 @@
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
@@ -13563,9 +13437,8 @@
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
@@ -13598,18 +13471,9 @@
}
},
"node_modules/ts-matches": {
"version": "v5.4.1",
"license": "MIT"
},
"node_modules/ts-morph": {
"version": "23.0.0",
"resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-23.0.0.tgz",
"integrity": "sha512-FcvFx7a9E8TUe6T3ShihXJLiJOiqyafzFKUO4aqIHDUCIvADdGNShcbc2W5PMr3LerXRv7mafvFZ9lRENxJmug==",
"optional": true,
"dependencies": {
"@ts-morph/common": "~0.24.0",
"code-block-writer": "^13.0.1"
}
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/ts-matches/-/ts-matches-5.5.1.tgz",
"integrity": "sha512-UFYaKgfqlg9FROK7bdpYqFwG1CJvP4kOJdjXuWoqxo9jCmANoDw1GxkSCpJgoTeIiSTaTH5Qr1klSspb8c+ydg=="
},
"node_modules/ts-node": {
"version": "10.9.2",
@@ -13655,8 +13519,7 @@
},
"node_modules/tslib": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
"license": "0BSD"
},
"node_modules/tslint": {
"version": "6.1.3",
@@ -14358,9 +14221,8 @@
},
"node_modules/webpack-dev-server/node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
@@ -14644,9 +14506,8 @@
},
"node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},

View File

@@ -1,8 +1,9 @@
{
"name": "startos-ui",
"version": "0.3.5.2",
"version": "0.3.6-alpha.3",
"author": "Start9 Labs, Inc",
"homepage": "https://start9.com/",
"license": "MIT",
"scripts": {
"ng": "ng",
"check": "npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:install && npm run check:setup",
@@ -11,7 +12,7 @@
"check:install": "tsc --project projects/install-wizard/tsconfig.json --noEmit --skipLibCheck",
"check:setup": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck",
"check:ui": "tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck",
"build:deps": "npx rimraf .angular/cache && (cd ../sdk && npm ci && npm run build) && (cd ../patch-db/client && npm ci && npm run build)",
"build:deps": "rm -rf .angular/cache && (cd ../patch-db/client && npm ci && npm run build) && (cd ../sdk && make bundle)",
"build:install": "ng run install-wizard:build",
"build:setup": "ng run setup-wizard:build",
"build:ui": "ng run ui:build",
@@ -42,8 +43,7 @@
"@angular/router": "^17.3.1",
"@angular/service-worker": "^17.3.1",
"@materia-ui/ngx-monaco-editor": "^6.0.0",
"@start9labs/argon2": "^0.1.0",
"@start9labs/emver": "^0.1.5",
"@start9labs/argon2": "^0.2.2",
"@start9labs/start-sdk": "file:../sdk/dist",
"@taiga-ui/addon-charts": "4.0.0-rc.7",
"@taiga-ui/addon-commerce": "4.0.0-rc.7",
@@ -58,8 +58,11 @@
"@taiga-ui/legacy": "4.0.0-rc.7",
"@taiga-ui/styles": "4.0.0-rc.7",
"@tinkoff/ng-dompurify": "4.0.0",
"@noble/curves": "^1.4.0",
"@noble/hashes": "^1.4.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
"cbor": "npm:@jprochazk/cbor@^0.4.9",
"cbor-web": "^8.1.0",
"core-js": "^3.21.1",
@@ -71,6 +74,7 @@
"jose": "^4.9.0",
"js-yaml": "^4.1.0",
"marked": "^4.0.0",
"mime": "^4.0.3",
"monaco-editor": "^0.33.0",
"mustache": "^4.2.0",
"ng-qrcode": "^17.0.0",
@@ -79,7 +83,7 @@
"pbkdf2": "^3.1.2",
"rxjs": "^7.5.6",
"swiper": "^8.2.4",
"ts-matches": "^5.2.1",
"ts-matches": "^5.5.1",
"tslib": "^2.6.3",
"uuid": "^8.3.2",
"zone.js": "^0.14.2"

View File

@@ -1,11 +1,15 @@
{
"name": null,
"ackWelcome": "0.3.6.1",
"ackWelcome": "0.0.0",
"marketplace": {
"selectedUrl": "https://registry.start9.com/",
"knownHosts": {
"https://registry.start9.com/": {},
"https://community-registry.start9.com/": {}
"https://registry.start9.com/": {
"name": "Start9 Registry"
},
"https://community-registry.start9.com/": {
"name": "Community Registry"
}
}
},
"dev": {},
@@ -16,5 +20,6 @@
},
"ackInstructions": {},
"theme": "Dark",
"widgets": []
"widgets": [],
"ack-welcome": "0.3.6-alpha.3"
}

View File

@@ -18,11 +18,14 @@ export class MockApiService implements ApiService {
capacity: 73264762332,
used: null,
startOs: {
version: '0.2.17',
full: true,
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
timestamp: new Date().toISOString(),
version: '0.2.17',
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: null,
},
@@ -41,11 +44,14 @@ export class MockApiService implements ApiService {
capacity: 73264762332,
used: null,
startOs: {
version: '0.3.3',
full: true,
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
timestamp: new Date().toISOString(),
version: '0.2.17',
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: null,
},
@@ -64,11 +70,14 @@ export class MockApiService implements ApiService {
capacity: 73264762332,
used: null,
startOs: {
version: '0.3.2',
full: true,
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
timestamp: new Date().toISOString(),
version: '0.2.17',
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: 'guid-guid-guid-guid',
},

View File

@@ -0,0 +1,70 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
Input,
} from '@angular/core'
import { AbstractMarketplaceService } from '../../services/marketplace.service'
import { MarketplacePkg } from '../../types'
import { map } from 'rxjs'
import { Exver } from '@start9labs/shared'
// @TODO Alex use Taiga modal
import { ModalController } from '@ionic/angular'
@Component({
selector: 'release-notes',
templateUrl: './release-notes.component.html',
styleUrls: ['./release-notes.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReleaseNotesComponent {
@Input() pkg!: MarketplacePkg
private selected: string | null = null
readonly notes$ = this.marketplaceService.getSelectedStore$().pipe(
map(s => {
return Object.entries(this.pkg.otherVersions)
.filter(
([v, _]) =>
this.exver.getFlavor(v) === this.pkg.flavor &&
this.exver.compareExver(this.pkg.version, v) === 1,
)
.reduce(
(obj, [version, info]) => ({
...obj,
[version]: info.releaseNotes,
}),
{
[`${this.pkg.version} (current)`]: this.pkg.releaseNotes,
},
)
}),
)
constructor(
private readonly marketplaceService: AbstractMarketplaceService,
private readonly exver: Exver,
private readonly modalCtrl: ModalController,
) {}
async dismiss() {
return this.modalCtrl.dismiss()
}
isSelected(key: string): boolean {
return this.selected === key
}
setSelected(selected: string) {
this.selected = this.isSelected(selected) ? null : selected
}
getDocSize(key: string, { nativeElement }: ElementRef<HTMLElement>) {
return this.isSelected(key) ? nativeElement.scrollHeight : 0
}
asIsOrder(a: any, b: any) {
return 0
}
}

View File

@@ -2,7 +2,7 @@ import { TuiAccordion } from '@taiga-ui/kit'
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import {
EmverPipesModule,
ExverPipesModule,
MarkdownPipeModule,
SafeLinksDirective,
} from '@start9labs/shared'
@@ -12,11 +12,12 @@ import {
FilterVersionsPipe,
ReleaseNotesComponent,
} from './release-notes.component'
import { ReleaseNotesComponent } from './release-notes.component'
@NgModule({
imports: [
CommonModule,
EmverPipesModule,
ExverPipesModule,
MarkdownPipeModule,
NgDompurifyModule,
SafeLinksDirective,
@@ -28,4 +29,4 @@ import {
declarations: [ReleaseNotesComponent],
exports: [ReleaseNotesComponent],
})
export class ReleaseNotesModule {}
export class ReleaseNotesComponentModule {}

View File

@@ -5,6 +5,7 @@ import {
Input,
Output,
} from '@angular/core'
import { T } from '@start9labs/start-sdk'
@Component({
selector: 'marketplace-categories',
@@ -14,7 +15,7 @@ import {
})
export class CategoriesComponent {
@Input()
categories?: string[]
categories!: Map<string, T.Category>
@Input()
category = ''

View File

@@ -6,7 +6,7 @@
<div class="background">
<img
[src]="determineIcon(marketplace)"
alt="{{ pkg.manifest.title }} Icon"
alt="{{ pkg.title }} Icon"
/>
</div>
<!-- darkening overlay -->
@@ -15,14 +15,32 @@
<img
[src]="determineIcon(marketplace)"
class="icon"
alt="{{ pkg.manifest.title }} Icon"
alt="{{ pkg.title }} Icon"
/>
<div class="detail">
<span class="detail-title" ticker>
{{ pkg.manifest.title }}
{{ pkg.title }}
</span>
<span class="detail-description">
{{ pkg.manifest.description.short }}
{{ pkg.description.short }}
</span>
</div>
</div>
<!-- @TODO Alex -->
<!-- <ion-item
class="service-card"
[routerLink]="['/marketplace', pkg.id]"
[queryParams]="{ flavor: pkg.flavor, version: pkg.version }"
>
<ion-thumbnail slot="start">
<img alt="" [src]="pkg.icon | trustUrl" />
</ion-thumbnail>
<ion-label>
<h2 class="montserrat">
<strong>{{ pkg.title }}</strong>
</h2>
<h3>{{ pkg.description.short }}</h3>
<ng-content></ng-content>
</ion-label>
</ion-item> -->

View File

@@ -1,80 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
Inject,
Input,
Pipe,
PipeTransform,
} from '@angular/core'
import { AbstractMarketplaceService } from '../../services/marketplace.service'
import { PolymorpheusContent } from '@taiga-ui/polymorpheus'
import { TuiDialogContext, TuiDialogService } from '@taiga-ui/core'
import { MarketplacePkg } from '../../types'
import { Observable } from 'rxjs'
import { Emver } from '@start9labs/shared'
import { KeyValue } from '@angular/common'
@Component({
selector: 'release-notes',
templateUrl: './release-notes.component.html',
styleUrls: ['./release-notes.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReleaseNotesComponent {
constructor(
private readonly emver: Emver,
private readonly marketplaceService: AbstractMarketplaceService,
@Inject(TuiDialogService) private readonly dialogs: TuiDialogService,
) {}
@Input({ required: true })
pkg!: MarketplacePkg
notes$!: Observable<Record<string, string>>
ngOnChanges() {
this.notes$ = this.marketplaceService.fetchReleaseNotes$(
this.pkg.manifest.id,
)
}
asIsOrder(a: KeyValue<string, string>, b: KeyValue<string, string>) {
const a1 = a.key.split('.')
const b1 = b.key.split('.')
// contingency in case there's a 4th or 5th version
const len = Math.min(a1.length, b1.length)
// look through each version number and compare.
for (let i = 0; i < len; i++) {
const a2 = +a1[i] || 0
const b2 = +b1[i] || 0
if (a2 !== b2) {
// sort descending
return a2 > b2 ? -1 : 1
}
}
return a1.length - b1.length
}
async showReleaseNotes(content: PolymorpheusContent<TuiDialogContext>) {
this.dialogs
.open(content, {
label: 'Previous Release Notes',
})
.subscribe()
}
}
@Pipe({
name: 'filterVersions',
standalone: true,
})
export class FilterVersionsPipe implements PipeTransform {
transform(
notes: Record<string, string>,
pkgVersion: string,
): Record<string, string> {
delete notes[pkgVersion]
return notes
}
}

View File

@@ -1,5 +1,7 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { MarketplacePkg } from '../../../types'
import { ModalController } from '@ionic/angular'
import { ReleaseNotesComponent } from '../../../modals/release-notes/release-notes.component'
@Component({
selector: 'marketplace-about',
@@ -10,4 +12,15 @@ import { MarketplacePkg } from '../../../types'
export class AboutComponent {
@Input({ required: true })
pkg!: MarketplacePkg
constructor(private readonly modalCtrl: ModalController) {}
async presentModalNotes() {
const modal = await this.modalCtrl.create({
componentProps: { pkg: this.pkg },
component: ReleaseNotesComponent,
})
await modal.present()
}
}

View File

@@ -11,7 +11,7 @@
></marketplace-additional-item>
<!-- git hash -->
<marketplace-additional-item
*ngIf="pkg.manifest.gitHash as gitHash; else noHash"
*ngIf="pkg.gitHash as gitHash; else noHash"
(click)="copyService.copy(gitHash)"
[data]="gitHash"
label="Git Hash"
@@ -29,7 +29,7 @@
<!-- license -->
<marketplace-additional-item
(click)="presentModalMd('License')"
[data]="pkg.manifest.license"
[data]="pkg.license"
label="License"
icon="@tui.chevron-right"
class="item-pointer"
@@ -46,29 +46,29 @@
<ng-content />
<!-- links -->
<marketplace-additional-link
*ngIf="pkg.manifest.marketingSite"
[url]="pkg.manifest.marketingSite"
*ngIf="pkg.marketingSite"
[url]="pkg.marketingSite"
label="Marketing Site"
icon="@tui.external-link"
class="item-pointer"
></marketplace-additional-link>
<marketplace-additional-link
*ngIf="pkg.manifest.upstreamRepo"
[url]="pkg.manifest.upstreamRepo"
*ngIf="pkg.upstreamRepo"
[url]="pkg.upstreamRepo"
label="Source Repository"
icon="@tui.external-link"
class="item-pointer"
></marketplace-additional-link>
<marketplace-additional-link
*ngIf="pkg.manifest.wrapperRepo"
[url]="pkg.manifest.wrapperRepo"
*ngIf="pkg.wrapperRepo"
[url]="pkg.wrapperRepo"
label="Wrapper Repository"
icon="@tui.external-link"
class="item-pointer"
></marketplace-additional-link>
<marketplace-additional-link
*ngIf="pkg.manifest.supportSite"
[url]="pkg.manifest.supportSite"
*ngIf="pkg.supportSite"
[url]="pkg.supportSite"
label="Support Site"
icon="@tui.external-link"
class="item-pointer"
@@ -76,3 +76,98 @@
</div>
</div>
</div>
<!-- <ion-item-divider>Additional Info</ion-item-divider>
<ion-grid *ngIf="pkg">
<ion-row>
<ion-col responsiveCol sizeXs="12" sizeMd="6">
<ion-item-group>
<ion-item
*ngIf="pkg.gitHash as gitHash; else noHash"
button
detail="false"
(click)="copy(gitHash)"
>
<ion-label>
<h2>Git Hash</h2>
<p>{{ gitHash }}</p>
</ion-label>
<ion-icon slot="end" name="copy-outline"></ion-icon>
</ion-item>
<ng-template #noHash>
<ion-item>
<ion-label>
<h2>Git Hash</h2>
<p>Unknown</p>
</ion-label>
</ion-item>
</ng-template>
<ion-item button detail="false" (click)="presentAlertVersions()">
<ion-label>
<h2>Other Versions</h2>
<p>Click to view other versions</p>
</ion-label>
<ion-icon slot="end" name="chevron-forward"></ion-icon>
</ion-item>
<ion-item button detail="false" (click)="presentModalMd('license')">
<ion-label>
<h2>License</h2>
<p>{{ pkg.license }}</p>
</ion-label>
<ion-icon slot="end" name="chevron-forward"></ion-icon>
</ion-item>
<ion-item
button
detail="false"
(click)="presentModalMd('instructions')"
>
<ion-label>
<h2>Instructions</h2>
<p>Click to view instructions</p>
</ion-label>
<ion-icon slot="end" name="chevron-forward"></ion-icon>
</ion-item>
</ion-item-group>
</ion-col>
<ion-col responsiveCol sizeXs="12" sizeMd="6">
<ion-item-group>
<ion-item
[href]="pkg.upstreamRepo"
target="_blank"
rel="noreferrer"
detail="false"
>
<ion-label>
<h2>Source Repository</h2>
<p>{{ pkg.upstreamRepo }}</p>
</ion-label>
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-item>
<ion-item
[href]="pkg.wrapperRepo"
target="_blank"
rel="noreferrer"
detail="false"
>
<ion-label>
<h2>Wrapper Repository</h2>
<p>{{ pkg.wrapperRepo }}</p>
</ion-label>
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-item>
<ion-item
[href]="pkg.supportSite"
[disabled]="!pkg.supportSite"
target="_blank"
rel="noreferrer"
detail="false"
>
<ion-label>
<h2>Support Site</h2>
<p>{{ pkg.supportSite || 'Not provided' }}</p>
</ion-label>
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-item>
</ion-item-group>
</ion-col>
</ion-row>
</ion-grid> -->

View File

@@ -7,7 +7,7 @@ import {
import { ActivatedRoute } from '@angular/router'
import { TuiDialogService } from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { CopyService, MarkdownComponent } from '@start9labs/shared'
import { CopyService, Exver, MarkdownComponent } from '@start9labs/shared'
import { MarketplacePkg } from '../../../types'
import { AbstractMarketplaceService } from '../../../services/marketplace.service'
@@ -38,7 +38,7 @@ export class AdditionalComponent {
size: 'l',
data: {
content: this.marketplaceService.fetchStatic$(
this.pkg.manifest.id,
this.pkg.id,
label.toLowerCase(),
this.url,
),

View File

@@ -0,0 +1,21 @@
<ion-item-divider>Alternative Implementations</ion-item-divider>
<ion-grid>
<ion-row>
<ion-col *ngFor="let pkg of pkgs" responsiveCol sizeSm="12" sizeMd="6">
<ion-item
[routerLink]="['/marketplace', pkg.id]"
[queryParams]="{ flavor: pkg.flavor }"
>
<ion-thumbnail slot="start">
<img alt="" style="border-radius: 100%" [src]="pkg.icon | trustUrl" />
</ion-thumbnail>
<ion-label>
<h2>
{{ pkg.title }}
</h2>
<p>{{ pkg.version }}</p>
</ion-label>
</ion-item>
</ion-col>
</ion-row>
</ion-grid>

View File

@@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { MarketplacePkg } from '../../../types'
@Component({
selector: 'marketplace-flavors',
templateUrl: 'flavors.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FlavorsComponent {
@Input()
pkgs!: MarketplacePkg[]
}

View File

@@ -0,0 +1,19 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { ResponsiveColModule, SharedPipesModule } from '@start9labs/shared'
import { FlavorsComponent } from './flavors.component'
@NgModule({
imports: [
CommonModule,
RouterModule,
IonicModule,
SharedPipesModule,
ResponsiveColModule,
],
declarations: [FlavorsComponent],
exports: [FlavorsComponent],
})
export class FlavorsModule {}

View File

@@ -26,11 +26,11 @@ export class FilterPackagesPipe implements PipeTransform {
distance: 16,
keys: [
{
name: 'manifest.title',
name: 'title',
weight: 1,
},
{
name: 'manifest.id',
name: 'id',
weight: 0.5,
},
],
@@ -42,19 +42,19 @@ export class FilterPackagesPipe implements PipeTransform {
useExtendedSearch: true,
keys: [
{
name: 'manifest.title',
name: 'title',
weight: 1,
},
{
name: 'manifest.id',
name: 'id',
weight: 0.5,
},
{
name: 'manifest.description.short',
name: 'description.short',
weight: 0.4,
},
{
name: 'manifest.description.long',
name: 'description.long',
weight: 0.1,
},
],
@@ -71,7 +71,8 @@ export class FilterPackagesPipe implements PipeTransform {
.filter(p => category === 'all' || p.categories.includes(category!))
.sort((a, b) => {
return (
new Date(b.publishedAt).valueOf() - new Date(a.publishedAt).valueOf()
new Date(b.s9pk.publishedAt).valueOf() -
new Date(a.s9pk.publishedAt).valueOf()
)
})
.map(a => ({ ...a }))

View File

@@ -8,8 +8,6 @@ export * from './pages/list/item/item.component'
export * from './pages/list/item/item.module'
export * from './pages/list/search/search.component'
export * from './pages/list/search/search.module'
export * from './pages/release-notes/release-notes.component'
export * from './pages/release-notes/release-notes.module'
export * from './pages/show/about/about.component'
export * from './pages/show/about/about.module'
export * from './pages/show/additional/additional-link.component'
@@ -20,6 +18,8 @@ export * from './pages/show/dependencies/dependencies.component'
export * from './pages/show/dependencies/dependency-item.component'
export * from './pages/show/screenshots/screenshots.component'
export * from './pages/show/hero/hero.component'
export * from './pages/show/flavors/flavors.component'
export * from './pages/show/flavors/flavors.module'
export * from './pipes/filter-packages.pipe'

View File

@@ -20,18 +20,13 @@ export abstract class AbstractMarketplaceService {
abstract getPackage$(
id: string,
version: string,
version: string | null,
flavor: string | null,
url?: string,
): Observable<MarketplacePkg> // could be {} so need to check in show page
abstract fetchReleaseNotes$(
id: string,
url?: string,
): Observable<Record<string, string>>
): Observable<MarketplacePkg>
abstract fetchStatic$(
id: string,
type: string,
url?: string,
pkg: MarketplacePkg,
type: 'LICENSE.md' | 'instructions.md',
): Observable<string>
}

View File

@@ -1,49 +1,39 @@
import { Url } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
export type StoreURL = string
export type StoreName = string
export interface StoreIdentity {
url: StoreURL
name?: StoreName
export type GetPackageReq = {
id: string
version: string | null
otherVersions: 'short'
}
export type GetPackageRes = T.GetPackageResponse & {
otherVersions: { [version: string]: T.PackageInfoShort }
}
export type Marketplace = Record<StoreURL, StoreData | null>
export interface StoreData {
info: StoreInfo
export type GetPackagesReq = {
id: null
version: null
otherVersions: 'short'
}
export type GetPackagesRes = {
[id: T.PackageId]: GetPackageRes
}
export type StoreIdentity = {
url: string
name?: string
}
export type Marketplace = Record<string, StoreData | null>
export type StoreData = {
info: T.RegistryInfo
packages: MarketplacePkg[]
}
export interface StoreInfo {
name: StoreName
categories: string[]
}
export type StoreIdentityWithData = StoreData & StoreIdentity
export interface MarketplacePkg {
icon: Url
license: Url
screenshots?: string[]
instructions: Url
manifest: T.Manifest
categories: string[]
versions: string[]
dependencyMetadata: {
[id: string]: DependencyMetadata
export type MarketplacePkg = T.PackageVersionInfo &
Omit<GetPackageRes, 'best'> & {
id: T.PackageId
version: string
flavor: string | null
}
publishedAt: string
}
export interface DependencyMetadata {
title: string
icon: Url
optional: boolean
hidden: boolean
}
export interface Dependency {
description: string | null
optional: boolean
}

View File

@@ -19,7 +19,7 @@ export class AppComponent {
let route = 'home'
if (inProgress) {
route = inProgress.complete ? 'success' : 'loading'
route = inProgress.status === 'complete' ? '/success' : '/loading'
}
await this.router.navigate([route])

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { ServerBackupSelectModal } from './server-backup-select.page'
import { PasswordPageModule } from '../password/password.module'
@NgModule({
declarations: [ServerBackupSelectModal],
imports: [CommonModule, FormsModule, IonicModule, PasswordPageModule],
exports: [ServerBackupSelectModal],
})
export class ServerBackupSelectModule {}

View File

@@ -0,0 +1,24 @@
<ion-header>
<ion-toolbar>
<ion-title>Select Server to Restore</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-item *ngFor="let server of servers" button (click)="select(server)">
<ion-label>
<h2>
<b>Local Hostname</b>
: {{ server.hostname }}.local
</h2>
<h2>
<b>StartOS Version</b>
: {{ server.version }}
</h2>
<h2>
<b>Created</b>
: {{ server.timestamp | date : 'medium' }}
</h2>
</ion-label>
</ion-item>
</ion-content>

View File

@@ -0,0 +1,44 @@
import { Component, Input } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { StartOSDiskInfoWithId } from 'src/app/services/api/api.service'
import { PasswordPage } from '../password/password.page'
@Component({
selector: 'server-backup-select',
templateUrl: 'server-backup-select.page.html',
styleUrls: ['server-backup-select.page.scss'],
})
export class ServerBackupSelectModal {
@Input() servers: StartOSDiskInfoWithId[] = []
constructor(private readonly modalController: ModalController) {}
cancel() {
this.modalController.dismiss()
}
async select(server: StartOSDiskInfoWithId): Promise<void> {
this.presentModalPassword(server)
}
private async presentModalPassword(
server: StartOSDiskInfoWithId,
): Promise<void> {
const modal = await this.modalController.create({
component: PasswordPage,
componentProps: { passwordHash: server.passwordHash },
})
modal.onDidDismiss().then(res => {
if (res.role === 'success') {
this.modalController.dismiss(
{
serverId: server.id,
recoveryPassword: res.data.password,
},
'success',
)
}
})
await modal.present()
}
}

View File

@@ -1,32 +1,30 @@
import * as jose from 'node-jose'
import {
DiskInfo,
DiskListResponse,
PartitionInfo,
StartOSDiskInfo,
Log,
SetupStatus,
FollowLogsRes,
FollowLogsReq,
} from '@start9labs/shared'
import { Observable } from 'rxjs'
import { T } from '@start9labs/start-sdk'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import { Observable } from 'rxjs'
export abstract class ApiService {
pubkey?: jose.JWK.Key
abstract getSetupStatus(): Promise<SetupStatus | null> // setup.status
abstract getStatus(): Promise<T.SetupStatusRes | null> // setup.status
abstract getPubKey(): Promise<void> // setup.get-pubkey
abstract getDrives(): Promise<DiskListResponse> // setup.disk.list
abstract verifyCifs(cifs: CifsRecoverySource): Promise<StartOSDiskInfo> // setup.cifs.verify
abstract attach(importInfo: AttachReq): Promise<void> // setup.attach
abstract execute(setupInfo: ExecuteReq): Promise<void> // setup.execute
abstract complete(): Promise<CompleteRes> // setup.complete
abstract verifyCifs(
cifs: T.VerifyCifsParams,
): Promise<Record<string, StartOSDiskInfo>> // setup.cifs.verify
abstract attach(importInfo: T.AttachParams): Promise<T.SetupProgress> // setup.attach
abstract execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
abstract complete(): Promise<T.SetupResult> // setup.complete
abstract exit(): Promise<void> // setup.exit
abstract followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> // setup.logs.follow
abstract openLogsWebsocket$(
config: WebSocketSubjectConfig<Log>,
): Observable<Log>
abstract openProgressWebsocket$(guid: string): Observable<T.FullProgress>
async encrypt(toEncrypt: string): Promise<Encrypted> {
async encrypt(toEncrypt: string): Promise<T.EncryptedWire> {
if (!this.pubkey) throw new Error('No pubkey found!')
const encrypted = await jose.JWE.createEncrypt(this.pubkey!)
.update(toEncrypt)
@@ -37,66 +35,13 @@ export abstract class ApiService {
}
}
type Encrypted = {
encrypted: string
export type WebsocketConfig<T> = Omit<WebSocketSubjectConfig<T>, 'url'>
export type StartOSDiskInfoWithId = StartOSDiskInfo & {
id: string
}
export type AttachReq = {
guid: string
startOsPassword: Encrypted
}
export type ExecuteReq = {
startOsLogicalname: string
startOsPassword: Encrypted
recoverySource: RecoverySource | null
recoveryPassword: Encrypted | null
}
export type CompleteRes = {
torAddress: string
lanAddress: string
rootCa: string
}
export type DiskBackupTarget = {
vendor: string | null
model: string | null
logicalname: string | null
label: string | null
capacity: number
used: number | null
startOs: StartOSDiskInfo | null
}
export type CifsBackupTarget = {
hostname: string
path: string
username: string
mountable: boolean
startOs: StartOSDiskInfo | null
}
export type DiskRecoverySource = {
type: 'disk'
logicalname: string // partition logicalname
}
export type BackupRecoverySource = {
type: 'backup'
target: CifsRecoverySource | DiskRecoverySource
}
export type RecoverySource = BackupRecoverySource | DiskMigrateSource
export type DiskMigrateSource = {
type: 'migrate'
guid: string
}
export type CifsRecoverySource = {
type: 'cifs'
hostname: string
path: string
username: string
password: Encrypted | null
export type StartOSDiskInfoFull = StartOSDiskInfoWithId & {
partition: PartitionInfo
drive: DiskInfo
}

View File

@@ -1,4 +1,4 @@
import { inject, Injectable } from '@angular/core'
import { Inject, Injectable } from '@angular/core'
import {
DiskListResponse,
StartOSDiskInfo,
@@ -12,26 +12,35 @@ import {
FollowLogsRes,
FollowLogsReq,
} from '@start9labs/shared'
import {
ApiService,
CifsRecoverySource,
DiskRecoverySource,
AttachReq,
ExecuteReq,
CompleteRes,
} from './api.service'
import { T } from '@start9labs/start-sdk'
import { ApiService } from './api.service'
import * as jose from 'node-jose'
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
import { Observable } from 'rxjs'
import { DOCUMENT } from '@angular/common'
import { webSocket } from 'rxjs/webSocket'
@Injectable({
providedIn: 'root',
})
export class LiveApiService extends ApiService {
private readonly http = inject(HttpService)
constructor(
private readonly http: HttpService,
@Inject(DOCUMENT) private readonly document: Document,
) {
super()
}
async getSetupStatus() {
return this.rpcRequest<SetupStatus | null>({
openProgressWebsocket$(guid: string): Observable<T.FullProgress> {
const { location } = this.document.defaultView!
const host = location.host
return webSocket({
url: `ws://${host}/ws/rpc/${guid}`,
})
}
async getStatus(): Promise<T.SetupStatusRes | null> {
return this.rpcRequest<T.SetupStatusRes | null>({
method: 'setup.status',
params: {},
})
@@ -44,7 +53,7 @@ export class LiveApiService extends ApiService {
* this wil all public/private key, which means that there is no information loss
* through the network.
*/
async getPubKey() {
async getPubKey(): Promise<void> {
const response: jose.JWK.Key = await this.rpcRequest({
method: 'setup.get-pubkey',
params: {},
@@ -53,29 +62,31 @@ export class LiveApiService extends ApiService {
this.pubkey = response
}
async getDrives() {
async getDrives(): Promise<DiskListResponse> {
return this.rpcRequest<DiskListResponse>({
method: 'setup.disk.list',
params: {},
})
}
async verifyCifs(source: CifsRecoverySource) {
async verifyCifs(
source: T.VerifyCifsParams,
): Promise<Record<string, StartOSDiskInfo>> {
source.path = source.path.replace('/\\/g', '/')
return this.rpcRequest<StartOSDiskInfo>({
return this.rpcRequest<Record<string, StartOSDiskInfo>>({
method: 'setup.cifs.verify',
params: source,
})
}
async attach(params: AttachReq) {
await this.rpcRequest<void>({
async attach(params: T.AttachParams): Promise<T.SetupProgress> {
return this.rpcRequest<T.SetupProgress>({
method: 'setup.attach',
params,
})
}
async execute(setupInfo: ExecuteReq) {
async execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> {
if (setupInfo.recoverySource?.type === 'backup') {
if (isCifsSource(setupInfo.recoverySource.target)) {
setupInfo.recoverySource.target.path =
@@ -83,22 +94,14 @@ export class LiveApiService extends ApiService {
}
}
await this.rpcRequest<void>({
return this.rpcRequest<T.SetupProgress>({
method: 'setup.execute',
params: setupInfo,
})
}
async followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> {
return this.rpcRequest({ method: 'setup.logs.follow', params })
}
openLogsWebsocket$({ url }: WebSocketSubjectConfig<Log>): Observable<Log> {
return webSocket(`http://start.local/ws/${url}`)
}
async complete() {
const res = await this.rpcRequest<CompleteRes>({
async complete(): Promise<T.SetupResult> {
const res = await this.rpcRequest<T.SetupResult>({
method: 'setup.complete',
params: {},
})
@@ -109,7 +112,7 @@ export class LiveApiService extends ApiService {
}
}
async exit() {
async exit(): Promise<void> {
await this.rpcRequest<void>({
method: 'setup.exit',
params: {},
@@ -130,7 +133,7 @@ export class LiveApiService extends ApiService {
}
function isCifsSource(
source: CifsRecoverySource | DiskRecoverySource | null,
): source is CifsRecoverySource {
return !!(source as CifsRecoverySource)?.hostname
source: T.BackupTargetFS | null,
): source is T.Cifs & { type: 'cifs' } {
return !!(source as T.Cifs)?.hostname
}

View File

@@ -1,32 +1,151 @@
import { Injectable } from '@angular/core'
import {
DiskListResponse,
StartOSDiskInfo,
encodeBase64,
FollowLogsReq,
FollowLogsRes,
getSetupStatusMock,
Log,
pauseFor,
} from '@start9labs/shared'
import {
ApiService,
AttachReq,
CifsRecoverySource,
CompleteRes,
ExecuteReq,
} from './api.service'
import { ApiService } from './api.service'
import * as jose from 'node-jose'
import { interval, map, Observable } from 'rxjs'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import { T } from '@start9labs/start-sdk'
import {
Observable,
concatMap,
delay,
from,
interval,
map,
mergeScan,
of,
startWith,
switchMap,
switchScan,
takeWhile,
} from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class MockApiService extends ApiService {
async getSetupStatus() {
return getSetupStatusMock()
// fullProgress$(): Observable<T.FullProgress> {
// const phases = [
// {
// name: 'Preparing Data',
// progress: null,
// },
// {
// name: 'Transferring Data',
// progress: null,
// },
// {
// name: 'Finalizing Setup',
// progress: null,
// },
// ]
// return from(phases).pipe(
// switchScan((acc, val, i) => {}, { overall: null, phases }),
// )
// }
// namedProgress$(namedProgress: T.NamedProgress): Observable<T.NamedProgress> {
// return of(namedProgress).pipe(startWith(namedProgress))
// }
// progress$(progress: T.Progress): Observable<T.Progress> {}
// websocket
openProgressWebsocket$(guid: string): Observable<T.FullProgress> {
return of(PROGRESS)
// const numPhases = PROGRESS.phases.length
// return of(PROGRESS).pipe(
// switchMap(full =>
// from(PROGRESS.phases).pipe(
// mergeScan((full, phase, i) => {
// if (
// !phase.progress ||
// typeof phase.progress !== 'object' ||
// !phase.progress.total
// ) {
// full.phases[i].progress = true
// if (
// full.overall &&
// typeof full.overall === 'object' &&
// full.overall.total
// ) {
// const step = full.overall.total / numPhases
// full.overall.done += step
// }
// return of(full).pipe(delay(2000))
// } else {
// const total = phase.progress.total
// const step = total / 4
// let done = phase.progress.done
// return interval(1000).pipe(
// takeWhile(() => done < total),
// map(() => {
// done += step
// console.error(done)
// if (
// full.overall &&
// typeof full.overall === 'object' &&
// full.overall.total
// ) {
// const step = full.overall.total / numPhases / 4
// full.overall.done += step
// }
// if (done === total) {
// full.phases[i].progress = true
// if (i === numPhases - 1) {
// full.overall = true
// }
// }
// return full
// }),
// )
// }
// }, full),
// ),
// ),
// )
}
async getPubKey() {
private statusIndex = 0
async getStatus(): Promise<T.SetupStatusRes | null> {
await pauseFor(1000)
this.statusIndex++
switch (this.statusIndex) {
case 2:
return {
status: 'running',
progress: PROGRESS,
guid: 'progress-guid',
}
case 3:
return {
status: 'complete',
torAddress: 'https://asdafsadasdasasdasdfasdfasdf.onion',
lanAddress: 'https://adjective-noun.local',
rootCa: encodeBase64(rootCA),
}
default:
return null
}
}
async getPubKey(): Promise<void> {
await pauseFor(1000)
// randomly generated
@@ -42,7 +161,7 @@ export class MockApiService extends ApiService {
})
}
async getDrives() {
async getDrives(): Promise<DiskListResponse> {
await pauseFor(1000)
return [
{
@@ -56,11 +175,14 @@ export class MockApiService extends ApiService {
capacity: 1979120929996,
used: null,
startOs: {
version: '0.2.17',
full: true,
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
version: '0.2.17',
timestamp: new Date().toISOString(),
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: null,
},
@@ -79,11 +201,14 @@ export class MockApiService extends ApiService {
capacity: 73264762332,
used: null,
startOs: {
version: '0.3.3',
full: true,
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
version: '0.2.17',
timestamp: new Date().toISOString(),
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: null,
},
@@ -102,11 +227,14 @@ export class MockApiService extends ApiService {
capacity: 73264762332,
used: null,
startOs: {
version: '0.3.2',
full: true,
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
'1234-5678-9876-5432': {
hostname: 'adjective-noun',
version: '0.2.17',
timestamp: new Date().toISOString(),
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: null,
},
},
guid: 'guid-guid-guid-guid',
},
@@ -117,43 +245,41 @@ export class MockApiService extends ApiService {
]
}
async verifyCifs(params: CifsRecoverySource) {
async verifyCifs(
params: T.VerifyCifsParams,
): Promise<Record<string, StartOSDiskInfo>> {
await pauseFor(1000)
return {
version: '0.3.0',
full: true,
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: '',
}
}
async attach(params: AttachReq) {
await pauseFor(1000)
}
async execute(setupInfo: ExecuteReq) {
await pauseFor(1000)
}
async followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> {
await pauseFor(1000)
return {
startCursor: 'fakestartcursor',
guid: 'fake-guid',
}
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return interval(500).pipe(
map(() => ({
'9876-5432-1234-5678': {
hostname: 'adjective-noun',
version: '0.3.6',
timestamp: new Date().toISOString(),
message: 'fake log entry',
})),
)
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
wrappedKey: '',
},
}
}
async complete(): Promise<CompleteRes> {
async attach(params: T.AttachParams): Promise<T.SetupProgress> {
await pauseFor(1000)
return {
progress: PROGRESS,
guid: 'progress-guid',
}
}
async execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> {
await pauseFor(1000)
return {
progress: PROGRESS,
guid: 'progress-guid',
}
}
async complete(): Promise<T.SetupResult> {
await pauseFor(1000)
return {
torAddress: 'https://asdafsadasdasasdasdfasdfasdf.onion',
@@ -162,7 +288,7 @@ export class MockApiService extends ApiService {
}
}
async exit() {
async exit(): Promise<void> {
await pauseFor(1000)
}
}
@@ -189,3 +315,8 @@ Rf3ZOPm9QP92YpWyYDkfAU04xdDo1vR0MYjKPkl4LjRqSU/tcCJnPMbJiwq+bWpX
2WJoEBXB/p15Kn6JxjI0ze2SnSI48JZ8it4fvxrhOo0VoLNIuCuNXJOwU17Rdl1W
YJidaq7je6k18AdgPA0Kh8y1XtfUH3fTaVw4
-----END CERTIFICATE-----`
const PROGRESS = {
overall: null,
phases: [],
}

View File

@@ -1,5 +1,6 @@
import { inject, Injectable } from '@angular/core'
import { ApiService, RecoverySource } from './api.service'
import { ApiService } from './api.service'
import { T } from '@start9labs/start-sdk'
@Injectable({
providedIn: 'root',
@@ -8,8 +9,7 @@ export class StateService {
private readonly api = inject(ApiService)
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
recoverySource?: RecoverySource
recoveryPassword?: string
recoverySource?: T.RecoverySource<string>
async importDrive(guid: string, password: string): Promise<void> {
await this.api.attach({
@@ -25,9 +25,13 @@ export class StateService {
await this.api.execute({
startOsLogicalname: storageLogicalname,
startOsPassword: await this.api.encrypt(password),
recoverySource: this.recoverySource || null,
recoveryPassword: this.recoveryPassword
? await this.api.encrypt(this.recoveryPassword)
recoverySource: this.recoverySource
? this.recoverySource.type === 'migrate'
? this.recoverySource
: {
...this.recoverySource,
password: await this.api.encrypt(this.recoverySource.password),
}
: null,
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="1.5em" viewBox="0 0 24 24" width="1.5em">
<path
vector-effect="non-scaling-stroke"
clip-rule="evenodd"
d="M7.2395 3.45152L4.01497 3.49988C3.46275 3.50816 3.00837 3.06721 3.00008 2.51499C2.9918 1.96277 3.43275 1.50839 3.98497 1.50011L9.98497 1.41012C10.0214 1.40957 10.0575 1.41098 10.093 1.41425C10.6089 1.4111 11.1258 1.60635 11.5194 2L16 6.48053C16.781 7.26158 16.781 8.52791 16 9.30895L12.5492 12.7597L10.3089 15C9.52787 15.7811 8.26154 15.781 7.48049 15L5.24023 12.7597L2.99997 10.5195C2.21892 9.73843 2.21892 8.4721 2.99997 7.69105L7.2395 3.45152ZM10.1052 3.41422L4.41418 9.10527L5.80892 10.5H11.9805L14.5858 7.89474L10.1052 3.41422ZM20.4206 13.5445L20.4223 13.5453C20.6348 10.9945 18.7416 9.37791 17.9715 8.83085C17.9293 8.80059 17.8726 8.79856 17.8281 8.82545C17.7836 8.85234 17.7676 8.8632 17.7761 8.91357C17.9704 10.0886 16.2673 11.5428 16.2673 11.5428C15.8623 11.9726 15.5886 12.3895 15.4811 12.9331C15.2176 14.2662 16.0981 15.5842 17.4524 15.8519C18.8068 16.1196 20.1167 15.2659 20.3802 13.9329C20.4067 13.7992 20.4166 13.6763 20.4206 13.5445ZM3 18C2.44772 18 2 18.4477 2 19V21C2 21.5523 2.44772 22 3 22H21C21.5523 22 22 21.5523 22 21V19C22 18.4477 21.5523 18 21 18H3Z"
fill="currentColor"
stroke="none"
fill-rule="evenodd"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core'
import { TuiLoaderModule } from '@taiga-ui/core'
import { tuiAsDialog } from '@taiga-ui/cdk'
import { LoadingComponent } from './loading.component'
import { LoadingService } from './loading.service'
@NgModule({
imports: [TuiLoaderModule],
declarations: [LoadingComponent],
exports: [LoadingComponent],
providers: [tuiAsDialog(LoadingService)],
})
export class LoadingModule {}

View File

@@ -1,12 +0,0 @@
import { NgModule } from '@angular/core'
import {
EmverComparesPipe,
EmverDisplayPipe,
EmverSatisfiesPipe,
} from './emver.pipe'
@NgModule({
declarations: [EmverComparesPipe, EmverDisplayPipe, EmverSatisfiesPipe],
exports: [EmverComparesPipe, EmverDisplayPipe, EmverSatisfiesPipe],
})
export class EmverPipesModule {}

View File

@@ -1,52 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
import { Emver } from '../../services/emver.service'
@Pipe({
name: 'satisfiesEmver',
})
export class EmverSatisfiesPipe implements PipeTransform {
constructor(private readonly emver: Emver) {}
transform(versionUnderTest?: string, range?: string): boolean {
return (
!!versionUnderTest &&
!!range &&
this.emver.satisfies(versionUnderTest, range)
)
}
}
@Pipe({
name: 'compareEmver',
})
export class EmverComparesPipe implements PipeTransform {
constructor(private readonly emver: Emver) {}
transform(first: string, second: string): SemverResult {
try {
return this.emver.compare(first, second) as SemverResult
} catch (e) {
console.error(`emver comparison failed`, e, first, second)
return 'comparison-impossible'
}
}
}
// left compared to right - if 1, version on left is higher; if 0, values the same; if -1, version on left is lower
type SemverResult = 0 | 1 | -1 | 'comparison-impossible'
@Pipe({
name: 'displayEmver',
})
export class EmverDisplayPipe implements PipeTransform {
constructor() {}
transform(version: string): string {
return displayEmver(version)
}
}
export function displayEmver(version: string): string {
const vs = version.split('.')
if (vs.length === 4) return `${vs[0]}.${vs[1]}.${vs[2]}~${vs[3]}`
return version
}

View File

@@ -0,0 +1,8 @@
import { NgModule } from '@angular/core'
import { ExverComparesPipe, ExverSatisfiesPipe } from './exver.pipe'
@NgModule({
declarations: [ExverComparesPipe, ExverSatisfiesPipe],
exports: [ExverComparesPipe, ExverSatisfiesPipe],
})
export class ExverPipesModule {}

View File

@@ -0,0 +1,35 @@
import { Pipe, PipeTransform } from '@angular/core'
import { Exver } from '../../services/exver.service'
@Pipe({
name: 'satisfiesExver',
})
export class ExverSatisfiesPipe implements PipeTransform {
constructor(private readonly exver: Exver) {}
transform(versionUnderTest?: string, range?: string): boolean {
return (
!!versionUnderTest &&
!!range &&
this.exver.satisfies(versionUnderTest, range)
)
}
}
@Pipe({
name: 'compareExver',
})
export class ExverComparesPipe implements PipeTransform {
constructor(private readonly exver: Exver) {}
transform(first: string, second: string): SemverResult {
try {
return this.exver.compareExver(first, second) as SemverResult
} catch (e) {
console.error(`exver comparison failed`, e, first, second)
return 'comparison-impossible'
}
}
}
// left compared to right - if 1, version on left is higher; if 0, values the same; if -1, version on left is lower
type SemverResult = 0 | 1 | -1 | 'comparison-impossible'

View File

@@ -8,6 +8,8 @@ export * from './classes/rpc-error'
export * from './components/initializing/logs-window.component'
export * from './components/initializing/initializing.component'
export * from './components/loading/loading.component'
export * from './components/loading/loading.component'
export * from './components/loading/loading.module'
export * from './components/loading/loading.service'
export * from './components/markdown/markdown.component'
export * from './components/markdown/markdown.component.module'
@@ -20,8 +22,8 @@ export * from './directives/safe-links.directive'
export * from './mocks/get-setup-status'
export * from './pipes/emver/emver.module'
export * from './pipes/emver/emver.pipe'
export * from './pipes/exver/exver.module'
export * from './pipes/exver/exver.pipe'
export * from './pipes/markdown/markdown.module'
export * from './pipes/markdown/markdown.pipe'
export * from './pipes/shared/shared.module'
@@ -33,7 +35,7 @@ export * from './pipes/unit-conversion/unit-conversion.pipe'
export * from './services/copy.service'
export * from './services/download-html.service'
export * from './services/emver.service'
export * from './services/exver.service'
export * from './services/error.service'
export * from './services/http.service'
export * from './services/setup.service'

View File

@@ -1,18 +0,0 @@
import { Injectable } from '@angular/core'
import * as emver from '@start9labs/emver'
@Injectable({
providedIn: 'root',
})
export class Emver {
constructor() {}
compare(lhs?: string, rhs?: string): number | null {
if (!lhs || !rhs) return null
return emver.compare(lhs, rhs)
}
satisfies(version: string, range: string): boolean {
return emver.satisfies(version, range)
}
}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@angular/core'
import { VersionRange, ExtendedVersion, Version } from '@start9labs/start-sdk'
@Injectable({
providedIn: 'root',
})
export class Exver {
constructor() {}
compareExver(lhs: string, rhs: string): number | null {
if (!lhs || !rhs) return null
try {
return ExtendedVersion.parse(lhs).compareForSort(
ExtendedVersion.parse(rhs),
)
} catch (e) {
return null
}
}
greaterThanOrEqual(lhs: string, rhs: string): boolean | null {
if (!lhs || !rhs) return null
try {
return ExtendedVersion.parse(lhs).greaterThanOrEqual(
ExtendedVersion.parse(rhs),
)
} catch (e) {
return null
}
}
compareOsVersion(current: string, other: string) {
return Version.parse(current).compare(Version.parse(other))
}
satisfies(version: string, range: string): boolean {
return VersionRange.parse(range).satisfiedBy(ExtendedVersion.parse(version))
}
getFlavor(version: string): string | null {
return ExtendedVersion.parse(version).flavor
}
}

View File

@@ -19,6 +19,7 @@ export type FetchLogsRes = {
export interface Log {
timestamp: string
message: string
bootId: string
}
export type DiskListResponse = DiskInfo[]
@@ -37,13 +38,14 @@ export interface PartitionInfo {
label: string | null
capacity: number
used: number | null
startOs: StartOSDiskInfo | null
startOs: Record<string, StartOSDiskInfo>
guid: string | null
}
export type StartOSDiskInfo = {
hostname: string
version: string
full: boolean
timestamp: string
passwordHash: string | null
wrappedKey: string | null
}

View File

@@ -5,6 +5,8 @@ export enum Method {
POST = 'POST',
}
type ParamPrimitive = string | number | boolean
export interface HttpOptions {
method: Method
url: string
@@ -12,7 +14,7 @@ export interface HttpOptions {
[header: string]: string | string[]
}
params?: {
[param: string]: string | string[]
[param: string]: ParamPrimitive | ParamPrimitive[]
}
responseType?: 'json' | 'text' | 'arrayBuffer'
body?: any
@@ -28,7 +30,7 @@ export interface HttpAngularOptions {
[header: string]: string | string[]
}
params?: {
[param: string]: string | string[]
[param: string]: ParamPrimitive | ParamPrimitive[]
}
responseType?: 'json' | 'text' | 'arrayBuffer'
}

View File

@@ -1,4 +1,4 @@
import { APP_INITIALIZER, Provider } from '@angular/core'
import { APP_INITIALIZER, inject, Provider } from '@angular/core'
import { UntypedFormBuilder } from '@angular/forms'
import { Router } from '@angular/router'
import {
@@ -30,6 +30,7 @@ import { DateTransformerService } from './services/date-transformer.service'
import { DatetimeTransformerService } from './services/datetime-transformer.service'
import { MarketplaceService } from './services/marketplace.service'
import { ThemeSwitcherService } from './services/theme-switcher.service'
import { StorageService } from './services/storage.service'
const {
useMocks,
@@ -64,9 +65,14 @@ export const APP_PROVIDERS: Provider[] = [
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
{
provide: PatchDB,
deps: [PatchDbSource, PATCH_CACHE],
useClass: PatchDB,
},
{
provide: APP_INITIALIZER,
deps: [AuthService, ClientStorageService, Router],
deps: [StorageService, AuthService, ClientStorageService, Router],
useFactory: appInitializer,
multi: true,
},
@@ -86,14 +92,27 @@ export const APP_PROVIDERS: Provider[] = [
provide: AbstractCategoryService,
useClass: CategoryService,
},
{
provide: TUI_DIALOGS_CLOSE,
useFactory: () =>
inject(StateService).pipe(
pairwise(),
filter(
([prev, curr]) =>
prev === 'running' && (curr === 'error' || curr === 'initializing'),
),
),
},
]
export function appInitializer(
storage: StorageService,
auth: AuthService,
localStorage: ClientStorageService,
router: Router,
): () => void {
return () => {
storage.migrate036()
auth.init()
localStorage.init()
router.initialNavigation()

View File

@@ -0,0 +1,167 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
OnInit,
} from '@angular/core'
import { FormGroup, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router'
import { CT } from '@start9labs/start-sdk'
import {
tuiMarkControlAsTouchedAndValidate,
TuiValueChangesModule,
} from '@taiga-ui/cdk'
import { TuiDialogContext, TuiModeModule } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { TuiDialogFormService } from '@taiga-ui/kit'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { compare, Operation } from 'fast-json-patch'
import { FormModule } from 'src/app/components/form/form.module'
import { InvalidService } from 'src/app/components/form/invalid.service'
import { FormService } from 'src/app/services/form.service'
export interface ActionButton<T> {
text: string
handler?: (value: T) => Promise<boolean | void> | void
link?: string
}
export interface FormContext<T> {
spec: CT.InputSpec
buttons: ActionButton<T>[]
value?: T
patch?: Operation[]
}
@Component({
standalone: true,
selector: 'app-form',
template: `
<form
[formGroup]="form"
(submit.capture.prevent)="(0)"
(reset.capture.prevent.stop)="onReset()"
(tuiValueChanges)="markAsDirty()"
>
<form-group [spec]="spec"></form-group>
<footer tuiMode="onDark">
<ng-content></ng-content>
<ng-container *ngFor="let button of buttons; let last = last">
<button
*ngIf="button.handler; else link"
tuiButton
[appearance]="last ? 'primary' : 'flat'"
[type]="last ? 'submit' : 'button'"
(click)="onClick(button.handler)"
>
{{ button.text }}
</button>
<ng-template #link>
<a
tuiButton
appearance="flat"
[routerLink]="button.link"
(click)="close()"
>
{{ button.text }}
</a>
</ng-template>
</ng-container>
</footer>
</form>
`,
styles: [
`
footer {
position: sticky;
bottom: 0;
z-index: 10;
display: flex;
justify-content: flex-end;
padding: 1rem 0;
margin: 1rem 0 -1rem;
gap: 1rem;
background: var(--tui-elevation-01);
border-top: 1px solid var(--tui-base-02);
}
`,
],
imports: [
CommonModule,
ReactiveFormsModule,
RouterModule,
TuiValueChangesModule,
TuiButtonModule,
TuiModeModule,
FormModule,
],
providers: [InvalidService],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormComponent<T extends Record<string, any>> implements OnInit {
private readonly dialogFormService = inject(TuiDialogFormService)
private readonly formService = inject(FormService)
private readonly invalidService = inject(InvalidService)
private readonly context = inject<TuiDialogContext<void, FormContext<T>>>(
POLYMORPHEUS_CONTEXT,
{ optional: true },
)
@Input() spec = this.context?.data.spec || {}
@Input() buttons = this.context?.data.buttons || []
@Input() patch = this.context?.data.patch || []
@Input() value?: T = this.context?.data.value
form = new FormGroup({})
ngOnInit() {
this.dialogFormService.markAsPristine()
this.form = this.formService.createForm(this.spec, this.value)
this.process(this.patch)
}
onReset() {
const { value } = this.form
this.form = this.formService.createForm(this.spec)
this.process(compare(this.form.value, value))
tuiMarkControlAsTouchedAndValidate(this.form)
this.markAsDirty()
}
async onClick(handler: Required<ActionButton<T>>['handler']) {
tuiMarkControlAsTouchedAndValidate(this.form)
this.invalidService.scrollIntoView()
if (this.form.valid && (await handler(this.form.value as T))) {
this.close()
}
}
markAsDirty() {
this.dialogFormService.markAsDirty()
}
close() {
this.context?.$implicit.complete()
}
private process(patch: Operation[]) {
patch.forEach(({ op, path }) => {
const control = this.form.get(path.substring(1).split('/'))
if (!control || !control.parent) return
if (op !== 'remove') {
control.markAsDirty()
control.markAsTouched()
}
control.parent.markAsDirty()
control.parent.markAsTouched()
})
}
}

View File

@@ -0,0 +1,30 @@
import { Directive, ElementRef, inject, OnDestroy, OnInit } from '@angular/core'
import { ControlContainer, NgControl } from '@angular/forms'
import { InvalidService } from './invalid.service'
@Directive({
selector: 'form-control, form-array, form-object',
})
export class ControlDirective implements OnInit, OnDestroy {
private readonly invalidService = inject(InvalidService, { optional: true })
private readonly element: ElementRef<HTMLElement> = inject(ElementRef)
private readonly control =
inject(NgControl, { optional: true }) ||
inject(ControlContainer, { optional: true })
get invalid(): boolean {
return !!this.control?.invalid
}
scrollIntoView() {
this.element.nativeElement.scrollIntoView({ behavior: 'smooth' })
}
ngOnInit() {
this.invalidService?.add(this)
}
ngOnDestroy() {
this.invalidService?.remove(this)
}
}

View File

@@ -0,0 +1,35 @@
import { inject } from '@angular/core'
import { FormControlComponent } from './form-control/form-control.component'
import { CT } from '@start9labs/start-sdk'
export abstract class Control<Spec extends CT.ValueSpec, Value> {
private readonly control: FormControlComponent<Spec, Value> =
inject(FormControlComponent)
get invalid(): boolean {
return this.control.touched && this.control.invalid
}
get spec(): Spec {
return this.control.spec
}
// TODO: Properly handle already set immutable value
get readOnly(): boolean {
return (
!!this.value && !!this.control.control?.pristine && this.control.immutable
)
}
get value(): Value | null {
return this.control.value
}
set value(value: Value | null) {
this.control.onInput(value)
}
onFocus(focused: boolean) {
this.control.onFocus(focused)
}
}

View File

@@ -0,0 +1,58 @@
<div class="label">
{{ spec.name }}
<tui-tooltip
*ngIf="spec.description || spec.disabled"
[content]="spec | hint"
></tui-tooltip>
<button
tuiLink
type="button"
class="add"
[disabled]="!canAdd"
(click)="add()"
>
+ Add
</button>
</div>
<tui-error [error]="order | tuiFieldError | async"></tui-error>
<ng-container *ngFor="let item of array.control.controls; let index = index">
<form-object
*ngIf="spec.spec.type === 'object'; else control"
class="object"
[class.object_open]="!!open.get(item)"
[formGroup]="$any(item)"
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
[open]="!!open.get(item)"
(openChange)="open.set(item, $event)"
>
{{ item.value | mustache : $any(spec.spec).displayAs }}
<ng-container *ngTemplateOutlet="remove"></ng-container>
</form-object>
<ng-template #control>
<form-control
class="control"
tuiTextfieldSize="m"
[tuiTextfieldLabelOutside]="true"
[tuiTextfieldIcon]="remove"
[formControl]="$any(item)"
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
></form-control>
</ng-template>
<ng-template #remove>
<button
tuiIconButton
type="button"
class="remove"
iconLeft="tuiIconTrash"
appearance="icon"
size="m"
title="Remove"
(click.stop)="removeAt(index)"
></button>
</ng-template>
</ng-container>

View File

@@ -0,0 +1,50 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: block;
margin: 2rem 0;
}
.label {
display: flex;
font-size: 1.25rem;
font-weight: bold;
}
.add {
font-size: 1rem;
padding: 0 1rem;
margin-left: auto;
}
.object {
display: block;
position: relative;
&_open::after,
&:last-child::after {
opacity: 0;
}
&:after {
@include transition(opacity);
content: '';
position: absolute;
bottom: -0.5rem;
height: 1px;
left: 3rem;
right: 1rem;
background: var(--tui-clear);
}
}
.remove {
margin-left: auto;
pointer-events: auto;
}
.control {
display: block;
margin: 0.5rem 0;
}

View File

@@ -0,0 +1,91 @@
import { Component, HostBinding, inject, Input } from '@angular/core'
import { AbstractControl, FormArrayName } from '@angular/forms'
import { TUI_PARENT_ANIMATION, TuiDestroyService } from '@taiga-ui/cdk'
import {
TUI_ANIMATION_OPTIONS,
TuiDialogService,
tuiFadeIn,
tuiHeightCollapse,
} from '@taiga-ui/core'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { CT } from '@start9labs/start-sdk'
import { filter, takeUntil } from 'rxjs'
import { FormService } from 'src/app/services/form.service'
import { ERRORS } from '../form-group/form-group.component'
@Component({
selector: 'form-array',
templateUrl: './form-array.component.html',
styleUrls: ['./form-array.component.scss'],
animations: [tuiFadeIn, tuiHeightCollapse, TUI_PARENT_ANIMATION],
providers: [TuiDestroyService],
})
export class FormArrayComponent {
@Input()
spec!: CT.ValueSpecList
@HostBinding('@tuiParentAnimation')
readonly animation = { value: '', ...inject(TUI_ANIMATION_OPTIONS) }
readonly order = ERRORS
readonly array = inject(FormArrayName)
readonly open = new Map<AbstractControl, boolean>()
private warned = false
private readonly formService = inject(FormService)
private readonly dialogs = inject(TuiDialogService)
private readonly destroy$ = inject(TuiDestroyService)
get canAdd(): boolean {
return (
!this.spec.disabled &&
(!this.spec.maxLength ||
this.spec.maxLength >= this.array.control.controls.length)
)
}
add() {
if (!this.warned && this.spec.warning) {
this.dialogs
.open<boolean>(TUI_PROMPT, {
label: 'Warning',
size: 's',
data: { content: this.spec.warning, yes: 'Ok', no: 'Cancel' },
})
.pipe(filter(Boolean), takeUntil(this.destroy$))
.subscribe(() => {
this.addItem()
})
} else {
this.addItem()
}
this.warned = true
}
removeAt(index: number) {
this.dialogs
.open<boolean>(TUI_PROMPT, {
label: 'Confirm',
size: 's',
data: {
content: 'Are you sure you want to delete this entry?',
yes: 'Delete',
no: 'Cancel',
},
})
.pipe(filter(Boolean), takeUntil(this.destroy$))
.subscribe(() => {
this.removeItem(index)
})
}
private removeItem(index: number) {
this.open.delete(this.array.control.at(index))
this.array.control.removeAt(index)
}
private addItem() {
this.array.control.insert(0, this.formService.getListItem(this.spec))
this.open.set(this.array.control.at(0), true)
}
}

View File

@@ -0,0 +1,31 @@
<tui-input
[maskito]="mask"
[tuiTextfieldCustomContent]="color"
[tuiTextfieldCleaner]="false"
[tuiHintContent]="spec | hint"
[readOnly]="readOnly"
[disabled]="!!spec.disabled"
[pseudoInvalid]="invalid"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input>
<ng-template #color>
<div class="wrapper" [style.color]="value">
<input
*ngIf="!readOnly && !spec.disabled"
type="color"
class="color"
tabindex="-1"
[(ngModel)]="value"
(click.stop)="(0)"
/>
<tui-icon
icon="tuiIconPaintLarge"
tuiAppearance="icon"
class="icon"
></tui-icon>
</div>
</ng-template>

View File

@@ -0,0 +1,33 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
.wrapper {
position: relative;
width: 1.5rem;
height: 1.5rem;
pointer-events: auto;
&::after {
content: '';
position: absolute;
height: 0.3rem;
width: 1.4rem;
bottom: 0.125rem;
background: currentColor;
border-radius: 0.125rem;
pointer-events: none;
}
}
.color {
@include fullsize();
opacity: 0;
}
.icon {
@include fullsize();
pointer-events: none;
input:hover + & {
opacity: 1;
}
}

View File

@@ -0,0 +1,15 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
import { MaskitoOptions } from '@maskito/core'
@Component({
selector: 'form-color',
templateUrl: './form-color.component.html',
styleUrls: ['./form-color.component.scss'],
})
export class FormColorComponent extends Control<CT.ValueSpecColor, string> {
readonly mask: MaskitoOptions = {
mask: ['#', ...Array(6).fill(/[0-9a-f]/i)],
}
}

View File

@@ -0,0 +1,39 @@
<ng-container [ngSwitch]="spec.type">
<form-color *ngSwitchCase="'color'"></form-color>
<form-datetime *ngSwitchCase="'datetime'"></form-datetime>
<form-multiselect *ngSwitchCase="'multiselect'"></form-multiselect>
<form-number *ngSwitchCase="'number'"></form-number>
<form-select *ngSwitchCase="'select'"></form-select>
<form-text *ngSwitchCase="'text'"></form-text>
<form-textarea *ngSwitchCase="'textarea'"></form-textarea>
<form-toggle *ngSwitchCase="'toggle'"></form-toggle>
</ng-container>
<tui-error [error]="order | tuiFieldError | async"></tui-error>
<ng-template
*ngIf="spec.warning || immutable"
#warning
let-completeWith="completeWith"
>
{{ spec.warning }}
<p *ngIf="immutable">This value cannot be changed once set!</p>
<div class="buttons">
<button
tuiButton
type="button"
appearance="secondary"
size="s"
(click)="completeWith(true)"
>
Rollback
</button>
<button
tuiButton
type="button"
appearance="flat"
size="s"
(click)="completeWith(false)"
>
Accept
</button>
</div>
</ng-template>

View File

@@ -0,0 +1,11 @@
:host {
display: block;
}
.buttons {
margin-top: 0.5rem;
:first-child {
margin-right: 0.5rem;
}
}

View File

@@ -0,0 +1,71 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
TemplateRef,
ViewChild,
} from '@angular/core'
import { AbstractTuiNullableControl } from '@taiga-ui/cdk'
import {
TuiAlertService,
TuiDialogContext,
TuiNotification,
} from '@taiga-ui/core'
import { filter, takeUntil } from 'rxjs'
import { CT } from '@start9labs/start-sdk'
import { ERRORS } from '../form-group/form-group.component'
import { FORM_CONTROL_PROVIDERS } from './form-control.providers'
@Component({
selector: 'form-control',
templateUrl: './form-control.component.html',
styleUrls: ['./form-control.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: FORM_CONTROL_PROVIDERS,
})
export class FormControlComponent<
T extends CT.ValueSpec,
V,
> extends AbstractTuiNullableControl<V> {
@Input()
spec!: T
@ViewChild('warning')
warning?: TemplateRef<TuiDialogContext<boolean>>
warned = false
focused = false
readonly order = ERRORS
private readonly alerts = inject(TuiAlertService)
get immutable(): boolean {
return 'immutable' in this.spec && this.spec.immutable
}
onFocus(focused: boolean) {
this.focused = focused
this.updateFocused(focused)
}
onInput(value: V | null) {
const previous = this.value
if (!this.warned && this.warning) {
this.alerts
.open<boolean>(this.warning, {
label: 'Warning',
status: TuiNotification.Warning,
hasCloseButton: false,
autoClose: false,
})
.pipe(filter(Boolean), takeUntil(this.destroy$))
.subscribe(() => {
this.value = previous
})
}
this.warned = true
this.value = value === '' ? null : value
}
}

View File

@@ -0,0 +1,25 @@
import { forwardRef, Provider } from '@angular/core'
import { TUI_VALIDATION_ERRORS } from '@taiga-ui/kit'
import { CT } from '@start9labs/start-sdk'
import { FormControlComponent } from './form-control.component'
interface ValidatorsPatternError {
actualValue: string
requiredPattern: string | RegExp
}
export const FORM_CONTROL_PROVIDERS: Provider[] = [
{
provide: TUI_VALIDATION_ERRORS,
deps: [forwardRef(() => FormControlComponent)],
useFactory: (control: FormControlComponent<CT.ValueSpec, string>) => ({
required: 'Required',
pattern: ({ requiredPattern }: ValidatorsPatternError) =>
('patterns' in control.spec &&
control.spec.patterns.find(
({ regex }) => String(regex) === String(requiredPattern),
)?.description) ||
'Invalid format',
}),
},
]

View File

@@ -0,0 +1,43 @@
<ng-container [ngSwitch]="spec.inputmode" [tuiHintContent]="spec.description">
<tui-input-time
*ngSwitchCase="'time'"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[ngModel]="getTime(value)"
(ngModelChange)="value = $event?.toString() || null"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input-time>
<tui-input-date
*ngSwitchCase="'date'"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[min]="spec.min ? (spec.min | tuiMapper : getLimit)[0] : min"
[max]="spec.max ? (spec.max | tuiMapper : getLimit)[0] : max"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input-date>
<tui-input-date-time
*ngSwitchCase="'datetime-local'"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[min]="spec.min ? (spec.min | tuiMapper : getLimit) : min"
[max]="spec.max ? (spec.max | tuiMapper : getLimit) : max"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input-date-time>
</ng-container>

View File

@@ -0,0 +1,36 @@
import { Component } from '@angular/core'
import {
TUI_FIRST_DAY,
TUI_LAST_DAY,
TuiDay,
tuiPure,
TuiTime,
} from '@taiga-ui/cdk'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-datetime',
templateUrl: './form-datetime.component.html',
})
export class FormDatetimeComponent extends Control<
CT.ValueSpecDatetime,
string
> {
readonly min = TUI_FIRST_DAY
readonly max = TUI_LAST_DAY
@tuiPure
getTime(value: string | null) {
return value ? TuiTime.fromString(value) : null
}
getLimit(limit: string): [TuiDay, TuiTime] {
return [
TuiDay.jsonParse(limit.slice(0, 10)),
limit.length === 10
? new TuiTime(0, 0)
: TuiTime.fromString(limit.slice(-5)),
]
}
}

View File

@@ -0,0 +1,30 @@
<ng-container
*ngFor="let entry of spec | keyvalue : asIsOrder"
tuiMode="onDark"
[ngSwitch]="entry.value.type"
[tuiTextfieldCleaner]="true"
>
<form-object
*ngSwitchCase="'object'"
class="g-form-control"
[formGroupName]="entry.key"
[spec]="$any(entry.value)"
></form-object>
<form-union
*ngSwitchCase="'union'"
class="g-form-control"
[formGroupName]="entry.key"
[spec]="$any(entry.value)"
></form-union>
<form-array
*ngSwitchCase="'list'"
[formArrayName]="entry.key"
[spec]="$any(entry.value)"
></form-array>
<form-control
*ngSwitchDefault
class="g-form-control"
[formControlName]="entry.key"
[spec]="entry.value"
></form-control>
</ng-container>

View File

@@ -0,0 +1,35 @@
form-group .g-form-control:not(:first-child) {
margin-top: 1rem;
}
form-group .g-form-group {
position: relative;
padding-left: var(--tui-height-m);
&::before,
&::after {
content: '';
position: absolute;
background: var(--tui-clear);
}
&::before {
top: 0;
left: calc(1rem - 1px);
bottom: 0.5rem;
width: 2px;
}
&::after {
left: 0.75rem;
bottom: 0;
width: 0.5rem;
height: 0.5rem;
border-radius: 100%;
}
}
form-group tui-tooltip {
z-index: 1;
margin-left: 0.25rem;
}

View File

@@ -0,0 +1,35 @@
import {
ChangeDetectionStrategy,
Component,
Input,
ViewEncapsulation,
} from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { FORM_GROUP_PROVIDERS } from './form-group.providers'
export const ERRORS = [
'required',
'pattern',
'notNumber',
'numberNotInteger',
'numberNotInRange',
'listNotUnique',
'listNotInRange',
'listItemIssue',
]
@Component({
selector: 'form-group',
templateUrl: './form-group.component.html',
styleUrls: ['./form-group.component.scss'],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
viewProviders: [FORM_GROUP_PROVIDERS],
})
export class FormGroupComponent {
@Input() spec: CT.InputSpec = {}
asIsOrder() {
return 0
}
}

View File

@@ -0,0 +1,34 @@
import { Provider, SkipSelf } from '@angular/core'
import {
TUI_ARROW_MODE,
tuiInputDateOptionsProvider,
tuiInputTimeOptionsProvider,
} from '@taiga-ui/kit'
import { TUI_DEFAULT_ERROR_MESSAGE } from '@taiga-ui/core'
import { ControlContainer } from '@angular/forms'
import { identity, of } from 'rxjs'
export const FORM_GROUP_PROVIDERS: Provider[] = [
{
provide: TUI_DEFAULT_ERROR_MESSAGE,
useValue: of('Unknown error'),
},
{
provide: ControlContainer,
deps: [[new SkipSelf(), ControlContainer]],
useFactory: identity,
},
{
provide: TUI_ARROW_MODE,
useValue: {
interactive: null,
disabled: null,
},
},
tuiInputDateOptionsProvider({
nativePicker: true,
}),
tuiInputTimeOptionsProvider({
nativePicker: true,
}),
]

View File

@@ -0,0 +1,18 @@
<tui-multi-select
[tuiHintContent]="spec | hint"
[disabled]="disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[editable]="false"
[disabledItemHandler]="disabledItemHandler"
[(ngModel)]="selected"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<select
tuiSelect
multiple
[items]="items"
[disabledItemHandler]="disabledItemHandler"
></select>
</tui-multi-select>

View File

@@ -0,0 +1,49 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
import { tuiPure } from '@taiga-ui/cdk'
import { invert } from '@start9labs/shared'
@Component({
selector: 'form-multiselect',
templateUrl: './form-multiselect.component.html',
})
export class FormMultiselectComponent extends Control<
CT.ValueSpecMultiselect,
readonly string[]
> {
private readonly inverted = invert(this.spec.values)
private readonly isDisabled = (item: string) =>
Array.isArray(this.spec.disabled) &&
this.spec.disabled.includes(this.inverted[item])
private readonly isExceedingLimit = (item: string) =>
!!this.spec.maxLength &&
this.selected.length >= this.spec.maxLength &&
!this.selected.includes(item)
readonly disabledItemHandler = (item: string): boolean =>
this.isDisabled(item) || this.isExceedingLimit(item)
readonly items = Object.values(this.spec.values)
get disabled(): boolean {
return typeof this.spec.disabled === 'string'
}
get selected(): string[] {
return this.memoize(this.value)
}
set selected(value: string[]) {
this.value = Object.entries(this.spec.values)
.filter(([_, v]) => value.includes(v))
.map(([k]) => k)
}
@tuiPure
private memoize(value: null | readonly string[]): string[] {
return value?.map(key => this.spec.values[key]) || []
}
}

View File

@@ -0,0 +1,18 @@
<tui-input-number
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[tuiTextfieldPostfix]="spec.units || ''"
[pseudoInvalid]="invalid"
[precision]="Infinity"
[decimal]="spec.integer ? 'never' : 'not-zero'"
[min]="spec.min ?? -Infinity"
[max]="spec.max ?? Infinity"
[step]="spec.step || 0"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<input tuiTextfield [placeholder]="spec.placeholder || ''" />
</tui-input-number>

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-number',
templateUrl: './form-number.component.html',
})
export class FormNumberComponent extends Control<CT.ValueSpecNumber, number> {
protected readonly Infinity = Infinity
}

View File

@@ -0,0 +1,25 @@
<h3 class="title" (click)="toggle()">
<button
tuiIconButton
size="s"
iconLeft="tuiIconChevronDown"
type="button"
class="button"
[class.button_open]="open"
[style.border-radius.%]="100"
[appearance]="invalid ? 'destructive' : 'secondary'"
></button>
<ng-content></ng-content>
{{ spec.name }}
<tui-tooltip
*ngIf="spec.description"
[content]="spec.description"
(click.stop)="(0)"
></tui-tooltip>
</h3>
<tui-expand class="expand" [expanded]="open">
<div class="g-form-group" [class.g-form-group_invalid]="invalid">
<form-group [spec]="spec.spec"></form-group>
</div>
</tui-expand>

View File

@@ -0,0 +1,41 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.title {
position: relative;
height: var(--tui-height-l);
display: flex;
align-items: center;
cursor: pointer;
font: var(--tui-font-text-l);
font-weight: bold;
margin: 0 0 -0.75rem;
}
.button {
@include transition(transform);
margin-right: 1rem;
&_open {
transform: rotate(180deg);
}
}
.expand {
align-self: stretch;
}
.g-form-group {
padding-top: 0.75rem;
&_invalid::before,
&_invalid::after {
background: var(--tui-error-bg);
}
}

View File

@@ -0,0 +1,38 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
inject,
Input,
Output,
} from '@angular/core'
import { ControlContainer } from '@angular/forms'
import { CT } from '@start9labs/start-sdk'
@Component({
selector: 'form-object',
templateUrl: './form-object.component.html',
styleUrls: ['./form-object.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormObjectComponent {
@Input()
spec!: CT.ValueSpecObject
@Input()
open = false
@Output()
readonly openChange = new EventEmitter<boolean>()
private readonly container = inject(ControlContainer)
get invalid() {
return !this.container.valid && this.container.touched
}
toggle() {
this.open = !this.open
this.openChange.emit(this.open)
}
}

View File

@@ -0,0 +1,18 @@
<tui-select
[tuiHintContent]="spec | hint"
[disabled]="disabled"
[readOnly]="readOnly"
[tuiTextfieldCleaner]="!spec.required"
[pseudoInvalid]="invalid"
[(ngModel)]="selected"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<select
tuiSelect
[placeholder]="spec.name"
[items]="items"
[disabledItemHandler]="disabledItemHandler"
></select>
</tui-select>

View File

@@ -0,0 +1,30 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { invert } from '@start9labs/shared'
import { Control } from '../control'
@Component({
selector: 'form-select',
templateUrl: './form-select.component.html',
})
export class FormSelectComponent extends Control<CT.ValueSpecSelect, string> {
private readonly inverted = invert(this.spec.values)
readonly items = Object.values(this.spec.values)
readonly disabledItemHandler = (item: string) =>
Array.isArray(this.spec.disabled) &&
this.spec.disabled.includes(this.inverted[item])
get disabled(): boolean {
return typeof this.spec.disabled === 'string'
}
get selected(): string | null {
return (this.value && this.spec.values[this.value]) || null
}
set selected(value: string | null) {
this.value = (value && this.inverted[value]) || null
}
}

View File

@@ -0,0 +1,44 @@
<tui-input
[tuiTextfieldCustomContent]="spec.masked || spec.generate ? toggle : ''"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<input
tuiTextfield
[class.masked]="spec.masked && masked"
[placeholder]="spec.placeholder || ''"
[attr.minLength]="spec.minLength"
[attr.maxLength]="spec.maxLength"
[attr.inputmode]="spec.inputmode"
/>
</tui-input>
<ng-template #toggle>
<button
*ngIf="spec.generate"
tuiIconButton
type="button"
appearance="icon"
title="Generate"
size="xs"
class="button"
iconLeft="tuiIconRefreshCcw"
(click)="generate()"
></button>
<button
*ngIf="spec.masked"
tuiIconButton
type="button"
appearance="icon"
title="Toggle masking"
size="xs"
class="button"
[iconLeft]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
(click)="masked = !masked"
></button>
</ng-template>

View File

@@ -0,0 +1,8 @@
.button {
pointer-events: auto;
margin-left: 0.25rem;
}
.masked {
-webkit-text-security: disc;
}

View File

@@ -0,0 +1,16 @@
import { Component } from '@angular/core'
import { CT, utils } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-text',
templateUrl: './form-text.component.html',
styleUrls: ['./form-text.component.scss'],
})
export class FormTextComponent extends Control<CT.ValueSpecText, string> {
masked = true
generate() {
this.value = utils.getDefaultString(this.spec.generate || '')
}
}

View File

@@ -0,0 +1,15 @@
<tui-textarea
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[expandable]="true"
[rows]="6"
[maxLength]="spec.maxLength"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<textarea tuiTextfield [placeholder]="spec.placeholder || ''"></textarea>
</tui-textarea>

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-textarea',
templateUrl: './form-textarea.component.html',
})
export class FormTextareaComponent extends Control<
CT.ValueSpecTextarea,
string
> {}

View File

@@ -0,0 +1,11 @@
{{ spec.name }}
<tui-tooltip
*ngIf="spec.description || spec.disabled"
[tuiHintContent]="spec | hint"
></tui-tooltip>
<tui-toggle
size="l"
[disabled]="!!spec.disabled || readOnly"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
></tui-toggle>

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
import { Control } from '../control'
@Component({
selector: 'form-toggle',
templateUrl: './form-toggle.component.html',
host: { class: 'g-toggle' },
})
export class FormToggleComponent extends Control<CT.ValueSpecToggle, boolean> {}

View File

@@ -0,0 +1,11 @@
<form-control
[spec]="selectSpec"
formControlName="selection"
(tuiValueChanges)="onUnion($event)"
></form-control>
<tui-elastic-container class="g-form-group" formGroupName="value">
<form-group
class="group"
[spec]="(union && spec.variants[union].spec) || {}"
></form-group>
</tui-elastic-container>

View File

@@ -0,0 +1,8 @@
:host {
display: block;
}
.group {
display: block;
margin-top: 1rem;
}

View File

@@ -0,0 +1,55 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
OnChanges,
} from '@angular/core'
import { ControlContainer, FormGroupName } from '@angular/forms'
import { CT } from '@start9labs/start-sdk'
import { FormService } from 'src/app/services/form.service'
import { tuiPure } from '@taiga-ui/cdk'
@Component({
selector: 'form-union',
templateUrl: './form-union.component.html',
styleUrls: ['./form-union.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
viewProviders: [
{
provide: ControlContainer,
useExisting: FormGroupName,
},
],
})
export class FormUnionComponent implements OnChanges {
@Input()
spec!: CT.ValueSpecUnion
selectSpec!: CT.ValueSpecSelect
private readonly form = inject(FormGroupName)
private readonly formService = inject(FormService)
get union(): string {
return this.form.value.selection
}
@tuiPure
onUnion(union: string) {
this.form.control.setControl(
'value',
this.formService.getFormGroup(
union ? this.spec.variants[union].spec : {},
),
{
emitEvent: false,
},
)
}
ngOnChanges() {
this.selectSpec = this.formService.getUnionSelectSpec(this.spec, this.union)
if (this.union) this.onUnion(this.union)
}
}

View File

@@ -0,0 +1,107 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { MaskitoModule } from '@maskito/angular'
import { TuiMapperPipeModule, TuiValueChangesModule } from '@taiga-ui/cdk'
import {
TuiErrorModule,
TuiExpandModule,
TuiHintModule,
TuiLinkModule,
TuiModeModule,
TuiTextfieldControllerModule,
TuiTooltipModule,
} from '@taiga-ui/core'
import {
TuiAppearanceModule,
TuiButtonModule,
TuiIconModule,
} from '@taiga-ui/experimental'
import {
TuiElasticContainerModule,
TuiFieldErrorPipeModule,
TuiInputDateModule,
TuiInputDateTimeModule,
TuiInputFilesModule,
TuiInputModule,
TuiInputNumberModule,
TuiInputTimeModule,
TuiMultiSelectModule,
TuiPromptModule,
TuiSelectModule,
TuiTagModule,
TuiTextareaModule,
TuiToggleModule,
} from '@taiga-ui/kit'
import { FormGroupComponent } from './form-group/form-group.component'
import { FormTextComponent } from './form-text/form-text.component'
import { FormToggleComponent } from './form-toggle/form-toggle.component'
import { FormTextareaComponent } from './form-textarea/form-textarea.component'
import { FormNumberComponent } from './form-number/form-number.component'
import { FormSelectComponent } from './form-select/form-select.component'
import { FormMultiselectComponent } from './form-multiselect/form-multiselect.component'
import { FormUnionComponent } from './form-union/form-union.component'
import { FormObjectComponent } from './form-object/form-object.component'
import { FormArrayComponent } from './form-array/form-array.component'
import { FormControlComponent } from './form-control/form-control.component'
import { MustachePipe } from './mustache.pipe'
import { ControlDirective } from './control.directive'
import { FormColorComponent } from './form-color/form-color.component'
import { FormDatetimeComponent } from './form-datetime/form-datetime.component'
import { HintPipe } from './hint.pipe'
@NgModule({
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
TuiInputModule,
TuiInputNumberModule,
TuiInputFilesModule,
TuiTextareaModule,
TuiSelectModule,
TuiMultiSelectModule,
TuiToggleModule,
TuiTooltipModule,
TuiHintModule,
TuiModeModule,
TuiTagModule,
TuiButtonModule,
TuiExpandModule,
TuiTextfieldControllerModule,
TuiLinkModule,
TuiPromptModule,
TuiErrorModule,
TuiFieldErrorPipeModule,
TuiValueChangesModule,
TuiElasticContainerModule,
MaskitoModule,
TuiIconModule,
TuiAppearanceModule,
TuiInputDateModule,
TuiInputTimeModule,
TuiInputDateTimeModule,
TuiMapperPipeModule,
],
declarations: [
FormGroupComponent,
FormControlComponent,
FormColorComponent,
FormDatetimeComponent,
FormTextComponent,
FormToggleComponent,
FormTextareaComponent,
FormNumberComponent,
FormSelectComponent,
FormMultiselectComponent,
FormUnionComponent,
FormObjectComponent,
FormArrayComponent,
MustachePipe,
HintPipe,
ControlDirective,
],
exports: [FormGroupComponent],
})
export class FormModule {}

View File

@@ -0,0 +1,21 @@
import { Pipe, PipeTransform } from '@angular/core'
import { CT } from '@start9labs/start-sdk'
@Pipe({
name: 'hint',
})
export class HintPipe implements PipeTransform {
transform(spec: CT.ValueSpec): string {
const hint = []
if (spec.description) {
hint.push(spec.description)
}
if ('disabled' in spec && typeof spec.disabled === 'string') {
hint.push(`Disabled: ${spec.disabled}`)
}
return hint.join('\n\n')
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@angular/core'
import { ControlDirective } from './control.directive'
@Injectable()
export class InvalidService {
private readonly controls: ControlDirective[] = []
scrollIntoView() {
this.controls.find(({ invalid }) => invalid)?.scrollIntoView()
}
add(control: ControlDirective) {
this.controls.push(control)
}
remove(control: ControlDirective) {
this.controls.splice(this.controls.indexOf(control), 1)
}
}

View File

@@ -0,0 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core'
const Mustache = require('mustache')
@Pipe({
name: 'mustache',
})
export class MustachePipe implements PipeTransform {
transform(value: any, displayAs: string): string {
return displayAs && Mustache.render(displayAs, value)
}
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { BackupServerSelectModal } from './backup-server-select.page'
import { AppRecoverSelectPageModule } from 'src/app/modals/app-recover-select/app-recover-select.module'
@NgModule({
declarations: [BackupServerSelectModal],
imports: [CommonModule, FormsModule, IonicModule, AppRecoverSelectPageModule],
exports: [BackupServerSelectModal],
})
export class BackupServerSelectModule {}

View File

@@ -0,0 +1,35 @@
<ion-header>
<ion-toolbar>
<ion-title>Select Server Backup</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-item-group>
<ion-item
*ngFor="let server of target.entry.startOs | keyvalue"
button
(click)="presentModalPassword(server.key, server.value)"
>
<ion-label>
<h2>
<b>Local Hostname</b>
: {{ server.value.hostname }}.local
</h2>
<h2>
<b>StartOS Version</b>
: {{ server.value.version }}
</h2>
<h2>
<b>Created</b>
: {{ server.value.timestamp | date : 'medium' }}
</h2>
</ion-label>
</ion-item>
</ion-item-group>
</ion-content>

View File

@@ -0,0 +1,118 @@
import { Component, Input } from '@angular/core'
import { ModalController, NavController } from '@ionic/angular'
import * as argon2 from '@start9labs/argon2'
import {
ErrorService,
LoadingService,
StartOSDiskInfo,
} from '@start9labs/shared'
import {
PasswordPromptComponent,
PromptOptions,
} from 'src/app/modals/password-prompt.component'
import {
BackupInfo,
CifsBackupTarget,
DiskBackupTarget,
} from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'
import { AppRecoverSelectPage } from '../app-recover-select/app-recover-select.page'
@Component({
selector: 'backup-server-select',
templateUrl: 'backup-server-select.page.html',
styleUrls: ['backup-server-select.page.scss'],
})
export class BackupServerSelectModal {
@Input() target!: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>
constructor(
private readonly modalCtrl: ModalController,
private readonly loader: LoadingService,
private readonly api: ApiService,
private readonly navCtrl: NavController,
private readonly errorService: ErrorService,
) {}
dismiss() {
this.modalCtrl.dismiss()
}
async presentModalPassword(
serverId: string,
{ passwordHash }: StartOSDiskInfo,
): Promise<void> {
const options: PromptOptions = {
title: 'Password Required',
message:
'Enter the password that was used to encrypt this backup. On the next screen, you will select the individual services you want to restore.',
label: 'Decrypt Backup',
placeholder: 'Enter password',
buttonText: 'Next',
}
const modal = await this.modalCtrl.create({
component: PasswordPromptComponent,
componentProps: { options },
canDismiss: async password => {
if (password === null) {
return true
}
try {
argon2.verify(passwordHash!, password)
await this.restoreFromBackup(serverId, password)
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
}
},
})
modal.present()
}
private async restoreFromBackup(
serverId: string,
password: string,
): Promise<void> {
const loader = this.loader.open('Decrypting drive...').subscribe()
try {
const backupInfo = await this.api.getBackupInfo({
targetId: this.target.id,
serverId,
password,
})
this.presentModalSelect(serverId, backupInfo, password)
} finally {
loader.unsubscribe()
}
}
private async presentModalSelect(
serverId: string,
backupInfo: BackupInfo,
password: string,
): Promise<void> {
const modal = await this.modalCtrl.create({
componentProps: {
targetId: this.target.id,
serverId,
backupInfo,
password,
},
presentingElement: await this.modalCtrl.getTop(),
component: AppRecoverSelectPage,
})
modal.onDidDismiss().then(res => {
if (res.role === 'success') {
this.modalCtrl.dismiss(undefined, 'success')
this.navCtrl.navigateRoot('/services')
}
})
await modal.present()
}
}

View File

@@ -0,0 +1,104 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
} from '@angular/core'
import { compare, getValueByPointer, Operation } from 'fast-json-patch'
import { isObject } from '@start9labs/shared'
import { tuiIsNumber } from '@taiga-ui/cdk'
import { CommonModule } from '@angular/common'
import { TuiNotificationModule } from '@taiga-ui/core'
@Component({
selector: 'config-dep',
template: `
<tui-notification>
<h3 style="margin: 0 0 0.5rem; font-size: 1.25rem;">
{{ package }}
</h3>
The following modifications have been made to {{ package }} to satisfy
{{ dep }}:
<ul>
<li *ngFor="let d of diff" [innerHTML]="d"></li>
</ul>
To accept these modifications, click "Save".
</tui-notification>
`,
standalone: true,
imports: [CommonModule, TuiNotificationModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConfigDepComponent implements OnChanges {
@Input()
package = ''
@Input()
dep = ''
@Input()
original: object = {}
@Input()
value: object = {}
diff: string[] = []
ngOnChanges() {
this.diff = compare(this.original, this.value).map(
op => `${this.getPath(op)}: ${this.getMessage(op)}`,
)
}
private getPath(operation: Operation): string {
const path = operation.path
.substring(1)
.split('/')
.map(node => {
const num = Number(node)
return isNaN(num) ? node : num
})
if (tuiIsNumber(path[path.length - 1])) {
path.pop()
}
return path.join(' &rarr; ')
}
private getMessage(operation: Operation): string {
switch (operation.op) {
case 'add':
return `Added ${this.getNewValue(operation.value)}`
case 'remove':
return `Removed ${this.getOldValue(operation.path)}`
case 'replace':
return `Changed from ${this.getOldValue(
operation.path,
)} to ${this.getNewValue(operation.value)}`
default:
return `Unknown operation`
}
}
private getOldValue(path: any): string {
const val = getValueByPointer(this.original, path)
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'entry'
} else {
return 'list'
}
}
private getNewValue(val: any): string {
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'new entry'
} else {
return 'new list'
}
}
}

View File

@@ -0,0 +1,261 @@
import { CommonModule } from '@angular/common'
import { Component, Inject, ViewChild } from '@angular/core'
import {
ErrorService,
getErrorMessage,
isEmptyObject,
LoadingService,
} from '@start9labs/shared'
import { CT, T } from '@start9labs/start-sdk'
import { TuiButtonModule } from '@taiga-ui/experimental'
import {
TuiDialogContext,
TuiDialogService,
TuiLoaderModule,
TuiModeModule,
TuiNotificationModule,
} from '@taiga-ui/core'
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { compare, Operation } from 'fast-json-patch'
import { PatchDB } from 'patch-db-client'
import { endWith, firstValueFrom, Subscription } from 'rxjs'
import { ActionButton, FormComponent } from 'src/app/components/form.component'
import { InvalidService } from 'src/app/components/form/invalid.service'
import { ConfigDepComponent } from 'src/app/modals/config-dep.component'
import { UiPipeModule } from 'src/app/pipes/ui/ui.module'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import {
getAllPackages,
getManifest,
getPackage,
} from 'src/app/util/get-package-data'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { Breakages } from 'src/app/services/api/api.types'
import { DependentInfo } from 'src/app/types/dependent-info'
export interface PackageConfigData {
readonly pkgId: string
readonly dependentInfo?: DependentInfo
}
@Component({
template: `
<tui-loader
*ngIf="loadingText"
size="l"
[textContent]="loadingText"
></tui-loader>
<tui-notification
*ngIf="!loadingText && (loadingError || !pkg)"
status="error"
>
<div [innerHTML]="loadingError"></div>
</tui-notification>
<ng-container
*ngIf="
!loadingText && !loadingError && pkg && (pkg | toManifest) as manifest
"
>
<tui-notification *ngIf="success" status="success">
{{ manifest.title }} has been automatically configured with recommended
defaults. Make whatever changes you want, then click "Save".
</tui-notification>
<config-dep
*ngIf="dependentInfo && value && original"
[package]="manifest.title"
[dep]="dependentInfo.title"
[original]="original"
[value]="value"
></config-dep>
<tui-notification *ngIf="!manifest.hasConfig" status="warning">
No config options for {{ manifest.title }} {{ manifest.version }}.
</tui-notification>
<app-form
tuiMode="onDark"
[spec]="spec"
[value]="value || {}"
[buttons]="buttons"
[patch]="patch"
>
<button
tuiButton
appearance="flat"
type="reset"
[style.margin-right]="'auto'"
>
Reset Defaults
</button>
</app-form>
</ng-container>
`,
styles: [
`
tui-notification {
font-size: 1rem;
margin-bottom: 1rem;
}
`,
],
standalone: true,
imports: [
CommonModule,
FormComponent,
TuiLoaderModule,
TuiNotificationModule,
TuiButtonModule,
TuiModeModule,
ConfigDepComponent,
UiPipeModule,
],
providers: [InvalidService],
})
export class ConfigModal {
@ViewChild(FormComponent)
private readonly form?: FormComponent<Record<string, any>>
readonly pkgId = this.context.data.pkgId
readonly dependentInfo = this.context.data.dependentInfo
loadingError = ''
loadingText = this.dependentInfo
? `Setting properties to accommodate ${this.dependentInfo.title}`
: 'Loading Config'
pkg?: PackageDataEntry
spec: CT.InputSpec = {}
patch: Operation[] = []
buttons: ActionButton<any>[] = [
{
text: 'Save',
handler: value => this.save(value),
},
]
original: object | null = null
value: object | null = null
constructor(
@Inject(POLYMORPHEUS_CONTEXT)
private readonly context: TuiDialogContext<void, PackageConfigData>,
private readonly dialogs: TuiDialogService,
private readonly errorService: ErrorService,
private readonly loader: LoadingService,
private readonly embassyApi: ApiService,
private readonly patchDb: PatchDB<DataModel>,
) {}
get success(): boolean {
return (
!!this.form &&
!this.form.form.dirty &&
!this.original &&
!this.pkg?.status?.configured
)
}
async ngOnInit() {
try {
this.pkg = await getPackage(this.patchDb, this.pkgId)
if (!this.pkg) {
this.loadingError = 'This service does not exist'
return
}
if (this.dependentInfo) {
const depConfig = await this.embassyApi.dryConfigureDependency({
dependencyId: this.pkgId,
dependentId: this.dependentInfo.id,
})
this.original = depConfig.oldConfig
this.value = depConfig.newConfig || this.original
this.spec = depConfig.spec
this.patch = compare(this.original, this.value)
} else {
const { config, spec } = await this.embassyApi.getPackageConfig({
id: this.pkgId,
})
this.original = config
this.value = config
this.spec = spec
}
} catch (e: any) {
this.loadingError = String(getErrorMessage(e))
} finally {
this.loadingText = ''
}
}
private async save(config: any) {
const loader = new Subscription()
try {
if (hasCurrentDeps(this.pkgId, await getAllPackages(this.patchDb))) {
await this.configureDeps(config, loader)
} else {
await this.configure(config, loader)
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async configureDeps(
config: Record<string, any>,
loader: Subscription,
) {
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Checking dependent services...').subscribe())
const breakages = await this.embassyApi.drySetPackageConfig({
id: this.pkgId,
config,
})
loader.unsubscribe()
loader.closed = false
if (isEmptyObject(breakages) || (await this.approveBreakages(breakages))) {
await this.configure(config, loader)
}
}
private async configure(config: Record<string, any>, loader: Subscription) {
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Saving...').subscribe())
await this.embassyApi.setPackageConfig({ id: this.pkgId, config })
this.context.$implicit.complete()
}
private async approveBreakages(breakages: T.PackageId[]): Promise<boolean> {
const packages = await getAllPackages(this.patchDb)
const message =
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
const content = `${message}${breakages.map(
id => `<li><b>${getManifest(packages[id]).title}</b></li>`,
)}</ul>`
const data: TuiPromptData = { content, yes: 'Continue', no: 'Cancel' }
return firstValueFrom(
this.dialogs.open<boolean>(TUI_PROMPT, { data }).pipe(endWith(false)),
)
}
}

View File

@@ -0,0 +1,42 @@
import { NgIf } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { TuiIconModule, TuiTitleModule } from '@taiga-ui/experimental'
import { ConfigService } from 'src/app/services/config.service'
import { StoreIconComponent } from './store-icon.component'
@Component({
standalone: true,
selector: '[registry]',
template: `
<store-icon
[url]="registry.url"
[marketplace]="marketplace"
size="40px"
></store-icon>
<div tuiTitle>
{{ registry.name }}
<div tuiSubtitle>{{ registry.url }}</div>
</div>
<tui-icon
*ngIf="registry.selected; else content"
icon="tuiIconCheck"
[style.color]="'var(--tui-positive)'"
></tui-icon>
<ng-template #content><ng-content></ng-content></ng-template>
`,
styles: [':host { border-radius: 0.25rem; width: stretch; }'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf, StoreIconComponent, TuiIconModule, TuiTitleModule],
})
export class MarketplaceRegistryComponent {
readonly marketplace = inject(ConfigService).marketplace
@Input()
registry!: { url: string; selected: boolean; name?: string }
}

View File

@@ -0,0 +1,46 @@
import { NgIf } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { sameUrl } from '@start9labs/shared'
@Component({
standalone: true,
selector: 'store-icon',
template: `
<img
*ngIf="icon; else noIcon"
[style.border-radius.%]="100"
[style.max-width]="size || '100%'"
[src]="icon"
alt="Marketplace Icon"
/>
<ng-template #noIcon>
<img
[style.max-width]="size || '100%'"
[style.border-radius]="0"
src="assets/img/storefront-outline.png"
alt="Marketplace Icon"
/>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf],
})
export class StoreIconComponent {
@Input()
url = ''
@Input()
size?: string
@Input()
marketplace!: any
get icon() {
const { start9, community } = this.marketplace
if (sameUrl(this.url, start9)) {
return 'assets/img/icon_transparent.png'
} else if (sameUrl(this.url, community)) {
return 'assets/img/community-store.png'
}
return null
}
}

View File

@@ -0,0 +1,94 @@
import {
AfterViewInit,
Component,
ElementRef,
Input,
ViewChild,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { IonicModule, ModalController } from '@ionic/angular'
import { TuiTextfieldComponent } from '@taiga-ui/core'
import { TuiInputPasswordModule } from '@taiga-ui/kit'
export interface PromptOptions {
title: string
message: string
label: string
placeholder: string
buttonText: string
}
@Component({
standalone: true,
template: `
<ion-header>
<ion-toolbar>
<ion-title>{{ options.title }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="cancel()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<p>{{ options.message }}</p>
<p>
<tui-input-password [(ngModel)]="password" (keydown.enter)="confirm()">
{{ options.label }}
<input tuiTextfield [placeholder]="options.placeholder" />
</tui-input-password>
</p>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-button
class="ion-padding-end"
slot="end"
color="dark"
(click)="cancel()"
>
Cancel
</ion-button>
<ion-button
class="ion-padding-end"
slot="end"
color="primary"
strong="true"
[disabled]="!password"
(click)="confirm()"
>
{{ options.buttonText }}
</ion-button>
</ion-toolbar>
</ion-footer>
`,
imports: [IonicModule, FormsModule, TuiInputPasswordModule],
})
export class PasswordPromptComponent implements AfterViewInit {
@ViewChild(TuiTextfieldComponent, { read: ElementRef })
input?: ElementRef<HTMLInputElement>
@Input()
options!: PromptOptions
password = ''
constructor(private modalCtrl: ModalController) {}
ngAfterViewInit() {
setTimeout(() => {
this.input?.nativeElement.focus({ preventScroll: true })
}, 300)
}
cancel() {
return this.modalCtrl.dismiss(null, 'cancel')
}
confirm() {
return this.modalCtrl.dismiss(this.password, 'confirm')
}
}

View File

@@ -0,0 +1,123 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { TuiAutoFocusModule } from '@taiga-ui/cdk'
import { TuiDialogContext, TuiTextfieldControllerModule } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { TuiInputModule } from '@taiga-ui/kit'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@tinkoff/ng-polymorpheus'
@Component({
standalone: true,
template: `
<p>{{ options.message }}</p>
<p *ngIf="options.warning" class="warning">{{ options.warning }}</p>
<form (ngSubmit)="submit(value.trim())">
<tui-input
tuiAutoFocus
[tuiTextfieldLabelOutside]="!options.label"
[tuiTextfieldCustomContent]="options.useMask ? toggle : ''"
[ngModelOptions]="{ standalone: true }"
[(ngModel)]="value"
>
{{ options.label }}
<span *ngIf="options.required !== false && options.label">*</span>
<input
tuiTextfield
[class.masked]="options.useMask && masked && value"
[placeholder]="options.placeholder || ''"
/>
</tui-input>
<footer class="g-buttons">
<button
tuiButton
type="button"
appearance="secondary"
(click)="cancel()"
>
Cancel
</button>
<button tuiButton [disabled]="!value && options.required !== false">
{{ options.buttonText || 'Submit' }}
</button>
</footer>
</form>
<ng-template #toggle>
<button
tuiIconButton
type="button"
appearance="icon"
title="Toggle masking"
size="xs"
class="button"
[iconLeft]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
(click)="masked = !masked"
></button>
</ng-template>
`,
styles: [
`
.warning {
color: var(--tui-warning-fill);
}
.button {
pointer-events: auto;
margin-left: 0.25rem;
}
.masked {
-webkit-text-security: disc;
}
`,
],
imports: [
CommonModule,
FormsModule,
TuiInputModule,
TuiButtonModule,
TuiTextfieldControllerModule,
TuiAutoFocusModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PromptModal {
masked = this.options.useMask
value = this.options.initialValue || ''
constructor(
@Inject(POLYMORPHEUS_CONTEXT)
private readonly context: TuiDialogContext<string, PromptOptions>,
) {}
get options(): PromptOptions {
return this.context.data
}
cancel() {
this.context.$implicit.complete()
}
submit(value: string) {
if (value || !this.options.required) {
this.context.$implicit.next(value)
}
}
}
export const PROMPT = new PolymorpheusComponent(PromptModal)
export interface PromptOptions {
message: string
label?: string
warning?: string
buttonText?: string
placeholder?: string
required?: boolean
useMask?: boolean
initialValue?: string | null
}

View File

@@ -0,0 +1,21 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
const ROUTES: Routes = [
{
path: '',
loadChildren: () =>
import('./home/home.module').then(m => m.HomePageModule),
},
{
path: 'logs',
loadChildren: () =>
import('./logs/logs.module').then(m => m.LogsPageModule),
},
]
@NgModule({
imports: [RouterModule.forChild(ROUTES)],
exports: [RouterModule],
})
export class DiagnosticModule {}

View File

@@ -0,0 +1,222 @@
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
import { TUI_CONFIRM } from '@taiga-ui/kit'
import { Component, Inject } from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import { LoadingService } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { filter } from 'rxjs'
import { DiagnosticService } from '../services/diagnostic.service'
========
import { Component } from '@angular/core'
import { AlertController } from '@ionic/angular'
import { LoadingService } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api/embassy-api.service'
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
@Component({
selector: 'diagnostic-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage {
restarted = false
error?: {
code: number
problem: string
solution: string
details?: string
}
constructor(
private readonly loader: LoadingService,
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
private readonly api: DiagnosticService,
private readonly dialogs: TuiDialogService,
@Inject(WINDOW) private readonly window: Window,
========
private readonly api: ApiService,
private readonly alertCtrl: AlertController,
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
) {}
async ngOnInit() {
try {
const error = await this.api.diagnosticGetError()
// incorrect drive
if (error.code === 15) {
this.error = {
code: 15,
problem: 'Unknown storage drive detected',
solution:
'To use a different storage drive, replace the current one and click RESTART SERVER below. To use the current storage drive, click USE CURRENT DRIVE below, then follow instructions. No data will be erased during this process.',
details: error.data?.details,
}
// no drive
} else if (error.code === 20) {
this.error = {
code: 20,
problem: 'Storage drive not found',
solution:
'Insert your StartOS storage drive and click RESTART SERVER below.',
details: error.data?.details,
}
// drive corrupted
} else if (error.code === 25) {
this.error = {
code: 25,
problem:
'Storage drive corrupted. This could be the result of data corruption or physical damage.',
solution:
'It may or may not be possible to re-use this drive by reformatting and recovering from backup. To enter recovery mode, click ENTER RECOVERY MODE below, then follow instructions. No data will be erased during this step.',
details: error.data?.details,
}
// filesystem I/O error - disk needs repair
} else if (error.code === 2) {
this.error = {
code: 2,
problem: 'Filesystem I/O error.',
solution:
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
details: error.data?.details,
}
// disk management error - disk needs repair
} else if (error.code === 48) {
this.error = {
code: 48,
problem: 'Disk management error.',
solution:
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
details: error.data?.details,
}
} else {
this.error = {
code: error.code,
problem: error.message,
solution: 'Please contact support.',
details: error.data?.details,
}
}
} catch (e) {
console.error(e)
}
}
async restart(): Promise<void> {
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
const loader = this.loader.open('').subscribe()
========
const loader = this.loader.open('Loading...').subscribe()
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
try {
await this.api.diagnosticRestart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
}
}
async forgetDrive(): Promise<void> {
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
const loader = this.loader.open('').subscribe()
========
const loader = this.loader.open('Loading...').subscribe()
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
try {
await this.api.diagnosticForgetDrive()
await this.api.diagnosticRestart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
}
}
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
async presentAlertSystemRebuild() {
this.dialogs
.open(TUI_CONFIRM, {
label: 'Warning',
size: 's',
data: {
no: 'Cancel',
yes: 'Rebuild',
content:
'<p>This action will tear down all service containers and rebuild them from scratch. No data will be deleted.</p><p>A system rebuild can be useful if your system gets into a bad state, and it should only be performed if you are experiencing general performance or reliability issues.</p><p>It may take up to an hour to complete. During this time, you will lose all connectivity to your Start9 server.</p>',
},
})
.pipe(filter(Boolean))
.subscribe(() => {
try {
this.systemRebuild()
} catch (e) {
console.error(e)
}
})
}
========
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
async presentAlertRepairDisk() {
this.dialogs
.open(TUI_CONFIRM, {
label: 'Warning',
size: 's',
data: {
no: 'Cancel',
yes: 'Repair',
content:
'<p>This action should only be executed if directed by a Start9 support specialist.</p><p>If anything happens to the device during the reboot, such as losing power or unplugging the drive, the filesystem <i>will</i> be in an unrecoverable state. Please proceed with caution.</p>',
},
})
.pipe(filter(Boolean))
.subscribe(() => {
try {
this.repairDisk()
} catch (e) {
console.error(e)
}
})
}
refreshPage(): void {
this.window.location.reload()
}
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
private async systemRebuild(): Promise<void> {
const loader = this.loader.open('').subscribe()
try {
await this.api.systemRebuild()
await this.api.restart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
}
}
private async repairDisk(): Promise<void> {
const loader = this.loader.open('').subscribe()
========
private async repairDisk(): Promise<void> {
const loader = this.loader.open('Loading...').subscribe()
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
try {
await this.api.diagnosticRepairDisk()
await this.api.diagnosticRestart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { TuiProgressModule } from '@taiga-ui/kit'
import { LogsModule } from 'src/app/pages/init/logs/logs.module'
import { InitPage } from './init.page'
const routes: Routes = [
{
path: '',
component: InitPage,
},
]
@NgModule({
imports: [
CommonModule,
LogsModule,
TuiProgressModule,
RouterModule.forChild(routes),
],
declarations: [InitPage],
})
export class InitPageModule {}

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