chore: refactor interfaces (#2849)

* chore: refactor interfaces

* chore: fix uptime
This commit is contained in:
Alex Inkin
2025-03-17 22:07:52 +04:00
committed by GitHub
parent be0371fb11
commit a18ab7f1e9
26 changed files with 917 additions and 620 deletions

202
web/package-lock.json generated
View File

@@ -25,18 +25,18 @@
"@noble/hashes": "^1.4.0",
"@start9labs/argon2": "^0.2.2",
"@start9labs/start-sdk": "file:../sdk/baseDist",
"@taiga-ui/addon-charts": "4.24.0",
"@taiga-ui/addon-commerce": "4.24.0",
"@taiga-ui/addon-mobile": "4.24.0",
"@taiga-ui/addon-table": "4.24.0",
"@taiga-ui/cdk": "4.24.0",
"@taiga-ui/core": "4.24.0",
"@taiga-ui/event-plugins": "4.4.0",
"@taiga-ui/icons": "4.24.0",
"@taiga-ui/kit": "4.24.0",
"@taiga-ui/layout": "4.24.0",
"@taiga-ui/legacy": "4.24.0",
"@taiga-ui/polymorpheus": "4.8.0",
"@taiga-ui/addon-charts": "4.28.0",
"@taiga-ui/addon-commerce": "4.28.0",
"@taiga-ui/addon-mobile": "4.28.0",
"@taiga-ui/addon-table": "4.28.0",
"@taiga-ui/cdk": "4.28.0",
"@taiga-ui/core": "4.28.0",
"@taiga-ui/event-plugins": "4.4.1",
"@taiga-ui/icons": "4.28.0",
"@taiga-ui/kit": "4.28.0",
"@taiga-ui/layout": "4.28.0",
"@taiga-ui/legacy": "4.28.0",
"@taiga-ui/polymorpheus": "4.9.0",
"@tinkoff/ng-dompurify": "4.0.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",
@@ -3420,9 +3420,9 @@
}
},
"node_modules/@maskito/angular": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-3.2.1.tgz",
"integrity": "sha512-Qb9qY6AeG23KWuROF2gcHzUxXiaaMIFuoA/ekqI4TAtBDEO2D7ImQ0oTkdsFfbDLZoI3phmy70H4f+gidEnfag==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-3.4.0.tgz",
"integrity": "sha512-iMFP/siEgU9Ki+g1PReZlA5+LlBMp6inqXGG5KCezhmDleZnG5lL9gxk3+ktJvKu+2kayLcwyBeUKXPwMBVt9w==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
@@ -3431,35 +3431,35 @@
"peerDependencies": {
"@angular/core": ">=16.0.0",
"@angular/forms": ">=16.0.0",
"@maskito/core": "^3.2.1"
"@maskito/core": "^3.4.0"
}
},
"node_modules/@maskito/core": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@maskito/core/-/core-3.2.1.tgz",
"integrity": "sha512-gpoJbFFsq2CFz9smqEvZfTPVMt/gkRoiL14idL1sLXEDbWZyeKce0SgusHzHXaOkEegly4BIJGazCNG2hsPNYg==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@maskito/core/-/core-3.4.0.tgz",
"integrity": "sha512-gFM6qk675YwOEGhxu9Xm6/sl1TZBRab6+B3Gstqml7xJopHHZ0rUOrWXwmX0z2JI+1PsgUL/ftV/CSZ8CpIONg==",
"license": "Apache-2.0",
"peer": true
},
"node_modules/@maskito/kit": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-3.2.1.tgz",
"integrity": "sha512-p9Tmr4BMZs7geNWMYMQaP4Dc+Sre8OeE6Vynz1RGsJJdjxmocY2mihXKldecS8c9aXfHvLU658F9YtorL3Akjg==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-3.4.0.tgz",
"integrity": "sha512-jkexr7wjAqFeMpyc7s0IlinL+3F9xC4BYUHDQcEqlAJisDgVFtGCZZK/RvV1C+HGDn2gtzzVrJ3G/OY66k6EXg==",
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"@maskito/core": "^3.2.1"
"@maskito/core": "^3.4.0"
}
},
"node_modules/@maskito/phone": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@maskito/phone/-/phone-3.2.1.tgz",
"integrity": "sha512-F1hrnpP1UyMhVO0nZAMoA0kkS8rVTc4kVUZ8mcxz98weIAKYo3wiuLOMXb1cg7/ejcpEnqDTRe1rzBxwtRAKeQ==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@maskito/phone/-/phone-3.4.0.tgz",
"integrity": "sha512-KR6JuuWhTumIOCUV3CzPhh1niCXcuqsogNsLW3YfdmeVo8GygS9isnHNbSaAA/b9OnmIEkh25mur6x3yEJuYjA==",
"license": "Apache-2.0",
"peer": true,
"peerDependencies": {
"@maskito/core": "^3.2.1",
"@maskito/kit": "^3.2.1",
"@maskito/core": "^3.4.0",
"@maskito/kit": "^3.4.0",
"libphonenumber-js": ">=1.0.0"
}
},
@@ -4415,9 +4415,9 @@
"link": true
},
"node_modules/@taiga-ui/addon-charts": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-4.24.0.tgz",
"integrity": "sha512-x75l4Dj1l7ForatbIdk4O8NzM6fQlKRp9UsXzMrKSoKj9O3jGHsFyMV67FhBS9oVitU4+3MIgPLuj09qpG1gBg==",
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-4.28.0.tgz",
"integrity": "sha512-Lvi2R8Y50kBbfbru31YHon+CEpnOzAx0G4GnqjN2goTLNQ6iX7pgUeyRyiXI4ay1yLrzVIOZJhSmBwWSDocZEg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4426,15 +4426,15 @@
"@angular/common": ">=16.0.0",
"@angular/core": ">=16.0.0",
"@ng-web-apis/common": "^4.11.1",
"@taiga-ui/cdk": "^4.24.0",
"@taiga-ui/core": "^4.24.0",
"@taiga-ui/cdk": "^4.28.0",
"@taiga-ui/core": "^4.28.0",
"@taiga-ui/polymorpheus": "^4.8.0"
}
},
"node_modules/@taiga-ui/addon-commerce": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.24.0.tgz",
"integrity": "sha512-YAbhTZHXnN5TT9oO+KJX/NAehuHgMZ/QoVIuzQR16HiIoKTQQWznSipThnX2hinXrLNBas0O2Teo4Ij5kGIEOQ==",
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-commerce/-/addon-commerce-4.28.0.tgz",
"integrity": "sha512-VYygBL7oySCZYLBimGJPx/VGGtUGhpes3XwBHAPBmmyiVxct0kxXzhCQdAvNMQcSvDzXDBjg3wmJiUbZA/uHGQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4443,22 +4443,22 @@
"@angular/common": ">=16.0.0",
"@angular/core": ">=16.0.0",
"@angular/forms": ">=16.0.0",
"@maskito/angular": "^3.2.1",
"@maskito/core": "^3.2.1",
"@maskito/kit": "^3.2.1",
"@maskito/angular": "^3.4.0",
"@maskito/core": "^3.4.0",
"@maskito/kit": "^3.4.0",
"@ng-web-apis/common": "^4.11.1",
"@taiga-ui/cdk": "^4.24.0",
"@taiga-ui/core": "^4.24.0",
"@taiga-ui/i18n": "^4.24.0",
"@taiga-ui/kit": "^4.24.0",
"@taiga-ui/cdk": "^4.28.0",
"@taiga-ui/core": "^4.28.0",
"@taiga-ui/i18n": "^4.28.0",
"@taiga-ui/kit": "^4.28.0",
"@taiga-ui/polymorpheus": "^4.8.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/addon-mobile": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-mobile/-/addon-mobile-4.24.0.tgz",
"integrity": "sha512-YdkhhgWsmtgi85sPbjibqa/BxdqKkETav/6O0KgFmBGvS4MF/Hr2sOXWMDUfskSALee9BJBNPdf8NrV/zNpcXw==",
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-mobile/-/addon-mobile-4.28.0.tgz",
"integrity": "sha512-1RRaX37Ddl24q4nHrMEz6iDqHWi/mkTyXQ+kADX7+ydx9JkbU2H4R+qXrOx4+GUi93Y05HvAWCNCToBu3Ytt2A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4468,18 +4468,18 @@
"@angular/common": ">=16.0.0",
"@angular/core": ">=16.0.0",
"@ng-web-apis/common": "^4.11.1",
"@taiga-ui/cdk": "^4.24.0",
"@taiga-ui/core": "^4.24.0",
"@taiga-ui/kit": "^4.24.0",
"@taiga-ui/layout": "^4.24.0",
"@taiga-ui/cdk": "^4.28.0",
"@taiga-ui/core": "^4.28.0",
"@taiga-ui/kit": "^4.28.0",
"@taiga-ui/layout": "^4.28.0",
"@taiga-ui/polymorpheus": "^4.8.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/addon-table": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-table/-/addon-table-4.24.0.tgz",
"integrity": "sha512-da/5FUmyrG6+pS9XgBY7B78EU6O1M9GPh9fxTtYTDjRgEpFdb/4TX0J79v5InSTGQV77ITVkpqbTB1moRgUVfA==",
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-table/-/addon-table-4.28.0.tgz",
"integrity": "sha512-C8MW6kJ3T9zy51rSxqYApll+S84oizK6C85gZyDM3gEV2RAlK2DP+r657ZlEwEgobrFCtBZe++TT7ZoKpQBBHg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4488,18 +4488,18 @@
"@angular/common": ">=16.0.0",
"@angular/core": ">=16.0.0",
"@ng-web-apis/intersection-observer": "^4.11.1",
"@taiga-ui/cdk": "^4.24.0",
"@taiga-ui/core": "^4.24.0",
"@taiga-ui/i18n": "^4.24.0",
"@taiga-ui/kit": "^4.24.0",
"@taiga-ui/cdk": "^4.28.0",
"@taiga-ui/core": "^4.28.0",
"@taiga-ui/i18n": "^4.28.0",
"@taiga-ui/kit": "^4.28.0",
"@taiga-ui/polymorpheus": "^4.8.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/cdk": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.24.0.tgz",
"integrity": "sha512-vsI+Jyzj4yfMolCg42OOyDvT30g1WFoupfOGlUk8A9R2HTVhMTeFeoA186A7c5KLI3e9fxj/60Rc9LbIbacnDA==",
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-4.28.0.tgz",
"integrity": "sha512-P2vK+4WDnSt/nnilqxvDS4lyMAEH/M73z9YSzyH5mEwVTNxD3m82jJgpHqV5Re7geooAyaKqS6MJwDxaN0+9eQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.8.1"
@@ -4522,15 +4522,15 @@
"@ng-web-apis/platform": "^4.11.1",
"@ng-web-apis/resize-observer": "^4.11.1",
"@ng-web-apis/screen-orientation": "^4.11.1",
"@taiga-ui/event-plugins": "^4.4.0",
"@taiga-ui/event-plugins": "^4.4.1",
"@taiga-ui/polymorpheus": "^4.8.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/core": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.24.0.tgz",
"integrity": "sha512-tHHxdwXtvMI8W2Ct1YgLZGg5UEDRPq3j3fYwLV0P/G+17TU1rVeF9/JxVe8ZdZ1Mo55LNLDflTnenC13GK0R/w==",
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-4.28.0.tgz",
"integrity": "sha512-4eP6PJvmHZCrV/9apxfu6Bgj7L72yjVg1R5c4j1MsVmMESLCCRGlk0hPPvuxVQ+ZYrOZwNeWyKHPZDPL5uQawA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4544,17 +4544,17 @@
"@angular/router": ">=16.0.0",
"@ng-web-apis/common": "^4.11.1",
"@ng-web-apis/mutation-observer": "^4.11.1",
"@taiga-ui/cdk": "^4.24.0",
"@taiga-ui/event-plugins": "^4.4.0",
"@taiga-ui/i18n": "^4.24.0",
"@taiga-ui/cdk": "^4.28.0",
"@taiga-ui/event-plugins": "^4.4.1",
"@taiga-ui/i18n": "^4.28.0",
"@taiga-ui/polymorpheus": "^4.8.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/event-plugins": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/event-plugins/-/event-plugins-4.4.0.tgz",
"integrity": "sha512-Tv8C0p5EZXl7s1Vc+MrLbAblbYvyswomY/xvyFcI9NgMj6JyfsStu6jpCiRMfzojz3G70PRFsk0+WwI19lRJCQ==",
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@taiga-ui/event-plugins/-/event-plugins-4.4.1.tgz",
"integrity": "sha512-gwEkgyZsbAdRfmb98KlKWivYVF88eP0bOtbHwfj8Ec8DgJ5809qFqeWvJEIxZZ829iox1m8z2UuVrqN2/tI1tQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.3.0"
@@ -4566,9 +4566,9 @@
}
},
"node_modules/@taiga-ui/i18n": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.24.0.tgz",
"integrity": "sha512-F3s+SGJ6QkGIWDpTcJvqiE++WHHF95o11tokQvzFq9pCLfMrxehNx38Dv/JnZK3FA2vYlVqdaQc0OsRMfMDRMw==",
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.28.0.tgz",
"integrity": "sha512-kM7bbqllzir4nEk3X+YMKATm23UoKJeWSGmwnjLEmhWkpNAGqfErDRbE2puf+jXy7eufGhaB7ht/mK4+HkLXbw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
@@ -4581,18 +4581,18 @@
}
},
"node_modules/@taiga-ui/icons": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-4.24.0.tgz",
"integrity": "sha512-VGWKMuVayab+/MJ60JuxRM2zTJG2WelDmUuTMdYqnh+zAWTn7FoPjDgAREiT7m84XryZdKtkUqGK6aLI+J62Sg==",
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-4.28.0.tgz",
"integrity": "sha512-1QS7gvYHuTRUUodE58OXm+4Ree5FhFe0co0Lj+3sqeqkYb495z5q3CXBNiXD3y8IcDTjNuYkxKxEthbPnQrsVQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.3.0"
}
},
"node_modules/@taiga-ui/kit": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.24.0.tgz",
"integrity": "sha512-qz+OpsAyHWUT6fcfBPd+RJTv98J5USezh028B7TpSLSSyRE6mbklKIj2gteJQVnwVS0AXwm69WgQDXMVolbJnw==",
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-4.28.0.tgz",
"integrity": "sha512-JEHUZhWU0vgPorvO3l9POzWKPbFQA57jFh9Iv5/RlWxMI8EUI+OKH5J8z1ptX+RJE2dWB9+Yi84zasgr8TWcSA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4602,25 +4602,25 @@
"@angular/core": ">=16.0.0",
"@angular/forms": ">=16.0.0",
"@angular/router": ">=16.0.0",
"@maskito/angular": "^3.2.1",
"@maskito/core": "^3.2.1",
"@maskito/kit": "^3.2.1",
"@maskito/phone": "^3.2.1",
"@maskito/angular": "^3.4.0",
"@maskito/core": "^3.4.0",
"@maskito/kit": "^3.4.0",
"@maskito/phone": "^3.4.0",
"@ng-web-apis/common": "^4.11.1",
"@ng-web-apis/intersection-observer": "^4.11.1",
"@ng-web-apis/mutation-observer": "^4.11.1",
"@ng-web-apis/resize-observer": "^4.11.1",
"@taiga-ui/cdk": "^4.24.0",
"@taiga-ui/core": "^4.24.0",
"@taiga-ui/i18n": "^4.24.0",
"@taiga-ui/cdk": "^4.28.0",
"@taiga-ui/core": "^4.28.0",
"@taiga-ui/i18n": "^4.28.0",
"@taiga-ui/polymorpheus": "^4.8.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/layout": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.24.0.tgz",
"integrity": "sha512-xwWB2iXeQWipzw0jTvqFL6V/20bhyM5lNT2AyAo6EcI8fX1N8x/RThaz9crf1jexajynHm4qirGT1M8tblg+Pw==",
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/layout/-/layout-4.28.0.tgz",
"integrity": "sha512-NlXdEmXGhYvTWeSSpGlT9XS0SU1aQDuFAMFBSDVsZqLPWh2DTnNsxSf1/b6UYMmX5JKXhH/bRVvX97N5L5XZqQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4628,17 +4628,17 @@
"peerDependencies": {
"@angular/common": ">=16.0.0",
"@angular/core": ">=16.0.0",
"@taiga-ui/cdk": "^4.24.0",
"@taiga-ui/core": "^4.24.0",
"@taiga-ui/kit": "^4.24.0",
"@taiga-ui/cdk": "^4.28.0",
"@taiga-ui/core": "^4.28.0",
"@taiga-ui/kit": "^4.28.0",
"@taiga-ui/polymorpheus": "^4.8.0",
"rxjs": ">=7.0.0"
}
},
"node_modules/@taiga-ui/legacy": {
"version": "4.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/legacy/-/legacy-4.24.0.tgz",
"integrity": "sha512-J3CJwDuaVV1zMUunZWru6Syg1cFPL0uF8qWyAis+tgllfYxFpUstvJfKhqezSauXifFsDqcLJYKGu3/fMLIsmA==",
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/legacy/-/legacy-4.28.0.tgz",
"integrity": "sha512-mWE5w7alYsT8GMBNTfcvrf/sJjh1li2/mTykH/aoWklgYHHmSt6moY4Myi8wKdlRFBzi82eXsvJcUSCwD8Y5ew==",
"license": "Apache-2.0",
"dependencies": {
"tslib": ">=2.8.1"
@@ -4648,9 +4648,9 @@
}
},
"node_modules/@taiga-ui/polymorpheus": {
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/polymorpheus/-/polymorpheus-4.8.0.tgz",
"integrity": "sha512-gNXk8SVxXf/5wtmm6XeFMQ9RzY0xbM9E4vFxSGwnNegVZtv3T08YX2uoxPgUbgck2/GS9N5B5KvjjbVa0T0L9A==",
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/polymorpheus/-/polymorpheus-4.9.0.tgz",
"integrity": "sha512-TbIIwslbEnxunKuL9OyPZdmefrvJEK6HYiADEKQHUMUs4Pk2UbhMckUieURo83yPDamk/Mww+Nu/g60J/4uh2w==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.1"
@@ -9496,9 +9496,9 @@
}
},
"node_modules/libphonenumber-js": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.19.tgz",
"integrity": "sha512-bW/Yp/9dod6fmyR+XqSUL1N5JE7QRxQ3KrBIbYS1FTv32e5i3SEtQVX+71CYNv8maWNSOgnlCoNp9X78f/cKiA==",
"version": "1.12.6",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.6.tgz",
"integrity": "sha512-PJiS4ETaUfCOFLpmtKzAbqZQjCCKVu2OhTV4SVNNE7c2nu/dACvtCqj4L0i/KWNnIgRv7yrILvBj5Lonv5Ncxw==",
"license": "MIT",
"peer": true
},

View File

@@ -47,18 +47,18 @@
"@noble/hashes": "^1.4.0",
"@start9labs/argon2": "^0.2.2",
"@start9labs/start-sdk": "file:../sdk/baseDist",
"@taiga-ui/addon-charts": "4.24.0",
"@taiga-ui/addon-commerce": "4.24.0",
"@taiga-ui/addon-mobile": "4.24.0",
"@taiga-ui/addon-table": "4.24.0",
"@taiga-ui/cdk": "4.24.0",
"@taiga-ui/core": "4.24.0",
"@taiga-ui/event-plugins": "4.4.0",
"@taiga-ui/icons": "4.24.0",
"@taiga-ui/kit": "4.24.0",
"@taiga-ui/layout": "4.24.0",
"@taiga-ui/legacy": "4.24.0",
"@taiga-ui/polymorpheus": "4.8.0",
"@taiga-ui/addon-charts": "4.28.0",
"@taiga-ui/addon-commerce": "4.28.0",
"@taiga-ui/addon-mobile": "4.28.0",
"@taiga-ui/addon-table": "4.28.0",
"@taiga-ui/cdk": "4.28.0",
"@taiga-ui/core": "4.28.0",
"@taiga-ui/event-plugins": "4.4.1",
"@taiga-ui/icons": "4.28.0",
"@taiga-ui/kit": "4.28.0",
"@taiga-ui/layout": "4.28.0",
"@taiga-ui/legacy": "4.28.0",
"@taiga-ui/polymorpheus": "4.9.0",
"@tinkoff/ng-dompurify": "4.0.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",

View File

@@ -127,6 +127,11 @@ tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] {
inset 0 1px rgba(255, 255, 255, 0.15),
inset 0 0 1rem rgba(0, 0, 0, 0.25),
var(--tui-shadow-medium);
[tuiOption] {
justify-content: flex-start;
gap: 0.5rem;
}
}
[tuiSidebar] > div.t-wrapper {

View File

@@ -105,11 +105,6 @@ import { ABOUT } from './about.component'
opacity: 0.5;
}
[tuiOption] {
justify-content: flex-start;
gap: 0.5rem;
}
:host-context(tui-root._mobile) {
[tuiIconButton] {
box-shadow: inset -1.25rem 0 0 -1rem var(--status);

View File

@@ -0,0 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core'
import { toAcmeName } from 'src/app/utils/acme'
@Pipe({
standalone: true,
name: 'acme',
})
export class AcmePipe implements PipeTransform {
transform(value: string | null = null): string {
return toAcmeName(value)
}
}

View File

@@ -0,0 +1,129 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { CopyService } from '@start9labs/shared'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import {
TuiButton,
tuiButtonOptionsProvider,
TuiDataList,
TuiDropdown,
} from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { InterfaceComponent } from './interface.component'
@Component({
standalone: true,
selector: 'td[actions]',
template: `
<div class="desktop">
<ng-content />
@if (interface.serviceInterface().type === 'ui') {
<a
tuiIconButton
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
[href]="actions()"
>
Launch UI
</a>
}
<button tuiIconButton iconStart="@tui.qr-code" (click)="showQR()">
Show QR
</button>
<button
tuiIconButton
iconStart="@tui.copy"
(click)="copyService.copy(actions())"
>
Copy URL
</button>
</div>
<div class="mobile">
<button
tuiIconButton
iconStart="@tui.ellipsis-vertical"
tuiDropdownOpen
[tuiDropdown]="dropdown"
>
Actions
<ng-template #dropdown let-close>
<tui-data-list>
<tui-opt-group>
@if (interface.serviceInterface().type === 'ui') {
<a
tuiOption
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
[href]="actions()"
>
Launch UI
</a>
<button tuiOption iconStart="@tui.qr-code" (click)="showQR()">
Show QR
</button>
<button
tuiOption
iconStart="@tui.copy"
(click)="copyService.copy(actions()); close()"
>
Copy URL
</button>
}
</tui-opt-group>
<tui-opt-group><ng-content select="[tuiOption]" /></tui-opt-group>
</tui-data-list>
</ng-template>
</button>
</div>
`,
styles: `
:host {
text-align: right;
grid-area: 1 / 2 / 3 / 3;
}
.desktop {
}
.mobile {
display: none;
}
:host-context(tui-root._mobile) {
.desktop {
display: none;
}
.mobile {
display: block;
}
}
`,
imports: [TuiButton, TuiDropdown, TuiDataList],
providers: [tuiButtonOptionsProvider({ appearance: 'icon' })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceActionsComponent {
readonly dialogs = inject(TuiResponsiveDialogService)
readonly copyService = inject(CopyService)
readonly interface = inject(InterfaceComponent)
readonly actions = input.required<string>()
showQR() {
this.dialogs
.open(new PolymorpheusComponent(QRModal), {
size: 'auto',
label: 'Interface URL',
data: this.actions(),
})
.subscribe()
}
}

View File

@@ -1,54 +0,0 @@
import { TuiButton } from '@taiga-ui/core'
import {
ChangeDetectionStrategy,
Component,
Input,
inject,
} from '@angular/core'
import { AddressItemComponent } from './address-item.component'
import { AddressDetails, AddressesService } from './interface.utils'
@Component({
standalone: true,
selector: 'app-address-group',
template: `
<div>
@if (addresses.length && !service.static) {
<button
class="icon-add-btn"
tuiIconButton
iconStart="@tui.plus"
(click)="service.add()"
></button>
}
<ng-content />
</div>
@for (address of addresses; track $index) {
<app-address-item [label]="address.label" [address]="address.url" />
} @empty {
@if (!service.static) {
<button
tuiButton
iconStart="@tui.plus"
[style.align-self]="'flex-start'"
(click)="service.add()"
>
Add Address
</button>
}
}
`,
imports: [AddressItemComponent, TuiButton],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: `
.icon-add-btn {
float: right;
margin-left: 2rem;
}
`,
})
export class AddressGroupComponent {
readonly service = inject(AddressesService)
@Input({ required: true }) addresses!: AddressDetails[]
}

View File

@@ -1,97 +0,0 @@
import { TuiCell } from '@taiga-ui/layout'
import { TuiBadge } from '@taiga-ui/kit'
import { NgIf } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { WA_WINDOW } from '@ng-web-apis/common'
import { CopyService } from '@start9labs/shared'
import { TuiDialogService, TuiTitle, TuiButton } from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { mask } from 'src/app/utils/mask'
import { InterfaceComponent } from './interface.component'
import { AddressesService } from './interface.utils'
@Component({
standalone: true,
selector: 'app-address-item',
template: `
<div tuiCell>
<tui-badge appearance="success">
{{ label }}
</tui-badge>
<h3 tuiTitle>
<span tuiSubtitle>
{{ interface.serviceInterface.masked ? mask : address }}
</span>
</h3>
<button
*ngIf="interface.serviceInterface.type === 'ui'"
tuiIconButton
iconStart="@tui.external-link"
appearance="icon"
(click)="launch(address)"
>
Launch
</button>
<button
tuiIconButton
iconStart="@tui.qr-code"
appearance="icon"
(click)="showQR(address)"
>
Show QR code
</button>
<button
tuiIconButton
iconStart="@tui.copy"
appearance="icon"
(click)="copyService.copy(address)"
>
Copy URL
</button>
<button
tuiIconButton
iconStart="@tui.trash"
appearance="icon"
(click)="service.remove()"
>
Destroy
</button>
</div>
`,
imports: [NgIf, TuiCell, TuiTitle, TuiButton, TuiBadge],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddressItemComponent {
private readonly window = inject(WA_WINDOW)
private readonly dialogs = inject(TuiDialogService)
readonly service = inject(AddressesService)
readonly copyService = inject(CopyService)
readonly interface = inject(InterfaceComponent)
@Input() label?: string
@Input({ required: true }) address!: string
get mask(): string {
return mask(this.address, 64)
}
launch(url: string): void {
this.window.open(url, '_blank', 'noreferrer')
}
showQR(data: string) {
this.dialogs
.open(new PolymorpheusComponent(QRModal), {
size: 'auto',
data,
})
.subscribe()
}
}

View File

@@ -0,0 +1,281 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import {
TuiAppearance,
TuiButton,
TuiDataList,
TuiDialogOptions,
TuiIcon,
TuiLink,
} from '@taiga-ui/core'
import { TUI_CONFIRM, TuiTooltip } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { defaultIfEmpty, firstValueFrom, map } from 'rxjs'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { AcmePipe } from 'src/app/routes/portal/components/interfaces/acme.pipe'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { toAcmeName } from 'src/app/utils/acme'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { InterfaceActionsComponent } from './actions.component'
import { AddressDetails } from './interface.utils'
import { MaskPipe } from './mask.pipe'
type ClearnetForm = {
domain: string
acme: string
}
@Component({
standalone: true,
selector: 'section[clearnet]',
template: `
<header>
Clearnet
<tui-icon [tuiTooltip]="tooltip" />
<ng-template #tooltip>
Add a clearnet address to expose this interface on the Internet.
Clearnet addresses are fully public and not anonymous.
<a
tuiLink
href="https://docs.start9.com/latest/user-manual/interface-addresses#clearnet"
target="_blank"
rel="noreferrer"
>
Learn More
</a>
</ng-template>
@if (clearnet().length) {
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="add()"
>
Add
</button>
<button
tuiButton
appearance="accent"
[iconStart]="isPublic() ? '@tui.globe-lock' : '@tui.globe'"
(click)="toggle()"
>
Make {{ isPublic() ? 'private' : 'public' }}
</button>
}
</header>
@if (clearnet().length) {
<table [appTable]="['Domain', 'ACME', 'URL', '']">
@for (address of clearnet(); track $index) {
<tr>
<td [style.width.rem]="15">{{ address.label }}</td>
<td>{{ address.acme | acme }}</td>
<td>{{ address.url | mask }}</td>
<td [actions]="address.url">
<button
tuiButton
appearance="primary-destructive"
[style.margin-inline-end.rem]="0.5"
(click)="remove(address)"
>
Delete
</button>
<button
tuiOption
tuiAppearance="action-destructive"
iconStart="@tui.trash"
(click)="remove(address)"
>
Delete
</button>
</td>
</tr>
}
</table>
} @else {
<app-placeholder icon="@tui.app-window">
No interfaces available
<button tuiButton iconStart="@tui.plus" (click)="add()">Add</button>
</app-placeholder>
}
`,
host: { class: 'g-card' },
imports: [
TuiButton,
TuiIcon,
TuiTooltip,
TuiLink,
TuiDataList,
TuiAppearance,
PlaceholderComponent,
TableComponent,
MaskPipe,
AcmePipe,
InterfaceActionsComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceClearnetComponent {
private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly interface = inject(InterfaceComponent)
readonly isPublic = computed(() => this.interface.serviceInterface().public)
readonly clearnet = input.required<readonly AddressDetails[]>()
readonly acme = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'acme')
.pipe(map(acme => Object.keys(acme))),
{ initialValue: [] },
)
async remove({ url }: AddressDetails) {
const confirm = await firstValueFrom(
this.dialogs
.open(TUI_CONFIRM, { label: 'Are you sure?', size: 's' })
.pipe(defaultIfEmpty(false)),
)
if (!confirm) {
return
}
const loader = this.loader.open('Removing').subscribe()
const params = { domain: new URL(url).hostname }
try {
if (this.interface.packageId()) {
await this.api.pkgRemoveDomain({
...params,
package: this.interface.packageId(),
host: this.interface.serviceInterface().addressInfo.hostId,
})
} else {
await this.api.serverRemoveDomain(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
async toggle() {
const loader = this.loader
.open(`Making ${this.isPublic() ? 'private' : 'public'}`)
.subscribe()
const params = {
internalPort: this.interface.serviceInterface().addressInfo.internalPort,
public: !this.isPublic(),
}
try {
if (this.interface.packageId()) {
await this.api.pkgBindingSetPubic({
...params,
host: this.interface.serviceInterface().addressInfo.hostId,
package: this.interface.packageId(),
})
} else {
await this.api.serverBindingSetPubic(params)
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async add() {
const options: Partial<TuiDialogOptions<FormContext<ClearnetForm>>> = {
label: 'Select Domain/Subdomain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
domain: ISB.Value.text({
name: 'Domain',
description: 'The domain or subdomain you want to use',
placeholder: `e.g. 'mydomain.com' or 'sub.mydomain.com'`,
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
acme: ISB.Value.select({
name: 'ACME Provider',
description:
'Select which ACME provider to use for obtaining your SSL certificate. Add new ACME providers in the System tab. Optionally use your system Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.',
values: this.acme().reduce(
(obj, url) => ({
...obj,
[url]: toAcmeName(url),
}),
{ none: 'None (use system Root CA)' } as Record<string, string>,
),
default: '',
}),
}),
),
buttons: [
{
text: 'Save',
handler: async value => this.save(value),
},
],
},
}
this.formDialog.open(FormComponent, options)
}
private async save(domainInfo: ClearnetForm): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
const { domain, acme } = domainInfo
const params = {
domain,
acme: acme === 'none' ? null : acme,
private: false,
}
try {
if (this.interface.packageId()) {
await this.api.pkgAddDomain({
...params,
package: this.interface.packageId(),
host: this.interface.serviceInterface().addressInfo.hostId,
})
} else {
await this.api.serverAddDomain(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,111 +0,0 @@
import { Directive, Input } from '@angular/core'
import { AddressesService } from '../interface.utils'
import { inject } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogOptions } from '@taiga-ui/core'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { InterfaceComponent } from '../interface.component'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { ISB, utils } from '@start9labs/start-sdk'
import { toAcmeName } from 'src/app/utils/acme'
type ClearnetForm = {
domain: string
acme: string
}
@Directive({
standalone: true,
selector: '[clearnetAddresses]',
providers: [
{ provide: AddressesService, useExisting: ClearnetAddressesDirective },
],
})
export class ClearnetAddressesDirective implements AddressesService {
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
@Input({ required: true }) acme!: string[]
static = false
async add() {
const options: Partial<TuiDialogOptions<FormContext<ClearnetForm>>> = {
label: 'Select Domain/Subdomain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
domain: ISB.Value.text({
name: 'Domain',
description: 'The domain or subdomain you want to use',
placeholder: `e.g. 'mydomain.com' or 'sub.mydomain.com'`,
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
acme: ISB.Value.select({
name: 'ACME Provider',
description:
'Select which ACME provider to use for obtaining your SSL certificate. Add new ACME providers in the System tab. Optionally use your system Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.',
values: this.acme.reduce(
(obj, url) => ({
...obj,
[url]: toAcmeName(url),
}),
{ none: 'None (use system Root CA)' } as Record<string, string>,
),
default: '',
}),
}),
),
buttons: [
{
text: 'Save',
handler: async value => this.save(value),
},
],
},
}
this.formDialog.open(FormComponent, options)
}
async remove() {}
private async save(domainInfo: ClearnetForm): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
const { domain, acme } = domainInfo
const params = {
domain,
acme: acme === 'none' ? null : acme,
private: false,
}
try {
if (this.interface.packageId) {
await this.api.pkgAddDomain({
...params,
package: this.interface.packageId,
host: this.interface.serviceInterface.addressInfo.hostId,
})
} else {
await this.api.serverAddDomain(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,15 +0,0 @@
import { Directive } from '@angular/core'
import { AddressesService } from '../interface.utils'
@Directive({
standalone: true,
selector: '[localAddresses]',
providers: [
{ provide: AddressesService, useExisting: LocalAddressesDirective },
],
})
export class LocalAddressesDirective implements AddressesService {
static = true
async add() {}
async remove() {}
}

View File

@@ -1,87 +0,0 @@
import { Directive, inject } from '@angular/core'
import { AddressesService } from '../interface.utils'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { TuiDialogOptions } from '@taiga-ui/core'
import { FormComponent, FormContext } from '../../form.component'
import { ISB, utils } from '@start9labs/start-sdk'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { InterfaceComponent } from '../interface.component'
type OnionForm = {
key: string
}
@Directive({
standalone: true,
selector: '[torAddresses]',
providers: [
{ provide: AddressesService, useExisting: TorAddressesDirective },
],
})
export class TorAddressesDirective implements AddressesService {
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
static = false
async add() {
const options: Partial<TuiDialogOptions<FormContext<OnionForm>>> = {
label: 'Select Domain/Subdomain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
key: ISB.Value.text({
name: 'Private Key (optional)',
description:
'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) address. If not provided, a random key will be generated and used.',
required: false,
default: null,
patterns: [utils.Patterns.base64],
}),
}),
),
buttons: [
{
text: 'Save',
handler: async value => this.save(value),
},
],
},
}
this.formDialog.open(FormComponent, options)
}
async remove() {}
private async save(form: OnionForm): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
try {
let onion = form.key
? await this.api.addTorKey({ key: form.key })
: await this.api.generateTorKey({})
onion = `${onion}.onion`
if (this.interface.packageId) {
await this.api.pkgAddOnion({
onion,
package: this.interface.packageId,
host: this.interface.serviceInterface.addressInfo.hostId,
})
} else {
await this.api.serverAddOnion({ onion })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,112 +1,42 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { TuiSurface } from '@taiga-ui/core'
import { TuiCardLarge } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { AddressGroupComponent } from 'src/app/routes/portal/components/interfaces/address-group.component'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ClearnetAddressesDirective } from './directives/clearnet.directive'
import { TorAddressesDirective } from './directives/tor.directive'
import { LocalAddressesDirective } from './directives/local.directive'
import { AddressDetails } from './interface.utils'
import { map } from 'rxjs'
import { ChangeDetectionStrategy, Component, input, Input } from '@angular/core'
import { tuiButtonOptionsProvider } from '@taiga-ui/core'
import { InterfaceClearnetComponent } from 'src/app/routes/portal/components/interfaces/clearnet.component'
import { InterfaceLocalComponent } from 'src/app/routes/portal/components/interfaces/local.component'
import { InterfaceTorComponent } from 'src/app/routes/portal/components/interfaces/tor.component'
import { MappedServiceInterface } from './interface.utils'
@Component({
standalone: true,
selector: 'app-interface',
template: `
<h3 class="g-title">Clearnet</h3>
<app-address-group
*ngIf="acme$ | async as acme"
clearnetAddresses
tuiCardLarge="compact"
tuiSurface="floating"
[acme]="acme"
[addresses]="serviceInterface.addresses.clearnet"
>
<em>
Add a clearnet address to expose this interface on the Internet.
Clearnet addresses are fully public and not anonymous.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#clearnet"
target="_blank"
rel="noreferrer"
>
<strong>Learn More</strong>
</a>
</em>
</app-address-group>
<h3 class="g-title">Tor</h3>
<app-address-group
torAddresses
tuiCardLarge="compact"
tuiSurface="floating"
[addresses]="serviceInterface.addresses.tor"
>
<em>
Add an onion address to anonymously expose this interface on the
darknet. Onion addresses can only be reached over the Tor network.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#tor"
target="_blank"
rel="noreferrer"
>
<strong>Learn More</strong>
</a>
</em>
</app-address-group>
<h3 class="g-title">Local</h3>
<app-address-group
localAddresses
tuiCardLarge="compact"
tuiSurface="floating"
[addresses]="serviceInterface.addresses.local"
>
<em>
Local addresses can only be accessed by devices connected to the same
LAN as your server, either directly or using a VPN.
<a
href="https://docs.start9.com/latest/user-manual/interface-addresses#local"
target="_blank"
rel="noreferrer"
>
<strong>Learn More</strong>
</a>
</em>
</app-address-group>
<section [clearnet]="serviceInterface().addresses.clearnet"></section>
<section [tor]="serviceInterface().addresses.tor"></section>
<section [local]="serviceInterface().addresses.local"></section>
`,
styles: `
:host {
display: flex;
flex-direction: column;
gap: 1rem;
}
section {
padding-block-end: 1rem;
}
:host-context(tui-root:not(._mobile)) section ::ng-deep > header {
background: none;
}
`,
providers: [tuiButtonOptionsProvider({ size: 'xs' })],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
AddressGroupComponent,
TuiCardLarge,
TuiSurface,
ClearnetAddressesDirective,
TorAddressesDirective,
LocalAddressesDirective,
InterfaceClearnetComponent,
InterfaceTorComponent,
InterfaceLocalComponent,
],
})
export class InterfaceComponent {
readonly acme$ = inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'acme')
.pipe(map(acme => Object.keys(acme)))
@Input() packageId?: string
@Input({ required: true }) serviceInterface!: MappedServiceInterface
}
export type MappedServiceInterface = T.ServiceInterface & {
addresses: {
clearnet: AddressDetails[]
local: AddressDetails[]
tor: AddressDetails[]
}
readonly packageId = input('')
readonly serviceInterface = input.required<MappedServiceInterface>()
}

View File

@@ -29,10 +29,7 @@ export function getAddresses(
tor: AddressDetails[]
} {
const addressInfo = serviceInterface.addressInfo
let hostnames = host.hostnameInfo[addressInfo.internalPort]
hostnames = hostnames.filter(
const hostnames = host.hostnameInfo[addressInfo.internalPort].filter(
h =>
config.isLocalhost() ||
h.kind !== 'ip' ||
@@ -121,7 +118,17 @@ export function getAddresses(
}
}
export type MappedServiceInterface = T.ServiceInterface & {
public: boolean
addresses: {
clearnet: AddressDetails[]
local: AddressDetails[]
tor: AddressDetails[]
}
}
export type AddressDetails = {
label: string
url: string
acme?: string | null
}

View File

@@ -0,0 +1,52 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { TuiIcon, TuiLink } from '@taiga-ui/core'
import { TuiTooltip } from '@taiga-ui/kit'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { InterfaceActionsComponent } from './actions.component'
import { AddressDetails } from './interface.utils'
import { MaskPipe } from './mask.pipe'
@Component({
standalone: true,
selector: 'section[local]',
template: `
<header>
Local
<tui-icon [tuiTooltip]="tooltip" />
<ng-template #tooltip>
Local addresses can only be accessed by devices connected to the same
LAN as your server, either directly or using a VPN.
<a
tuiLink
href="https://docs.start9.com/latest/user-manual/interface-addresses#local"
target="_blank"
rel="noreferrer"
>
Learn More
</a>
</ng-template>
</header>
<table [appTable]="['Network Interface', 'URL', '']">
@for (address of local(); track $index) {
<tr>
<td [style.width.rem]="15">{{ address.label }}</td>
<td>{{ address.url | mask }}</td>
<td [actions]="address.url"></td>
</tr>
}
</table>
`,
host: { class: 'g-card' },
imports: [
TuiIcon,
TuiTooltip,
TuiLink,
TableComponent,
InterfaceActionsComponent,
MaskPipe,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceLocalComponent {
readonly local = input.required<readonly AddressDetails[]>()
}

View File

@@ -0,0 +1,16 @@
import { inject, Pipe, PipeTransform } from '@angular/core'
import { InterfaceComponent } from './interface.component'
@Pipe({
standalone: true,
name: 'mask',
})
export class MaskPipe implements PipeTransform {
private readonly interface = inject(InterfaceComponent)
transform(value: string): string {
return this.interface.serviceInterface().masked
? '●'.repeat(Math.min(64, value.length))
: value
}
}

View File

@@ -0,0 +1,203 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
import {
TuiAppearance,
TuiButton,
TuiDialogOptions,
TuiIcon,
TuiLink,
TuiOption,
} from '@taiga-ui/core'
import { TUI_CONFIRM, TuiTooltip } from '@taiga-ui/kit'
import { defaultIfEmpty, firstValueFrom } from 'rxjs'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { InterfaceActionsComponent } from './actions.component'
import { AddressDetails } from './interface.utils'
import { MaskPipe } from './mask.pipe'
type OnionForm = {
key: string
}
@Component({
standalone: true,
selector: 'section[tor]',
template: `
<header>
Tor
<tui-icon [tuiTooltip]="tooltip" />
<ng-template #tooltip>
Add an onion address to anonymously expose this interface on the
darknet. Onion addresses can only be reached over the Tor network.
<a
tuiLink
href="https://docs.start9.com/latest/user-manual/interface-addresses#tor"
target="_blank"
rel="noreferrer"
>
Learn More
</a>
</ng-template>
</header>
@if (tor().length) {
<table [appTable]="['Protocol', 'URL', '']">
@for (address of tor(); track $index) {
<tr>
<td [style.width.rem]="15">{{ address.label }}</td>
<td>{{ address.url | mask }}</td>
<td [actions]="address.url">
<button
tuiButton
appearance="primary-destructive"
[style.margin-inline-end.rem]="0.5"
(click)="remove(address)"
>
Delete
</button>
<button
tuiOption
tuiAppearance="action-destructive"
iconStart="@tui.trash"
(click)="remove(address)"
>
Delete
</button>
</td>
</tr>
}
</table>
} @else {
<app-placeholder icon="@tui.app-window">
No Tor addresses available
<button tuiButton iconStart="@tui.plus">Add</button>
</app-placeholder>
}
`,
host: { class: 'g-card' },
imports: [
TuiButton,
TuiIcon,
TuiTooltip,
TuiLink,
TuiAppearance,
TuiOption,
TableComponent,
PlaceholderComponent,
MaskPipe,
InterfaceActionsComponent,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceTorComponent {
private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
readonly tor = input.required<readonly AddressDetails[]>()
async remove({ url }: AddressDetails) {
const confirm = await firstValueFrom(
this.dialogs
.open(TUI_CONFIRM, { label: 'Are you sure?', size: 's' })
.pipe(defaultIfEmpty(false)),
)
if (!confirm) {
return
}
const loader = this.loader.open('Removing').subscribe()
const params = { onion: new URL(url).hostname }
try {
if (this.interface.packageId()) {
await this.api.pkgRemoveOnion({
...params,
package: this.interface.packageId(),
host: this.interface.serviceInterface().addressInfo.hostId,
})
} else {
await this.api.serverRemoveOnion(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
async add() {
const options: Partial<TuiDialogOptions<FormContext<OnionForm>>> = {
label: 'Select Domain/Subdomain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
key: ISB.Value.text({
name: 'Private Key (optional)',
description:
'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) address. If not provided, a random key will be generated and used.',
required: false,
default: null,
patterns: [utils.Patterns.base64],
}),
}),
),
buttons: [
{
text: 'Save',
handler: async value => this.save(value),
},
],
},
}
this.formDialog.open(FormComponent, options)
}
private async save(form: OnionForm): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
try {
let onion = form.key
? await this.api.addTorKey({ key: form.key })
: await this.api.generateTorKey({})
onion = `${onion}.onion`
if (this.interface.packageId) {
await this.api.pkgAddOnion({
onion,
package: this.interface.packageId(),
host: this.interface.serviceInterface().addressInfo.hostId,
})
} else {
await this.api.serverAddOnion({ onion })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,21 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
@Component({
standalone: true,
selector: 'table[appTable]',
template: `
<thead>
<tr>
@for (header of appTable(); track $index) {
<th>{{ header }}</th>
}
</tr>
</thead>
<tbody><ng-content /></tbody>
`,
host: { class: 'g-table' },
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TableComponent {
readonly appTable = input.required<readonly string[]>()
}

View File

@@ -6,7 +6,7 @@ import { QrCodeModule } from 'ng-qrcode'
@Component({
standalone: true,
selector: 'qr',
template: '<qr-code [value]="context.data" size="400"></qr-code>',
template: '<qr-code [value]="context.data" size="350"></qr-code>',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [QrCodeModule],
})

View File

@@ -73,7 +73,7 @@ export default class DashboardComponent {
getInstalledPrimaryStatus(b) > getInstalledPrimaryStatus(a) ? -1 : 1
readonly uptime: TuiComparator<any> = (a, b) =>
a.startedAt || '' > b.startedAt || '' ? -1 : 1
a.status.started || '' > a.status.started || '' ? -1 : 1
sorter = this.name
}

View File

@@ -27,7 +27,7 @@ import { StatusComponent } from './status.component'
<a [routerLink]="routerLink">{{ manifest.title }}</a>
</td>
<td [style.grid-area]="'2 / 2'">{{ manifest.version }}</td>
<td [appUptime]="$any(pkg).startedAt"></td>
<td [appUptime]="$any(pkg.status).started"></td>
<td
[style.grid-area]="'3 / 2'"
appStatus

View File

@@ -11,10 +11,10 @@ import { getPkgId } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { getAddresses } from '../../../components/interfaces/interface.utils'
@Component({
template: `

View File

@@ -99,6 +99,10 @@ const ICONS = {
.active {
color: var(--tui-text-primary);
[tuiTitle] {
font-weight: bold;
}
}
:host-context(tui-root._mobile) {

View File

@@ -5,11 +5,11 @@ import { T } from '@start9labs/start-sdk'
import { TuiButton } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { Observable, map } from 'rxjs'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import {
InterfaceComponent,
getAddresses,
MappedServiceInterface,
} from 'src/app/routes/portal/components/interfaces/interface.component'
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
} from 'src/app/routes/portal/components/interfaces/interface.utils'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'

View File

@@ -1,3 +0,0 @@
export function mask(val: string, max: number = Infinity): string {
return '●'.repeat(Math.min(max, val.length))
}

View File

@@ -120,7 +120,7 @@ hr {
}
}
> table {
> table[tuiTable] {
margin: 0 -0.5rem;
td:empty,
@@ -131,15 +131,15 @@ hr {
> header {
position: absolute;
top: 0;
left: 0;
right: 0;
inset: 0 0 auto 0;
height: 3rem;
display: flex;
align-items: center;
padding: 0.5rem 1rem;
background: var(--tui-background-neutral-1);
gap: 0.25rem;
padding: 0 1rem;
font: var(--tui-font-text-l);
font-weight: bold;
background: var(--tui-background-neutral-1);
}
> footer {
@@ -152,45 +152,51 @@ hr {
.g-table:not([tuiTable]) {
width: stretch;
min-width: 40rem;
border: 1px solid var(--tui-background-neutral-1);
border-spacing: 0;
border-collapse: collapse;
border-radius: var(--tui-radius-s);
overflow: hidden;
box-shadow: inset 0 0 0 1px var(--tui-background-neutral-1);
td,
th {
position: relative;
font: var(--tui-font-text-s);
text-align: left;
height: 2rem;
padding: 0 0.25rem;
box-shadow: inset 0 -1px var(--tui-background-neutral-1);
padding: 0.5rem 0.75rem;
border: 1px solid var(--tui-background-neutral-1);
border-left: 0;
border-right: 0;
text-overflow: ellipsis;
}
th {
background: var(--tui-background-neutral-1);
font-weight: bold;
text-align: left;
}
tui-root._mobile & {
min-width: 0;
border: none;
box-shadow: none;
border-radius: 0;
color: var(--tui-text-secondary);
thead {
display: none;
}
tbody {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
tr {
position: relative;
display: grid;
border-radius: var(--tui-radius-l);
padding: 0.375rem 0.5rem;
// TODO: Theme
background: rgba(0, 0, 0, 0.2);
padding: 0.5rem 0;
box-shadow: inset 0 -1px var(--tui-background-neutral-1);
&:last-child {
box-shadow: none;
}
}
tr:has(:checked) {
@@ -200,10 +206,13 @@ hr {
td,
th {
position: static;
height: auto;
min-height: 1.5rem;
align-content: center;
box-shadow: none;
border: none;
padding: 0;
&:first-child {
font-weight: bold;
color: var(--tui-text-primary);
}
&:not([tuiFade]) {
overflow: hidden;