refactor: refactor service page to get rid of ionic (#2421)

This commit is contained in:
Alex Inkin
2023-09-26 22:37:46 +04:00
committed by GitHub
parent 7e18aafe20
commit cb36754c46
42 changed files with 2451 additions and 122 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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',

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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))
})
}
}

View File

@@ -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
}
}

View File

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

View File

@@ -0,0 +1,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)
}
}

View File

@@ -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)'
}
}

View File

@@ -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
}
}
}

View File

@@ -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',
)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)'
}
}
}

View File

@@ -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
}
}

View File

@@ -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)),
)
}
}

View File

@@ -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
}
}

View File

@@ -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,
)
}

View File

@@ -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 })
}

View File

@@ -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)),
)
}
}

View File

@@ -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(),
}
})
}
}

View File

@@ -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'])
}
}

View File

@@ -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()
}
}

View File

@@ -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,
},
})
}
}

View File

@@ -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'),
})
}
}

View File

@@ -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)
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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'
}
}
}

View File

@@ -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 {}

View File

@@ -0,0 +1,9 @@
export interface DependencyInfo {
id: string
title: string
icon: string
version: string
errorText: string
actionText: string
action: () => any
}

View File

@@ -0,0 +1,6 @@
import { DependentInfo } from 'src/app/types/dependent-info'
export interface PackageConfigData {
readonly pkgId: string
readonly dependentInfo?: DependentInfo
}

View File

@@ -1 +0,0 @@
{{ (service$ | async)?.manifest?.title }}

View File

@@ -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),
})
}
}),
)
}

View File

@@ -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 {}

View File

@@ -1,3 +1,3 @@
export function toRouterLink(id: string): string {
return id.includes('/') ? id : `/portal/services/${id}`
return id.includes('/') ? id : `/portal/service/${id}`
}

View File

@@ -64,5 +64,6 @@ const routes: Routes = [
InsecureWarningComponentModule,
LaunchMenuComponentModule,
],
exports: [InterfaceInfoPipe],
})
export class AppShowPageModule {}

View File

@@ -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

View File

@@ -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;
}
}
}