mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-27 02:41:53 +00:00
refactor: refactor service page to get rid of ionic (#2421)
This commit is contained in:
@@ -50,6 +50,7 @@
|
||||
],
|
||||
"styles": [
|
||||
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less",
|
||||
"node_modules/@taiga-ui/styles/taiga-ui-global.less",
|
||||
"projects/shared/styles/taiga.scss",
|
||||
"projects/shared/styles/variables.scss",
|
||||
"projects/shared/styles/global.scss",
|
||||
|
||||
116
frontend/package-lock.json
generated
116
frontend/package-lock.json
generated
@@ -23,12 +23,13 @@
|
||||
"@start9labs/argon2": "^0.1.0",
|
||||
"@start9labs/emver": "^0.1.5",
|
||||
"@start9labs/start-sdk": "0.4.0-rev0.lib0.rc8.beta2",
|
||||
"@taiga-ui/addon-charts": "3.44.0",
|
||||
"@taiga-ui/cdk": "3.44.0",
|
||||
"@taiga-ui/core": "3.44.0",
|
||||
"@taiga-ui/experimental": "3.44.0",
|
||||
"@taiga-ui/icons": "3.44.0",
|
||||
"@taiga-ui/kit": "3.44.0",
|
||||
"@taiga-ui/addon-charts": "3.45.0",
|
||||
"@taiga-ui/cdk": "3.45.0",
|
||||
"@taiga-ui/core": "3.45.0",
|
||||
"@taiga-ui/experimental": "3.45.0",
|
||||
"@taiga-ui/icons": "3.45.0",
|
||||
"@taiga-ui/kit": "3.45.0",
|
||||
"@taiga-ui/styles": "3.45.0",
|
||||
"@tinkoff/ng-dompurify": "4.0.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"base64-js": "^1.5.1",
|
||||
@@ -3591,9 +3592,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@maskito/angular": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-1.5.1.tgz",
|
||||
"integrity": "sha512-unT8l4CLuehliS8alLYyPVLZHI+KyIm53Yll3yHJEtJy3Wz5rmCuj0h9IPaJ2clR/gXFAi5e5rLEX5SD1uWl4g==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-1.7.0.tgz",
|
||||
"integrity": "sha512-RcBEXkuUf5zyaNQZv26LxOZ2DrILR34Ci1OWRaeI0JfHSslKdMlbQMdWXvGKga6ChGY5Sfl64AsmQ1D2kMvGlQ==",
|
||||
"dependencies": {
|
||||
"tslib": "2.6.2"
|
||||
},
|
||||
@@ -3601,21 +3602,21 @@
|
||||
"@angular/common": ">=12.0.0",
|
||||
"@angular/core": ">=12.0.0",
|
||||
"@angular/forms": ">=12.0.0",
|
||||
"@maskito/core": "^1.5.1",
|
||||
"@maskito/core": "^1.7.0",
|
||||
"rxjs": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maskito/core": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@maskito/core/-/core-1.5.1.tgz",
|
||||
"integrity": "sha512-AkwUyNjtf4cIAluJc459jf2YRVTVvreMNUpnStx6Kzne1DHf5RZUNeVba+6QYQHe0Mn0E8ftYoYOT5Ac8Wd1ow=="
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@maskito/core/-/core-1.7.0.tgz",
|
||||
"integrity": "sha512-sjdv1MSJnWWor/Qy1u1+ZZtqejzfVt6zqMUfy5RToEPZSBWlsCg1JSfjRu9WRb3yirpZnj3j80JkTGCicFrvuw=="
|
||||
},
|
||||
"node_modules/@maskito/kit": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-1.5.1.tgz",
|
||||
"integrity": "sha512-/jMMAAmjUqplY62+UGVNwXjK+7XihYRpw4C51WpszrSlH6Sj//bEDcLIA5iqkJD+cK5ROoEyQ63XDRo0D9tUGg==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-1.7.0.tgz",
|
||||
"integrity": "sha512-LX/ngWFnPKWnQfvU9m5fss8NIBO261DlQdXOtlbhoXgyscVoR8RVeUHRVuRKEhJgd1I+dvlP1vUxms2qltFTjw==",
|
||||
"peerDependencies": {
|
||||
"@maskito/core": "^1.5.1"
|
||||
"@maskito/core": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@materia-ui/ngx-monaco-editor": {
|
||||
@@ -4040,9 +4041,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/addon-charts": {
|
||||
"version": "3.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-3.44.0.tgz",
|
||||
"integrity": "sha512-yC42GGVMBgAZm+6ej+UlQNO6Jl4JKT7cGRmz07Jvw+cd2KXDv6+A2cNqmI7EfJnig/3/Lfh560QIRz1uKcTQQw==",
|
||||
"version": "3.45.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-3.45.0.tgz",
|
||||
"integrity": "sha512-KR9d95Hix/+5oiXiyfnqnzpyxw7am689tZ69y+2Unv2kYENQqc0LXf2SG981rIHLJVr4mVDuBfmJ7I32M+zvVw==",
|
||||
"dependencies": {
|
||||
"tslib": ">=2.0.0"
|
||||
},
|
||||
@@ -4050,15 +4051,15 @@
|
||||
"@angular/common": ">=12.0.0",
|
||||
"@angular/core": ">=12.0.0",
|
||||
"@ng-web-apis/common": ">=3.0.0",
|
||||
"@taiga-ui/cdk": ">=3.44.0",
|
||||
"@taiga-ui/core": ">=3.44.0",
|
||||
"@taiga-ui/cdk": ">=3.45.0",
|
||||
"@taiga-ui/core": ">=3.45.0",
|
||||
"@tinkoff/ng-polymorpheus": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/cdk": {
|
||||
"version": "3.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-3.44.0.tgz",
|
||||
"integrity": "sha512-sr0vqcuc/ziMjdQTcVPOayhmDwPp4GW1W3lBeUmmuXJpGm7DKrohA0CRUZG+2QZvw7ePVG/G2jBM8268k4I2mw==",
|
||||
"version": "3.45.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-3.45.0.tgz",
|
||||
"integrity": "sha512-pFdy5yxkzPGYrtyA1e92SYYXel3uHb+3b2NFjNqbFzLnW3p0NYILZfQPb4I4U9X5cZBhVaFlPGsHXwswCZKqGw==",
|
||||
"dependencies": {
|
||||
"@ng-web-apis/common": "3.0.2",
|
||||
"@ng-web-apis/mutation-observer": "3.0.2",
|
||||
@@ -4080,11 +4081,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/core": {
|
||||
"version": "3.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-3.44.0.tgz",
|
||||
"integrity": "sha512-rd8+uADy38iIBjoVvUs5fH2oDGBVb0S/eb/PynQB5vh5zddGFsg10mGfjqtUKoLCdrMmlIVfSJ2Fv0AbibsIaQ==",
|
||||
"version": "3.45.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-3.45.0.tgz",
|
||||
"integrity": "sha512-16mCoBlorIx9PHZUGRWfX2K6LTMNo62h4bKOkZEz/l5nxzP+Wsa/vHgmditwE4eKg7v7nHGSPrdmNxlgzcs2dQ==",
|
||||
"dependencies": {
|
||||
"@taiga-ui/i18n": "^3.44.0",
|
||||
"@taiga-ui/i18n": "^3.45.0",
|
||||
"tslib": ">=2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -4096,34 +4097,34 @@
|
||||
"@angular/router": ">=12.0.0",
|
||||
"@ng-web-apis/common": ">=3.0.0",
|
||||
"@ng-web-apis/mutation-observer": ">=3.0.0",
|
||||
"@taiga-ui/cdk": ">=3.44.0",
|
||||
"@taiga-ui/i18n": ">=3.44.0",
|
||||
"@taiga-ui/cdk": ">=3.45.0",
|
||||
"@taiga-ui/i18n": ">=3.45.0",
|
||||
"@tinkoff/ng-event-plugins": ">=3.1.0",
|
||||
"@tinkoff/ng-polymorpheus": ">=4.0.0",
|
||||
"rxjs": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/experimental": {
|
||||
"version": "3.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/experimental/-/experimental-3.44.0.tgz",
|
||||
"integrity": "sha512-fR//2I2FwPPomAyoJSTb+Mla/WOmSZyhnjBVqTZ1jAEbZEmEYwYFMWmWCCorfGiHQhJz0W9d+A22/04FEn94BA==",
|
||||
"version": "3.45.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/experimental/-/experimental-3.45.0.tgz",
|
||||
"integrity": "sha512-XsYKHl+CSGd/Te4UtQb/nHOOo9jI6UjAcqna2D5aPi/sntgP1m2hY7Wu0mxtk9ExajuqZjxocJSx1mSorwDC/Q==",
|
||||
"dependencies": {
|
||||
"tslib": ">=2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=12.0.0",
|
||||
"@angular/core": ">=12.0.0",
|
||||
"@taiga-ui/cdk": ">=3.44.0",
|
||||
"@taiga-ui/core": ">=3.44.0",
|
||||
"@taiga-ui/kit": ">=3.44.0",
|
||||
"@taiga-ui/cdk": ">=3.45.0",
|
||||
"@taiga-ui/core": ">=3.45.0",
|
||||
"@taiga-ui/kit": ">=3.45.0",
|
||||
"@tinkoff/ng-polymorpheus": ">=4.0.0",
|
||||
"rxjs": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/i18n": {
|
||||
"version": "3.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-3.44.0.tgz",
|
||||
"integrity": "sha512-SzjCLKxhGicGEdTPcI6wCtJyoA+SdTZiimzvf1Xt03B+CCc/2rqsPL45XVlnVAwX4lyZUq0mHiA/OcxPlIme+Q==",
|
||||
"version": "3.45.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-3.45.0.tgz",
|
||||
"integrity": "sha512-Dx8QvGaEu/i7M/F0QXa4fRygk5pL8ZXCnIyvRVWcGoJG9Bzfueb+1gsyBp/b7ogHK3FSgj88QsN1EBW1L0IiXQ==",
|
||||
"dependencies": {
|
||||
"tslib": ">=2.0.0"
|
||||
},
|
||||
@@ -4134,24 +4135,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/icons": {
|
||||
"version": "3.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-3.44.0.tgz",
|
||||
"integrity": "sha512-hVWZfPQrGRG1MywuNwRh0jzOJsUFDMiRvdqZrLSs1iQBH/lwEQAZ2KoLHEgGt0GOTwK9DzgXIKVo4bwexs+EmA==",
|
||||
"version": "3.45.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-3.45.0.tgz",
|
||||
"integrity": "sha512-Dn3ImJx2o3vEGtUn05IeEj0JPEYU3wEUtyXcOpK1mqN2AxYnLmAFU/5AtYMeJeQoGEuFsRlGbZfcihieR1CPsQ==",
|
||||
"dependencies": {
|
||||
"tslib": ">=2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@taiga-ui/cdk": ">=3.44.0"
|
||||
"@taiga-ui/cdk": ">=3.45.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/kit": {
|
||||
"version": "3.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-3.44.0.tgz",
|
||||
"integrity": "sha512-klWCWT9IizqJAzCc+XauiTFmX11Qz2zwxvfpLZwAtVWz/cmllf05gL5ynL0LIXhGrzu+2YVQRys9cej2yN2G9Q==",
|
||||
"version": "3.45.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-3.45.0.tgz",
|
||||
"integrity": "sha512-BlQeWh6x041YOsxHU+e0BZaZofo8QZ04gAtKewvWXXUret0keBEbnXhFpyiQLQR0GjM1/0ls3rPHORW4rYYvUw==",
|
||||
"dependencies": {
|
||||
"@maskito/angular": "1.5.1",
|
||||
"@maskito/core": "1.5.1",
|
||||
"@maskito/kit": "1.5.1",
|
||||
"@maskito/angular": "1.7.0",
|
||||
"@maskito/core": "1.7.0",
|
||||
"@maskito/kit": "1.7.0",
|
||||
"@ng-web-apis/intersection-observer": "3.1.2",
|
||||
"text-mask-core": "5.1.2",
|
||||
"tslib": ">=2.0.0"
|
||||
@@ -4164,13 +4165,22 @@
|
||||
"@ng-web-apis/common": ">=3.0.0",
|
||||
"@ng-web-apis/mutation-observer": ">=3.0.0",
|
||||
"@ng-web-apis/resize-observer": ">=3.0.0",
|
||||
"@taiga-ui/cdk": ">=3.44.0",
|
||||
"@taiga-ui/core": ">=3.44.0",
|
||||
"@taiga-ui/i18n": ">=3.44.0",
|
||||
"@taiga-ui/cdk": ">=3.45.0",
|
||||
"@taiga-ui/core": ">=3.45.0",
|
||||
"@taiga-ui/i18n": ">=3.45.0",
|
||||
"@tinkoff/ng-polymorpheus": ">=4.0.0",
|
||||
"rxjs": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/styles": {
|
||||
"version": "3.45.0",
|
||||
"resolved": "https://registry.npmjs.org/@taiga-ui/styles/-/styles-3.45.0.tgz",
|
||||
"integrity": "sha512-WxJh5/U0JUsiJmPebw5x6PbCcfad7rV+soUhM3pdUb86uQs+grpqinIEXhhyeuIZrylofkyo70lgV13gJHRr/w==",
|
||||
"peerDependencies": {
|
||||
"@taiga-ui/cdk": ">=3.45.0",
|
||||
"tslib": ">=2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tinkoff/ng-dompurify": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tinkoff/ng-dompurify/-/ng-dompurify-4.0.0.tgz",
|
||||
|
||||
@@ -44,12 +44,13 @@
|
||||
"@materia-ui/ngx-monaco-editor": "^6.0.0",
|
||||
"@start9labs/argon2": "^0.1.0",
|
||||
"@start9labs/emver": "^0.1.5",
|
||||
"@taiga-ui/addon-charts": "3.44.0",
|
||||
"@taiga-ui/cdk": "3.44.0",
|
||||
"@taiga-ui/core": "3.44.0",
|
||||
"@taiga-ui/experimental": "3.44.0",
|
||||
"@taiga-ui/icons": "3.44.0",
|
||||
"@taiga-ui/kit": "3.44.0",
|
||||
"@taiga-ui/addon-charts": "3.45.0",
|
||||
"@taiga-ui/cdk": "3.45.0",
|
||||
"@taiga-ui/core": "3.45.0",
|
||||
"@taiga-ui/experimental": "3.45.0",
|
||||
"@taiga-ui/icons": "3.45.0",
|
||||
"@taiga-ui/kit": "3.45.0",
|
||||
"@taiga-ui/styles": "3.45.0",
|
||||
"@tinkoff/ng-dompurify": "4.0.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"base64-js": "^1.5.1",
|
||||
|
||||
@@ -21,11 +21,9 @@ const ROUTES: Routes = [
|
||||
import('./routes/desktop/desktop.module').then(m => m.DesktopModule),
|
||||
},
|
||||
{
|
||||
path: 'services',
|
||||
path: 'service',
|
||||
loadChildren: () =>
|
||||
import('./routes/services/services.module').then(
|
||||
m => m.ServicesModule,
|
||||
),
|
||||
import('./routes/service/service.module').then(m => m.ServiceModule),
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { CopyService } from '@start9labs/shared'
|
||||
import { TuiButtonModule, TuiDialogContext } from '@taiga-ui/core'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { QrCodeModule } from 'ng-qrcode'
|
||||
import { ActionResponse } from 'src/app/services/api/api.types'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
{{ context.data.message }}
|
||||
<ng-container *ngIf="context.data.value">
|
||||
<qr-code
|
||||
*ngIf="context.data.qr"
|
||||
size="240"
|
||||
[value]="context.data.value"
|
||||
></qr-code>
|
||||
<p>
|
||||
{{ context.data.value }}
|
||||
<button
|
||||
*ngIf="context.data.copyable"
|
||||
tuiIconButton
|
||||
appearance="flat"
|
||||
icon="tuiIconCopyLarge"
|
||||
(click)="copyService.copy(context.data.value)"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</p>
|
||||
</ng-container>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
qr-code {
|
||||
margin: 1rem auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, QrCodeModule, TuiButtonModule],
|
||||
})
|
||||
export class ServiceActionSuccessComponent {
|
||||
readonly copyService = inject(CopyService)
|
||||
readonly context =
|
||||
inject<TuiDialogContext<void, ActionResponse>>(POLYMORPHEUS_CONTEXT)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
|
||||
interface ActionItem {
|
||||
readonly icon: string
|
||||
readonly name: string
|
||||
readonly description: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: '[action]',
|
||||
template: `
|
||||
<tui-svg [src]="action.icon"></tui-svg>
|
||||
<div>
|
||||
<strong>{{ action.name }}</strong>
|
||||
<div>{{ action.description }}</div>
|
||||
</div>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [TuiSvgModule],
|
||||
})
|
||||
export class ServiceActionComponent {
|
||||
@Input({ required: true })
|
||||
action!: ActionItem
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiButtonModule, TuiDialogService } from '@taiga-ui/core'
|
||||
import { tuiPure } from '@taiga-ui/cdk'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter } from 'rxjs'
|
||||
import {
|
||||
PackageStatus,
|
||||
PrimaryStatus,
|
||||
renderPkgStatus,
|
||||
} from 'src/app/services/pkg-status-rendering.service'
|
||||
import {
|
||||
DataModel,
|
||||
InterfaceInfo,
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { ServiceConfigModal } from '../modals/config.component'
|
||||
import { PackageConfigData } from '../types/package-config-data'
|
||||
import { ToDependenciesPipe } from '../pipes/to-dependencies.pipe'
|
||||
|
||||
@Component({
|
||||
selector: 'service-actions',
|
||||
template: `
|
||||
<button
|
||||
*ngIf="isRunning"
|
||||
tuiButton
|
||||
appearance="secondary-destructive"
|
||||
icon="tuiIconSquare"
|
||||
(click)="tryStop()"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="isRunning"
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
icon="tuiIconRotateCw"
|
||||
(click)="tryRestart()"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="isStopped && isConfigured"
|
||||
tuiButton
|
||||
icon="tuiIconPlay"
|
||||
(click)="tryStart()"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="!isConfigured"
|
||||
tuiButton
|
||||
appearance="secondary-warning"
|
||||
icon="tuiIconTool"
|
||||
(click)="presentModalConfig()"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
`,
|
||||
styles: [':host { display: flex; gap: 1rem }'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
providers: [ToDependenciesPipe],
|
||||
imports: [CommonModule, TuiButtonModule],
|
||||
})
|
||||
export class ServiceActionsComponent {
|
||||
@Input({ required: true })
|
||||
service!: PackageDataEntry
|
||||
|
||||
constructor(
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly formDialog: FormDialogService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly dependencies: ToDependenciesPipe,
|
||||
) {}
|
||||
|
||||
private get id(): string {
|
||||
return this.service.manifest.id
|
||||
}
|
||||
|
||||
get interfaceInfo(): Record<string, InterfaceInfo> {
|
||||
return this.service.installed!['interfaceInfo']
|
||||
}
|
||||
|
||||
get isConfigured(): boolean {
|
||||
return this.service.installed!.status.configured
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this.getStatus(this.service).primary === PrimaryStatus.Running
|
||||
}
|
||||
|
||||
get isStopped(): boolean {
|
||||
return this.getStatus(this.service).primary === PrimaryStatus.Stopped
|
||||
}
|
||||
|
||||
@tuiPure
|
||||
getStatus(service: PackageDataEntry): PackageStatus {
|
||||
return renderPkgStatus(service)
|
||||
}
|
||||
|
||||
presentModalConfig(): void {
|
||||
this.formDialog.open<PackageConfigData>(ServiceConfigModal, {
|
||||
label: `${this.service.manifest.title} configuration`,
|
||||
data: { pkgId: this.id },
|
||||
})
|
||||
}
|
||||
|
||||
async tryStart(): Promise<void> {
|
||||
if (this.dependencies.transform(this.service).some(d => !!d.errorText)) {
|
||||
const depErrMsg = `${this.service.manifest.title} has unmet dependencies. It will not work as expected.`
|
||||
const proceed = await this.presentAlertStart(depErrMsg)
|
||||
|
||||
if (!proceed) return
|
||||
}
|
||||
|
||||
const alertMsg = this.service.manifest.alerts.start
|
||||
|
||||
if (alertMsg) {
|
||||
const proceed = await this.presentAlertStart(alertMsg)
|
||||
|
||||
if (!proceed) return
|
||||
}
|
||||
|
||||
this.start()
|
||||
}
|
||||
|
||||
async tryStop(): Promise<void> {
|
||||
const { title, alerts, id } = this.service.manifest
|
||||
|
||||
let content = alerts.stop || ''
|
||||
if (await hasCurrentDeps(this.patch, id)) {
|
||||
const depMessage = `Services that depend on ${title} will no longer work properly and may crash`
|
||||
content = content ? `${content}.\n\n${depMessage}` : depMessage
|
||||
}
|
||||
|
||||
if (content) {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
yes: 'Stop',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.stop())
|
||||
} else {
|
||||
this.stop()
|
||||
}
|
||||
}
|
||||
|
||||
async tryRestart(): Promise<void> {
|
||||
const { id, title } = this.service.manifest
|
||||
|
||||
if (await hasCurrentDeps(this.patch, id)) {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `Services that depend on ${title} may temporarily experiences issues`,
|
||||
yes: 'Restart',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.restart())
|
||||
} else {
|
||||
this.restart()
|
||||
}
|
||||
}
|
||||
|
||||
private async start(): Promise<void> {
|
||||
const loader = this.loader.open(`Starting...`).subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.startPackage({ id: this.id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async stop(): Promise<void> {
|
||||
const loader = this.loader.open(`Stopping...`).subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.stopPackage({ id: this.id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async restart(): Promise<void> {
|
||||
const loader = this.loader.open(`Restarting...`).subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.restartPackage({ id: this.id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async presentAlertStart(content: string): Promise<boolean> {
|
||||
return new Promise(async resolve => {
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.subscribe(response => resolve(response))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
import { AdditionalItem, FALLBACK_URL } from '../pipes/to-additional.pipe'
|
||||
|
||||
@Component({
|
||||
selector: '[additional]',
|
||||
template: `
|
||||
<div [style.flex]="1">
|
||||
<strong>{{ additional.name }}</strong>
|
||||
<div>{{ additional.description }}</div>
|
||||
</div>
|
||||
<tui-svg *ngIf="icon" [src]="icon"></tui-svg>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host._disabled {
|
||||
pointer-events: none;
|
||||
opacity: var(--tui-disabled-opacity);
|
||||
}
|
||||
`,
|
||||
],
|
||||
host: {
|
||||
'[attr.href]': 'additional.description',
|
||||
'[class._disabled]': 'disabled',
|
||||
target: '_blank',
|
||||
rel: 'noreferrer',
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, TuiSvgModule],
|
||||
})
|
||||
export class ServiceAdditionalComponent {
|
||||
@Input({ required: true })
|
||||
additional!: AdditionalItem
|
||||
|
||||
get disabled(): boolean {
|
||||
return this.additional.description === FALLBACK_URL
|
||||
}
|
||||
|
||||
get icon(): string | undefined {
|
||||
return this.additional.description.startsWith('http')
|
||||
? 'tuiIconExternalLinkLarge'
|
||||
: this.additional.icon
|
||||
}
|
||||
}
|
||||
@@ -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(' → ')
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { CopyService } from '@start9labs/shared'
|
||||
import { mask } from 'src/app/util/mask'
|
||||
import { TuiButtonModule, TuiLabelModule } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
selector: 'service-credential',
|
||||
template: `
|
||||
<label [style.flex]="1" [tuiLabel]="label">
|
||||
{{ masked ? mask : value }}
|
||||
</label>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat"
|
||||
[icon]="masked ? 'tuiIconEyeLarge' : 'tuiIconEyeOffLarge'"
|
||||
(click)="masked = !masked"
|
||||
>
|
||||
Toggle
|
||||
</button>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat"
|
||||
icon="tuiIconCopyLarge"
|
||||
(click)="copyService.copy(value)"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
box-shadow: 0 1px var(--tui-clear);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [TuiButtonModule, TuiLabelModule],
|
||||
})
|
||||
export class ServiceCredentialComponent {
|
||||
@Input()
|
||||
label = ''
|
||||
|
||||
@Input()
|
||||
value = ''
|
||||
|
||||
masked = true
|
||||
|
||||
readonly copyService = inject(CopyService)
|
||||
|
||||
get mask(): string {
|
||||
return mask(this.value, 64)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { EmverPipesModule } from '@start9labs/shared'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
import { DependencyInfo } from '../types/dependency-info'
|
||||
|
||||
@Component({
|
||||
selector: '[serviceDependency]',
|
||||
template: `
|
||||
<img [src]="dep.icon" alt="" />
|
||||
<span [style.flex]="1">
|
||||
<strong>
|
||||
<tui-svg
|
||||
*ngIf="dep.errorText"
|
||||
src="tuiIconAlertTriangle"
|
||||
[style.color]="color"
|
||||
></tui-svg>
|
||||
{{ dep.title }}
|
||||
</strong>
|
||||
<div>{{ dep.version | displayEmver }}</div>
|
||||
<div [style.color]="color">
|
||||
{{ dep.errorText || 'Satisfied' }}
|
||||
</div>
|
||||
</span>
|
||||
<div *ngIf="dep.actionText">
|
||||
{{ dep.actionText }}
|
||||
<tui-svg src="tuiIconArrowRight"></tui-svg>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
img {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
tui-svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [EmverPipesModule, CommonModule, TuiSvgModule],
|
||||
})
|
||||
export class ServiceDependencyComponent {
|
||||
@Input({ required: true, alias: 'serviceDependency' })
|
||||
dep!: DependencyInfo
|
||||
|
||||
get color(): string {
|
||||
return this.dep.errorText
|
||||
? 'var(--tui-warning-fill)'
|
||||
: 'var(--tui-success-fill)'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { TuiLoaderModule, TuiSvgModule } from '@taiga-ui/core'
|
||||
import {
|
||||
HealthCheckResult,
|
||||
HealthResult,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'service-health-check',
|
||||
template: `
|
||||
<tui-loader
|
||||
*ngIf="loading; else svg"
|
||||
[class.tui-skeleton]="!connected"
|
||||
[inheritColor]="!check.result"
|
||||
></tui-loader>
|
||||
<ng-template #svg>
|
||||
<tui-svg
|
||||
[src]="icon"
|
||||
[class.tui-skeleton]="!connected"
|
||||
[style.color]="color"
|
||||
></tui-svg>
|
||||
</ng-template>
|
||||
<div>
|
||||
<strong [class.tui-skeleton]="!connected">{{ check.name }}</strong>
|
||||
<div [class.tui-skeleton]="!connected" [style.color]="color">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tui-loader {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, TuiLoaderModule, TuiSvgModule],
|
||||
})
|
||||
export class ServiceHealthCheckComponent {
|
||||
@Input({ required: true })
|
||||
check!: HealthCheckResult
|
||||
|
||||
@Input()
|
||||
connected = false
|
||||
|
||||
get loading(): boolean {
|
||||
const { result } = this.check
|
||||
|
||||
return (
|
||||
!result ||
|
||||
result === HealthResult.Starting ||
|
||||
result === HealthResult.Loading
|
||||
)
|
||||
}
|
||||
|
||||
get icon(): string {
|
||||
switch (this.check.result) {
|
||||
case HealthResult.Success:
|
||||
return 'tuiIconCheckLarge'
|
||||
case HealthResult.Failure:
|
||||
return 'tuiIconAlertTriangleLarge'
|
||||
default:
|
||||
return 'tuiIconMinusLarge'
|
||||
}
|
||||
}
|
||||
|
||||
get color(): string {
|
||||
switch (this.check.result) {
|
||||
case HealthResult.Success:
|
||||
return 'var(--tui-success-fill)'
|
||||
case HealthResult.Failure:
|
||||
return 'var(--tui-warning-fill)'
|
||||
case HealthResult.Starting:
|
||||
case HealthResult.Loading:
|
||||
return 'var(--tui-primary)'
|
||||
default:
|
||||
return 'var(--tui-text-02)'
|
||||
}
|
||||
}
|
||||
|
||||
get message(): string {
|
||||
if (!this.check.result) {
|
||||
return 'Awaiting result...'
|
||||
}
|
||||
|
||||
const prefix =
|
||||
this.check.result !== HealthResult.Failure &&
|
||||
this.check.result !== HealthResult.Loading
|
||||
? this.check.result
|
||||
: ''
|
||||
|
||||
switch (this.check.result) {
|
||||
case HealthResult.Failure:
|
||||
return prefix + this.check.error
|
||||
case HealthResult.Starting:
|
||||
return `${prefix}...`
|
||||
case HealthResult.Success:
|
||||
return `${prefix}: ${this.check.message}`
|
||||
case HealthResult.Loading:
|
||||
return prefix + this.check.message
|
||||
default:
|
||||
return prefix
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DOCUMENT, CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { TuiButtonModule, TuiSvgModule } from '@taiga-ui/core'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { InterfaceInfo } from 'src/app/services/patch-db/data-model'
|
||||
import { ExtendedInterfaceInfo } from '../pipes/interface-info.pipe'
|
||||
|
||||
@Component({
|
||||
selector: 'button[serviceInterface]',
|
||||
template: `
|
||||
<tui-svg [src]="info.icon" [style.color]="info.color"></tui-svg>
|
||||
<div [style.flex]="1">
|
||||
<strong>{{ info.name }}</strong>
|
||||
<div>{{ info.description }}</div>
|
||||
<div [style.color]="info.color">{{ info.typeDetail }}</div>
|
||||
</div>
|
||||
<button
|
||||
*ngIf="info.type === 'ui'"
|
||||
tuiIconButton
|
||||
appearance="flat"
|
||||
icon="tuiIconExternalLinkLarge"
|
||||
[style.border-radius.%]="100"
|
||||
(click.stop.prevent)="launchUI(info)"
|
||||
[disabled]="disabled"
|
||||
></button>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [TuiButtonModule, CommonModule, TuiSvgModule],
|
||||
})
|
||||
export class ServiceInterfaceComponent {
|
||||
private readonly document = inject(DOCUMENT)
|
||||
private readonly config = inject(ConfigService)
|
||||
|
||||
@Input({ required: true, alias: 'serviceInterface' })
|
||||
info!: ExtendedInterfaceInfo
|
||||
|
||||
@Input()
|
||||
disabled = false
|
||||
|
||||
launchUI(info: InterfaceInfo) {
|
||||
this.document.defaultView?.open(
|
||||
this.config.launchableAddress(info),
|
||||
'_blank',
|
||||
'noreferrer',
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
import { ServiceMenu } from '../pipes/to-menu.pipe'
|
||||
|
||||
@Component({
|
||||
selector: '[serviceMenu]',
|
||||
template: `
|
||||
<tui-svg [src]="menu.icon"></tui-svg>
|
||||
<div [style.flex]="1">
|
||||
<strong>{{ menu.name }}</strong>
|
||||
<div>
|
||||
{{ menu.description }}
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
<tui-svg src="tuiIconChevronRightLarge"></tui-svg>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [TuiSvgModule],
|
||||
})
|
||||
export class ServiceMenuComponent {
|
||||
@Input({ required: true, alias: 'serviceMenu' })
|
||||
menu!: ServiceMenu
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { TuiProgressModule } from '@taiga-ui/kit'
|
||||
|
||||
@Component({
|
||||
selector: '[progress]',
|
||||
template: `
|
||||
<ng-content></ng-content>
|
||||
: {{ progress }}%
|
||||
<progress
|
||||
tuiProgressBar
|
||||
new
|
||||
size="xs"
|
||||
[style.color]="
|
||||
progress === 100 ? 'var(--tui-positive)' : 'var(--tui-link)'
|
||||
"
|
||||
[value]="progress / 100"
|
||||
></progress>
|
||||
`,
|
||||
styles: [':host { line-height: 2rem }'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [TuiProgressModule],
|
||||
})
|
||||
export class ServiceProgressComponent {
|
||||
@Input({ required: true })
|
||||
progress = 0
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostBinding,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { InstallProgress } from 'src/app/services/patch-db/data-model'
|
||||
import { StatusRendering } from 'src/app/services/pkg-status-rendering.service'
|
||||
import { InstallProgressPipeModule } from 'src/app/common/install-progress/install-progress.module'
|
||||
|
||||
@Component({
|
||||
selector: 'service-status',
|
||||
template: `
|
||||
<strong *ngIf="!installProgress; else installing">
|
||||
{{ connected ? rendering.display : 'Unknown' }}
|
||||
<span *ngIf="rendering.showDots" class="loading-dots"></span>
|
||||
</strong>
|
||||
<ng-template #installing>
|
||||
<strong *ngIf="installProgress | installProgressDisplay as progress">
|
||||
Installing
|
||||
<span class="loading-dots"></span>
|
||||
{{ progress }}
|
||||
</strong>
|
||||
</ng-template>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, InstallProgressPipeModule],
|
||||
})
|
||||
export class ServiceStatusComponent {
|
||||
@Input({ required: true })
|
||||
rendering!: StatusRendering
|
||||
|
||||
@Input()
|
||||
installProgress?: InstallProgress
|
||||
|
||||
@Input()
|
||||
connected = false
|
||||
|
||||
@HostBinding('style.color')
|
||||
get color(): string {
|
||||
if (!this.connected) return 'var(--tui-text-02)'
|
||||
|
||||
switch (this.rendering.color) {
|
||||
case 'danger':
|
||||
return 'var(--tui-error-fill)'
|
||||
case 'warning':
|
||||
return 'var(--tui-warning-fill)'
|
||||
case 'success':
|
||||
return 'var(--tui-success-fill)'
|
||||
case 'primary':
|
||||
return 'var(--tui-info-fill)'
|
||||
default:
|
||||
return 'var(--tui-text-02)'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
isEmptyObject,
|
||||
WithId,
|
||||
ErrorService,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiDialogContext, TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import {
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
PolymorpheusComponent,
|
||||
} from '@tinkoff/ng-polymorpheus'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter, switchMap, timer } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
Action,
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
PackageState,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { FormPage } from 'src/app/apps/ui/modals/form/form.page'
|
||||
import { ServiceActionComponent } from '../components/action.component'
|
||||
import { ServiceActionSuccessComponent } from '../components/action-success.component'
|
||||
import { GroupActionsPipe } from '../pipes/group-actions.pipe'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-container *ngIf="pkg$ | async as pkg">
|
||||
<section>
|
||||
<h3>Standard Actions</h3>
|
||||
<button
|
||||
class="g-action"
|
||||
[action]="action"
|
||||
(click)="tryUninstall(pkg)"
|
||||
></button>
|
||||
</section>
|
||||
<ng-container *ngIf="pkg.actions | groupActions as actionGroups">
|
||||
<h3>Actions for {{ pkg.manifest.title }}</h3>
|
||||
<div *ngFor="let group of actionGroups">
|
||||
<button
|
||||
*ngFor="let action of group"
|
||||
class="g-action"
|
||||
[action]="{
|
||||
name: action.name,
|
||||
description: action.description,
|
||||
icon: 'tuiIconPlayCircleLarge'
|
||||
}"
|
||||
(click)="handleAction(action)"
|
||||
></button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
h3 {
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
margin: 2rem 0 1rem;
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, ServiceActionComponent, GroupActionsPipe],
|
||||
})
|
||||
export class ServiceActionsModal {
|
||||
readonly pkg$ = this.patch
|
||||
.watch$('package-data', this.context.data)
|
||||
.pipe(filter(pkg => pkg.state === PackageState.Installed))
|
||||
|
||||
readonly action = {
|
||||
icon: 'tuiIconTrash2Large',
|
||||
name: 'Uninstall',
|
||||
description:
|
||||
'This will uninstall the service from StartOS and delete all data permanently.',
|
||||
}
|
||||
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<void, string>,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly router: Router,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly formDialog: FormDialogService,
|
||||
) {}
|
||||
|
||||
async handleAction(action: WithId<Action>) {
|
||||
if (action.disabled) {
|
||||
this.dialogs
|
||||
.open(action.disabled, {
|
||||
label: 'Forbidden',
|
||||
size: 's',
|
||||
})
|
||||
.subscribe()
|
||||
} else {
|
||||
if (action['input-spec'] && !isEmptyObject(action['input-spec'])) {
|
||||
this.formDialog.open(FormPage, {
|
||||
label: action.name,
|
||||
data: {
|
||||
spec: action['input-spec'],
|
||||
buttons: [
|
||||
{
|
||||
text: 'Execute',
|
||||
handler: async (value: any) =>
|
||||
this.executeAction(action.id, value),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
} else {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `Are you sure you want to execute action "${
|
||||
action.name
|
||||
}"? ${action.warning || ''}`,
|
||||
yes: 'Execute',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.executeAction(action.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async tryUninstall(pkg: PackageDataEntry): Promise<void> {
|
||||
const { title, alerts, id } = pkg.manifest
|
||||
|
||||
let content =
|
||||
alerts.uninstall ||
|
||||
`Uninstalling ${title} will permanently delete its data`
|
||||
|
||||
if (await hasCurrentDeps(this.patch, id)) {
|
||||
content = `${content}. Services that depend on ${title} will no longer work properly and may crash`
|
||||
}
|
||||
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
yes: 'Uninstall',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.uninstall())
|
||||
}
|
||||
|
||||
private async uninstall() {
|
||||
const loader = this.loader.open(`Beginning uninstall...`).subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.uninstallPackage({ id: this.context.data })
|
||||
this.embassyApi
|
||||
.setDbValue<boolean>(['ack-instructions', this.context.data], false)
|
||||
.catch(e => console.error('Failed to mark instructions as unseen', e))
|
||||
this.router.navigate(['portal', 'desktop'])
|
||||
this.context.$implicit.complete()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAction(
|
||||
actionId: string,
|
||||
input?: object,
|
||||
): Promise<boolean> {
|
||||
const loader = this.loader.open('Executing action...').subscribe()
|
||||
|
||||
try {
|
||||
const data = await this.embassyApi.executePackageAction({
|
||||
id: this.context.data,
|
||||
'action-id': actionId,
|
||||
input,
|
||||
})
|
||||
|
||||
timer(500)
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
this.dialogs.open(
|
||||
new PolymorpheusComponent(ServiceActionSuccessComponent),
|
||||
{
|
||||
label: 'Execution Complete',
|
||||
data,
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
asIsOrder() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, Inject, ViewChild } from '@angular/core'
|
||||
import {
|
||||
ErrorService,
|
||||
getErrorMessage,
|
||||
isEmptyObject,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
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 { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { getAllPackages, getPackage } from 'src/app/util/get-package-data'
|
||||
import { Breakages } from 'src/app/services/api/api.types'
|
||||
import { InvalidService } from 'src/app/common/form/invalid.service'
|
||||
import { ActionButton, FormPage } from 'src/app/apps/ui/modals/form/form.page'
|
||||
import { FormPageModule } from 'src/app/apps/ui/modals/form/form.module'
|
||||
import { PackageConfigData } from '../types/package-config-data'
|
||||
import { ConfigDepComponent } from '../components/config-dep.component'
|
||||
|
||||
@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">
|
||||
<tui-notification *ngIf="success" status="success">
|
||||
{{ pkg.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]="pkg.manifest.title"
|
||||
[dep]="dependentInfo.title"
|
||||
[original]="original"
|
||||
[value]="value"
|
||||
></config-dep>
|
||||
|
||||
<tui-notification *ngIf="!pkg.installed?.['has-config']" status="warning">
|
||||
No config options for {{ pkg.manifest.title }}
|
||||
{{ pkg.manifest.version }}.
|
||||
</tui-notification>
|
||||
|
||||
<form-page
|
||||
tuiMode="onDark"
|
||||
[spec]="spec"
|
||||
[value]="value || {}"
|
||||
[buttons]="buttons"
|
||||
[patch]="patch"
|
||||
>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
type="reset"
|
||||
[style.margin-right]="'auto'"
|
||||
>
|
||||
Reset Defaults
|
||||
</button>
|
||||
</form-page>
|
||||
</ng-container>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
tui-notification {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormPageModule,
|
||||
TuiLoaderModule,
|
||||
TuiNotificationModule,
|
||||
TuiButtonModule,
|
||||
TuiModeModule,
|
||||
ConfigDepComponent,
|
||||
],
|
||||
providers: [InvalidService],
|
||||
})
|
||||
export class ServiceConfigModal {
|
||||
@ViewChild(FormPage)
|
||||
private readonly form?: FormPage<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: 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?.installed?.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({
|
||||
'dependency-id': this.pkgId,
|
||||
'dependent-id': this.dependentInfo.id,
|
||||
})
|
||||
|
||||
this.original = depConfig['old-config']
|
||||
this.value = depConfig['new-config'] || 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 = getErrorMessage(e)
|
||||
} finally {
|
||||
this.loadingText = ''
|
||||
}
|
||||
}
|
||||
|
||||
private async save(config: any) {
|
||||
const loader = new Subscription()
|
||||
|
||||
try {
|
||||
await this.uploadFiles(config, loader)
|
||||
|
||||
if (await hasCurrentDeps(this.patchDb, this.pkgId)) {
|
||||
await this.configureDeps(config, loader)
|
||||
} else {
|
||||
await this.configure(config, loader)
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFiles(config: Record<string, any>, loader: Subscription) {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
|
||||
// TODO: Could be nested files
|
||||
const keys = Object.keys(config).filter(key => config[key] instanceof File)
|
||||
const message = `Uploading File${keys.length > 1 ? 's' : ''}...`
|
||||
|
||||
if (!keys.length) return
|
||||
|
||||
loader.add(this.loader.open(message).subscribe())
|
||||
|
||||
const hashes = await Promise.all(
|
||||
keys.map(key => this.embassyApi.uploadFile(config[key])),
|
||||
)
|
||||
keys.forEach((key, i) => (config[key] = hashes[i]))
|
||||
}
|
||||
|
||||
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: Breakages): 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}${Object.keys(breakages).map(
|
||||
id => `<li><b>${packages[id].manifest.title}</b></li>`,
|
||||
)}</ul>`
|
||||
const data: TuiPromptData = { content, yes: 'Continue', no: 'Cancel' }
|
||||
|
||||
return firstValueFrom(
|
||||
this.dialogs.open<boolean>(TUI_PROMPT, { data }).pipe(endWith(false)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { ErrorService, SharedPipesModule } from '@start9labs/shared'
|
||||
import { TuiForModule } from '@taiga-ui/cdk'
|
||||
import { TuiButtonModule } from '@taiga-ui/core'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { SkeletonListComponentModule } from 'src/app/common/skeleton-list/skeleton-list.component.module'
|
||||
import { ServiceCredentialComponent } from '../components/credential.component'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<skeleton-list *ngIf="loading$ | async; else loaded"></skeleton-list>
|
||||
<ng-template #loaded>
|
||||
<service-credential
|
||||
*ngFor="let cred of credentials | keyvalue : asIsOrder; empty: blank"
|
||||
[label]="cred.key"
|
||||
[value]="cred.value"
|
||||
></service-credential>
|
||||
</ng-template>
|
||||
<ng-template #blank>No credentials</ng-template>
|
||||
<button tuiButton icon="tuiIconRefreshCwLarge" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
button {
|
||||
float: right;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiForModule,
|
||||
TuiButtonModule,
|
||||
SharedPipesModule,
|
||||
SkeletonListComponentModule,
|
||||
ServiceCredentialComponent,
|
||||
],
|
||||
})
|
||||
export class ServiceCredentialsModal {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
readonly id = inject<{ data: string }>(POLYMORPHEUS_CONTEXT).data
|
||||
readonly loading$ = new BehaviorSubject(true)
|
||||
|
||||
credentials: Record<string, string> = {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.getCredentials()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this.getCredentials()
|
||||
}
|
||||
|
||||
private async getCredentials(): Promise<void> {
|
||||
this.loading$.next(true)
|
||||
|
||||
try {
|
||||
this.credentials = await this.api.getPackageCredentials({ id: this.id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading$.next(false)
|
||||
}
|
||||
}
|
||||
|
||||
asIsOrder(a: any, b: any) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { Observable } from 'rxjs'
|
||||
import { InterfaceAddressesComponentModule } from 'src/app/common/interface-addresses/interface-addresses.module'
|
||||
import { InterfaceInfo } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
interface Context {
|
||||
packageId: string
|
||||
interfaceId: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<interface-addresses
|
||||
*ngIf="interfaceInfo$ | async as interfaceInfo"
|
||||
[packageContext]="context"
|
||||
[addressInfo]="interfaceInfo.addressInfo"
|
||||
[isUi]="interfaceInfo.type === 'ui'"
|
||||
></interface-addresses>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, InterfaceAddressesComponentModule],
|
||||
})
|
||||
export class ServiceInterfaceModal {
|
||||
readonly context = inject<{ data: Context }>(POLYMORPHEUS_CONTEXT).data
|
||||
|
||||
readonly interfaceInfo$: Observable<InterfaceInfo> = inject(PatchDB).watch$(
|
||||
'package-data',
|
||||
this.context.packageId,
|
||||
'installed',
|
||||
'interfaceInfo',
|
||||
this.context.interfaceId,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
import { LogsComponentModule } from 'src/app/common/logs/logs.component.module'
|
||||
|
||||
@Component({
|
||||
template:
|
||||
'<logs [fetchLogs]="fetch" [followLogs]="follow" [context]="id"></logs>',
|
||||
styles: [
|
||||
`
|
||||
logs {
|
||||
display: block;
|
||||
height: 60vh;
|
||||
margin-bottom: 5rem;
|
||||
|
||||
::ng-deep ion-header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [LogsComponentModule],
|
||||
})
|
||||
export class ServiceLogsModal {
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
readonly id = inject<{ data: string }>(POLYMORPHEUS_CONTEXT).data
|
||||
|
||||
readonly follow = async (params: RR.FollowServerLogsReq) =>
|
||||
this.api.followPackageLogs({ id: this.id, ...params })
|
||||
|
||||
readonly fetch = async (params: RR.GetServerLogsReq) =>
|
||||
this.api.getPackageLogs({ id: this.id, ...params })
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { WithId } from '@start9labs/shared'
|
||||
import { Action, PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Pipe({
|
||||
name: 'groupActions',
|
||||
standalone: true,
|
||||
})
|
||||
export class GroupActionsPipe implements PipeTransform {
|
||||
transform(
|
||||
actions: PackageDataEntry['actions'],
|
||||
): Array<Array<WithId<Action>>> | null {
|
||||
if (!actions) return null
|
||||
|
||||
const noGroup = 'noGroup'
|
||||
const grouped = Object.entries(actions).reduce<
|
||||
Record<string, WithId<Action>[]>
|
||||
>((groups, [id, action]) => {
|
||||
const actionWithId = { id, ...action }
|
||||
const groupKey = action.group || noGroup
|
||||
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = [actionWithId]
|
||||
} else {
|
||||
groups[groupKey].push(actionWithId)
|
||||
}
|
||||
|
||||
return groups
|
||||
}, {})
|
||||
|
||||
return Object.values(grouped).map(group =>
|
||||
group.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { inject, Pipe, PipeTransform } from '@angular/core'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import {
|
||||
InterfaceInfo,
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { ServiceInterfaceModal } from '../modals/interface.component'
|
||||
|
||||
export interface ExtendedInterfaceInfo extends InterfaceInfo {
|
||||
id: string
|
||||
icon: string
|
||||
color: string
|
||||
typeDetail: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'interfaceInfo',
|
||||
standalone: true,
|
||||
})
|
||||
export class InterfaceInfoPipe implements PipeTransform {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
transform({
|
||||
manifest,
|
||||
installed,
|
||||
}: PackageDataEntry): ExtendedInterfaceInfo[] {
|
||||
return Object.entries(installed!.interfaceInfo).map(([id, val]) => {
|
||||
let color: string
|
||||
let icon: string
|
||||
let typeDetail: string
|
||||
|
||||
switch (val.type) {
|
||||
case 'ui':
|
||||
color = 'var(--tui-primary)'
|
||||
icon = 'tuiIconMonitorLarge'
|
||||
typeDetail = 'User Interface (UI)'
|
||||
break
|
||||
case 'p2p':
|
||||
color = 'var(--tui-info-fill)'
|
||||
icon = 'tuiIconUsersLarge'
|
||||
typeDetail = 'Peer-To-Peer Interface (P2P)'
|
||||
break
|
||||
case 'api':
|
||||
color = 'var(--tui-support-09)'
|
||||
icon = 'tuiIconTerminalLarge'
|
||||
typeDetail = 'Application Program Interface (API)'
|
||||
break
|
||||
case 'other':
|
||||
color = 'var(--tui-text-02)'
|
||||
icon = 'tuiIconBoxLarge'
|
||||
typeDetail = 'Unknown Interface Type'
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
...val,
|
||||
id,
|
||||
color,
|
||||
icon,
|
||||
typeDetail,
|
||||
action: () =>
|
||||
this.dialogs
|
||||
.open(new PolymorpheusComponent(ServiceInterfaceModal), {
|
||||
label: val.name,
|
||||
size: 'l',
|
||||
data: {
|
||||
packageId: manifest.id,
|
||||
interfaceId: id,
|
||||
},
|
||||
})
|
||||
.subscribe(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { ProgressData } from 'src/app/types/progress-data'
|
||||
import { packageLoadingProgress } from 'src/app/util/package-loading-progress'
|
||||
|
||||
@Pipe({
|
||||
name: 'progressData',
|
||||
standalone: true,
|
||||
})
|
||||
export class ProgressDataPipe implements PipeTransform {
|
||||
transform(pkg: PackageDataEntry): ProgressData | null {
|
||||
return packageLoadingProgress(pkg['install-progress'])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { inject, Pipe, PipeTransform } from '@angular/core'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { Manifest } from '@start9labs/marketplace'
|
||||
import { CopyService, MarkdownComponent } from '@start9labs/shared'
|
||||
import { from } from 'rxjs'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
|
||||
export const FALLBACK_URL = 'Not provided'
|
||||
|
||||
export interface AdditionalItem {
|
||||
name: string
|
||||
description: string
|
||||
icon?: string
|
||||
action?: () => void
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toAdditional',
|
||||
standalone: true,
|
||||
})
|
||||
export class ToAdditionalPipe implements PipeTransform {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly copyService = inject(CopyService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
transform({ manifest, installed }: PackageDataEntry): AdditionalItem[] {
|
||||
return [
|
||||
{
|
||||
name: 'Installed',
|
||||
description: new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
}).format(new Date(installed?.['installed-at'] || 0)),
|
||||
},
|
||||
{
|
||||
name: 'Git Hash',
|
||||
description: manifest['git-hash'] || 'Unknown',
|
||||
icon: manifest['git-hash'] ? 'tuiIconCopyLarge' : '',
|
||||
action: () =>
|
||||
manifest['git-hash'] && this.copyService.copy(manifest['git-hash']),
|
||||
},
|
||||
{
|
||||
name: 'License',
|
||||
description: manifest.license,
|
||||
icon: 'tuiIconChevronRightLarge',
|
||||
action: () => this.showLicense(manifest),
|
||||
},
|
||||
{
|
||||
name: 'Website',
|
||||
description: manifest['marketing-site'] || FALLBACK_URL,
|
||||
},
|
||||
{
|
||||
name: 'Source Repository',
|
||||
description: manifest['upstream-repo'],
|
||||
},
|
||||
{
|
||||
name: 'Support Site',
|
||||
description: manifest['support-site'] || FALLBACK_URL,
|
||||
},
|
||||
{
|
||||
name: 'Donation Link',
|
||||
description: manifest['donation-url'] || FALLBACK_URL,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
private showLicense({ id, version }: Manifest) {
|
||||
this.dialogs
|
||||
.open(new PolymorpheusComponent(MarkdownComponent), {
|
||||
label: 'License',
|
||||
size: 'l',
|
||||
data: {
|
||||
content: from(
|
||||
this.api.getStatic(
|
||||
`/public/package-data/${id}/${version}/LICENSE.md`,
|
||||
),
|
||||
),
|
||||
},
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { NavigationExtras, Router } from '@angular/router'
|
||||
import { Manifest } from '@start9labs/marketplace'
|
||||
import {
|
||||
DependencyErrorType,
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { DependentInfo } from 'src/app/types/dependent-info'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { ServiceConfigModal } from '../modals/config.component'
|
||||
import { DependencyInfo } from '../types/dependency-info'
|
||||
import { PackageConfigData } from '../types/package-config-data'
|
||||
|
||||
@Pipe({
|
||||
name: 'toDependencies',
|
||||
standalone: true,
|
||||
})
|
||||
export class ToDependenciesPipe implements PipeTransform {
|
||||
constructor(
|
||||
private readonly router: Router,
|
||||
private readonly formDialog: FormDialogService,
|
||||
) {}
|
||||
|
||||
transform(pkg: PackageDataEntry): DependencyInfo[] {
|
||||
if (!pkg.installed) return []
|
||||
|
||||
return Object.keys(pkg.installed['current-dependencies'])
|
||||
.filter(depId => !!pkg.manifest.dependencies[depId])
|
||||
.map(depId => this.setDepValues(pkg, depId))
|
||||
}
|
||||
|
||||
private setDepValues(pkg: PackageDataEntry, depId: string): DependencyInfo {
|
||||
let errorText = ''
|
||||
let actionText = 'View'
|
||||
let action = (): unknown =>
|
||||
this.router.navigate([`portal`, `service`, depId])
|
||||
|
||||
const error = pkg.installed!.status['dependency-errors'][depId]
|
||||
|
||||
if (error) {
|
||||
// health checks failed
|
||||
if (error.type === DependencyErrorType.HealthChecksFailed) {
|
||||
errorText = 'Health check failed'
|
||||
// not installed
|
||||
} else if (error.type === DependencyErrorType.NotInstalled) {
|
||||
errorText = 'Not installed'
|
||||
actionText = 'Install'
|
||||
action = () => this.fixDep(pkg, 'install', depId)
|
||||
// incorrect version
|
||||
} else if (error.type === DependencyErrorType.IncorrectVersion) {
|
||||
errorText = 'Incorrect version'
|
||||
actionText = 'Update'
|
||||
action = () => this.fixDep(pkg, 'update', depId)
|
||||
// not running
|
||||
} else if (error.type === DependencyErrorType.NotRunning) {
|
||||
errorText = 'Not running'
|
||||
actionText = 'Start'
|
||||
// config unsatisfied
|
||||
} else if (error.type === DependencyErrorType.ConfigUnsatisfied) {
|
||||
errorText = 'Config not satisfied'
|
||||
actionText = 'Auto config'
|
||||
action = () => this.fixDep(pkg, 'configure', depId)
|
||||
} else if (error.type === DependencyErrorType.Transitive) {
|
||||
errorText = 'Dependency has a dependency issue'
|
||||
}
|
||||
errorText = `${errorText}. ${pkg.manifest.title} will not work as expected.`
|
||||
}
|
||||
|
||||
const depInfo = pkg.installed!['dependency-info'][depId]
|
||||
|
||||
return {
|
||||
id: depId,
|
||||
version: pkg.manifest.dependencies[depId].version,
|
||||
title: depInfo?.title || depId,
|
||||
icon: depInfo?.icon || '',
|
||||
errorText,
|
||||
actionText,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
async fixDep(
|
||||
pkg: PackageDataEntry,
|
||||
action: 'install' | 'update' | 'configure',
|
||||
depId: string,
|
||||
): Promise<void> {
|
||||
switch (action) {
|
||||
case 'install':
|
||||
case 'update':
|
||||
return this.installDep(pkg.manifest, depId)
|
||||
case 'configure':
|
||||
return this.formDialog.open<PackageConfigData>(ServiceConfigModal, {
|
||||
label: `${
|
||||
pkg.installed!['dependency-info'][depId].title
|
||||
} configuration`,
|
||||
data: {
|
||||
pkgId: depId,
|
||||
dependentInfo: pkg.manifest,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async installDep(manifest: Manifest, depId: string): Promise<void> {
|
||||
const version = manifest.dependencies[depId].version
|
||||
|
||||
const dependentInfo: DependentInfo = {
|
||||
id: manifest.id,
|
||||
title: manifest.title,
|
||||
version,
|
||||
}
|
||||
const navigationExtras: NavigationExtras = {
|
||||
state: { dependentInfo },
|
||||
}
|
||||
|
||||
await this.router.navigate(['marketplace', depId], navigationExtras)
|
||||
}
|
||||
|
||||
private async configureDep(
|
||||
manifest: Manifest,
|
||||
dependencyId: string,
|
||||
): Promise<void> {
|
||||
const dependentInfo: DependentInfo = {
|
||||
id: manifest.id,
|
||||
title: manifest.title,
|
||||
}
|
||||
|
||||
return this.formDialog.open<PackageConfigData>(ServiceConfigModal, {
|
||||
label: 'Config',
|
||||
data: {
|
||||
pkgId: dependencyId,
|
||||
dependentInfo,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { inject, Pipe, PipeTransform, Type } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { Manifest } from '@start9labs/marketplace'
|
||||
import { MarkdownComponent } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { from } from 'rxjs'
|
||||
import {
|
||||
PackageDataEntry,
|
||||
InstalledPackageInfo,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { ProxyService } from 'src/app/services/proxy.service'
|
||||
import { PackageConfigData } from '../types/package-config-data'
|
||||
import { ServiceConfigModal } from '../modals/config.component'
|
||||
import { ServiceLogsModal } from '../modals/logs.component'
|
||||
import { ServiceCredentialsModal } from '../modals/credentials.component'
|
||||
import { ServiceActionsModal } from '../modals/actions.component'
|
||||
|
||||
export interface ServiceMenu {
|
||||
icon: string
|
||||
name: string
|
||||
description: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toMenu',
|
||||
standalone: true,
|
||||
})
|
||||
export class ToMenusPipe implements PipeTransform {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly route = inject(ActivatedRoute)
|
||||
private readonly router = inject(Router)
|
||||
private readonly proxyService = inject(ProxyService)
|
||||
|
||||
transform({ manifest, installed }: PackageDataEntry): ServiceMenu[] {
|
||||
const url = installed?.['marketplace-url']
|
||||
|
||||
return [
|
||||
{
|
||||
icon: 'tuiIconListLarge',
|
||||
name: 'Instructions',
|
||||
description: `Understand how to use ${manifest.title}`,
|
||||
action: () => this.showInstructions(manifest),
|
||||
},
|
||||
{
|
||||
icon: 'tuiIconSlidersLarge',
|
||||
name: 'Config',
|
||||
description: `Customize ${manifest.title}`,
|
||||
action: () => this.openConfig(manifest),
|
||||
},
|
||||
{
|
||||
icon: 'tuiIconKeyLarge',
|
||||
name: 'Credentials',
|
||||
description: `Password, keys, or other credentials of interest`,
|
||||
action: () =>
|
||||
this.showDialog(
|
||||
`${manifest.title} credentials`,
|
||||
manifest.id,
|
||||
ServiceCredentialsModal,
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: 'tuiIconZapLarge',
|
||||
name: 'Actions',
|
||||
description: `Uninstall and other commands specific to ${manifest.title}`,
|
||||
action: () =>
|
||||
this.showDialog(
|
||||
`${manifest.title} credentials`,
|
||||
manifest.id,
|
||||
ServiceActionsModal,
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: 'tuiIconShieldLarge',
|
||||
name: 'Outbound Proxy',
|
||||
description: `Proxy all outbound traffic from ${manifest.title}`,
|
||||
action: () => this.setProxy(manifest, installed!),
|
||||
},
|
||||
{
|
||||
icon: 'tuiIconFileTextLarge',
|
||||
name: 'Logs',
|
||||
description: `Raw, unfiltered logs`,
|
||||
action: () =>
|
||||
this.showDialog(
|
||||
`${manifest.title} logs`,
|
||||
manifest.id,
|
||||
ServiceLogsModal,
|
||||
),
|
||||
},
|
||||
url
|
||||
? {
|
||||
icon: 'tuiIconShoppingBagLarge',
|
||||
name: 'Marketplace Listing',
|
||||
description: `View ${manifest.title} on the Marketplace`,
|
||||
action: () =>
|
||||
this.router.navigate(['marketplace', manifest.id], {
|
||||
relativeTo: this.route,
|
||||
queryParams: { url },
|
||||
}),
|
||||
}
|
||||
: {
|
||||
icon: 'tuiIconShoppingBagLarge',
|
||||
name: 'Marketplace Listing',
|
||||
description: `This package was not installed from the marketplace`,
|
||||
action: () => {},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
private showInstructions({ title, id, version }: Manifest) {
|
||||
this.api
|
||||
.setDbValue<boolean>(['ack-instructions', id], true)
|
||||
.catch(e => console.error('Failed to mark instructions as seen', e))
|
||||
|
||||
this.dialogs
|
||||
.open(new PolymorpheusComponent(MarkdownComponent), {
|
||||
label: `${title} instructions`,
|
||||
size: 'l',
|
||||
data: {
|
||||
content: from(
|
||||
this.api.getStatic(
|
||||
`/public/package-data/${id}/${version}/INSTRUCTIONS.md`,
|
||||
),
|
||||
),
|
||||
},
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
private showDialog(label: string, data: any, modal: Type<any>) {
|
||||
this.dialogs
|
||||
.open(new PolymorpheusComponent(modal), {
|
||||
size: 'l',
|
||||
label,
|
||||
data,
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
private openConfig({ title, id }: Manifest) {
|
||||
this.formDialog.open<PackageConfigData>(ServiceConfigModal, {
|
||||
label: `${title} configuration`,
|
||||
data: { pkgId: id },
|
||||
})
|
||||
}
|
||||
|
||||
private setProxy(
|
||||
{ id }: Manifest,
|
||||
{ outboundProxy, interfaceInfo }: InstalledPackageInfo,
|
||||
) {
|
||||
this.proxyService.presentModalSetOutboundProxy({
|
||||
outboundProxy,
|
||||
packageId: id,
|
||||
hasP2P: Object.values(interfaceInfo).some(i => i.type === 'p2p'),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import {
|
||||
PackageStatus,
|
||||
renderPkgStatus,
|
||||
} from 'src/app/services/pkg-status-rendering.service'
|
||||
|
||||
@Pipe({
|
||||
name: 'toStatus',
|
||||
standalone: true,
|
||||
})
|
||||
export class ToStatusPipe implements PipeTransform {
|
||||
transform(pkg: PackageDataEntry): PackageStatus {
|
||||
return renderPkgStatus(pkg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<ng-container *ngIf="service$ | async as service">
|
||||
<ng-container *tuiLet="!!(connected$ | async) as connected">
|
||||
<ng-container *ngIf="showProgress(service); else installed">
|
||||
<ng-container *ngIf="service | progressData as progress">
|
||||
<p [progress]="progress.downloadProgress">Downloading</p>
|
||||
<p [progress]="progress.validateProgress">Validating</p>
|
||||
<p [progress]="progress.unpackProgress">Unpacking</p>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #installed>
|
||||
<ng-container *ngIf="service | toStatus as status">
|
||||
<section>
|
||||
<h3 class="title">Status</h3>
|
||||
<service-status
|
||||
class="status"
|
||||
[connected]="connected"
|
||||
[installProgress]="service['install-progress']"
|
||||
[rendering]="$any(getRendering(status))"
|
||||
></service-status>
|
||||
<service-actions
|
||||
*ngIf="isInstalled(service) && connected"
|
||||
[service]="service"
|
||||
></service-actions>
|
||||
</section>
|
||||
|
||||
<ng-container *ngIf="isInstalled(service) && !isBackingUp(status)">
|
||||
<section>
|
||||
<h3 class="title">Interfaces</h3>
|
||||
<button
|
||||
*ngFor="let info of service | interfaceInfo"
|
||||
class="g-action"
|
||||
[serviceInterface]="info"
|
||||
[disabled]="!isRunning(status)"
|
||||
(click)="info.action()"
|
||||
></button>
|
||||
</section>
|
||||
|
||||
<ng-container *ngIf="isRunning(status)">
|
||||
<section *ngIf="health$ | async as checks">
|
||||
<h3 class="title">Health Checks</h3>
|
||||
<service-health-check
|
||||
*ngFor="let check of checks"
|
||||
class="g-action"
|
||||
[check]="check"
|
||||
[connected]="connected"
|
||||
></service-health-check>
|
||||
</section>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="service | toDependencies as dependencies">
|
||||
<section *ngIf="dependencies.length">
|
||||
<h3 class="title">Dependencies</h3>
|
||||
<button
|
||||
*ngFor="let dep of dependencies"
|
||||
class="g-action"
|
||||
[serviceDependency]="dep"
|
||||
(click)="dep.action()"
|
||||
></button>
|
||||
</section>
|
||||
</ng-container>
|
||||
|
||||
<section>
|
||||
<h3 class="title">Menu</h3>
|
||||
<button
|
||||
*ngFor="let menu of service | toMenu"
|
||||
class="g-action"
|
||||
[serviceMenu]="menu"
|
||||
(click)="menu.action()"
|
||||
>
|
||||
<div
|
||||
*ngIf="menu.name === 'Outbound Proxy'"
|
||||
[style.color]="
|
||||
service.installed?.outboundProxy
|
||||
? 'var(--tui-success-fill)'
|
||||
: 'var(--tui-warning-fill)'
|
||||
"
|
||||
>
|
||||
{{ this.getProxy(service.installed?.outboundProxy) }}
|
||||
</div>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="title">Additional Info</h3>
|
||||
<ng-container *ngFor="let additional of service | toAdditional">
|
||||
<a
|
||||
*ngIf="additional.description.startsWith('http'); else button"
|
||||
class="g-action"
|
||||
[additional]="additional"
|
||||
></a>
|
||||
<ng-template #button>
|
||||
<button
|
||||
class="g-action"
|
||||
[class.g-action_static]="!additional.icon"
|
||||
[additional]="additional"
|
||||
(click)="additional.action && additional.action()"
|
||||
></button>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</section>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,26 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
padding: 1px 2rem 3rem;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
|
||||
// TODO: Theme
|
||||
background: #373a3f;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
margin: 2rem 0 1rem;
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: x-large;
|
||||
margin: 1em 0;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { getPkgId, isEmptyObject } from '@start9labs/shared'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { map, Observable, tap } from 'rxjs'
|
||||
import {
|
||||
DataModel,
|
||||
HealthCheckResult,
|
||||
PackageDataEntry,
|
||||
PackageState,
|
||||
ServiceOutboundProxy,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import {
|
||||
PackageStatus,
|
||||
PrimaryRendering,
|
||||
PrimaryStatus,
|
||||
StatusRendering,
|
||||
} from 'src/app/services/pkg-status-rendering.service'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { NavigationService } from '../../components/navigation/navigation.service'
|
||||
import { toRouterLink } from '../../utils/to-router-link'
|
||||
|
||||
const STATES = [
|
||||
PackageState.Installing,
|
||||
PackageState.Updating,
|
||||
PackageState.Restoring,
|
||||
]
|
||||
|
||||
@Component({
|
||||
templateUrl: 'service.component.html',
|
||||
styleUrls: ['service.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ServiceComponent {
|
||||
private readonly route = inject(ActivatedRoute)
|
||||
private readonly router = inject(Router)
|
||||
private readonly navigation = inject(NavigationService)
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
|
||||
readonly pkgId = getPkgId(this.route)
|
||||
|
||||
readonly connected$ = inject(ConnectionService).connected$
|
||||
|
||||
readonly service$ = this.patch.watch$('package-data', this.pkgId).pipe(
|
||||
tap(pkg => {
|
||||
// if package disappears, navigate to list page
|
||||
if (!pkg) {
|
||||
this.router.navigate(['..'], { relativeTo: this.route })
|
||||
} else {
|
||||
this.navigation.addTab({
|
||||
icon: pkg.icon,
|
||||
title: pkg.manifest.title,
|
||||
routerLink: toRouterLink(pkg.manifest.id),
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
readonly health$: Observable<HealthCheckResult[] | null> = this.patch
|
||||
.watch$('package-data', this.pkgId, 'installed', 'status', 'main')
|
||||
.pipe(
|
||||
map(main =>
|
||||
main.status !== 'running' || isEmptyObject(main.health)
|
||||
? null
|
||||
: Object.values(main.health),
|
||||
),
|
||||
)
|
||||
|
||||
getRendering({ primary }: PackageStatus): StatusRendering {
|
||||
return PrimaryRendering[primary]
|
||||
}
|
||||
|
||||
isInstalled({ state }: PackageDataEntry): boolean {
|
||||
return state === PackageState.Installed
|
||||
}
|
||||
|
||||
isRunning({ primary }: PackageStatus): boolean {
|
||||
return primary === PrimaryStatus.Running
|
||||
}
|
||||
|
||||
isBackingUp({ primary }: PackageStatus): boolean {
|
||||
return primary === PrimaryStatus.BackingUp
|
||||
}
|
||||
|
||||
showProgress({ state }: PackageDataEntry): boolean {
|
||||
return STATES.includes(state)
|
||||
}
|
||||
|
||||
getProxy(proxy?: ServiceOutboundProxy): string {
|
||||
switch (proxy) {
|
||||
case 'primary':
|
||||
return 'System Primary'
|
||||
case 'mirror':
|
||||
return 'Mirror P2P'
|
||||
default:
|
||||
return proxy?.proxyId || 'None'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { TuiLetModule } from '@taiga-ui/cdk'
|
||||
|
||||
import { ServiceComponent } from './service.component'
|
||||
import { ServiceProgressComponent } from './components/progress.component'
|
||||
import { ServiceStatusComponent } from './components/status.component'
|
||||
import { ServiceActionsComponent } from './components/actions.component'
|
||||
import { ServiceInterfaceComponent } from './components/interface.component'
|
||||
import { ServiceHealthCheckComponent } from './components/health-check.component'
|
||||
import { ServiceDependencyComponent } from './components/dependency.component'
|
||||
import { ServiceMenuComponent } from './components/menu.component'
|
||||
import { ServiceAdditionalComponent } from './components/additional.component'
|
||||
|
||||
import { ProgressDataPipe } from './pipes/progress-data.pipe'
|
||||
import { ToDependenciesPipe } from './pipes/to-dependencies.pipe'
|
||||
import { ToStatusPipe } from './pipes/to-status.pipe'
|
||||
import { InterfaceInfoPipe } from './pipes/interface-info.pipe'
|
||||
import { ToMenusPipe } from './pipes/to-menu.pipe'
|
||||
import { ToAdditionalPipe } from './pipes/to-additional.pipe'
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: ':pkgId',
|
||||
component: ServiceComponent,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiLetModule,
|
||||
|
||||
ServiceProgressComponent,
|
||||
ServiceStatusComponent,
|
||||
ServiceActionsComponent,
|
||||
ServiceInterfaceComponent,
|
||||
ServiceHealthCheckComponent,
|
||||
ServiceDependencyComponent,
|
||||
ServiceMenuComponent,
|
||||
ServiceAdditionalComponent,
|
||||
|
||||
ProgressDataPipe,
|
||||
ToDependenciesPipe,
|
||||
ToStatusPipe,
|
||||
InterfaceInfoPipe,
|
||||
ToMenusPipe,
|
||||
ToAdditionalPipe,
|
||||
|
||||
RouterModule.forChild(ROUTES),
|
||||
],
|
||||
declarations: [ServiceComponent],
|
||||
exports: [ServiceComponent],
|
||||
})
|
||||
export class ServiceModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface DependencyInfo {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
version: string
|
||||
errorText: string
|
||||
actionText: string
|
||||
action: () => any
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { DependentInfo } from 'src/app/types/dependent-info'
|
||||
|
||||
export interface PackageConfigData {
|
||||
readonly pkgId: string
|
||||
readonly dependentInfo?: DependentInfo
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{{ (service$ | async)?.manifest?.title }}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { getPkgId } from '@start9labs/shared'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { tap } from 'rxjs'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { NavigationService } from '../../components/navigation/navigation.service'
|
||||
import { toRouterLink } from '../../utils/to-router-link'
|
||||
|
||||
@Component({
|
||||
templateUrl: 'service.component.html',
|
||||
styleUrls: ['service.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ServiceComponent {
|
||||
private readonly route = inject(ActivatedRoute)
|
||||
private readonly router = inject(Router)
|
||||
private readonly navigation = inject(NavigationService)
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
|
||||
readonly service$ = this.patch
|
||||
.watch$('package-data', getPkgId(this.route))
|
||||
.pipe(
|
||||
tap(pkg => {
|
||||
// if package disappears, navigate to list page
|
||||
if (!pkg) {
|
||||
this.router.navigate(['..'], { relativeTo: this.route })
|
||||
} else {
|
||||
this.navigation.addTab({
|
||||
icon: pkg.icon,
|
||||
title: pkg.manifest.title,
|
||||
routerLink: toRouterLink(pkg.manifest.id),
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { ServiceComponent } from './service.component'
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: ':pkgId',
|
||||
component: ServiceComponent,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, RouterModule.forChild(ROUTES)],
|
||||
declarations: [ServiceComponent],
|
||||
exports: [ServiceComponent],
|
||||
})
|
||||
export class ServicesModule {}
|
||||
@@ -1,3 +1,3 @@
|
||||
export function toRouterLink(id: string): string {
|
||||
return id.includes('/') ? id : `/portal/services/${id}`
|
||||
return id.includes('/') ? id : `/portal/service/${id}`
|
||||
}
|
||||
|
||||
@@ -64,5 +64,6 @@ const routes: Routes = [
|
||||
InsecureWarningComponentModule,
|
||||
LaunchMenuComponentModule,
|
||||
],
|
||||
exports: [InterfaceInfoPipe],
|
||||
})
|
||||
export class AppShowPageModule {}
|
||||
|
||||
@@ -53,8 +53,8 @@ export class LogsComponent {
|
||||
params: ServerLogsReq,
|
||||
) => Promise<LogsRes>
|
||||
@Input({ required: true }) context!: string
|
||||
@Input({ required: true }) defaultBack!: string
|
||||
@Input({ required: true }) pageTitle!: string
|
||||
@Input() defaultBack = ''
|
||||
@Input() pageTitle = ''
|
||||
|
||||
loading = true
|
||||
infiniteStatus: 0 | 1 | 2 = 0
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
@font-face {
|
||||
font-family: 'text-security-disc';
|
||||
src: url('/assets/fonts/text-security-disc.woff2') format('woff2');
|
||||
@@ -364,3 +366,40 @@ ul {
|
||||
cursor: pointer;
|
||||
margin: 0 12px 6px 0;
|
||||
}
|
||||
|
||||
.g-action {
|
||||
@include transition(background);
|
||||
@include clearbtn();
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
text-align: left;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0 -1rem;
|
||||
line-height: 1.25rem;
|
||||
border-radius: 0.5rem;
|
||||
color: var(--tui-text-01);
|
||||
--tui-skeleton-radius: 1rem;
|
||||
}
|
||||
|
||||
a.g-action,
|
||||
button.g-action {
|
||||
&:hover {
|
||||
background: var(--tui-clear);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
box-shadow: 0 0.51rem 0 -0.5rem;
|
||||
}
|
||||
|
||||
&_static {
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user