diff --git a/frontend/angular.json b/frontend/angular.json index d2f159ff2..5af567f09 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -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", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 81545020d..90c1ddf7f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index b21673832..592463a34 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/projects/ui/src/app/apps/portal/portal.module.ts b/frontend/projects/ui/src/app/apps/portal/portal.module.ts index 85e49a6fd..ef6abe620 100644 --- a/frontend/projects/ui/src/app/apps/portal/portal.module.ts +++ b/frontend/projects/ui/src/app/apps/portal/portal.module.ts @@ -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', diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/components/action-success.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/components/action-success.component.ts new file mode 100644 index 000000000..90086cb9e --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/components/action-success.component.ts @@ -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 }} + + + + {{ context.data.value }} + + Copy + + + + `, + 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>(POLYMORPHEUS_CONTEXT) +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/components/action.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/components/action.component.ts new file mode 100644 index 000000000..5861b0fd0 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/components/action.component.ts @@ -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: ` + + + {{ action.name }} + {{ action.description }} + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [TuiSvgModule], +}) +export class ServiceActionComponent { + @Input({ required: true }) + action!: ActionItem +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/components/actions.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/components/actions.component.ts new file mode 100644 index 000000000..80577811b --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/components/actions.component.ts @@ -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: ` + + Stop + + + + Restart + + + + Start + + + + Configure + + `, + 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, + private readonly dependencies: ToDependenciesPipe, + ) {} + + private get id(): string { + return this.service.manifest.id + } + + get interfaceInfo(): Record { + 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(ServiceConfigModal, { + label: `${this.service.manifest.title} configuration`, + data: { pkgId: this.id }, + }) + } + + async tryStart(): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return new Promise(async resolve => { + this.dialogs + .open(TUI_PROMPT, { + label: 'Warning', + size: 's', + data: { + content, + yes: 'Continue', + no: 'Cancel', + }, + }) + .subscribe(response => resolve(response)) + }) + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/components/additional.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/components/additional.component.ts new file mode 100644 index 000000000..9b74fcba3 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/components/additional.component.ts @@ -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: ` + + {{ additional.name }} + {{ additional.description }} + + + `, + 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 + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/components/config-dep.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/components/config-dep.component.ts new file mode 100644 index 000000000..14becf2e8 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/components/config-dep.component.ts @@ -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: ` + + + {{ package }} + + The following modifications have been made to {{ package }} to satisfy + {{ dep }}: + + + + To accept these modifications, click "Save". + + `, + 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' + } + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/components/credential.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/components/credential.component.ts new file mode 100644 index 000000000..1c11145fb --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/components/credential.component.ts @@ -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: ` + + {{ masked ? mask : value }} + + + Toggle + + + Copy + + `, + 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) + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/components/dependency.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/components/dependency.component.ts new file mode 100644 index 000000000..0faa48a58 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/components/dependency.component.ts @@ -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: ` + + + + + {{ dep.title }} + + {{ dep.version | displayEmver }} + + {{ dep.errorText || 'Satisfied' }} + + + + {{ dep.actionText }} + + + `, + 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)' + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/components/health-check.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/components/health-check.component.ts new file mode 100644 index 000000000..68848965b --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/components/health-check.component.ts @@ -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: ` + + + + + + {{ check.name }} + + {{ message }} + + + `, + 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 + } + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/components/interface.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/components/interface.component.ts new file mode 100644 index 000000000..2794c2693 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/components/interface.component.ts @@ -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: ` + + + {{ info.name }} + {{ info.description }} + {{ info.typeDetail }} + + + `, + 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', + ) + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/components/menu.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/components/menu.component.ts new file mode 100644 index 000000000..a227d4a6f --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/components/menu.component.ts @@ -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: ` + + + {{ menu.name }} + + {{ menu.description }} + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [TuiSvgModule], +}) +export class ServiceMenuComponent { + @Input({ required: true, alias: 'serviceMenu' }) + menu!: ServiceMenu +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/components/progress.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/components/progress.component.ts new file mode 100644 index 000000000..9fda9bf54 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/components/progress.component.ts @@ -0,0 +1,27 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core' +import { TuiProgressModule } from '@taiga-ui/kit' + +@Component({ + selector: '[progress]', + template: ` + + : {{ progress }}% + + `, + styles: [':host { line-height: 2rem }'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [TuiProgressModule], +}) +export class ServiceProgressComponent { + @Input({ required: true }) + progress = 0 +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/components/status.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/components/status.component.ts new file mode 100644 index 000000000..59eb44e8b --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/components/status.component.ts @@ -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: ` + + {{ connected ? rendering.display : 'Unknown' }} + + + + + Installing + + {{ progress }} + + + `, + 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)' + } + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/modals/actions.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/modals/actions.component.ts new file mode 100644 index 000000000..686eb4a99 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/modals/actions.component.ts @@ -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: ` + + + Standard Actions + + + + Actions for {{ pkg.manifest.title }} + + + + + + `, + 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, + private readonly embassyApi: ApiService, + private readonly dialogs: TuiDialogService, + private readonly errorService: ErrorService, + private readonly loader: LoadingService, + private readonly router: Router, + private readonly patch: PatchDB, + private readonly formDialog: FormDialogService, + ) {} + + async handleAction(action: WithId) { + 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 { + 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(['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 { + 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 + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/modals/config.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/modals/config.component.ts new file mode 100644 index 000000000..880a297f5 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/modals/config.component.ts @@ -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: ` + + + + + + + + + {{ pkg.manifest.title }} has been automatically configured with + recommended defaults. Make whatever changes you want, then click "Save". + + + + + + No config options for {{ pkg.manifest.title }} + {{ pkg.manifest.version }}. + + + + + Reset Defaults + + + + `, + 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> + + 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[] = [ + { + text: 'Save', + handler: value => this.save(value), + }, + ] + + original: object | null = null + value: object | null = null + + constructor( + @Inject(POLYMORPHEUS_CONTEXT) + private readonly context: TuiDialogContext, + private readonly dialogs: TuiDialogService, + private readonly errorService: ErrorService, + private readonly loader: LoadingService, + private readonly embassyApi: ApiService, + private readonly patchDb: PatchDB, + ) {} + + 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, 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, + 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, 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 { + 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:' + const content = `${message}${Object.keys(breakages).map( + id => `${packages[id].manifest.title}`, + )}` + const data: TuiPromptData = { content, yes: 'Continue', no: 'Cancel' } + + return firstValueFrom( + this.dialogs.open(TUI_PROMPT, { data }).pipe(endWith(false)), + ) + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/modals/credentials.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/modals/credentials.component.ts new file mode 100644 index 000000000..1fd75a42e --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/modals/credentials.component.ts @@ -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: ` + + + + + No credentials + + Refresh + + `, + 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 = {} + + async ngOnInit() { + await this.getCredentials() + } + + async refresh() { + await this.getCredentials() + } + + private async getCredentials(): Promise { + 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 + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/modals/interface.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/modals/interface.component.ts new file mode 100644 index 000000000..199afc3d0 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/modals/interface.component.ts @@ -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: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CommonModule, InterfaceAddressesComponentModule], +}) +export class ServiceInterfaceModal { + readonly context = inject<{ data: Context }>(POLYMORPHEUS_CONTEXT).data + + readonly interfaceInfo$: Observable = inject(PatchDB).watch$( + 'package-data', + this.context.packageId, + 'installed', + 'interfaceInfo', + this.context.interfaceId, + ) +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/modals/logs.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/modals/logs.component.ts new file mode 100644 index 000000000..65648c85f --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/modals/logs.component.ts @@ -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: + '', + 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 }) +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/group-actions.pipe.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/group-actions.pipe.ts new file mode 100644 index 000000000..5aad8c68f --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/group-actions.pipe.ts @@ -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>> | null { + if (!actions) return null + + const noGroup = 'noGroup' + const grouped = Object.entries(actions).reduce< + Record[]> + >((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)), + ) + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/interface-info.pipe.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/interface-info.pipe.ts new file mode 100644 index 000000000..8ab557b2b --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/interface-info.pipe.ts @@ -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(), + } + }) + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/progress-data.pipe.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/progress-data.pipe.ts new file mode 100644 index 000000000..6fc7d3956 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/progress-data.pipe.ts @@ -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']) + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/to-additional.pipe.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/to-additional.pipe.ts new file mode 100644 index 000000000..fcf07bbe1 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/to-additional.pipe.ts @@ -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() + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/to-dependencies.pipe.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/to-dependencies.pipe.ts new file mode 100644 index 000000000..c0951d6d6 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/to-dependencies.pipe.ts @@ -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 { + switch (action) { + case 'install': + case 'update': + return this.installDep(pkg.manifest, depId) + case 'configure': + return this.formDialog.open(ServiceConfigModal, { + label: `${ + pkg.installed!['dependency-info'][depId].title + } configuration`, + data: { + pkgId: depId, + dependentInfo: pkg.manifest, + }, + }) + } + } + + private async installDep(manifest: Manifest, depId: string): Promise { + 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 { + const dependentInfo: DependentInfo = { + id: manifest.id, + title: manifest.title, + } + + return this.formDialog.open(ServiceConfigModal, { + label: 'Config', + data: { + pkgId: dependencyId, + dependentInfo, + }, + }) + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/to-menu.pipe.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/to-menu.pipe.ts new file mode 100644 index 000000000..133269fad --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/to-menu.pipe.ts @@ -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(['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) { + this.dialogs + .open(new PolymorpheusComponent(modal), { + size: 'l', + label, + data, + }) + .subscribe() + } + + private openConfig({ title, id }: Manifest) { + this.formDialog.open(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'), + }) + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/to-status.pipe.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/to-status.pipe.ts new file mode 100644 index 000000000..6d915b94c --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/pipes/to-status.pipe.ts @@ -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) + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.html b/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.html new file mode 100644 index 000000000..d58506443 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.html @@ -0,0 +1,106 @@ + + + + + Downloading + Validating + Unpacking + + + + + + + Status + + + + + + + Interfaces + + + + + + Health Checks + + + + + + + Dependencies + + + + + + Menu + + + {{ this.getProxy(service.installed?.outboundProxy) }} + + + + + + Additional Info + + + + + + + + + + + + diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.scss b/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.scss new file mode 100644 index 000000000..3f186a990 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.scss @@ -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; +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.ts new file mode 100644 index 000000000..7e08c9687 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.ts @@ -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) + + 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 = 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' + } + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/service.module.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/service.module.ts new file mode 100644 index 000000000..f4cb13244 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/service.module.ts @@ -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 {} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/types/dependency-info.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/types/dependency-info.ts new file mode 100644 index 000000000..a28c44a24 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/types/dependency-info.ts @@ -0,0 +1,9 @@ +export interface DependencyInfo { + id: string + title: string + icon: string + version: string + errorText: string + actionText: string + action: () => any +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/types/package-config-data.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/types/package-config-data.ts new file mode 100644 index 000000000..3981773fc --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/types/package-config-data.ts @@ -0,0 +1,6 @@ +import { DependentInfo } from 'src/app/types/dependent-info' + +export interface PackageConfigData { + readonly pkgId: string + readonly dependentInfo?: DependentInfo +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/services/service.component.html b/frontend/projects/ui/src/app/apps/portal/routes/services/service.component.html deleted file mode 100644 index 044b180cf..000000000 --- a/frontend/projects/ui/src/app/apps/portal/routes/services/service.component.html +++ /dev/null @@ -1 +0,0 @@ -{{ (service$ | async)?.manifest?.title }} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/services/service.component.scss b/frontend/projects/ui/src/app/apps/portal/routes/services/service.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/frontend/projects/ui/src/app/apps/portal/routes/services/service.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/services/service.component.ts deleted file mode 100644 index ab64abb9a..000000000 --- a/frontend/projects/ui/src/app/apps/portal/routes/services/service.component.ts +++ /dev/null @@ -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) - - 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), - }) - } - }), - ) -} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/services/services.module.ts b/frontend/projects/ui/src/app/apps/portal/routes/services/services.module.ts deleted file mode 100644 index 3312e476f..000000000 --- a/frontend/projects/ui/src/app/apps/portal/routes/services/services.module.ts +++ /dev/null @@ -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 {} diff --git a/frontend/projects/ui/src/app/apps/portal/utils/to-router-link.ts b/frontend/projects/ui/src/app/apps/portal/utils/to-router-link.ts index 40a9ce418..459eed52d 100644 --- a/frontend/projects/ui/src/app/apps/portal/utils/to-router-link.ts +++ b/frontend/projects/ui/src/app/apps/portal/utils/to-router-link.ts @@ -1,3 +1,3 @@ export function toRouterLink(id: string): string { - return id.includes('/') ? id : `/portal/services/${id}` + return id.includes('/') ? id : `/portal/service/${id}` } diff --git a/frontend/projects/ui/src/app/apps/ui/pages/services/app-show/app-show.module.ts b/frontend/projects/ui/src/app/apps/ui/pages/services/app-show/app-show.module.ts index e81f6e294..704b867d6 100644 --- a/frontend/projects/ui/src/app/apps/ui/pages/services/app-show/app-show.module.ts +++ b/frontend/projects/ui/src/app/apps/ui/pages/services/app-show/app-show.module.ts @@ -64,5 +64,6 @@ const routes: Routes = [ InsecureWarningComponentModule, LaunchMenuComponentModule, ], + exports: [InterfaceInfoPipe], }) export class AppShowPageModule {} diff --git a/frontend/projects/ui/src/app/common/logs/logs.component.ts b/frontend/projects/ui/src/app/common/logs/logs.component.ts index 195767962..776d6fa1c 100644 --- a/frontend/projects/ui/src/app/common/logs/logs.component.ts +++ b/frontend/projects/ui/src/app/common/logs/logs.component.ts @@ -53,8 +53,8 @@ export class LogsComponent { params: ServerLogsReq, ) => Promise @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 diff --git a/frontend/projects/ui/src/styles.scss b/frontend/projects/ui/src/styles.scss index 8180ca0cf..145147082 100644 --- a/frontend/projects/ui/src/styles.scss +++ b/frontend/projects/ui/src/styles.scss @@ -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; + } + } +}
+ {{ context.data.value }} + + Copy + +
Downloading
Validating
Unpacking