refactor downstream for 036 changes (#2577)

refactor codebase for 036 changes
This commit is contained in:
Matt Hill
2024-03-24 12:12:55 -06:00
committed by GitHub
parent 22cd2e3337
commit 3b669193f6
152 changed files with 1360 additions and 1352 deletions

View File

@@ -5,7 +5,6 @@ import {
DriveComponent, DriveComponent,
LoadingModule, LoadingModule,
RELATIVE_URL, RELATIVE_URL,
UnitConversionPipesModule,
WorkspaceConfig, WorkspaceConfig,
} from '@start9labs/shared' } from '@start9labs/shared'
import { TuiDialogModule, TuiRootModule } from '@taiga-ui/core' import { TuiDialogModule, TuiRootModule } from '@taiga-ui/core'
@@ -42,7 +41,6 @@ const {
TuiIconModule, TuiIconModule,
TuiSurfaceModule, TuiSurfaceModule,
TuiTitleModule, TuiTitleModule,
UnitConversionPipesModule,
], ],
providers: [ providers: [
{ {

View File

@@ -2,7 +2,7 @@
<div class="box-container"> <div class="box-container">
<div class="box-container-title"> <div class="box-container-title">
<h3 class="small-caps">What's new</h3> <h3 class="small-caps">What's new</h3>
<p *ngIf="pkg['published-at'] as published"> <p *ngIf="pkg.publishedAt as published">
<span class="small-caps">Latest Release</span> <span class="small-caps">Latest Release</span>
&nbsp;-&nbsp; &nbsp;-&nbsp;
<span class="box-container-title-date"> <span class="box-container-title-date">
@@ -16,7 +16,7 @@
<p <p
safeLinks safeLinks
class="box-container-details-notes" class="box-container-details-notes"
[innerHTML]="pkg.manifest['release-notes'] | markdown | dompurify" [innerHTML]="pkg.manifest.releaseNotes | markdown | dompurify"
></p> ></p>
</div> </div>
<button <button

View File

@@ -16,7 +16,6 @@ import {
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus' import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { import {
CopyService, CopyService,
copyToClipboard,
displayEmver, displayEmver,
Emver, Emver,
MarkdownComponent, MarkdownComponent,

View File

@@ -2,7 +2,6 @@ import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { SharedPipesModule } from '@start9labs/shared' import { SharedPipesModule } from '@start9labs/shared'
import { MarketplacePkg } from '../../../types' import { MarketplacePkg } from '../../../types'
import { MimeTypePipeModule } from '../../../pipes/mime-type.pipe'
@Component({ @Component({
selector: 'marketplace-package-hero', selector: 'marketplace-package-hero',
@@ -11,14 +10,14 @@ import { MimeTypePipeModule } from '../../../pipes/mime-type.pipe'
<div class="inner-container box-shadow-lg"> <div class="inner-container box-shadow-lg">
<!-- icon --> <!-- icon -->
<img <img
[src]="pkg | mimeType | trustUrl" [src]="pkg.icon | trustUrl"
class="box-shadow-lg" class="box-shadow-lg"
alt="{{ pkg.manifest.title }} Icon" alt="{{ pkg.manifest.title }} Icon"
/> />
<!-- color background --> <!-- color background -->
<div class="color-background"> <div class="color-background">
<img <img
[src]="pkg | mimeType | trustUrl" [src]="pkg.icon | trustUrl"
alt="{{ pkg.manifest.title }} background image" alt="{{ pkg.manifest.title }} background image"
/> />
</div> </div>
@@ -144,7 +143,7 @@ import { MimeTypePipeModule } from '../../../pipes/mime-type.pipe'
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [CommonModule, SharedPipesModule, MimeTypePipeModule], imports: [CommonModule, SharedPipesModule],
}) })
export class MarketplacePackageHeroComponent { export class MarketplacePackageHeroComponent {
@Input({ required: true }) @Input({ required: true })

View File

@@ -164,7 +164,7 @@ export class CifsComponent {
const target: CifsBackupTarget = { const target: CifsBackupTarget = {
...this.form.getRawValue(), ...this.form.getRawValue(),
mountable: true, mountable: true,
'embassy-os': diskInfo, startOs: diskInfo,
} }
this.dialogs this.dialogs

View File

@@ -111,7 +111,7 @@ export class PasswordComponent {
} }
try { try {
const passwordHash = this.target!['embassy-os']?.['password-hash'] || '' const passwordHash = this.target!.startOs?.passwordHash || ''
argon2.verify(passwordHash, this.password.value) argon2.verify(passwordHash, this.password.value)
this.context.completeWith(this.password.value) this.context.completeWith(this.password.value)

View File

@@ -1,5 +1,5 @@
import { Component, Input } from '@angular/core' import { Component, Input } from '@angular/core'
import { RouterLink } from '@angular/router' import { RouterModule } from '@angular/router'
import { import {
TuiCellModule, TuiCellModule,
TuiIconModule, TuiIconModule,
@@ -37,7 +37,7 @@ import {
</span> </span>
</a> </a>
`, `,
imports: [RouterLink, TuiIconModule, TuiCellModule, TuiTitleModule], imports: [RouterModule, TuiIconModule, TuiCellModule, TuiTitleModule],
}) })
export class RecoverComponent { export class RecoverComponent {
@Input() disabled = false @Input() disabled = false

View File

@@ -1,6 +1,6 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, inject, OnInit } from '@angular/core' import { Component, inject, OnInit } from '@angular/core'
import { RouterLink } from '@angular/router' import { RouterModule } from '@angular/router'
import { ErrorService } from '@start9labs/shared' import { ErrorService } from '@start9labs/shared'
import { import {
TuiButtonModule, TuiButtonModule,
@@ -110,7 +110,7 @@ import { StateService } from 'src/app/services/state.service'
`, `,
imports: [ imports: [
CommonModule, CommonModule,
RouterLink, RouterModule,
TuiCardModule, TuiCardModule,
TuiButtonModule, TuiButtonModule,
TuiIconsModule, TuiIconsModule,

View File

@@ -98,7 +98,7 @@ export default class RecoverPage {
} }
empty(drive: DiskBackupTarget) { empty(drive: DiskBackupTarget) {
return !drive['embassy-os']?.full return !drive.startOs?.full
} }
async getDrives() { async getDrives() {

View File

@@ -162,9 +162,9 @@ export default class SuccessPage implements AfterViewInit {
try { try {
const ret = await this.api.complete() const ret = await this.api.complete()
if (!this.isKiosk) { if (!this.isKiosk) {
this.torAddress = ret['tor-address'].replace(/^https:/, 'http:') this.torAddress = ret.torAddress.replace(/^https:/, 'http:')
this.lanAddress = ret['lan-address'].replace(/^https:/, 'http:') this.lanAddress = ret.lanAddress.replace(/^https:/, 'http:')
this.cert = ret['root-ca'] this.cert = ret.rootCa
await this.api.exit() await this.api.exit()
} }

View File

@@ -139,7 +139,7 @@ export class MockApiService extends ApiService {
async followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> { async followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> {
await pauseFor(1000) await pauseFor(1000)
return { return {
'start-cursor': 'fakestartcursor', startCursor: 'fakestartcursor',
guid: 'fake-guid', guid: 'fake-guid',
} }
} }

View File

@@ -5,7 +5,6 @@ import {
inject, inject,
NgZone, NgZone,
} from '@angular/core' } from '@angular/core'
import { DOCUMENT } from '@angular/common'
import { ANIMATION_FRAME } from '@ng-web-apis/common' import { ANIMATION_FRAME } from '@ng-web-apis/common'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { tuiZonefree } from '@taiga-ui/cdk' import { tuiZonefree } from '@taiga-ui/cdk'

View File

@@ -18,8 +18,8 @@ export async function getSetupStatusMock(): Promise<SetupStatus | null> {
const progress = tries - 1 const progress = tries - 1
return { return {
'bytes-transferred': restoreOrMigrate ? progress : 0, bytesTransferred: restoreOrMigrate ? progress : 0,
'total-bytes': restoreOrMigrate ? total : null, totalBytes: restoreOrMigrate ? total : null,
complete: progress === total, complete: progress === total,
} }
} }

View File

@@ -43,8 +43,8 @@ export class SetupService extends Observable<number> {
return 1 return 1
} }
return progress['total-bytes'] return progress.totalBytes
? progress['bytes-transferred'] / progress['total-bytes'] ? progress.bytesTransferred / progress.totalBytes
: 0 : 0
}), }),
takeWhile(value => value !== 1, true), takeWhile(value => value !== 1, true),

View File

@@ -67,7 +67,7 @@ export class LogsPage implements OnInit {
if (!response.entries.length) return if (!response.entries.length) return
this.startCursor = response['start-cursor'] this.startCursor = response.startCursor
this.logs = [convertAnsi(response.entries), ...this.logs] this.logs = [convertAnsi(response.entries), ...this.logs]
this.scrollTop = this.scrollbar?.nativeElement.scrollTop || 0 this.scrollTop = this.scrollbar?.nativeElement.scrollTop || 0
} catch (e: any) { } catch (e: any) {

View File

@@ -1,145 +1,145 @@
import { CommonModule } from '@angular/common' // import { CommonModule } from '@angular/common'
import { // import {
ChangeDetectionStrategy, // ChangeDetectionStrategy,
Component, // Component,
HostListener, // HostListener,
Input, // Input,
} from '@angular/core' // } from '@angular/core'
import { // import {
TuiBadgedContentModule, // TuiBadgedContentModule,
TuiBadgeNotificationModule, // TuiBadgeNotificationModule,
TuiButtonModule, // TuiButtonModule,
TuiIconModule, // TuiIconModule,
} from '@taiga-ui/experimental' // } from '@taiga-ui/experimental'
import { RouterLink } from '@angular/router' // import { RouterLink } from '@angular/router'
import { TickerModule } from '@start9labs/shared' // import { TickerModule } from '@start9labs/shared'
import { TuiDataListModule, TuiHostedDropdownModule } from '@taiga-ui/core' // import { TuiDataListModule, TuiHostedDropdownModule } from '@taiga-ui/core'
import { Action, ActionsComponent } from './actions.component' // import { Action, ActionsComponent } from './actions.component'
@Component({ // @Component({
selector: '[appCard]', // selector: '[appCard]',
template: ` // template: `
<span class="link"> // <span class="link">
<tui-badged-content [style.--tui-radius.rem]="1.5"> // <tui-badged-content [style.--tui-radius.rem]="1.5">
@if (badge) { // @if (badge) {
<tui-badge-notification size="m" tuiSlot="top"> // <tui-badge-notification size="m" tuiSlot="top">
{{ badge }} // {{ badge }}
</tui-badge-notification> // </tui-badge-notification>
} // }
@if (icon?.startsWith('tuiIcon')) { // @if (icon?.startsWith('tuiIcon')) {
<tui-icon class="icon" [icon]="icon" /> // <tui-icon class="icon" [icon]="icon" />
} @else { // } @else {
<img alt="" class="icon" [src]="icon" /> // <img alt="" class="icon" [src]="icon" />
} // }
</tui-badged-content> // </tui-badged-content>
<label ticker class="title">{{ title }}</label> // <label ticker class="title">{{ title }}</label>
</span> // </span>
@if (isService) { // @if (isService) {
<span class="side"> // <span class="side">
<tui-hosted-dropdown // <tui-hosted-dropdown
[content]="content" // [content]="content"
(click.stop.prevent)="(0)" // (click.stop.prevent)="(0)"
(pointerdown.stop)="(0)" // (pointerdown.stop)="(0)"
> // >
<button // <button
tuiIconButton // tuiIconButton
appearance="outline" // appearance="outline"
size="xs" // size="xs"
iconLeft="tuiIconMoreHorizontal" // iconLeft="tuiIconMoreHorizontal"
[style.border-radius.%]="100" // [style.border-radius.%]="100"
> // >
Actions // Actions
</button> // </button>
<ng-template #content let-close="close"> // <ng-template #content let-close="close">
<app-actions [actions]="actions" (click)="close()"> // <app-actions [actions]="actions" (click)="close()">
{{ title }} // {{ title }}
</app-actions> // </app-actions>
</ng-template> // </ng-template>
</tui-hosted-dropdown> // </tui-hosted-dropdown>
</span> // </span>
} // }
`, // `,
styles: [ // styles: [
` // `
:host { // :host {
display: flex; // display: flex;
height: 5.5rem; // height: 5.5rem;
width: 12.5rem; // width: 12.5rem;
border-radius: var(--tui-radius-l); // border-radius: var(--tui-radius-l);
overflow: hidden; // overflow: hidden;
box-shadow: 0 0.25rem 0.25rem rgb(0 0 0 / 25%); // box-shadow: 0 0.25rem 0.25rem rgb(0 0 0 / 25%);
// TODO: Theme // // TODO: Theme
background: rgb(111 109 109); // background: rgb(111 109 109);
} // }
.link { // .link {
display: flex; // display: flex;
flex: 1; // flex: 1;
flex-direction: column; // flex-direction: column;
align-items: center; // align-items: center;
justify-content: center; // justify-content: center;
color: white; // color: white;
gap: 0.25rem; // gap: 0.25rem;
padding: 0 0.5rem; // padding: 0 0.5rem;
font: var(--tui-font-text-m); // font: var(--tui-font-text-m);
white-space: nowrap; // white-space: nowrap;
overflow: hidden; // overflow: hidden;
} // }
.icon { // .icon {
width: 2.5rem; // width: 2.5rem;
height: 2.5rem; // height: 2.5rem;
border-radius: 100%; // border-radius: 100%;
color: var(--tui-text-01-night); // color: var(--tui-text-01-night);
} // }
.side { // .side {
width: 3rem; // width: 3rem;
display: flex; // display: flex;
align-items: center; // align-items: center;
justify-content: center; // justify-content: center;
box-shadow: 0 0.25rem 0.25rem rgb(0 0 0 / 25%); // box-shadow: 0 0.25rem 0.25rem rgb(0 0 0 / 25%);
// TODO: Theme // // TODO: Theme
background: #4b4a4a; // background: #4b4a4a;
} // }
`, // `,
], // ],
standalone: true, // standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, // changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ // imports: [
CommonModule, // CommonModule,
RouterLink, // RouterLink,
TuiButtonModule, // TuiButtonModule,
TuiHostedDropdownModule, // TuiHostedDropdownModule,
TuiDataListModule, // TuiDataListModule,
TuiIconModule, // TuiIconModule,
TickerModule, // TickerModule,
TuiBadgedContentModule, // TuiBadgedContentModule,
TuiBadgeNotificationModule, // TuiBadgeNotificationModule,
ActionsComponent, // ActionsComponent,
], // ],
}) // })
export class CardComponent { // export class CardComponent {
@Input({ required: true }) // @Input({ required: true })
id!: string // id!: string
@Input({ required: true }) // @Input({ required: true })
icon!: string // icon!: string
@Input({ required: true }) // @Input({ required: true })
title!: string // title!: string
@Input() // @Input()
actions: Record<string, readonly Action[]> = {} // actions: Record<string, readonly Action[]> = {}
@Input() // @Input()
badge: number | null = null // badge: number | null = null
get isService(): boolean { // get isService(): boolean {
return !this.id.includes('/') // return !this.id.includes('/')
} // }
// Prevents Firefox from starting a native drag // // Prevents Firefox from starting a native drag
@HostListener('pointerdown.prevent') // @HostListener('pointerdown.prevent')
onDown() {} // onDown() {}
} // }

View File

@@ -8,7 +8,7 @@ import {
} from '@angular/core' } from '@angular/core'
import { FormGroup, ReactiveFormsModule } from '@angular/forms' import { FormGroup, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes' import { InputSpec } from '@start9labs/start-sdk/cjs/sdk/lib/config/configTypes'
import { import {
tuiMarkControlAsTouchedAndValidate, tuiMarkControlAsTouchedAndValidate,
TuiValueChangesModule, TuiValueChangesModule,

View File

@@ -37,13 +37,13 @@ import { ConfigService } from 'src/app/services/config.service'
<div tuiCell> <div tuiCell>
<div tuiTitle> <div tuiTitle>
<strong>CA fingerprint</strong> <strong>CA fingerprint</strong>
<div tuiSubtitle>{{ server['ca-fingerprint'] }}</div> <div tuiSubtitle>{{ server.caFingerprint }}</div>
</div> </div>
<button <button
tuiIconButton tuiIconButton
appearance="icon" appearance="icon"
iconLeft="tuiIconCopy" iconLeft="tuiIconCopy"
(click)="copyService.copy(server['ca-fingerprint'])" (click)="copyService.copy(server.caFingerprint)"
> >
Copy Copy
</button> </button>
@@ -62,7 +62,7 @@ import { ConfigService } from 'src/app/services/config.service'
], ],
}) })
export class AboutComponent { export class AboutComponent {
readonly server$ = inject(PatchDB<DataModel>).watch$('server-info') readonly server$ = inject(PatchDB<DataModel>).watch$('serverInfo')
readonly copyService = inject(CopyService) readonly copyService = inject(CopyService)
readonly gitHash = inject(ConfigService).gitHash readonly gitHash = inject(ConfigService).gitHash
} }

View File

@@ -50,8 +50,8 @@ export class HeaderConnectionComponent {
inject(ConnectionService).networkConnected$, inject(ConnectionService).networkConnected$,
inject(ConnectionService).websocketConnected$.pipe(startWith(false)), inject(ConnectionService).websocketConnected$.pipe(startWith(false)),
inject(PatchDB<DataModel>) inject(PatchDB<DataModel>)
.watch$('server-info', 'status-info') .watch$('serverInfo', 'statusInfo')
.pipe(startWith({ restarting: false, 'shutting-down': false })), .pipe(startWith({ restarting: false, shuttingDown: false })),
]).pipe( ]).pipe(
map(([network, websocket, status]) => { map(([network, websocket, status]) => {
if (!network) if (!network)
@@ -68,7 +68,7 @@ export class HeaderConnectionComponent {
icon: 'tuiIconCloudOff', icon: 'tuiIconCloudOff',
status: 'warning', status: 'warning',
} }
if (status['shutting-down']) if (status.shuttingDown)
return { return {
message: 'Shutting Down', message: 'Shutting Down',
color: 'var(--tui-neutral-fill)', color: 'var(--tui-neutral-fill)',

View File

@@ -169,7 +169,7 @@ export class HeaderComponent {
'ui', 'ui',
'gaming', 'gaming',
'snake', 'snake',
'high-score', 'highScore',
) )
} }

View File

@@ -46,7 +46,7 @@ import { NotificationService } from '../../services/notification.service'
tuiCell tuiCell
[notification]="not" [notification]="not"
> >
<ng-container *ngIf="not['package-id'] as pkgId"> <ng-container *ngIf="not.packageId as pkgId">
{{ $any(packageData[pkgId])?.manifest.title || pkgId }} {{ $any(packageData[pkgId])?.manifest.title || pkgId }}
</ng-container> </ng-container>
<button <button
@@ -57,11 +57,11 @@ import { NotificationService } from '../../services/notification.service'
(click)="markSeen(notifications, not)" (click)="markSeen(notifications, not)"
></button> ></button>
<a <a
*ngIf="not['package-id'] && packageData[not['package-id']]" *ngIf="not.packageId && packageData[not.packageId]"
tuiButton tuiButton
size="xs" size="xs"
appearance="secondary" appearance="secondary"
[routerLink]="getLink(not['package-id'] || '')" [routerLink]="getLink(not.packageId || '')"
> >
View Service View Service
</a> </a>
@@ -104,7 +104,7 @@ export class HeaderNotificationsComponent {
private readonly patch = inject(PatchDB<DataModel>) private readonly patch = inject(PatchDB<DataModel>)
private readonly service = inject(NotificationService) private readonly service = inject(NotificationService)
readonly packageData$ = this.patch.watch$('package-data').pipe(first()) readonly packageData$ = this.patch.watch$('packageData').pipe(first())
readonly notifications$ = new Subject<ServerNotifications>() readonly notifications$ = new Subject<ServerNotifications>()
@@ -112,7 +112,7 @@ export class HeaderNotificationsComponent {
ngAfterViewInit() { ngAfterViewInit() {
this.patch this.patch
.watch$('server-info', 'unreadNotifications', 'recent') .watch$('serverInfo', 'unreadNotifications', 'recent')
.pipe( .pipe(
tap(recent => this.notifications$.next(recent)), tap(recent => this.notifications$.next(recent)),
first(), first(),

View File

@@ -0,0 +1,87 @@
import { NgIf } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import { CopyService } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import {
TuiButtonModule,
TuiCellModule,
TuiTitleModule,
} from '@taiga-ui/experimental'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { QRComponent } from 'src/app/common/qr.component'
import { mask } from 'src/app/util/mask'
@Component({
standalone: true,
selector: 'app-interface-address',
template: `
<div tuiCell>
<h3 tuiTitle>
<span tuiSubtitle>{{ isMasked ? mask : address }}</span>
</h3>
<tui-badge appearance="success">
{{ label }}
</tui-badge>
<button
*ngIf="isUi"
tuiIconButton
iconLeft="tuiIconExternalLink"
appearance="icon"
(click)="launch(address)"
>
Launch
</button>
<button
tuiIconButton
iconLeft="tuiIconGrid"
appearance="icon"
(click)="showQR(address)"
>
Show QR code
</button>
<button
tuiIconButton
iconLeft="tuiIconCopy"
appearance="icon"
(click)="copyService.copy(address)"
>
Copy QR code
</button>
</div>
`,
imports: [NgIf, TuiCellModule, TuiTitleModule, TuiButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceAddressComponent {
private readonly window = inject(WINDOW)
private readonly dialogs = inject(TuiDialogService)
readonly copyService = inject(CopyService)
@Input() label?: string
@Input({ required: true }) address!: string
@Input({ required: true }) isMasked!: boolean
@Input({ required: true }) isUi!: boolean
get mask(): string {
return mask(this.address, 64)
}
launch(url: string): void {
this.window.open(url, '_blank', 'noreferrer')
}
showQR(data: string) {
this.dialogs
.open(new PolymorpheusComponent(QRComponent), {
size: 'auto',
data,
})
.subscribe()
}
}

View File

@@ -1,4 +1,4 @@
import { NgIf } from '@angular/common' import { NgForOf, NgIf } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@@ -20,10 +20,9 @@ import {
} from 'src/app/apps/portal/components/interfaces/interface.utils' } from 'src/app/apps/portal/components/interfaces/interface.utils'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service' import { FormDialogService } from 'src/app/services/form-dialog.service'
import { DomainInfo, NetworkInfo } from 'src/app/services/patch-db/data-model' import { NetworkInfo } from 'src/app/services/patch-db/data-model'
import { getClearnetAddress } from 'src/app/util/clearnetAddress' import { InterfaceAddressComponent } from './interface-addresses.component'
import { InterfaceComponent } from './interface.component' import { InterfaceComponent } from './interface.component'
import { InterfacesComponent } from './interfaces.component'
type ClearnetForm = { type ClearnetForm = {
domain: string domain: string
@@ -45,32 +44,36 @@ type ClearnetForm = {
</a> </a>
</em> </em>
<ng-container <ng-container
*ngIf="interfaces.addressInfo.domainInfo as domainInfo; else noClearnet" *ngIf="
interface.serviceInterface.addresses.clearnet as addresses;
else empty
"
> >
<app-interface <app-interface-address
label="Clearnet" *ngFor="let address of addresses"
[hostname]="getClearnet(domainInfo)" [label]="address.label"
[isUi]="interfaces.isUi" [address]="address.url"
[isMasked]="interface.serviceInterface.masked"
[isUi]="interface.serviceInterface.type === 'ui'"
/> />
<div [style.display]="'flex'" [style.gap.rem]="1"> <div [style.display]="'flex'" [style.gap.rem]="1">
<button tuiButton size="s" (click)="add()">Update</button>
<button tuiButton size="s" appearance="danger-solid" (click)="remove()"> <button tuiButton size="s" appearance="danger-solid" (click)="remove()">
Remove Remove
</button> </button>
</div> </div>
</ng-container> </ng-container>
<ng-template #noClearnet> <ng-template #empty>
<button <button
tuiButton tuiButton
iconLeft="tuiIconPlus" iconLeft="tuiIconPlus"
[style.align-self]="'flex-start'" [style.align-self]="'flex-start'"
(click)="add()" (click)="add()"
> >
Add Clearnet Add Address
</button> </button>
</ng-template> </ng-template>
`, `,
imports: [InterfaceComponent, NgIf, TuiButtonModule], imports: [NgForOf, InterfaceAddressComponent, NgIf, TuiButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class InterfaceClearnetComponent { export class InterfaceClearnetComponent {
@@ -79,21 +82,14 @@ export class InterfaceClearnetComponent {
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService) private readonly dialogs = inject(TuiDialogService)
readonly interfaces = inject(InterfacesComponent) readonly interface = inject(InterfaceComponent)
@Input({ required: true }) network!: NetworkInfo @Input({ required: true }) network!: NetworkInfo
getClearnet(clearnet: DomainInfo): string {
return getClearnetAddress('https', clearnet)
}
async add() { async add() {
const { domainInfo } = this.interfaces.addressInfo
const { domain = '', subdomain = '' } = domainInfo || {}
const options: Partial<TuiDialogOptions<FormContext<ClearnetForm>>> = { const options: Partial<TuiDialogOptions<FormContext<ClearnetForm>>> = {
label: 'Select Domain/Subdomain', label: 'Select Domain/Subdomain',
data: { data: {
value: { domain, subdomain },
spec: await getClearnetSpec(this.network), spec: await getClearnetSpec(this.network),
buttons: [ buttons: [
{ {
@@ -118,9 +114,9 @@ export class InterfaceClearnetComponent {
const loader = this.loader.open('Removing...').subscribe() const loader = this.loader.open('Removing...').subscribe()
try { try {
if (this.interfaces.packageContext) { if (this.interface.packageContext) {
await this.api.setInterfaceClearnetAddress({ await this.api.setInterfaceClearnetAddress({
...this.interfaces.packageContext, ...this.interface.packageContext,
domainInfo: null, domainInfo: null,
}) })
} else { } else {
@@ -138,9 +134,9 @@ export class InterfaceClearnetComponent {
const loader = this.loader.open('Saving...').subscribe() const loader = this.loader.open('Saving...').subscribe()
try { try {
if (this.interfaces.packageContext) { if (this.interface.packageContext) {
await this.api.setInterfaceClearnetAddress({ await this.api.setInterfaceClearnetAddress({
...this.interfaces.packageContext, ...this.interface.packageContext,
domainInfo, domainInfo,
}) })
} else { } else {

View File

@@ -1,8 +1,8 @@
import { CommonModule } from '@angular/common' import { NgForOf, NgIf } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiButtonModule } from '@taiga-ui/experimental' import { TuiButtonModule } from '@taiga-ui/experimental'
import { InterfacesComponent } from './interfaces.component'
import { InterfaceComponent } from './interface.component' import { InterfaceComponent } from './interface.component'
import { InterfaceAddressComponent } from './interface-addresses.component'
@Component({ @Component({
standalone: true, standalone: true,
@@ -19,41 +19,44 @@ import { InterfaceComponent } from './interface.component'
<strong>View instructions</strong> <strong>View instructions</strong>
</a> </a>
</em> </em>
<a
*ngIf="!interfaces.packageContext"
tuiButton
iconLeft="tuiIconDownload"
href="/public/eos/local.crt"
[download]="interfaces.addressInfo.lanHostname + '.crt'"
[style.align-self]="'flex-start'"
>
Download Root CA
</a>
<app-interface
label="Local"
[hostname]="interfaces.addressInfo.lanHostname"
[isUi]="interfaces.isUi"
></app-interface>
<ng-container <ng-container
*ngFor="let iface of interfaces.addressInfo.ipInfo | keyvalue" *ngIf="
interface.serviceInterface.addresses.local as addresses;
else empty
"
> >
<app-interface <app-interface-address
*ngIf="iface.value.ipv4 as ipv4" *ngFor="let address of addresses"
[label]="iface.key + ' (IPv4)'" [label]="address.label"
[hostname]="ipv4" [address]="address.url"
[isUi]="interfaces.isUi" [isMasked]="interface.serviceInterface.masked"
></app-interface> [isUi]="interface.serviceInterface.type === 'ui'"
<app-interface />
*ngIf="iface.value.ipv6 as ipv6" <div [style.display]="'flex'" [style.gap.rem]="1">
[label]="iface.key + ' (IPv6)'" <button tuiButton size="s" appearance="danger-solid" (click)="remove()">
[hostname]="ipv6" Remove
[isUi]="interfaces.isUi" </button>
></app-interface> </div>
</ng-container> </ng-container>
<ng-template #empty>
<button
tuiButton
iconLeft="tuiIconPlus"
[style.align-self]="'flex-start'"
(click)="add()"
>
Add Address
</button>
</ng-template>
`, `,
imports: [InterfaceComponent, CommonModule, TuiButtonModule], imports: [NgForOf, NgIf, InterfaceAddressComponent, TuiButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class InterfaceLocalComponent { export class InterfaceLocalComponent {
readonly interfaces = inject(InterfacesComponent) readonly interface = inject(InterfaceComponent)
async add() {}
async remove() {}
} }

View File

@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { InterfaceAddressComponent } from './interface-addresses.component'
import { InterfaceComponent } from './interface.component' import { InterfaceComponent } from './interface.component'
import { InterfacesComponent } from './interfaces.component' import { NgForOf, NgIf } from '@angular/common'
@Component({ @Component({
standalone: true, standalone: true,
@@ -17,15 +18,49 @@ import { InterfacesComponent } from './interfaces.component'
<strong>View instructions</strong> <strong>View instructions</strong>
</a> </a>
</em> </em>
<app-interface
label="Tor" <ng-container
[hostname]="interfaces.addressInfo.torHostname" *ngIf="interface.serviceInterface.addresses.tor as addresses; else empty"
[isUi]="interfaces.isUi" >
></app-interface> <app-interface-address
*ngFor="let address of addresses"
[label]="address.label"
[address]="address.url"
[isMasked]="interface.serviceInterface.masked"
[isUi]="interface.serviceInterface.type === 'ui'"
/>
<div [style.display]="'flex'" [style.gap.rem]="1">
<button tuiButton size="s" appearance="danger-solid" (click)="remove()">
Remove
</button>
</div>
</ng-container>
<ng-template #empty>
<button
tuiButton
iconLeft="tuiIconPlus"
[style.align-self]="'flex-start'"
(click)="add()"
>
Add Address
</button>
</ng-template>
<app-interface-address
*ngFor="let address of interface.serviceInterface.addresses.tor"
[label]="address.label"
[address]="address.url"
[isMasked]="interface.serviceInterface.masked"
[isUi]="interface.serviceInterface.type === 'ui'"
/>
`, `,
imports: [InterfaceComponent], imports: [NgForOf, NgIf, InterfaceAddressComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class InterfaceTorComponent { export class InterfaceTorComponent {
readonly interfaces = inject(InterfacesComponent) readonly interface = inject(InterfaceComponent)
async add() {}
async remove() {}
} }

View File

@@ -1,79 +1,61 @@
import { NgIf } from '@angular/common' import { CommonModule } from '@angular/common'
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
inject, inject,
Input, Input,
} from '@angular/core' } from '@angular/core'
import { WINDOW } from '@ng-web-apis/common' import { T } from '@start9labs/start-sdk'
import { CopyService } from '@start9labs/shared' import { TuiCardModule, TuiSurfaceModule } from '@taiga-ui/experimental'
import { TuiDialogService } from '@taiga-ui/core' import { PatchDB } from 'patch-db-client'
import { import { InterfaceClearnetComponent } from 'src/app/apps/portal/components/interfaces/interface-clearnet.component'
TuiButtonModule, import { InterfaceLocalComponent } from 'src/app/apps/portal/components/interfaces/interface-local.component'
TuiCellModule, import { InterfaceTorComponent } from 'src/app/apps/portal/components/interfaces/interface-tor.component'
TuiTitleModule, import { DataModel } from 'src/app/services/patch-db/data-model'
} from '@taiga-ui/experimental' import { AddressDetails } from './interface.utils'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { QRComponent } from 'src/app/common/qr.component'
@Component({ @Component({
standalone: true, standalone: true,
selector: 'app-interface', selector: 'app-interface',
template: ` template: `
<div tuiCell> <h3 class="g-title">Clearnet</h3>
<h3 tuiTitle> <app-interface-clearnet
{{ label }} *ngIf="network$ | async as network"
<span tuiSubtitle>{{ hostname }}</span> tuiCardLarge="compact"
</h3> tuiSurface="elevated"
<button [network]="network"
*ngIf="isUi" />
tuiIconButton
iconLeft="tuiIconExternalLink" <h3 class="g-title">Tor</h3>
appearance="icon" <app-interface-tor tuiCardLarge="compact" tuiSurface="elevated" />
(click)="launch(hostname)"
> <h3 class="g-title">Local</h3>
Launch <app-interface-local tuiCardLarge="compact" tuiSurface="elevated" />
</button>
<button
tuiIconButton
iconLeft="tuiIconGrid"
appearance="icon"
(click)="showQR(hostname)"
>
Show QR code
</button>
<button
tuiIconButton
iconLeft="tuiIconCopy"
appearance="icon"
(click)="copyService.copy(hostname)"
>
Copy QR code
</button>
</div>
`, `,
imports: [NgIf, TuiCellModule, TuiTitleModule, TuiButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
InterfaceTorComponent,
InterfaceLocalComponent,
InterfaceClearnetComponent,
TuiCardModule,
TuiSurfaceModule,
],
}) })
export class InterfaceComponent { export class InterfaceComponent {
private readonly window = inject(WINDOW) readonly network$ = inject(PatchDB<DataModel>).watch$('serverInfo', 'network')
private readonly dialogs = inject(TuiDialogService)
readonly copyService = inject(CopyService)
@Input({ required: true }) label = '' @Input() packageContext?: {
@Input({ required: true }) hostname = '' packageId: string
@Input({ required: true }) isUi = false interfaceId: string
launch(url: string): void {
this.window.open(url, '_blank', 'noreferrer')
} }
@Input({ required: true }) serviceInterface!: ServiceInterfaceWithAddresses
}
showQR(data: string) { export type ServiceInterfaceWithAddresses = T.ServiceInterface & {
this.dialogs addresses: {
.open(new PolymorpheusComponent(QRComponent), { clearnet: AddressDetails[]
size: 'auto', local: AddressDetails[]
data, tor: AddressDetails[]
})
.subscribe()
} }
} }

View File

@@ -1,6 +1,7 @@
import { Config } from '@start9labs/start-sdk/lib/config/builder/config' import { T } from '@start9labs/start-sdk'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value' import { Config } from '@start9labs/start-sdk/cjs/sdk/lib/config/builder/config'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes' import { Value } from '@start9labs/start-sdk/cjs/sdk/lib/config/builder/value'
import { InputSpec } from '@start9labs/start-sdk/cjs/sdk/lib/config/configTypes'
import { TuiDialogOptions } from '@taiga-ui/core' import { TuiDialogOptions } from '@taiga-ui/core'
import { TuiPromptData } from '@taiga-ui/kit' import { TuiPromptData } from '@taiga-ui/kit'
import { NetworkInfo } from 'src/app/services/patch-db/data-model' import { NetworkInfo } from 'src/app/services/patch-db/data-model'
@@ -44,3 +45,96 @@ export function getClearnetSpec({
}), }),
) )
} }
export type AddressDetails = {
label?: string
url: string
}
export function getAddresses(
serviceInterface: T.ServiceInterfaceWithHostInfo,
): {
clearnet: AddressDetails[]
local: AddressDetails[]
tor: AddressDetails[]
} {
const host = serviceInterface.hostInfo
const addressInfo = serviceInterface.addressInfo
const username = addressInfo.username ? addressInfo.username + '@' : ''
const suffix = addressInfo.suffix || ''
const hostnames =
host.kind === 'multi'
? host.hostnames
: host.hostname
? [host.hostname]
: []
const clearnet: AddressDetails[] = []
const local: AddressDetails[] = []
const tor: AddressDetails[] = []
hostnames.forEach(h => {
let scheme = ''
let port = ''
if (h.hostname.sslPort) {
port = h.hostname.sslPort === 443 ? '' : `:${h.hostname.sslPort}`
scheme = addressInfo.bindOptions.addSsl?.scheme
? `${addressInfo.bindOptions.addSsl.scheme}://`
: ''
}
if (h.hostname.port) {
port = h.hostname.port === 80 ? '' : `:${h.hostname.port}`
scheme = addressInfo.bindOptions.scheme
? `${addressInfo.bindOptions.scheme}://`
: ''
}
if (h.kind === 'onion') {
tor.push({
label: h.hostname.sslPort ? 'HTTPS' : 'HTTP',
url: toHref(scheme, username, h.hostname.value, port, suffix),
})
} else {
const hostnameKind = h.hostname.kind
if (hostnameKind === 'domain') {
tor.push({
url: toHref(
scheme,
username,
`${h.hostname.subdomain}.${h.hostname.domain}`,
port,
suffix,
),
})
} else {
local.push({
label:
hostnameKind === 'local'
? 'Local'
: `${h.networkInterfaceId} (${hostnameKind})`,
url: toHref(scheme, username, h.hostname.value, port, suffix),
})
}
}
})
return {
clearnet,
local,
tor,
}
}
function toHref(
scheme: string,
username: string,
hostname: string,
port: string,
suffix: string,
): string {
return `${scheme}${username}${hostname}${port}${suffix}`
}

View File

@@ -1,55 +0,0 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { TuiCardModule, TuiSurfaceModule } from '@taiga-ui/experimental'
import { PatchDB } from 'patch-db-client'
import { InterfaceClearnetComponent } from 'src/app/apps/portal/components/interfaces/interface-clearnet.component'
import { InterfaceLocalComponent } from 'src/app/apps/portal/components/interfaces/interface-local.component'
import { InterfaceTorComponent } from 'src/app/apps/portal/components/interfaces/interface-tor.component'
import { AddressInfo, DataModel } from 'src/app/services/patch-db/data-model'
@Component({
standalone: true,
selector: 'app-interfaces',
template: `
<h3 class="g-title">Clearnet</h3>
<app-interface-clearnet
*ngIf="network$ | async as network"
tuiCardLarge="compact"
tuiSurface="elevated"
[network]="network"
/>
<h3 class="g-title">Tor</h3>
<app-interface-tor tuiCardLarge="compact" tuiSurface="elevated" />
<h3 class="g-title">Local</h3>
<app-interface-local tuiCardLarge="compact" tuiSurface="elevated" />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
InterfaceTorComponent,
InterfaceLocalComponent,
InterfaceClearnetComponent,
TuiCardModule,
TuiSurfaceModule,
],
})
export class InterfacesComponent {
readonly network$ = inject(PatchDB<DataModel>).watch$(
'server-info',
'network',
)
@Input() packageContext?: {
packageId: string
interfaceId: string
}
@Input({ required: true }) addressInfo!: AddressInfo
@Input({ required: true }) isUi!: boolean
}

View File

@@ -25,7 +25,7 @@ export class LogsFetchDirective {
}), }),
), ),
), ),
tap(res => this.component.setCursor(res['start-cursor'])), tap(res => this.component.setCursor(res.startCursor)),
map(({ entries }) => convertAnsi(entries)), map(({ entries }) => convertAnsi(entries)),
catchError(e => { catchError(e => {
this.errors.handleError(e) this.errors.handleError(e)

View File

@@ -43,7 +43,7 @@ export class LogsPipe implements PipeTransform {
map(() => getMessage(true)), map(() => getMessage(true)),
), ),
defer(() => followLogs(this.options)).pipe( defer(() => followLogs(this.options)).pipe(
tap(r => this.logs.setCursor(r['start-cursor'])), tap(r => this.logs.setCursor(r.startCursor)),
switchMap(r => this.api.openLogsWebsocket$(this.toConfig(r.guid))), switchMap(r => this.api.openLogsWebsocket$(this.toConfig(r.guid))),
bufferTime(1000), bufferTime(1000),
filter(logs => !!logs.length), filter(logs => !!logs.length),

View File

@@ -6,7 +6,7 @@ import {
isEmptyObject, isEmptyObject,
LoadingService, LoadingService,
} from '@start9labs/shared' } from '@start9labs/shared'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes' import { InputSpec } from '@start9labs/start-sdk/cjs/sdk/lib/config/configTypes'
import { TuiButtonModule } from '@taiga-ui/experimental' import { TuiButtonModule } from '@taiga-ui/experimental'
import { import {
TuiDialogContext, TuiDialogContext,
@@ -27,7 +27,11 @@ import {
PackageDataEntry, PackageDataEntry,
} from 'src/app/services/patch-db/data-model' } from 'src/app/services/patch-db/data-model'
import { hasCurrentDeps } from 'src/app/util/has-deps' import { hasCurrentDeps } from 'src/app/util/has-deps'
import { getAllPackages, getPackage } from 'src/app/util/get-package-data' import {
getAllPackages,
getManifest,
getPackage,
} from 'src/app/util/get-package-data'
import { Breakages } from 'src/app/services/api/api.types' import { Breakages } from 'src/app/services/api/api.types'
import { InvalidService } from 'src/app/common/form/invalid.service' import { InvalidService } from 'src/app/common/form/invalid.service'
import { import {
@@ -35,6 +39,7 @@ import {
FormComponent, FormComponent,
} from 'src/app/apps/portal/components/form.component' } from 'src/app/apps/portal/components/form.component'
import { DependentInfo } from 'src/app/types/dependent-info' import { DependentInfo } from 'src/app/types/dependent-info'
import { ToManifestPipe } from '../pipes/to-manifest'
export interface PackageConfigData { export interface PackageConfigData {
readonly pkgId: string readonly pkgId: string
@@ -52,23 +57,26 @@ export interface PackageConfigData {
<div [innerHTML]="loadingError"></div> <div [innerHTML]="loadingError"></div>
</tui-notification> </tui-notification>
<ng-container *ngIf="!loadingText && !loadingError && pkg"> <ng-container
*ngIf="
!loadingText && !loadingError && pkg && (pkg | toManifest) as manifest
"
>
<tui-notification *ngIf="success" status="success"> <tui-notification *ngIf="success" status="success">
{{ pkg.manifest.title }} has been automatically configured with {{ manifest.title }} has been automatically configured with recommended
recommended defaults. Make whatever changes you want, then click "Save". defaults. Make whatever changes you want, then click "Save".
</tui-notification> </tui-notification>
<config-dep <config-dep
*ngIf="dependentInfo && value && original" *ngIf="dependentInfo && value && original"
[package]="pkg.manifest.title" [package]="manifest.title"
[dep]="dependentInfo.title" [dep]="dependentInfo.title"
[original]="original" [original]="original"
[value]="value" [value]="value"
/> />
<tui-notification *ngIf="!pkg.installed?.['has-config']" status="warning"> <tui-notification *ngIf="!manifest.hasConfig" status="warning">
No config options for {{ pkg.manifest.title }} No config options for {{ manifest.title }} {{ manifest.version }}.
{{ pkg.manifest.version }}.
</tui-notification> </tui-notification>
<app-form <app-form
@@ -106,6 +114,7 @@ export interface PackageConfigData {
TuiButtonModule, TuiButtonModule,
TuiModeModule, TuiModeModule,
ConfigDepComponent, ConfigDepComponent,
ToManifestPipe,
], ],
providers: [InvalidService], providers: [InvalidService],
}) })
@@ -149,7 +158,7 @@ export class ServiceConfigModal {
!!this.form && !!this.form &&
!this.form.form.dirty && !this.form.form.dirty &&
!this.original && !this.original &&
!this.pkg?.installed?.status?.configured !this.pkg?.status?.configured
) )
} }
@@ -165,12 +174,12 @@ export class ServiceConfigModal {
if (this.dependentInfo) { if (this.dependentInfo) {
const depConfig = await this.embassyApi.dryConfigureDependency({ const depConfig = await this.embassyApi.dryConfigureDependency({
'dependency-id': this.pkgId, dependencyId: this.pkgId,
'dependent-id': this.dependentInfo.id, dependentId: this.dependentInfo.id,
}) })
this.original = depConfig['old-config'] this.original = depConfig.oldConfig
this.value = depConfig['new-config'] || this.original this.value = depConfig.newConfig || this.original
this.spec = depConfig.spec this.spec = depConfig.spec
this.patch = compare(this.original, this.value) this.patch = compare(this.original, this.value)
} else { } else {
@@ -195,7 +204,7 @@ export class ServiceConfigModal {
try { try {
await this.uploadFiles(config, loader) await this.uploadFiles(config, loader)
if (hasCurrentDeps(this.pkg!)) { if (hasCurrentDeps(this.pkgId, await getAllPackages(this.patchDb))) {
await this.configureDeps(config, loader) await this.configureDeps(config, loader)
} else { } else {
await this.configure(config, loader) await this.configure(config, loader)
@@ -260,7 +269,7 @@ export class ServiceConfigModal {
const message = const message =
'As a result of this change, the following services will no longer work properly and may crash:<ul>' '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( const content = `${message}${Object.keys(breakages).map(
id => `<li><b>${packages[id].manifest.title}</b></li>`, id => `<li><b>${getManifest(packages[id]).title}</b></li>`,
)}</ul>` )}</ul>`
const data: TuiPromptData = { content, yes: 'Continue', no: 'Cancel' } const data: TuiPromptData = { content, yes: 'Continue', no: 'Cancel' }

View File

@@ -1,15 +0,0 @@
import { inject, Pipe, PipeTransform } from '@angular/core'
import { BadgeService } from '../services/badge.service'
import { Observable } from 'rxjs'
@Pipe({
name: 'toBadge',
standalone: true,
})
export class ToBadgePipe implements PipeTransform {
readonly badge = inject(BadgeService)
transform(id: string): Observable<number> {
return this.badge.getCount(id)
}
}

View File

@@ -0,0 +1,14 @@
import { Pipe, PipeTransform } from '@angular/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { Manifest } from '@start9labs/marketplace'
import { getManifest } from 'src/app/util/get-package-data'
@Pipe({
name: 'toManifest',
standalone: true,
})
export class ToManifestPipe implements PipeTransform {
transform(pkg: PackageDataEntry): Manifest {
return getManifest(pkg)
}
}

View File

@@ -10,24 +10,23 @@ import {
TuiButtonModule, TuiButtonModule,
tuiButtonOptionsProvider, tuiButtonOptionsProvider,
} from '@taiga-ui/experimental' } from '@taiga-ui/experimental'
import { map, of } from 'rxjs' import { map, Observable } from 'rxjs'
import { UIComponent } from 'src/app/apps/portal/routes/dashboard/ui.component' import { UILaunchComponent } from 'src/app/apps/portal/routes/dashboard/ui.component'
import { ActionsService } from 'src/app/apps/portal/services/actions.service' import { ActionsService } from 'src/app/apps/portal/services/actions.service'
import { DepErrorService } from 'src/app/services/dep-error.service' import { DepErrorService } from 'src/app/services/dep-error.service'
import { import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
PackageDataEntry, import { getManifest } from 'src/app/util/get-package-data'
PackageMainStatus, import { Manifest } from '@start9labs/marketplace'
} from 'src/app/services/patch-db/data-model'
@Component({ @Component({
standalone: true, standalone: true,
selector: 'fieldset[appControls]', selector: 'fieldset[appControls]',
template: ` template: `
@if (isRunning) { @if (pkg.status.main.status === 'running') {
<button <button
tuiIconButton tuiIconButton
iconLeft="tuiIconSquare" iconLeft="tuiIconSquare"
(click)="actions.stop(appControls)" (click)="actions.stop(manifest)"
> >
Stop Stop
</button> </button>
@@ -35,17 +34,17 @@ import {
<button <button
tuiIconButton tuiIconButton
iconLeft="tuiIconRotateCw" iconLeft="tuiIconRotateCw"
(click)="actions.restart(appControls)" (click)="actions.restart(manifest)"
> >
Restart Restart
</button> </button>
} @else { } @else {
<button <button
*tuiLet="hasUnmet(appControls) | async as hasUnmet" *tuiLet="hasUnmet(pkg) | async as hasUnmet"
tuiIconButton tuiIconButton
iconLeft="tuiIconPlay" iconLeft="tuiIconPlay"
[disabled]="!isConfigured" [disabled]="!this.pkg.status.configured"
(click)="actions.start(appControls, !!hasUnmet)" (click)="actions.start(manifest, !!hasUnmet)"
> >
Start Start
</button> </button>
@@ -53,48 +52,39 @@ import {
<button <button
tuiIconButton tuiIconButton
iconLeft="tuiIconTool" iconLeft="tuiIconTool"
(click)="actions.configure(appControls)" (click)="actions.configure(manifest)"
> >
Configure Configure
</button> </button>
} }
<app-ui [pkg]="appControls" /> <app-ui-launch [pkg]="pkg" />
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButtonModule, UIComponent, TuiLetModule, AsyncPipe], imports: [TuiButtonModule, UILaunchComponent, TuiLetModule, AsyncPipe],
providers: [tuiButtonOptionsProvider({ size: 's', appearance: 'none' })], providers: [tuiButtonOptionsProvider({ size: 's', appearance: 'none' })],
}) })
export class ControlsComponent { export class ControlsComponent {
private readonly errors = inject(DepErrorService) private readonly errors = inject(DepErrorService)
@Input() @Input()
appControls!: PackageDataEntry pkg!: PackageDataEntry
get manifest(): Manifest {
return getManifest(this.pkg)
}
readonly actions = inject(ActionsService) readonly actions = inject(ActionsService)
get isRunning(): boolean { @tuiPure
return ( hasUnmet(pkg: PackageDataEntry): Observable<boolean> {
this.appControls.installed?.status.main.status === const id = getManifest(pkg).id
PackageMainStatus.Running return this.errors.getPkgDepErrors$(id).pipe(
map(errors =>
Object.keys(pkg.currentDependencies)
.map(id => !!(errors[id] as any)?.[id]) // @TODO fix
.some(Boolean),
),
) )
} }
get isConfigured(): boolean {
return !!this.appControls.installed?.status.configured
}
@tuiPure
hasUnmet({ installed, manifest }: PackageDataEntry) {
return installed
? this.errors.getPkgDepErrors$(manifest.id).pipe(
map(errors =>
Object.keys(installed['current-dependencies'])
.filter(id => !!manifest.dependencies[id])
.map(id => !!(errors[manifest.id] as any)?.[id]) // @TODO fix
.some(Boolean),
),
)
: of(false)
}
} }

View File

@@ -11,29 +11,26 @@ import { ControlsComponent } from 'src/app/apps/portal/routes/dashboard/controls
import { StatusComponent } from 'src/app/apps/portal/routes/dashboard/status.component' import { StatusComponent } from 'src/app/apps/portal/routes/dashboard/status.component'
import { ConnectionService } from 'src/app/services/connection.service' import { ConnectionService } from 'src/app/services/connection.service'
import { PkgDependencyErrors } from 'src/app/services/dep-error.service' import { PkgDependencyErrors } from 'src/app/services/dep-error.service'
import { import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
PackageDataEntry, import { getManifest } from 'src/app/util/get-package-data'
PackageState,
} from 'src/app/services/patch-db/data-model'
import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
@Component({ @Component({
standalone: true, standalone: true,
selector: 'tr[appService]', selector: 'tr[appService]',
template: ` template: `
<td><img alt="logo" [src]="appService.icon" /></td> <td><img alt="logo" [src]="pkg.icon" /></td>
<td> <td>
<a [routerLink]="routerLink">{{ appService.manifest.title }}</a> <a [routerLink]="routerLink">{{ manifest.title }}</a>
</td> </td>
<td>{{ appService.manifest.version }}</td> <td>{{ manifest.version }}</td>
<td <td appStatus [pkg]="pkg" [hasDepErrors]="hasError(depErrors)"></td>
[appStatus]="appService"
[appStatusError]="hasError(appServiceError)"
></td>
<td [style.text-align]="'center'"> <td [style.text-align]="'center'">
<fieldset <fieldset
[disabled]="!installed || !(connected$ | async)" appControls
[appControls]="appService" [disabled]="
this.pkg.stateInfo.state !== 'installed' || !(connected$ | async)
"
[pkg]="pkg"
></fieldset> ></fieldset>
</td> </td>
`, `,
@@ -57,19 +54,19 @@ import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
}) })
export class ServiceComponent { export class ServiceComponent {
@Input() @Input()
appService!: PackageDataEntry pkg!: PackageDataEntry
@Input() @Input()
appServiceError?: PkgDependencyErrors depErrors?: PkgDependencyErrors
readonly connected$ = inject(ConnectionService).connected$ readonly connected$ = inject(ConnectionService).connected$
get routerLink() { get manifest() {
return `/portal/service/${this.appService.manifest.id}` return getManifest(this.pkg)
} }
get installed(): boolean { get routerLink() {
return this.appService.state === PackageState.Installed return `/portal/service/${this.manifest.id}`
} }
@tuiPure @tuiPure

View File

@@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ServiceComponent } from 'src/app/apps/portal/routes/dashboard/service.component' import { ServiceComponent } from 'src/app/apps/portal/routes/dashboard/service.component'
import { ServicesService } from 'src/app/apps/portal/services/services.service' import { ServicesService } from 'src/app/apps/portal/services/services.service'
import { DepErrorService } from 'src/app/services/dep-error.service' import { DepErrorService } from 'src/app/services/dep-error.service'
import { ToManifestPipe } from '../../pipes/to-manifest'
@Component({ @Component({
standalone: true, standalone: true,
@@ -21,10 +22,11 @@ import { DepErrorService } from 'src/app/services/dep-error.service'
</thead> </thead>
<tbody> <tbody>
@if (errors$ | async; as errors) { @if (errors$ | async; as errors) {
@for (service of services$ | async; track $index) { @for (pkg of services$ | async; track $index) {
<tr <tr
[appService]="service" appService
[appServiceError]="errors[service.manifest.id]" [pkg]="pkg"
[depErrors]="errors[(pkg | toManifest).id]"
></tr> ></tr>
} @empty { } @empty {
<tr> <tr>
@@ -78,7 +80,7 @@ import { DepErrorService } from 'src/app/services/dep-error.service'
} }
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServiceComponent, AsyncPipe], imports: [ServiceComponent, AsyncPipe, ToManifestPipe],
}) })
export class ServicesComponent { export class ServicesComponent {
readonly services$ = inject(ServicesService) readonly services$ = inject(ServicesService)

View File

@@ -2,16 +2,13 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { tuiPure } from '@taiga-ui/cdk' import { tuiPure } from '@taiga-ui/cdk'
import { TuiLoaderModule } from '@taiga-ui/core' import { TuiLoaderModule } from '@taiga-ui/core'
import { TuiIconModule } from '@taiga-ui/experimental' import { TuiIconModule } from '@taiga-ui/experimental'
import { import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
PackageDataEntry,
PackageState,
} from 'src/app/services/patch-db/data-model'
import { import {
HealthStatus, HealthStatus,
PrimaryStatus, PrimaryStatus,
renderPkgStatus, renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service' } from 'src/app/services/pkg-status-rendering.service'
import { packageLoadingProgress } from 'src/app/util/package-loading-progress' import { InstallingProgressDisplayPipe } from '../service/pipes/install-progress.pipe'
@Component({ @Component({
standalone: true, standalone: true,
@@ -41,27 +38,23 @@ import { packageLoadingProgress } from 'src/app/util/package-loading-progress'
}) })
export class StatusComponent { export class StatusComponent {
@Input() @Input()
appStatus!: PackageDataEntry pkg!: PackageDataEntry
@Input() @Input()
appStatusError = false hasDepErrors = false
get healthy(): boolean { get healthy(): boolean {
const status = this.getStatus(this.appStatus) const status = this.getStatus(this.pkg)
return ( return (
!this.appStatusError && // no deps error !this.hasDepErrors && // no deps error
!!this.appStatus.installed?.status.configured && // no config needed !!this.pkg.status.configured && // no config needed
status.primary !== PackageState.NeedsUpdate && // no update needed
status.health !== HealthStatus.Failure // no health issues status.health !== HealthStatus.Failure // no health issues
) )
} }
get loading(): boolean { get loading(): boolean {
return ( return !!this.pkg.stateInfo || this.color === 'var(--tui-info-fill)'
!!this.appStatus['install-progress'] ||
this.color === 'var(--tui-info-fill)'
)
} }
@tuiPure @tuiPure
@@ -70,17 +63,15 @@ export class StatusComponent {
} }
get status(): string { get status(): string {
if (this.appStatus['install-progress']) { if (this.pkg.stateInfo.installingInfo) {
return `Installing... ${packageLoadingProgress(this.appStatus['install-progress'])?.totalProgress || 0}%` return `Installing...${new InstallingProgressDisplayPipe().transform(this.pkg.stateInfo.installingInfo.progress.overall)}`
} }
switch (this.getStatus(this.appStatus).primary) { switch (this.getStatus(this.pkg).primary) {
case PrimaryStatus.Running: case PrimaryStatus.Running:
return 'Running' return 'Running'
case PrimaryStatus.Stopped: case PrimaryStatus.Stopped:
return 'Stopped' return 'Stopped'
case PackageState.NeedsUpdate:
return 'Needs Update'
case PrimaryStatus.NeedsConfig: case PrimaryStatus.NeedsConfig:
return 'Needs Config' return 'Needs Config'
case PrimaryStatus.Updating: case PrimaryStatus.Updating:
@@ -103,14 +94,13 @@ export class StatusComponent {
} }
get color(): string { get color(): string {
if (this.appStatus['install-progress']) { if (this.pkg.stateInfo.installingInfo) {
return 'var(--tui-info-fill)' return 'var(--tui-info-fill)'
} }
switch (this.getStatus(this.appStatus).primary) { switch (this.getStatus(this.pkg).primary) {
case PrimaryStatus.Running: case PrimaryStatus.Running:
return 'var(--tui-success-fill)' return 'var(--tui-success-fill)'
case PackageState.NeedsUpdate:
case PrimaryStatus.NeedsConfig: case PrimaryStatus.NeedsConfig:
return 'var(--tui-warning-fill)' return 'var(--tui-warning-fill)'
case PrimaryStatus.Updating: case PrimaryStatus.Updating:

View File

@@ -4,20 +4,19 @@ import {
inject, inject,
Input, Input,
} from '@angular/core' } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { tuiPure } from '@taiga-ui/cdk' import { tuiPure } from '@taiga-ui/cdk'
import { TuiDataListModule, TuiHostedDropdownModule } from '@taiga-ui/core' import { TuiDataListModule, TuiHostedDropdownModule } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental' import { TuiButtonModule } from '@taiga-ui/experimental'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
import { import {
InstalledPackageInfo,
InterfaceInfo,
PackageDataEntry, PackageDataEntry,
PackageMainStatus, PackageMainStatus,
} from 'src/app/services/patch-db/data-model' } from 'src/app/services/patch-db/data-model'
@Component({ @Component({
standalone: true, standalone: true,
selector: 'app-ui', selector: 'app-ui-launch',
template: ` template: `
@if (interfaces.length > 1) { @if (interfaces.length > 1) {
<tui-hosted-dropdown [content]="content"> <tui-hosted-dropdown [content]="content">
@@ -26,7 +25,7 @@ import {
iconLeft="tuiIconExternalLink" iconLeft="tuiIconExternalLink"
[disabled]="!isRunning" [disabled]="!isRunning"
> >
Interfaces Launch UI
</button> </button>
<ng-template #content> <ng-template #content>
<tui-data-list> <tui-data-list>
@@ -44,42 +43,44 @@ import {
</ng-template> </ng-template>
</tui-hosted-dropdown> </tui-hosted-dropdown>
} @else { } @else {
<a @if (interfaces[0]; as info) {
tuiIconButton <a
iconLeft="tuiIconExternalLink" tuiIconButton
target="_blank" iconLeft="tuiIconExternalLink"
rel="noreferrer" target="_blank"
[attr.href]="getHref(interfaces[0])" rel="noreferrer"
> [attr.href]="getHref(info)"
{{ interfaces[0]?.name }} >
</a> {{ info.name }}
</a>
}
} }
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButtonModule, TuiHostedDropdownModule, TuiDataListModule], imports: [TuiButtonModule, TuiHostedDropdownModule, TuiDataListModule],
}) })
export class UIComponent { export class UILaunchComponent {
private readonly config = inject(ConfigService) private readonly config = inject(ConfigService)
@Input() @Input()
pkg!: PackageDataEntry pkg!: PackageDataEntry
get interfaces(): readonly InterfaceInfo[] { get interfaces(): readonly T.ServiceInterfaceWithHostInfo[] {
return this.getInterfaces(this.pkg.installed) return this.getInterfaces(this.pkg)
} }
get isRunning(): boolean { get isRunning(): boolean {
return this.pkg.installed?.status.main.status === PackageMainStatus.Running return this.pkg.status.main.status === PackageMainStatus.Running
} }
@tuiPure @tuiPure
getInterfaces(info?: InstalledPackageInfo): InterfaceInfo[] { getInterfaces(pkg?: PackageDataEntry): T.ServiceInterfaceWithHostInfo[] {
return info return pkg
? Object.values(info.interfaceInfo).filter(({ type }) => type === 'ui') ? Object.values(pkg.serviceInterfaces).filter(({ type }) => type === 'ui')
: [] : []
} }
getHref(info?: InterfaceInfo): string | null { getHref(info?: T.ServiceInterfaceWithHostInfo): string | null {
return info && this.isRunning ? this.config.launchableAddress(info) : null return info && this.isRunning ? this.config.launchableAddress(info) : null
} }
} }

View File

@@ -8,20 +8,19 @@ import { tuiPure } from '@taiga-ui/cdk'
import { TuiButtonModule } from '@taiga-ui/experimental' import { TuiButtonModule } from '@taiga-ui/experimental'
import { DependencyInfo } from 'src/app/apps/portal/routes/service/types/dependency-info' import { DependencyInfo } from 'src/app/apps/portal/routes/service/types/dependency-info'
import { ActionsService } from 'src/app/apps/portal/services/actions.service' import { ActionsService } from 'src/app/apps/portal/services/actions.service'
import { import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
PackageDataEntry, import { Manifest } from '@start9labs/marketplace'
PackageMainStatus, import { getManifest } from 'src/app/util/get-package-data'
} from 'src/app/services/patch-db/data-model'
@Component({ @Component({
selector: 'service-actions', selector: 'service-actions',
template: ` template: `
@if (isRunning) { @if (pkg.status.main.status === 'running') {
<button <button
tuiButton tuiButton
appearance="secondary-destructive" appearance="secondary-destructive"
iconLeft="tuiIconSquare" iconLeft="tuiIconSquare"
(click)="actions.stop(service)" (click)="actions.stop(manifest)"
> >
Stop Stop
</button> </button>
@@ -30,17 +29,17 @@ import {
tuiButton tuiButton
appearance="secondary" appearance="secondary"
iconLeft="tuiIconRotateCw" iconLeft="tuiIconRotateCw"
(click)="actions.restart(service)" (click)="actions.restart(manifest)"
> >
Restart Restart
</button> </button>
} }
@if (isStopped && isConfigured) { @if (pkg.status.main.status === 'stopped' && isConfigured) {
<button <button
tuiButton tuiButton
iconLeft="tuiIconPlay" iconLeft="tuiIconPlay"
(click)="actions.start(service, hasUnmet(dependencies))" (click)="actions.start(manifest, hasUnmet(dependencies))"
> >
Start Start
</button> </button>
@@ -51,7 +50,7 @@ import {
tuiButton tuiButton
appearance="secondary-warning" appearance="secondary-warning"
iconLeft="tuiIconTool" iconLeft="tuiIconTool"
(click)="actions.configure(service)" (click)="actions.configure(manifest)"
> >
Configure Configure
</button> </button>
@@ -64,7 +63,7 @@ import {
}) })
export class ServiceActionsComponent { export class ServiceActionsComponent {
@Input({ required: true }) @Input({ required: true })
service!: PackageDataEntry pkg!: PackageDataEntry
@Input({ required: true }) @Input({ required: true })
dependencies: readonly DependencyInfo[] = [] dependencies: readonly DependencyInfo[] = []
@@ -72,19 +71,11 @@ export class ServiceActionsComponent {
readonly actions = inject(ActionsService) readonly actions = inject(ActionsService)
get isConfigured(): boolean { get isConfigured(): boolean {
return this.service.installed!.status.configured return this.pkg.status.configured
} }
get isRunning(): boolean { get manifest(): Manifest {
return ( return getManifest(this.pkg)
this.service.installed?.status.main.status === PackageMainStatus.Running
)
}
get isStopped(): boolean {
return (
this.service.installed?.status.main.status === PackageMainStatus.Stopped
)
} }
@tuiPure @tuiPure

View File

@@ -8,7 +8,7 @@ import { ServiceAdditionalItemComponent } from './additional-item.component'
selector: 'service-additional', selector: 'service-additional',
template: ` template: `
<h3 class="g-title">Additional Info</h3> <h3 class="g-title">Additional Info</h3>
<ng-container *ngFor="let additional of service | toAdditional"> <ng-container *ngFor="let additional of pkg | toAdditional">
<a <a
*ngIf="additional.description.startsWith('http'); else button" *ngIf="additional.description.startsWith('http'); else button"
class="g-action" class="g-action"
@@ -30,5 +30,5 @@ import { ServiceAdditionalItemComponent } from './additional-item.component'
}) })
export class ServiceAdditionalComponent { export class ServiceAdditionalComponent {
@Input({ required: true }) @Input({ required: true })
service!: PackageDataEntry pkg!: PackageDataEntry
} }

View File

@@ -91,23 +91,16 @@ export class ServiceHealthCheckComponent {
return 'Awaiting result...' return 'Awaiting result...'
} }
const prefix =
this.check.result !== HealthResult.Failure &&
this.check.result !== HealthResult.Loading
? this.check.result
: ''
switch (this.check.result) { switch (this.check.result) {
case HealthResult.Failure:
return prefix + this.check.error
case HealthResult.Starting: case HealthResult.Starting:
return `${prefix}...` return 'Starting...'
case HealthResult.Success: case HealthResult.Success:
return `${prefix}: ${this.check.message}` return `Success: ${this.check.message}`
case HealthResult.Loading: case HealthResult.Loading:
return prefix + this.check.message case HealthResult.Failure:
return this.check.message
default: default:
return prefix return this.check.result
} }
} }
} }

View File

@@ -11,7 +11,7 @@ import { ConfigService } from 'src/app/services/config.service'
import { ExtendedInterfaceInfo } from '../pipes/interface-info.pipe' import { ExtendedInterfaceInfo } from '../pipes/interface-info.pipe'
@Component({ @Component({
selector: 'a[serviceInterface]', selector: 'a[serviceInterfaceListItem]',
template: ` template: `
<tui-svg [src]="info.icon" [style.color]="info.color"></tui-svg> <tui-svg [src]="info.icon" [style.color]="info.color"></tui-svg>
<div [style.flex]="1"> <div [style.flex]="1">
@@ -35,10 +35,10 @@ import { ExtendedInterfaceInfo } from '../pipes/interface-info.pipe'
standalone: true, standalone: true,
imports: [TuiButtonModule, CommonModule, TuiSvgModule], imports: [TuiButtonModule, CommonModule, TuiSvgModule],
}) })
export class ServiceInterfaceComponent { export class ServiceInterfaceListItemComponent {
private readonly config = inject(ConfigService) private readonly config = inject(ConfigService)
@Input({ required: true, alias: 'serviceInterface' }) @Input({ required: true, alias: 'serviceInterfaceListItem' })
info!: ExtendedInterfaceInfo info!: ExtendedInterfaceInfo
@Input() @Input()

View File

@@ -0,0 +1,36 @@
import { NgForOf } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { PackageStatus } from 'src/app/services/pkg-status-rendering.service'
import { InterfaceInfoPipe } from '../pipes/interface-info.pipe'
import { ServiceInterfaceListItemComponent } from './interface-list-item.component'
import { RouterLink } from '@angular/router'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
@Component({
selector: 'service-interface-list',
template: `
<h3 class="g-title">Service Interfaces</h3>
<a
*ngFor="let info of pkg | interfaceInfo"
class="g-action"
[serviceInterfaceListItem]="info"
[disabled]="status.primary !== 'running'"
[routerLink]="info.routerLink"
></a>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
NgForOf,
RouterLink,
InterfaceInfoPipe,
ServiceInterfaceListItemComponent,
],
})
export class ServiceInterfaceListComponent {
@Input({ required: true })
pkg!: PackageDataEntry
@Input({ required: true })
status!: PackageStatus
}

View File

@@ -1,35 +0,0 @@
import { NgForOf } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { PackagePlus } from 'src/app/services/patch-db/data-model'
import {
PackageStatus,
PrimaryStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { InterfaceInfoPipe } from '../pipes/interface-info.pipe'
import { ServiceInterfaceComponent } from './interface.component'
import { RouterLink } from '@angular/router'
@Component({
selector: 'service-interfaces',
template: `
<h3 class="g-title">Interfaces</h3>
<a
*ngFor="let info of service.pkg | interfaceInfo"
class="g-action"
[serviceInterface]="info"
[disabled]="!isRunning(service.status)"
[routerLink]="info.routerLink"
></a>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgForOf, RouterLink, InterfaceInfoPipe, ServiceInterfaceComponent],
})
export class ServiceInterfacesComponent {
@Input({ required: true })
service!: PackagePlus
isRunning({ primary }: PackageStatus): boolean {
return primary === PrimaryStatus.Running
}
}

View File

@@ -8,7 +8,7 @@ import { RouterLink } from '@angular/router'
selector: 'service-menu', selector: 'service-menu',
template: ` template: `
<h3 class="g-title">Menu</h3> <h3 class="g-title">Menu</h3>
@for (menu of service | toMenu; track $index) { @for (menu of pkg | toMenu; track $index) {
@if (menu.routerLink) { @if (menu.routerLink) {
<a <a
class="g-action" class="g-action"
@@ -23,7 +23,7 @@ import { RouterLink } from '@angular/router'
(click)="menu.action?.()" (click)="menu.action?.()"
> >
@if (menu.name === 'Outbound Proxy') { @if (menu.name === 'Outbound Proxy') {
<div [style.color]="color">{{ proxy }}</div> <div [style.color]="color">{{ pkg.outboundProxy || 'None' }}</div>
} }
</button> </button>
} }
@@ -35,22 +35,11 @@ import { RouterLink } from '@angular/router'
}) })
export class ServiceMenuComponent { export class ServiceMenuComponent {
@Input({ required: true }) @Input({ required: true })
service!: PackageDataEntry pkg!: PackageDataEntry
get color(): string { get color(): string {
return this.service.installed?.outboundProxy return this.pkg.outboundProxy
? 'var(--tui-success-fill)' ? 'var(--tui-success-fill)'
: 'var(--tui-warning-fill)' : 'var(--tui-warning-fill)'
} }
get proxy(): string {
switch (this.service.installed?.outboundProxy) {
case 'primary':
return 'System Primary'
case 'mirror':
return 'Mirror P2P'
default:
return this.service.installed?.outboundProxy?.proxyId || 'None'
}
}
} }

View File

@@ -1,27 +1,30 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core' import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiProgressModule } from '@taiga-ui/kit' import { TuiProgressModule } from '@taiga-ui/kit'
import { Progress } from 'src/app/services/patch-db/data-model'
import { InstallingProgressPipe } from '../pipes/install-progress.pipe'
@Component({ @Component({
selector: '[progress]', selector: '[progress]',
template: ` template: `
<ng-content></ng-content> <ng-content></ng-content>
: {{ progress }}% @if (progress | installingProgress; as decimal) {
<progress : {{ decimal * 100 }}%
tuiProgressBar <progress
new tuiProgressBar
size="xs" new
[style.color]=" size="xs"
progress === 100 ? 'var(--tui-positive)' : 'var(--tui-link)' [style.color]="
" progress === true ? 'var(--tui-positive)' : 'var(--tui-link)'
[value]="progress / 100" "
></progress> [value]="decimal * 100"
></progress>
}
`, `,
styles: [':host { line-height: 2rem }'], styles: [':host { line-height: 2rem }'],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [TuiProgressModule], imports: [TuiProgressModule, InstallingProgressPipe],
}) })
export class ServiceProgressComponent { export class ServiceProgressComponent {
@Input({ required: true }) @Input({ required: true }) progress!: Progress
progress = 0
} }

View File

@@ -10,7 +10,7 @@ import { TuiLabelModule } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental' import { TuiButtonModule } from '@taiga-ui/experimental'
@Component({ @Component({
selector: 'service-credential', selector: 'service-property',
template: ` template: `
<label [style.flex]="1" [tuiLabel]="label"> <label [style.flex]="1" [tuiLabel]="label">
{{ masked ? mask : value }} {{ masked ? mask : value }}
@@ -48,7 +48,7 @@ import { TuiButtonModule } from '@taiga-ui/experimental'
standalone: true, standalone: true,
imports: [TuiButtonModule, TuiLabelModule], imports: [TuiButtonModule, TuiLabelModule],
}) })
export class ServiceCredentialComponent { export class ServicePropertyComponent {
@Input() @Input()
label = '' label = ''

View File

@@ -5,22 +5,27 @@ import {
HostBinding, HostBinding,
Input, Input,
} from '@angular/core' } from '@angular/core'
import { InstallProgress } from 'src/app/services/patch-db/data-model'
import { StatusRendering } from 'src/app/services/pkg-status-rendering.service' import { StatusRendering } from 'src/app/services/pkg-status-rendering.service'
import { InstallProgressPipe } from '../pipes/install-progress.pipe' import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
import { InstallingInfo } from 'src/app/services/patch-db/data-model'
import { UnitConversionPipesModule } from '@start9labs/shared'
@Component({ @Component({
selector: 'service-status', selector: 'service-status',
template: ` template: `
@if (installProgress) { @if (installingInfo) {
<strong> <strong>
Installing Installing
<span class="loading-dots"></span> <span class="loading-dots"></span>
{{ installProgress | installProgress }} {{ installingInfo.progress.overall | installingProgressString }}
</strong> </strong>
} @else { } @else {
{{ connected ? rendering.display : 'Unknown' }} {{ connected ? rendering.display : 'Unknown' }}
<!-- @TODO should show 'this may take a while' if sigtermTimeout is > 30s -->
<span *ngIf="sigtermTimeout && (sigtermTimeout | durationToSeconds) > 30">
. This may take a while
</span>
<span *ngIf="rendering.showDots" class="loading-dots"></span> <span *ngIf="rendering.showDots" class="loading-dots"></span>
} }
`, `,
@@ -35,18 +40,24 @@ import { InstallProgressPipe } from '../pipes/install-progress.pipe'
], ],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [CommonModule, InstallProgressPipe], imports: [
CommonModule,
InstallingProgressDisplayPipe,
UnitConversionPipesModule,
],
}) })
export class ServiceStatusComponent { export class ServiceStatusComponent {
@Input({ required: true }) @Input({ required: true })
rendering!: StatusRendering rendering!: StatusRendering
@Input() @Input()
installProgress?: InstallProgress installingInfo?: InstallingInfo
@Input() @Input()
connected = false connected = false
@Input() sigtermTimeout?: string | null = null
@HostBinding('style.color') @HostBinding('style.color')
get color(): string { get color(): string {
if (!this.connected) return 'var(--tui-text-02)' if (!this.connected) return 'var(--tui-text-02)'

View File

@@ -6,17 +6,17 @@ import { TuiButtonModule } from '@taiga-ui/experimental'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus' import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { BehaviorSubject } from 'rxjs' import { BehaviorSubject } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ServiceCredentialComponent } from '../components/credential.component' import { ServicePropertyComponent } from '../components/property.component'
@Component({ @Component({
template: ` template: `
@if (loading$ | async) { @if (loading$ | async) {
<tui-loader /> <tui-loader />
} @else { } @else {
@for (cred of credentials | keyvalue: asIsOrder; track cred) { @for (prop of properties | keyvalue: asIsOrder; track prop) {
<service-credential [label]="cred.key" [value]="cred.value" /> <service-property [label]="prop.key" [value]="prop.value" />
} @empty { } @empty {
No credentials No properties
} }
} }
<button tuiButton iconLeft="tuiIconRefreshCwLarge" (click)="refresh()"> <button tuiButton iconLeft="tuiIconRefreshCwLarge" (click)="refresh()">
@@ -36,32 +36,32 @@ import { ServiceCredentialComponent } from '../components/credential.component'
imports: [ imports: [
CommonModule, CommonModule,
TuiButtonModule, TuiButtonModule,
ServiceCredentialComponent, ServicePropertyComponent,
TuiLoaderModule, TuiLoaderModule,
], ],
}) })
export class ServiceCredentialsModal { export class ServicePropertiesModal {
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
readonly id = inject<{ data: string }>(POLYMORPHEUS_CONTEXT).data readonly id = inject<{ data: string }>(POLYMORPHEUS_CONTEXT).data
readonly loading$ = new BehaviorSubject(true) readonly loading$ = new BehaviorSubject(true)
credentials: Record<string, string> = {} properties: Record<string, string> = {}
async ngOnInit() { async ngOnInit() {
await this.getCredentials() await this.getProperties()
} }
async refresh() { async refresh() {
await this.getCredentials() await this.getProperties()
} }
private async getCredentials(): Promise<void> { private async getProperties(): Promise<void> {
this.loading$.next(true) this.loading$.next(true)
try { try {
this.credentials = await this.api.getPackageCredentials({ id: this.id }) this.properties = await this.api.getPackageProperties({ id: this.id })
} catch (e: any) { } catch (e: any) {
this.errorService.handleError(e) this.errorService.handleError(e)
} finally { } finally {

View File

@@ -1,6 +1,7 @@
import { Pipe, PipeTransform } from '@angular/core' import { Pipe, PipeTransform } from '@angular/core'
import { WithId } from '@start9labs/shared' import { WithId } from '@start9labs/shared'
import { Action, PackageDataEntry } from 'src/app/services/patch-db/data-model' import { ActionMetadata } from '@start9labs/start-sdk/cjs/sdk/lib/types'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
@Pipe({ @Pipe({
name: 'groupActions', name: 'groupActions',
@@ -9,12 +10,12 @@ import { Action, PackageDataEntry } from 'src/app/services/patch-db/data-model'
export class GroupActionsPipe implements PipeTransform { export class GroupActionsPipe implements PipeTransform {
transform( transform(
actions: PackageDataEntry['actions'], actions: PackageDataEntry['actions'],
): Array<Array<WithId<Action>>> | null { ): Array<Array<WithId<ActionMetadata>>> | null {
if (!actions) return null if (!actions) return null
const noGroup = 'noGroup' const noGroup = 'noGroup'
const grouped = Object.entries(actions).reduce< const grouped = Object.entries(actions).reduce<
Record<string, WithId<Action>[]> Record<string, WithId<ActionMetadata>[]>
>((groups, [id, action]) => { >((groups, [id, action]) => {
const actionWithId = { id, ...action } const actionWithId = { id, ...action }
const groupKey = action.group || noGroup const groupKey = action.group || noGroup

View File

@@ -1,5 +1,5 @@
import { Pipe, PipeTransform } from '@angular/core' import { Pipe, PipeTransform } from '@angular/core'
import { Progress } from 'src/app/services/patch-db/data-model' import { Progress } from '../../../../../services/patch-db/data-model'
@Pipe({ @Pipe({
standalone: true, standalone: true,

View File

@@ -1,10 +1,8 @@
import { Pipe, PipeTransform } from '@angular/core' import { Pipe, PipeTransform } from '@angular/core'
import { import { ServiceInterfaceWithHostInfo } from '@start9labs/start-sdk/cjs/sdk/lib/types'
InterfaceInfo, import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
export interface ExtendedInterfaceInfo extends InterfaceInfo { export interface ExtendedInterfaceInfo extends ServiceInterfaceWithHostInfo {
id: string id: string
icon: string icon: string
color: string color: string
@@ -17,8 +15,8 @@ export interface ExtendedInterfaceInfo extends InterfaceInfo {
standalone: true, standalone: true,
}) })
export class InterfaceInfoPipe implements PipeTransform { export class InterfaceInfoPipe implements PipeTransform {
transform({ installed }: PackageDataEntry): ExtendedInterfaceInfo[] { transform(pkg: PackageDataEntry): ExtendedInterfaceInfo[] {
return Object.entries(installed!.interfaceInfo).map(([id, val]) => { return Object.entries(pkg.serviceInterfaces).map(([id, val]) => {
let color: string let color: string
let icon: string let icon: string
let typeDetail: string let typeDetail: string
@@ -39,11 +37,6 @@ export class InterfaceInfoPipe implements PipeTransform {
icon = 'tuiIconTerminalLarge' icon = 'tuiIconTerminalLarge'
typeDetail = 'Application Program Interface (API)' typeDetail = 'Application Program Interface (API)'
break break
case 'other':
color = 'var(--tui-text-02)'
icon = 'tuiIconBoxLarge'
typeDetail = 'Unknown Interface Type'
break
} }
return { return {

View File

@@ -6,6 +6,7 @@ import { CopyService, MarkdownComponent } from '@start9labs/shared'
import { from } from 'rxjs' import { from } from 'rxjs'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model' import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { getManifest } from 'src/app/util/get-package-data'
export const FALLBACK_URL = 'Not provided' export const FALLBACK_URL = 'Not provided'
@@ -25,21 +26,22 @@ export class ToAdditionalPipe implements PipeTransform {
private readonly copyService = inject(CopyService) private readonly copyService = inject(CopyService)
private readonly dialogs = inject(TuiDialogService) private readonly dialogs = inject(TuiDialogService)
transform({ manifest, installed }: PackageDataEntry): AdditionalItem[] { transform(pkg: PackageDataEntry): AdditionalItem[] {
const manifest = getManifest(pkg)
return [ return [
{ {
name: 'Installed', name: 'Installed',
description: new Intl.DateTimeFormat('en-US', { description: new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'medium', timeStyle: 'medium',
}).format(new Date(installed?.['installed-at'] || 0)), }).format(new Date(pkg.installedAt || 0)),
}, },
{ {
name: 'Git Hash', name: 'Git Hash',
description: manifest['git-hash'] || 'Unknown', description: manifest.gitHash || 'Unknown',
icon: manifest['git-hash'] ? 'tuiIconCopyLarge' : '', icon: manifest.gitHash ? 'tuiIconCopyLarge' : '',
action: () => action: () =>
manifest['git-hash'] && this.copyService.copy(manifest['git-hash']), manifest.gitHash && this.copyService.copy(manifest.gitHash),
}, },
{ {
name: 'License', name: 'License',
@@ -49,19 +51,19 @@ export class ToAdditionalPipe implements PipeTransform {
}, },
{ {
name: 'Website', name: 'Website',
description: manifest['marketing-site'] || FALLBACK_URL, description: manifest.marketingSite || FALLBACK_URL,
}, },
{ {
name: 'Source Repository', name: 'Source Repository',
description: manifest['upstream-repo'], description: manifest.upstreamRepo,
}, },
{ {
name: 'Support Site', name: 'Support Site',
description: manifest['support-site'] || FALLBACK_URL, description: manifest.supportSite || FALLBACK_URL,
}, },
{ {
name: 'Donation Link', name: 'Donation Link',
description: manifest['donation-url'] || FALLBACK_URL, description: manifest.donationUrl || FALLBACK_URL,
}, },
] ]
} }

View File

@@ -11,12 +11,10 @@ import {
} from 'src/app/apps/portal/modals/config.component' } from 'src/app/apps/portal/modals/config.component'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service' import { FormDialogService } from 'src/app/services/form-dialog.service'
import { import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
InstalledPackageInfo,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { ProxyService } from 'src/app/services/proxy.service' import { ProxyService } from 'src/app/services/proxy.service'
import { ServiceCredentialsModal } from '../modals/credentials.component' import { ServicePropertiesModal } from '../modals/properties.component'
import { getManifest } from 'src/app/util/get-package-data'
export interface ServiceMenu { export interface ServiceMenu {
icon: string icon: string
@@ -37,8 +35,8 @@ export class ToMenuPipe implements PipeTransform {
private readonly formDialog = inject(FormDialogService) private readonly formDialog = inject(FormDialogService)
private readonly proxyService = inject(ProxyService) private readonly proxyService = inject(ProxyService)
transform({ manifest, installed }: PackageDataEntry): ServiceMenu[] { transform(pkg: PackageDataEntry): ServiceMenu[] {
const url = installed?.['marketplace-url'] const manifest = getManifest(pkg)
return [ return [
{ {
@@ -55,11 +53,11 @@ export class ToMenuPipe implements PipeTransform {
}, },
{ {
icon: 'tuiIconKeyLarge', icon: 'tuiIconKeyLarge',
name: 'Credentials', name: 'Properties',
description: `Password, keys, or other credentials of interest`, description: `Runtime information, credentials, and other values of interest`,
action: () => action: () =>
this.dialogs this.dialogs
.open(new PolymorpheusComponent(ServiceCredentialsModal), { .open(new PolymorpheusComponent(ServicePropertiesModal), {
label: `${manifest.title} credentials`, label: `${manifest.title} credentials`,
data: manifest.id, data: manifest.id,
}) })
@@ -75,7 +73,11 @@ export class ToMenuPipe implements PipeTransform {
icon: 'tuiIconShieldLarge', icon: 'tuiIconShieldLarge',
name: 'Outbound Proxy', name: 'Outbound Proxy',
description: `Proxy all outbound traffic from ${manifest.title}`, description: `Proxy all outbound traffic from ${manifest.title}`,
action: () => this.setProxy(manifest, installed!), action: () =>
this.proxyService.presentModalSetOutboundProxy(
pkg.outboundProxy,
manifest.id,
),
}, },
{ {
icon: 'tuiIconFileTextLarge', icon: 'tuiIconFileTextLarge',
@@ -83,13 +85,13 @@ export class ToMenuPipe implements PipeTransform {
description: `Raw, unfiltered logs`, description: `Raw, unfiltered logs`,
routerLink: 'logs', routerLink: 'logs',
}, },
url pkg.marketplaceUrl
? { ? {
icon: 'tuiIconShoppingBagLarge', icon: 'tuiIconShoppingBagLarge',
name: 'Marketplace Listing', name: 'Marketplace Listing',
description: `View ${manifest.title} on the Marketplace`, description: `View ${manifest.title} on the Marketplace`,
routerLink: `/portal/system/marketplace`, routerLink: `/portal/system/marketplace`,
params: { url, id: manifest.id }, params: { url: pkg.marketplaceUrl, id: manifest.id },
} }
: { : {
icon: 'tuiIconShoppingBagLarge', icon: 'tuiIconShoppingBagLarge',
@@ -125,15 +127,4 @@ export class ToMenuPipe implements PipeTransform {
data: { pkgId: id }, 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

@@ -16,7 +16,6 @@ import { filter, switchMap, timer } from 'rxjs'
import { FormComponent } from 'src/app/apps/portal/components/form.component' import { FormComponent } from 'src/app/apps/portal/components/form.component'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { import {
Action,
DataModel, DataModel,
PackageDataEntry, PackageDataEntry,
PackageState, PackageState,
@@ -26,10 +25,13 @@ import { FormDialogService } from 'src/app/services/form-dialog.service'
import { ServiceActionComponent } from '../components/action.component' import { ServiceActionComponent } from '../components/action.component'
import { ServiceActionSuccessComponent } from '../components/action-success.component' import { ServiceActionSuccessComponent } from '../components/action-success.component'
import { GroupActionsPipe } from '../pipes/group-actions.pipe' import { GroupActionsPipe } from '../pipes/group-actions.pipe'
import { ToManifestPipe } from 'src/app/apps/portal/pipes/to-manifest'
import { ActionMetadata } from '@start9labs/start-sdk/cjs/sdk/lib/types'
import { getAllPackages, getManifest } from 'src/app/util/get-package-data'
@Component({ @Component({
template: ` template: `
<ng-container *ngIf="pkg$ | async as pkg"> @if (pkg$ | async; as pkg) {
<section> <section>
<h3 class="g-title">Standard Actions</h3> <h3 class="g-title">Standard Actions</h3>
<button <button
@@ -40,7 +42,7 @@ import { GroupActionsPipe } from '../pipes/group-actions.pipe'
</section> </section>
<ng-container *ngIf="pkg.actions | groupActions as actionGroups"> <ng-container *ngIf="pkg.actions | groupActions as actionGroups">
<h3 *ngIf="actionGroups.length" class="g-title"> <h3 *ngIf="actionGroups.length" class="g-title">
Actions for {{ pkg.manifest.title }} Actions for {{ (pkg | toManifest).title }}
</h3> </h3>
<div *ngFor="let group of actionGroups"> <div *ngFor="let group of actionGroups">
<button <button
@@ -55,18 +57,18 @@ import { GroupActionsPipe } from '../pipes/group-actions.pipe'
></button> ></button>
</div> </div>
</ng-container> </ng-container>
</ng-container> }
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [CommonModule, ServiceActionComponent, GroupActionsPipe], imports: [CommonModule, ServiceActionComponent, GroupActionsPipe, ToManifestPipe],
}) })
export class ServiceActionsRoute { export class ServiceActionsRoute {
private readonly id = getPkgId(inject(ActivatedRoute)) private readonly id = getPkgId(inject(ActivatedRoute))
readonly pkg$ = this.patch readonly pkg$ = this.patch
.watch$('package-data', this.id) .watch$('packageData', this.id)
.pipe(filter(pkg => pkg.state === PackageState.Installed)) .pipe(filter(pkg => pkg.stateInfo.state === PackageState.Installed))
readonly action = { readonly action = {
icon: 'tuiIconTrash2Large', icon: 'tuiIconTrash2Large',
@@ -85,7 +87,7 @@ export class ServiceActionsRoute {
private readonly formDialog: FormDialogService, private readonly formDialog: FormDialogService,
) {} ) {}
async handleAction(action: WithId<Action>) { async handleAction(action: WithId<ActionMetadata>) {
if (action.disabled) { if (action.disabled) {
this.dialogs this.dialogs
.open(action.disabled, { .open(action.disabled, {
@@ -94,11 +96,11 @@ export class ServiceActionsRoute {
}) })
.subscribe() .subscribe()
} else { } else {
if (action['input-spec'] && !isEmptyObject(action['input-spec'])) { if (action.input && !isEmptyObject(action.input)) {
this.formDialog.open(FormComponent, { this.formDialog.open(FormComponent, {
label: action.name, label: action.name,
data: { data: {
spec: action['input-spec'], spec: action.input,
buttons: [ buttons: [
{ {
text: 'Execute', text: 'Execute',
@@ -128,13 +130,13 @@ export class ServiceActionsRoute {
} }
async tryUninstall(pkg: PackageDataEntry): Promise<void> { async tryUninstall(pkg: PackageDataEntry): Promise<void> {
const { title, alerts } = pkg.manifest const { title, alerts, id } = getManifest(pkg)
let content = let content =
alerts.uninstall || alerts.uninstall ||
`Uninstalling ${title} will permanently delete its data` `Uninstalling ${title} will permanently delete its data`
if (hasCurrentDeps(pkg)) { if (hasCurrentDeps(id, await getAllPackages(this.patch))) {
content = `${content}. Services that depend on ${title} will no longer work properly and may crash` content = `${content}. Services that depend on ${title} will no longer work properly and may crash`
} }
@@ -177,7 +179,7 @@ export class ServiceActionsRoute {
try { try {
const data = await this.embassyApi.executePackageAction({ const data = await this.embassyApi.executePackageAction({
id: this.id, id: this.id,
'action-id': actionId, actionId,
input, input,
}) })

View File

@@ -3,21 +3,22 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared' import { getPkgId } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { InterfacesComponent } from 'src/app/apps/portal/components/interfaces/interfaces.component' import { map } from 'rxjs'
import { InterfaceComponent } from 'src/app/apps/portal/components/interfaces/interface.component'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { getAddresses } from '../../../components/interfaces/interface.utils'
@Component({ @Component({
template: ` template: `
<app-interfaces <app-interface
*ngIf="interfaceInfo$ | async as interfaceInfo" *ngIf="interfaceInfo$ | async as interfaceInfo"
[packageContext]="context" [packageContext]="context"
[addressInfo]="interfaceInfo.addressInfo" [serviceInterface]="interfaceInfo"
[isUi]="interfaceInfo.type === 'ui'"
/> />
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, standalone: true,
imports: [CommonModule, InterfacesComponent], imports: [CommonModule, InterfaceComponent],
}) })
export class ServiceInterfaceRoute { export class ServiceInterfaceRoute {
private readonly route = inject(ActivatedRoute) private readonly route = inject(ActivatedRoute)
@@ -27,11 +28,17 @@ export class ServiceInterfaceRoute {
interfaceId: this.route.snapshot.paramMap.get('interfaceId') || '', interfaceId: this.route.snapshot.paramMap.get('interfaceId') || '',
} }
readonly interfaceInfo$ = inject(PatchDB<DataModel>).watch$( readonly interfaceInfo$ = inject(PatchDB<DataModel>)
'package-data', .watch$(
this.context.packageId, 'packageData',
'state-info', this.context.packageId,
'interfaceInfo', 'serviceInterfaces',
this.context.interfaceId, this.context.interfaceId,
) )
.pipe(
map(info => ({
...info,
addresses: getAddresses(info),
})),
)
} }

View File

@@ -24,7 +24,7 @@ export class ServiceOutletComponent {
map(() => this.route.firstChild?.snapshot.paramMap?.get('pkgId')), map(() => this.route.firstChild?.snapshot.paramMap?.get('pkgId')),
filter(Boolean), filter(Boolean),
distinctUntilChanged(), distinctUntilChanged(),
switchMap(id => this.patch.watch$('package-data', id)), switchMap(id => this.patch.watch$('packageData', id)),
tap(pkg => { tap(pkg => {
// if package disappears, navigate to list page // if package disappears, navigate to list page
if (!pkg) { if (!pkg) {

View File

@@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router' import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'
import { Manifest } from '@start9labs/marketplace' import { Manifest } from '@start9labs/marketplace'
import { getPkgId, isEmptyObject } from '@start9labs/shared' import { isEmptyObject } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { combineLatest, map, switchMap } from 'rxjs' import { combineLatest, map, switchMap } from 'rxjs'
import { ConnectionService } from 'src/app/services/connection.service' import { ConnectionService } from 'src/app/services/connection.service'
@@ -15,15 +15,12 @@ import { FormDialogService } from 'src/app/services/form-dialog.service'
import { import {
DataModel, DataModel,
HealthCheckResult, HealthCheckResult,
InstalledPackageInfo,
MainStatus, MainStatus,
PackageDataEntry, PackageDataEntry,
PackageState,
} from 'src/app/services/patch-db/data-model' } from 'src/app/services/patch-db/data-model'
import { import {
PackageStatus, PackageStatus,
PrimaryRendering, PrimaryRendering,
PrimaryStatus,
renderPkgStatus, renderPkgStatus,
StatusRendering, StatusRendering,
} from 'src/app/services/pkg-status-rendering.service' } from 'src/app/services/pkg-status-rendering.service'
@@ -32,7 +29,7 @@ import { ServiceActionsComponent } from '../components/actions.component'
import { ServiceAdditionalComponent } from '../components/additional.component' import { ServiceAdditionalComponent } from '../components/additional.component'
import { ServiceDependenciesComponent } from '../components/dependencies.component' import { ServiceDependenciesComponent } from '../components/dependencies.component'
import { ServiceHealthChecksComponent } from '../components/health-checks.component' import { ServiceHealthChecksComponent } from '../components/health-checks.component'
import { ServiceInterfacesComponent } from '../components/interfaces.component' import { ServiceInterfaceListComponent } from '../components/interface-list.component'
import { ServiceMenuComponent } from '../components/menu.component' import { ServiceMenuComponent } from '../components/menu.component'
import { ServiceProgressComponent } from '../components/progress.component' import { ServiceProgressComponent } from '../components/progress.component'
import { ServiceStatusComponent } from '../components/status.component' import { ServiceStatusComponent } from '../components/status.component'
@@ -40,43 +37,60 @@ import {
PackageConfigData, PackageConfigData,
ServiceConfigModal, ServiceConfigModal,
} from 'src/app/apps/portal/modals/config.component' } from 'src/app/apps/portal/modals/config.component'
import { ProgressDataPipe } from '../pipes/progress-data.pipe'
import { DependencyInfo } from '../types/dependency-info' import { DependencyInfo } from '../types/dependency-info'
import { getManifest } from 'src/app/util/get-package-data'
const STATES = [ import { InstallingProgressPipe } from 'src/app/apps/portal/routes/service/pipes/install-progress.pipe'
PackageState.Installing,
PackageState.Updating,
PackageState.Restoring,
]
@Component({ @Component({
template: ` template: `
@if (service$ | async; as service) { @if (service$ | async; as service) {
@if (showProgress(service.pkg)) { <h3 class="g-title">Status</h3>
@if (service.pkg | progressData; as progress) { <service-status
<p [progress]="progress.downloadProgress">Downloading</p> [connected]="!!(connected$ | async)"
<p [progress]="progress.validateProgress">Validating</p> [installingInfo]="service.pkg.stateInfo.installingInfo"
<p [progress]="progress.unpackProgress">Unpacking</p> [rendering]="getRendering(service.status)"
} [sigtermTimeout]="
service.pkg.status.main.status === 'stopping'
? service.pkg.status.main.timeout
: null
"
/>
@if (
service.pkg.stateInfo.state === 'installing' ||
service.pkg.stateInfo.state === 'updating' ||
service.pkg.stateInfo.state === 'restoring'
) {
<p
*ngFor="
let phase of service.pkg.stateInfo.installingInfo.progress.phases
"
[progress]="phase.progress"
>
{{ phase.name }}
</p>
} @else { } @else {
<h3 class="g-title">Status</h3> @if (
<service-status service.pkg.stateInfo.state === 'installed' &&
[connected]="!!(connected$ | async)" service.status.primary !== 'backing-up'
[installProgress]="service.pkg['install-progress']" ) {
[rendering]="$any(getRendering(service.status))" @if (connected$ | async) {
/> <service-actions
[pkg]="service.pkg"
[dependencies]="service.dependencies"
/>
}
@if (isInstalled(service.pkg) && (connected$ | async)) { <service-interface-list
<service-actions [pkg]="service.pkg"
[service]="service.pkg" [status]="service.status"
[dependencies]="service.dependencies" ]
/> />
}
@if (isInstalled(service.pkg) && !isBackingUp(service.status)) { @if (
<service-interfaces [service]="service" /> service.status.primary === 'running' && (health$ | async);
as checks
@if (isRunning(service.status) && (health$ | async); as checks) { ) {
<service-health-checks [checks]="checks" /> <service-health-checks [checks]="checks" />
} }
@@ -84,8 +98,8 @@ const STATES = [
<service-dependencies [dependencies]="service.dependencies" /> <service-dependencies [dependencies]="service.dependencies" />
} }
<service-menu [service]="service.pkg" /> <service-menu [pkg]="service.pkg" />
<service-additional [service]="service.pkg" /> <service-additional [pkg]="service.pkg" />
} }
} }
} }
@@ -94,17 +108,15 @@ const STATES = [
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
ServiceProgressComponent, ServiceProgressComponent,
ServiceStatusComponent, ServiceStatusComponent,
ServiceActionsComponent, ServiceActionsComponent,
ServiceInterfacesComponent, ServiceInterfaceListComponent,
ServiceHealthChecksComponent, ServiceHealthChecksComponent,
ServiceDependenciesComponent, ServiceDependenciesComponent,
ServiceMenuComponent, ServiceMenuComponent,
ServiceAdditionalComponent, ServiceAdditionalComponent,
InstallingProgressPipe,
ProgressDataPipe,
], ],
}) })
export class ServiceRoute { export class ServiceRoute {
@@ -115,12 +127,12 @@ export class ServiceRoute {
private readonly depErrorService = inject(DepErrorService) private readonly depErrorService = inject(DepErrorService)
private readonly router = inject(Router) private readonly router = inject(Router)
private readonly formDialog = inject(FormDialogService) private readonly formDialog = inject(FormDialogService)
readonly connected$ = inject(ConnectionService).connected$ readonly connected$ = inject(ConnectionService).connected$
readonly service$ = this.pkgId$.pipe( readonly service$ = this.pkgId$.pipe(
switchMap(pkgId => switchMap(pkgId =>
combineLatest([ combineLatest([
this.patch.watch$('package-data', pkgId), this.patch.watch$('packageData', pkgId),
this.depErrorService.getPkgDepErrors$(pkgId), this.depErrorService.getPkgDepErrors$(pkgId),
]), ]),
), ),
@@ -132,9 +144,10 @@ export class ServiceRoute {
} }
}), }),
) )
readonly health$ = this.pkgId$.pipe( readonly health$ = this.pkgId$.pipe(
switchMap(pkgId => switchMap(pkgId =>
this.patch.watch$('package-data', pkgId, 'installed', 'status', 'main'), this.patch.watch$('packageData', pkgId, 'status', 'main'),
), ),
map(toHealthCheck), map(toHealthCheck),
) )
@@ -143,53 +156,35 @@ export class ServiceRoute {
return PrimaryRendering[primary] 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)
}
private getDepInfo( private getDepInfo(
{ installed, manifest }: PackageDataEntry, pkg: PackageDataEntry,
depErrors: PkgDependencyErrors, depErrors: PkgDependencyErrors,
): DependencyInfo[] { ): DependencyInfo[] {
return installed const manifest = getManifest(pkg)
? Object.keys(installed['current-dependencies'])
.filter(depId => !!manifest.dependencies[depId]) return Object.keys(pkg.currentDependencies)
.map(depId => .filter(id => !!manifest.dependencies[id])
this.getDepValues(installed, manifest, depId, depErrors), .map(id => this.getDepValues(pkg, manifest, id, depErrors))
)
: []
} }
private getDepValues( private getDepValues(
pkgInstalled: InstalledPackageInfo, pkg: PackageDataEntry,
pkgManifest: Manifest, pkgManifest: Manifest,
depId: string, depId: string,
depErrors: PkgDependencyErrors, depErrors: PkgDependencyErrors,
): DependencyInfo { ): DependencyInfo {
const { errorText, fixText, fixAction } = this.getDepErrors( const { errorText, fixText, fixAction } = this.getDepErrors(
pkgInstalled, pkg,
pkgManifest, pkgManifest,
depId, depId,
depErrors, depErrors,
) )
const depInfo = pkgInstalled['dependency-info'][depId] const depInfo = pkg.dependencyInfo[depId]
return { return {
id: depId, id: depId,
version: pkgManifest.dependencies[depId].version, // do we want this version range? version: pkg.currentDependencies[depId].versionRange,
title: depInfo?.title || depId, title: depInfo?.title || depId,
icon: depInfo?.icon || '', icon: depInfo?.icon || '',
errorText: errorText errorText: errorText
@@ -205,12 +200,12 @@ export class ServiceRoute {
} }
private getDepErrors( private getDepErrors(
pkgInstalled: InstalledPackageInfo, pkg: PackageDataEntry,
pkgManifest: Manifest, pkgManifest: Manifest,
depId: string, depId: string,
depErrors: PkgDependencyErrors, depErrors: PkgDependencyErrors,
) { ) {
const depError = (depErrors[pkgManifest.id] as any)?.[depId] // @TODO fix const depError = depErrors[pkgManifest.id]
let errorText: string | null = null let errorText: string | null = null
let fixText: string | null = null let fixText: string | null = null
@@ -220,18 +215,15 @@ export class ServiceRoute {
if (depError.type === DependencyErrorType.NotInstalled) { if (depError.type === DependencyErrorType.NotInstalled) {
errorText = 'Not installed' errorText = 'Not installed'
fixText = 'Install' fixText = 'Install'
fixAction = () => fixAction = () => this.fixDep(pkg, pkgManifest, 'install', depId)
this.fixDep(pkgInstalled, pkgManifest, 'install', depId)
} else if (depError.type === DependencyErrorType.IncorrectVersion) { } else if (depError.type === DependencyErrorType.IncorrectVersion) {
errorText = 'Incorrect version' errorText = 'Incorrect version'
fixText = 'Update' fixText = 'Update'
fixAction = () => fixAction = () => this.fixDep(pkg, pkgManifest, 'update', depId)
this.fixDep(pkgInstalled, pkgManifest, 'update', depId)
} else if (depError.type === DependencyErrorType.ConfigUnsatisfied) { } else if (depError.type === DependencyErrorType.ConfigUnsatisfied) {
errorText = 'Config not satisfied' errorText = 'Config not satisfied'
fixText = 'Auto config' fixText = 'Auto config'
fixAction = () => fixAction = () => this.fixDep(pkg, pkgManifest, 'configure', depId)
this.fixDep(pkgInstalled, pkgManifest, 'configure', depId)
} else if (depError.type === DependencyErrorType.NotRunning) { } else if (depError.type === DependencyErrorType.NotRunning) {
errorText = 'Not running' errorText = 'Not running'
fixText = 'Start' fixText = 'Start'
@@ -250,7 +242,7 @@ export class ServiceRoute {
} }
async fixDep( async fixDep(
pkgInstalled: InstalledPackageInfo, pkg: PackageDataEntry,
pkgManifest: Manifest, pkgManifest: Manifest,
action: 'install' | 'update' | 'configure', action: 'install' | 'update' | 'configure',
depId: string, depId: string,
@@ -258,10 +250,10 @@ export class ServiceRoute {
switch (action) { switch (action) {
case 'install': case 'install':
case 'update': case 'update':
return this.installDep(pkgManifest, depId) return this.installDep(pkg, pkgManifest, depId)
case 'configure': case 'configure':
return this.formDialog.open<PackageConfigData>(ServiceConfigModal, { return this.formDialog.open<PackageConfigData>(ServiceConfigModal, {
label: `${pkgInstalled!['dependency-info'][depId].title} config`, label: `${pkg.dependencyInfo[depId].title} config`,
data: { data: {
pkgId: depId, pkgId: depId,
dependentInfo: pkgManifest, dependentInfo: pkgManifest,
@@ -270,13 +262,15 @@ export class ServiceRoute {
} }
} }
private async installDep(manifest: Manifest, depId: string): Promise<void> { private async installDep(
const version = manifest.dependencies[depId].version pkg: PackageDataEntry,
manifest: Manifest,
depId: string,
): Promise<void> {
const dependentInfo: DependentInfo = { const dependentInfo: DependentInfo = {
id: manifest.id, id: manifest.id,
title: manifest.title, title: manifest.title,
version, version: pkg.currentDependencies[depId].versionRange,
} }
const navigationExtras: NavigationExtras = { const navigationExtras: NavigationExtras = {
state: { dependentInfo }, state: { dependentInfo },

View File

@@ -62,8 +62,8 @@ export class BackupsStatusComponent {
private get hasBackup(): boolean { private get hasBackup(): boolean {
return ( return (
!!this.target['embassy-os'] && !!this.target.startOs &&
this.emver.compare(this.target['embassy-os'].version, '0.3.0') !== -1 this.emver.compare(this.target.startOs.version, '0.3.0') !== -1
) )
} }
} }

View File

@@ -30,7 +30,7 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
Running Running
</span> </span>
<ng-template #notRunning> <ng-template #notRunning>
{{ job.next | date : 'MMM d, y, h:mm a' }} {{ job.next | date: 'MMM d, y, h:mm a' }}
</ng-template> </ng-template>
</td> </td>
<td>{{ job.name }}</td> <td>{{ job.name }}</td>
@@ -38,7 +38,7 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
<tui-svg [src]="job.target.type | getBackupIcon"></tui-svg> <tui-svg [src]="job.target.type | getBackupIcon"></tui-svg>
{{ job.target.name }} {{ job.target.name }}
</td> </td>
<td>Packages: {{ job['package-ids'].length }}</td> <td>Packages: {{ job.packageIds.length }}</td>
</tr> </tr>
<ng-template #blank> <ng-template #blank>
<tr><td colspan="5">You have no active or upcoming backup jobs</td></tr> <tr><td colspan="5">You have no active or upcoming backup jobs</td></tr>
@@ -56,7 +56,7 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
}) })
export class BackupsUpcomingComponent { export class BackupsUpcomingComponent {
readonly current$ = inject(PatchDB<DataModel>) readonly current$ = inject(PatchDB<DataModel>)
.watch$('server-info', 'status-info', 'current-backup', 'job') .watch$('serverInfo', 'statusInfo', 'currentBackup', 'job')
.pipe(map(job => job || {})) .pipe(map(job => job || {}))
readonly upcoming$ = from(inject(ApiService).getBackupJobs({})).pipe( readonly upcoming$ = from(inject(ApiService).getBackupJobs({})).pipe(

View File

@@ -17,6 +17,7 @@ import {
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { firstValueFrom, map } from 'rxjs' import { firstValueFrom, map } from 'rxjs'
import { DataModel, PackageState } from 'src/app/services/patch-db/data-model' import { DataModel, PackageState } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/util/get-package-data'
interface Package { interface Package {
id: string id: string
@@ -90,16 +91,19 @@ export class BackupsBackupModal {
async ngOnInit() { async ngOnInit() {
this.pkgs = await firstValueFrom( this.pkgs = await firstValueFrom(
this.patch.watch$('package-data').pipe( this.patch.watch$('packageData').pipe(
map(pkgs => map(pkgs =>
Object.values(pkgs) Object.values(pkgs)
.map(({ manifest: { id, title }, icon, state }) => ({ .map(pkg => {
id, const { id, title } = getManifest(pkg)
title, return {
icon, id,
disabled: state !== PackageState.Installed, title,
checked: false, icon: pkg.icon,
})) disabled: pkg.stateInfo.state !== PackageState.Installed,
checked: false,
}
})
.sort((a, b) => .sort((a, b) =>
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1, b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
), ),

View File

@@ -53,10 +53,8 @@ import { ToHumanCronPipe } from '../pipes/to-human-cron.pipe'
(click)="selectPackages()" (click)="selectPackages()"
> >
Packages Packages
<tui-badge <tui-badge [appearance]="job.packageIds.length ? 'success' : 'warning'">
[appearance]="job['package-ids'].length ? 'success' : 'warning'" {{ job.packageIds.length + ' selected' }}
>
{{ job['package-ids'].length + ' selected' }}
</tui-badge> </tui-badge>
</button> </button>
<tui-input name="cron" [(ngModel)]="job.cron"> <tui-input name="cron" [(ngModel)]="job.cron">
@@ -145,7 +143,7 @@ export class BackupsEditModal {
selectPackages() { selectPackages() {
this.dialogs.open<string[]>(BACKUP, BACKUP_OPTIONS).subscribe(id => { this.dialogs.open<string[]>(BACKUP, BACKUP_OPTIONS).subscribe(id => {
this.job['package-ids'] = id this.job.packageIds = id
}) })
} }
} }

View File

@@ -62,10 +62,8 @@ import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
[style.background]="selected[index] ? 'var(--tui-clear)' : ''" [style.background]="selected[index] ? 'var(--tui-clear)' : ''"
> >
<td><tui-checkbox [(ngModel)]="selected[index]"></tui-checkbox></td> <td><tui-checkbox [(ngModel)]="selected[index]"></tui-checkbox></td>
<td>{{ run['started-at'] | date : 'medium' }}</td> <td>{{ run.startedAt | date: 'medium' }}</td>
<td> <td>{{ run.startedAt | duration: run.completedAt }} Minutes</td>
{{ run['started-at'] | duration : run['completed-at'] }} Minutes
</td>
<td> <td>
<tui-svg <tui-svg
*ngIf="run.report | hasError; else noError" *ngIf="run.report | hasError; else noError"
@@ -178,7 +176,7 @@ export class BackupsHistoryModal {
label: 'Backup Report', label: 'Backup Report',
data: { data: {
report: run.report, report: run.report,
timestamp: run['completed-at'], timestamp: run.completedAt,
}, },
}) })
.subscribe() .subscribe()

View File

@@ -22,9 +22,9 @@ import { EDIT } from './edit.component'
@Component({ @Component({
template: ` template: `
<tui-notification> <tui-notification>
Scheduling automatic backups is an excellent way to ensure your Embassy Scheduling automatic backups is an excellent way to ensure your StartOS
data is safely backed up. Your Embassy will issue a notification whenever data is safely backed up. StartOS will issue a notification whenever one
one of your scheduled backups succeeds or fails. of your scheduled backups succeeds or fails.
<a <a
href="https://docs.start9.com/latest/user-manual/backups/backup-jobs" href="https://docs.start9.com/latest/user-manual/backups/backup-jobs"
target="_blank" target="_blank"
@@ -57,7 +57,7 @@ import { EDIT } from './edit.component'
<tui-svg [src]="job.target.type | getBackupIcon"></tui-svg> <tui-svg [src]="job.target.type | getBackupIcon"></tui-svg>
{{ job.target.name }} {{ job.target.name }}
</td> </td>
<td>Packages: {{ job['package-ids'].length }}</td> <td>Packages: {{ job.packageIds.length }}</td>
<td>{{ (job.cron | toHumanCron).message }}</td> <td>{{ (job.cron | toHumanCron).message }}</td>
<td> <td>
<button <button
@@ -143,7 +143,7 @@ export class BackupsJobsModal implements OnInit {
data.name = job.name data.name = job.name
data.target = job.target data.target = job.target
data.cron = job.cron data.cron = job.cron
data['package-ids'] = job['package-ids'] data.packageIds = job.packageIds
}) })
} }

View File

@@ -21,19 +21,19 @@ import { TuiMapperPipeModule } from '@taiga-ui/cdk'
@Component({ @Component({
template: ` template: `
<ng-container *ngIf="packageData$ | toOptions : backups | async as options"> <ng-container *ngIf="packageData$ | toOptions: backups | async as options">
<div tuiGroup orientation="vertical" [style.width.%]="100"> <div tuiGroup orientation="vertical" [style.width.%]="100">
<tui-checkbox-block <tui-checkbox-block
*ngFor="let option of options" *ngFor="let option of options"
[disabled]="option.installed || option['newer-eos']" [disabled]="option.installed || option.newerStartOs"
[(ngModel)]="option.checked" [(ngModel)]="option.checked"
> >
<div [style.margin]="'0.75rem 0'"> <div [style.margin]="'0.75rem 0'">
<strong>{{ option.title }}</strong> <strong>{{ option.title }}</strong>
<div>Version {{ option.version }}</div> <div>Version {{ option.version }}</div>
<div>Backup made: {{ option.timestamp | date : 'medium' }}</div> <div>Backup made: {{ option.timestamp | date: 'medium' }}</div>
<div <div
*ngIf="option | tuiMapper : toMessage as message" *ngIf="option | tuiMapper: toMessage as message"
[style.color]="message.color" [style.color]="message.color"
> >
{{ message.text }} {{ message.text }}
@@ -73,11 +73,11 @@ export class BackupsRecoverModal {
inject<TuiDialogContext<void, RecoverData>>(POLYMORPHEUS_CONTEXT) inject<TuiDialogContext<void, RecoverData>>(POLYMORPHEUS_CONTEXT)
readonly packageData$ = inject(PatchDB<DataModel>) readonly packageData$ = inject(PatchDB<DataModel>)
.watch$('package-data') .watch$('packageData')
.pipe(take(1)) .pipe(take(1))
readonly toMessage = (option: RecoverOption) => { readonly toMessage = (option: RecoverOption) => {
if (option['newer-eos']) { if (option.newerStartOs) {
return { return {
text: `Unavailable. Backup was made on a newer version of StartOS.`, text: `Unavailable. Backup was made on a newer version of StartOS.`,
color: 'var(--tui-error-fill)', color: 'var(--tui-error-fill)',
@@ -98,7 +98,7 @@ export class BackupsRecoverModal {
} }
get backups(): Record<string, PackageBackupInfo> { get backups(): Record<string, PackageBackupInfo> {
return this.context.data.backupInfo['package-backups'] return this.context.data.backupInfo.packageBackups
} }
isDisabled(options: RecoverOption[]): boolean { isDisabled(options: RecoverOption[]): boolean {
@@ -109,11 +109,13 @@ export class BackupsRecoverModal {
const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id) const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id)
const loader = this.loader.open('Initializing...').subscribe() const loader = this.loader.open('Initializing...').subscribe()
const { targetId, password } = this.context.data
try { try {
await this.api.restorePackages({ await this.api.restorePackages({
ids, ids,
'target-id': this.context.data.targetId, targetId,
password: this.context.data.password, password,
}) })
this.context.$implicit.complete() this.context.$implicit.complete()

View File

@@ -102,7 +102,7 @@ export class BackupsTargetModal {
isDisabled(target: BackupTarget): boolean { isDisabled(target: BackupTarget): boolean {
return ( return (
!target.mountable || !target.mountable ||
(this.context.data.type === 'restore' && !target['embassy-os']) (this.context.data.type === 'restore' && !target.startOs)
) )
} }

View File

@@ -4,7 +4,7 @@ import { ErrorService, LoadingService } from '@start9labs/shared'
import { import {
unionSelectKey, unionSelectKey,
unionValueKey, unionValueKey,
} from '@start9labs/start-sdk/lib/config/configTypes' } from '@start9labs/start-sdk/cjs/sdk/lib/config/configTypes'
import { TuiNotificationModule } from '@taiga-ui/core' import { TuiNotificationModule } from '@taiga-ui/core'
import { TuiButtonModule, TuiFadeModule } from '@taiga-ui/experimental' import { TuiButtonModule, TuiFadeModule } from '@taiga-ui/experimental'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus' import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
@@ -55,7 +55,7 @@ import { BackupsTargetsComponent } from '../components/targets.component'
<div class="g-hidden-scrollbar" tuiFade> <div class="g-hidden-scrollbar" tuiFade>
<table <table
class="g-table" class="g-table"
[backupsPhysical]="targets?.['unknown-disks'] || null" [backupsPhysical]="targets?.unknownDisks || null"
(add)="addPhysical($event)" (add)="addPhysical($event)"
></table> ></table>
</div> </div>
@@ -106,7 +106,7 @@ export class BackupsTargetsModal implements OnInit {
this.targets = await this.api.getBackupTargets({}) this.targets = await this.api.getBackupTargets({})
} catch (e: any) { } catch (e: any) {
this.errorService.handleError(e) this.errorService.handleError(e)
this.targets = { 'unknown-disks': [], saved: [] } this.targets = { unknownDisks: [], saved: [] }
} finally { } finally {
this.loading$.next(false) this.loading$.next(false)
} }
@@ -162,7 +162,7 @@ export class BackupsTargetsModal implements OnInit {
}).then(response => { }).then(response => {
this.setTargets( this.setTargets(
this.targets?.saved.concat(response), this.targets?.saved.concat(response),
this.targets?.['unknown-disks'].filter(a => a !== disk), this.targets?.unknownDisks.filter(a => a !== disk),
) )
return true return true
}), }),
@@ -225,9 +225,9 @@ export class BackupsTargetsModal implements OnInit {
private setTargets( private setTargets(
saved: BackupTarget[] = this.targets?.saved || [], saved: BackupTarget[] = this.targets?.saved || [],
unknown: UnknownDisk[] = this.targets?.['unknown-disks'] || [], unknownDisks: UnknownDisk[] = this.targets?.unknownDisks || [],
) { ) {
this.targets = { ['unknown-disks']: unknown, saved } this.targets = { unknownDisks, saved }
} }
private async getSpec(target: BackupTarget) { private async getSpec(target: BackupTarget) {

View File

@@ -26,7 +26,7 @@ export class ToOptionsPipe implements PipeTransform {
id, id,
installed: !!packageData[id], installed: !!packageData[id],
checked: false, checked: false,
'newer-eos': this.compare(packageBackups[id].osVersion), newerStartOs: this.compare(packageBackups[id].osVersion),
})) }))
.sort((a, b) => .sort((a, b) =>
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1, b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,

View File

@@ -35,7 +35,7 @@ export class BackupsCreateService {
const loader = this.loader.open('Beginning backup...').subscribe() const loader = this.loader.open('Beginning backup...').subscribe()
await this.api await this.api
.createBackup({ 'target-id': targetId, 'package-ids': pkgIds }) .createBackup({ targetId, packageIds: pkgIds })
.finally(() => loader.unsubscribe()) .finally(() => loader.unsubscribe())
} }
} }

View File

@@ -44,7 +44,7 @@ export class BackupsRestoreService {
this.getRecoverData( this.getRecoverData(
target.id, target.id,
password, password,
target['embassy-os']?.['password-hash'] || '', target.startOs?.passwordHash || '',
), ),
), ),
take(1), take(1),
@@ -73,7 +73,7 @@ export class BackupsRestoreService {
const loader = this.loader.open('Decrypting drive...').subscribe() const loader = this.loader.open('Decrypting drive...').subscribe()
return this.api return this.api
.getBackupInfo({ 'target-id': targetId, password }) .getBackupInfo({ targetId, password })
.finally(() => loader.unsubscribe()) .finally(() => loader.unsubscribe())
}), }),
catchError(e => { catchError(e => {

View File

@@ -1,7 +1,7 @@
import { import {
unionSelectKey, unionSelectKey,
unionValueKey, unionValueKey,
} from '@start9labs/start-sdk/lib/config/configTypes' } from '@start9labs/start-sdk/cjs/sdk/lib/config/configTypes'
import { RR } from 'src/app/services/api/api.types' import { RR } from 'src/app/services/api/api.types'
export type BackupConfig = export type BackupConfig =

View File

@@ -4,5 +4,5 @@ export interface RecoverOption extends PackageBackupInfo {
id: string id: string
checked: boolean checked: boolean
installed: boolean installed: boolean
'newer-eos': boolean newerStartOs: boolean
} }

View File

@@ -1,6 +1,6 @@
import { Config } from '@start9labs/start-sdk/lib/config/builder/config' import { Config } from '@start9labs/start-sdk/cjs/sdk/lib/config/builder/config'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value' import { Value } from '@start9labs/start-sdk/cjs/sdk/lib/config/builder/value'
import { Variants } from '@start9labs/start-sdk/lib/config/builder/variants' import { Variants } from '@start9labs/start-sdk/cjs/sdk/lib/config/builder/variants'
export const dropboxSpec = Config.of({ export const dropboxSpec = Config.of({
name: Value.text({ name: Value.text({

View File

@@ -4,7 +4,7 @@ export class BackupJobBuilder {
name: string name: string
target: BackupTarget target: BackupTarget
cron: string cron: string
'package-ids': string[] packageIds: string[]
now = false now = false
constructor(readonly job: Partial<BackupJob>) { constructor(readonly job: Partial<BackupJob>) {
@@ -12,7 +12,7 @@ export class BackupJobBuilder {
this.name = name || '' this.name = name || ''
this.target = target || ({} as BackupTarget) this.target = target || ({} as BackupTarget)
this.cron = cron || '0 2 * * *' this.cron = cron || '0 2 * * *'
this['package-ids'] = job['package-ids'] || [] this.packageIds = job.packageIds || []
} }
buildCreate(): RR.CreateBackupJobReq { buildCreate(): RR.CreateBackupJobReq {
@@ -20,9 +20,9 @@ export class BackupJobBuilder {
return { return {
name, name,
'target-id': target.id, targetId: target.id,
cron, cron,
'package-ids': this['package-ids'], packageIds: this.packageIds,
now, now,
} }
} }
@@ -33,9 +33,9 @@ export class BackupJobBuilder {
return { return {
id, id,
name, name,
'target-id': target.id, targetId: target.id,
cron, cron,
'package-ids': this['package-ids'], packageIds: this.packageIds,
} }
} }
} }

View File

@@ -29,9 +29,10 @@ import {
import { ClientStorageService } from 'src/app/services/client-storage.service' import { ClientStorageService } from 'src/app/services/client-storage.service'
import { MarketplaceService } from 'src/app/services/marketplace.service' import { MarketplaceService } from 'src/app/services/marketplace.service'
import { hasCurrentDeps } from 'src/app/util/has-deps' import { hasCurrentDeps } from 'src/app/util/has-deps'
import { getAllPackages } from 'src/app/util/get-package-data' import { getAllPackages, getManifest } from 'src/app/util/get-package-data'
import { dryUpdate } from 'src/app/util/dry-update' import { dryUpdate } from 'src/app/util/dry-update'
import { MarketplaceAlertsService } from '../services/alerts.service' import { MarketplaceAlertsService } from '../services/alerts.service'
import { ToManifestPipe } from 'src/app/apps/portal/pipes/to-manifest'
@Component({ @Component({
selector: 'marketplace-controls', selector: 'marketplace-controls',
@@ -45,8 +46,11 @@ import { MarketplaceAlertsService } from '../services/alerts.service'
> >
View Installed View Installed
</button> </button>
@if (installed) { @if (
@switch (localVersion | compareEmver: pkg.manifest.version) { localPkg.stateInfo.state === 'installed' && (localPkg | toManifest);
as localManifest
) {
@switch (localManifest.version | compareEmver: pkg.manifest.version) {
@case (1) { @case (1) {
<button <button
tuiButton tuiButton
@@ -94,7 +98,7 @@ import { MarketplaceAlertsService } from '../services/alerts.service'
`, `,
standalone: true, standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, EmverPipesModule, TuiButtonModule], imports: [CommonModule, EmverPipesModule, TuiButtonModule, ToManifestPipe],
}) })
export class MarketplaceControlsComponent { export class MarketplaceControlsComponent {
private readonly alerts = inject(MarketplaceAlertsService) private readonly alerts = inject(MarketplaceAlertsService)
@@ -118,18 +122,10 @@ export class MarketplaceControlsComponent {
readonly showDevTools$ = inject(ClientStorageService).showDevTools$ readonly showDevTools$ = inject(ClientStorageService).showDevTools$
get installed(): boolean {
return this.localPkg?.state === PackageState.Installed
}
get localVersion(): string {
return this.localPkg?.manifest.version || ''
}
async tryInstall() { async tryInstall() {
const current = await firstValueFrom(this.marketplace.getSelectedHost$()) const current = await firstValueFrom(this.marketplace.getSelectedHost$())
const url = this.url || current.url const url = this.url || current.url
const originalUrl = this.localPkg?.installed?.['marketplace-url'] || '' const originalUrl = this.localPkg?.marketplaceUrl || ''
if (!this.localPkg) { if (!this.localPkg) {
if (await this.alerts.alertInstall(this.pkg)) this.install(url) if (await this.alerts.alertInstall(this.pkg)) this.install(url)
@@ -144,9 +140,11 @@ export class MarketplaceControlsComponent {
return return
} }
const localManifest = getManifest(this.localPkg)
if ( if (
hasCurrentDeps(this.localPkg) && hasCurrentDeps(localManifest.id, await getAllPackages(this.patch)) &&
this.emver.compare(this.localVersion, this.pkg.manifest.version) !== 0 this.emver.compare(localManifest.version, this.pkg.manifest.version) !== 0
) { ) {
this.dryInstall(url) this.dryInstall(url)
} else { } else {

View File

@@ -1,5 +1,4 @@
import { Component, inject, Input } from '@angular/core' import { Component, inject, Input } from '@angular/core'
import { NgIf } from '@angular/common'
import { TuiNotificationModule } from '@taiga-ui/core' import { TuiNotificationModule } from '@taiga-ui/core'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'

View File

@@ -92,7 +92,7 @@ export class MarketplaceRegistryModal {
private readonly hosts$ = inject(PatchDB<DataModel>).watch$( private readonly hosts$ = inject(PatchDB<DataModel>).watch$(
'ui', 'ui',
'marketplace', 'marketplace',
'known-hosts', 'knownHosts',
) )
readonly stores$ = combineLatest([ readonly stores$ = combineLatest([

View File

@@ -12,6 +12,6 @@ export class ToLocalPipe implements PipeTransform {
private readonly patch = inject(PatchDB<DataModel>) private readonly patch = inject(PatchDB<DataModel>)
transform(id: string): Observable<PackageDataEntry> { transform(id: string): Observable<PackageDataEntry> {
return this.patch.watch$('package-data', id).pipe(filter(Boolean)) return this.patch.watch$('packageData', id).pipe(filter(Boolean))
} }
} }

View File

@@ -18,8 +18,8 @@ export class MarketplaceAlertsService {
async alertMarketplace(url: string, originalUrl: string): Promise<boolean> { async alertMarketplace(url: string, originalUrl: string): Promise<boolean> {
const marketplaces = await firstValueFrom(this.marketplace$) const marketplaces = await firstValueFrom(this.marketplace$)
const name = marketplaces['known-hosts'][url]?.name || url const name = marketplaces.knownHosts[url]?.name || url
const source = marketplaces['known-hosts'][originalUrl]?.name || originalUrl const source = marketplaces.knownHosts[originalUrl]?.name || originalUrl
const message = source ? `installed from ${source}` : 'side loaded' const message = source ? `installed from ${source}` : 'side loaded'
return new Promise(async resolve => { return new Promise(async resolve => {

View File

@@ -1,4 +1,4 @@
import { ValueSpecObject } from '@start9labs/start-sdk/lib/config/configTypes' import { ValueSpecObject } from '@start9labs/start-sdk/cjs/sdk/lib/config/configTypes'
import { TuiDialogOptions } from '@taiga-ui/core' import { TuiDialogOptions } from '@taiga-ui/core'
import { TuiPromptData } from '@taiga-ui/kit' import { TuiPromptData } from '@taiga-ui/kit'

View File

@@ -21,7 +21,7 @@ import { toRouterLink } from '../../../utils/to-router-link'
selector: '[notificationItem]', selector: '[notificationItem]',
template: ` template: `
<td><ng-content /></td> <td><ng-content /></td>
<td>{{ notificationItem['created-at'] | date : 'MMM d, y, h:mm a' }}</td> <td>{{ notificationItem.createdAt | date: 'MMM d, y, h:mm a' }}</td>
<td [style.color]="color"> <td [style.color]="color">
<tui-svg [style.color]="color" [src]="icon"></tui-svg> <tui-svg [style.color]="color" [src]="icon"></tui-svg>
{{ notificationItem.title }} {{ notificationItem.title }}
@@ -70,8 +70,9 @@ export class NotificationItemComponent {
get manifest$(): Observable<Manifest> { get manifest$(): Observable<Manifest> {
return this.patch return this.patch
.watch$( .watch$(
'package-data', 'packageData',
this.notificationItem['package-id'] || '', this.notificationItem.packageId || '',
'stateInfo',
'manifest', 'manifest',
) )
.pipe(first()) .pipe(first())

View File

@@ -14,12 +14,12 @@ import { SettingsUpdateComponent } from './update.component'
selector: 'settings-menu', selector: 'settings-menu',
template: ` template: `
<ng-container *ngIf="server$ | async as server; else loading"> <ng-container *ngIf="server$ | async as server; else loading">
<settings-sync *ngIf="!server['ntp-synced']" /> <settings-sync *ngIf="!server.ntpSynced" />
<section *ngFor="let cat of service.settings | keyvalue: asIsOrder"> <section *ngFor="let cat of service.settings | keyvalue: asIsOrder">
<h3 class="g-title" (click)="addClick(cat.key)">{{ cat.key }}</h3> <h3 class="g-title" (click)="addClick(cat.key)">{{ cat.key }}</h3>
<settings-update <settings-update
*ngIf="cat.key === 'General'" *ngIf="cat.key === 'General'"
[updated]="server['status-info'].updated" [updated]="server.statusInfo.updated"
/> />
<ng-container *ngFor="let btn of cat.value"> <ng-container *ngFor="let btn of cat.value">
<settings-button [button]="btn"> <settings-button [button]="btn">
@@ -32,13 +32,7 @@ import { SettingsUpdateComponent } from './update.component'
: 'var(--tui-success-fill)' : 'var(--tui-success-fill)'
" "
> >
{{ {{ server.network.outboundProxy || 'None' }}
!server.network.outboundProxy
? 'None'
: server.network.outboundProxy === 'primary'
? 'System Primary'
: server.network.outboundProxy.proxyId
}}
</div> </div>
</settings-button> </settings-button>
</ng-container> </ng-container>
@@ -76,7 +70,7 @@ export class SettingsMenuComponent {
private readonly clientStorageService = inject(ClientStorageService) private readonly clientStorageService = inject(ClientStorageService)
private readonly alerts = inject(TuiAlertService) private readonly alerts = inject(TuiAlertService)
readonly server$ = inject(PatchDB<DataModel>).watch$('server-info') readonly server$ = inject(PatchDB<DataModel>).watch$('serverInfo')
readonly service = inject(SettingsService) readonly service = inject(SettingsService)
manageClicks = 0 manageClicks = 0

View File

@@ -48,7 +48,7 @@ import { EOSService } from 'src/app/services/eos.service'
], ],
}) })
export class SettingsUpdateModal { export class SettingsUpdateModal {
readonly versions = Object.entries(this.eosService.eos?.['release-notes']!) readonly versions = Object.entries(this.eosService.eos?.releaseNotes!)
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
.reverse() .reverse()
.map(([version, notes]) => ({ .map(([version, notes]) => ({

View File

@@ -1,6 +1,6 @@
import { Config } from '@start9labs/start-sdk/lib/config/builder/config' import { Config } from '@start9labs/start-sdk/cjs/sdk/lib/config/builder/config'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value' import { Value } from '@start9labs/start-sdk/cjs/sdk/lib/config/builder/value'
import { Variants } from '@start9labs/start-sdk/lib/config/builder/variants' import { Variants } from '@start9labs/start-sdk/cjs/sdk/lib/config/builder/variants'
import { Proxy } from 'src/app/services/patch-db/data-model' import { Proxy } from 'src/app/services/patch-db/data-model'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec' import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
@@ -59,38 +59,18 @@ function getStrategyUnion(proxies: Proxy[]) {
proxy: { proxy: {
name: 'Proxy', name: 'Proxy',
spec: Config.of({ spec: Config.of({
proxyStrategy: Value.union( proxyId: Value.select({
{ name: 'Select Proxy',
name: 'Proxy Strategy', required: { default: null },
required: { default: 'primary' }, values: inboundProxies,
description: `<h5>Primary</h5>Use the <i>Primary Inbound</i> proxy from your proxy settings. If you do not have any inbound proxies, no proxy will be used }),
<h5>Other</h5>Use a specific proxy from your proxy settings
`,
},
Variants.of({
primary: {
name: 'Primary',
spec: Config.of({}),
},
other: {
name: 'Specific',
spec: Config.of({
proxyId: Value.select({
name: 'Select Proxy',
required: { default: null },
values: inboundProxies,
}),
}),
},
}),
),
}), }),
}, },
}), }),
) )
} }
export async function getStart9ToSpec(proxies: Proxy[]) { export function getStart9ToSpec(proxies: Proxy[]) {
return configBuilderToSpec( return configBuilderToSpec(
Config.of({ Config.of({
strategy: getStrategyUnion(proxies), strategy: getStrategyUnion(proxies),
@@ -98,7 +78,7 @@ export async function getStart9ToSpec(proxies: Proxy[]) {
) )
} }
export async function getCustomSpec(proxies: Proxy[]) { export function getCustomSpec(proxies: Proxy[]) {
return configBuilderToSpec( return configBuilderToSpec(
Config.of({ Config.of({
hostname: Value.text({ hostname: Value.text({

View File

@@ -69,7 +69,7 @@ export class SettingsDomainsComponent {
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService) private readonly dialogs = inject(TuiDialogService)
readonly domains$ = this.patch.watch$('server-info', 'network').pipe( readonly domains$ = this.patch.watch$('serverInfo', 'network').pipe(
map(network => { map(network => {
const start9ToSubdomain = network.start9ToSubdomain const start9ToSubdomain = network.start9ToSubdomain
const start9To = !start9ToSubdomain const start9To = !start9ToSubdomain
@@ -103,7 +103,7 @@ export class SettingsDomainsComponent {
async add() { async add() {
const proxies = await firstValueFrom( const proxies = await firstValueFrom(
this.patch.watch$('server-info', 'network', 'proxies'), this.patch.watch$('serverInfo', 'network', 'proxies'),
) )
const options: Partial<TuiDialogOptions<FormContext<any>>> = { const options: Partial<TuiDialogOptions<FormContext<any>>> = {
@@ -128,7 +128,7 @@ export class SettingsDomainsComponent {
async claim() { async claim() {
const proxies = await firstValueFrom( const proxies = await firstValueFrom(
this.patch.watch$('server-info', 'network', 'proxies'), this.patch.watch$('serverInfo', 'network', 'proxies'),
) )
const options: Partial<TuiDialogOptions<FormContext<any>>> = { const options: Partial<TuiDialogOptions<FormContext<any>>> = {
@@ -150,13 +150,11 @@ export class SettingsDomainsComponent {
this.formDialog.open(FormComponent, options) this.formDialog.open(FormComponent, options)
} }
// @TODO figure out how to get types here
private getNetworkStrategy(strategy: any) { private getNetworkStrategy(strategy: any) {
const { ipStrategy, proxyStrategy = {} } = strategy.unionValueKey return strategy.unionSelectKey === 'local'
const { unionSelectKey, unionValueKey = {} } = proxyStrategy ? { ipStrategy: strategy.unionValueKey.ipStrategy }
const proxyId = unionSelectKey === 'primary' ? null : unionValueKey.proxyId : { proxy: strategy.unionValueKey.proxyId }
return strategy.unionSelectKey === 'local' ? { ipStrategy } : { proxyId }
} }
private async deleteDomain(hostname?: string) { private async deleteDomain(hostname?: string) {
@@ -174,7 +172,7 @@ export class SettingsDomainsComponent {
loader.unsubscribe() loader.unsubscribe()
} }
} }
// @TODO figure out how to get types here
private async claimDomain({ strategy }: any): Promise<boolean> { private async claimDomain({ strategy }: any): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe() const loader = this.loader.open('Saving...').subscribe()
const networkStrategy = this.getNetworkStrategy(strategy) const networkStrategy = this.getNetworkStrategy(strategy)
@@ -189,7 +187,7 @@ export class SettingsDomainsComponent {
loader.unsubscribe() loader.unsubscribe()
} }
} }
// @TODO figure out how to get types here
private async save({ provider, strategy, hostname }: any): Promise<boolean> { private async save({ provider, strategy, hostname }: any): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe() const loader = this.loader.open('Saving...').subscribe()
const name = provider.unionSelectKey const name = provider.unionSelectKey

View File

@@ -9,8 +9,6 @@ import { TuiDialogService } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental' import { TuiButtonModule } from '@taiga-ui/experimental'
import { TuiInputModule } from '@taiga-ui/kit' import { TuiInputModule } from '@taiga-ui/kit'
import { ErrorService, LoadingService } from '@start9labs/shared' import { ErrorService, LoadingService } from '@start9labs/shared'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import { customSmtp } from '@start9labs/start-sdk/lib/config/configConstants'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { switchMap } from 'rxjs' import { switchMap } from 'rxjs'
import { FormModule } from 'src/app/common/form/form.module' import { FormModule } from 'src/app/common/form/form.module'
@@ -19,6 +17,8 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormService } from 'src/app/services/form.service' import { FormService } from 'src/app/services/form.service'
import { EmailInfoComponent } from './info.component' import { EmailInfoComponent } from './info.component'
import { InputSpec } from '@start9labs/start-sdk/cjs/sdk/lib/config/configTypes'
import { customSmtp } from '@start9labs/start-sdk/cjs/sdk/lib/config/configConstants'
@Component({ @Component({
template: ` template: `
@@ -84,7 +84,7 @@ export class SettingsEmailComponent {
testAddress = '' testAddress = ''
readonly spec: Promise<InputSpec> = configBuilderToSpec(customSmtp) readonly spec: Promise<InputSpec> = configBuilderToSpec(customSmtp)
readonly form$ = this.patch readonly form$ = this.patch
.watch$('server-info', 'smtp') .watch$('serverInfo', 'smtp')
.pipe( .pipe(
switchMap(async value => switchMap(async value =>
this.formService.createForm(await this.spec, value), this.formService.createForm(await this.spec, value),

View File

@@ -93,37 +93,6 @@ export class SettingsExperimentalComponent {
.subscribe(() => this.resetTor(this.wipe)) .subscribe(() => this.resetTor(this.wipe))
} }
zram(enabled: boolean) {
this.dialogs
.open(TUI_PROMPT, {
label: 'Confirm',
data: {
content: enabled
? 'Are you sure you want to disable zram? It provides significant performance benefits on low RAM devices.'
: 'Enable zram? It will only make a difference on lower RAM devices.',
yes: enabled ? 'Disable' : 'Enable',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.toggleZram(enabled))
}
private async toggleZram(enabled: boolean) {
const loader = this.loader
.open(enabled ? 'Disabling zram...' : 'Enabling zram...')
.subscribe()
try {
await this.api.toggleZram({ enable: !enabled })
this.alerts.open(`Zram ${enabled ? 'disabled' : 'enabled'}`).subscribe()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async resetTor(wipeState: boolean) { private async resetTor(wipeState: boolean) {
const loader = this.loader.open('Resetting Tor...').subscribe() const loader = this.loader.open('Resetting Tor...').subscribe()

View File

@@ -1,22 +0,0 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { InterfacesComponent } from 'src/app/apps/portal/components/interfaces/interfaces.component'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
template: `
<app-interfaces
*ngIf="ui$ | async as ui"
[style.max-width.rem]="50"
[addressInfo]="ui"
[isUi]="true"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, InterfacesComponent],
})
export class SettingsInterfacesComponent {
readonly ui$ = inject(PatchDB<DataModel>).watch$('server-info', 'ui')
}

View File

@@ -0,0 +1,70 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { T } from '@start9labs/start-sdk'
import { PatchDB } from 'patch-db-client'
import { Observable, map } from 'rxjs'
import {
InterfaceComponent,
ServiceInterfaceWithAddresses,
} from 'src/app/apps/portal/components/interfaces/interface.component'
import { getAddresses } from 'src/app/apps/portal/components/interfaces/interface.utils'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
template: `
<app-interface
*ngIf="ui$ | async as ui"
[style.max-width.rem]="50"
[serviceInterface]="ui"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, InterfaceComponent],
})
export class StartOsUiComponent {
readonly ui$: Observable<ServiceInterfaceWithAddresses> = inject(
PatchDB<DataModel>,
)
.watch$('serverInfo', 'ui')
.pipe(
map(hosts => {
const serviceInterface: T.ServiceInterfaceWithHostInfo = {
id: 'startos-ui',
name: 'StartOS UI',
description: 'The primary web user interface for StartOS',
type: 'ui',
hasPrimary: false,
disabled: false,
masked: false,
addressInfo: {
hostId: '',
username: null,
suffix: '',
bindOptions: {
scheme: 'http',
preferredExternalPort: 80,
addSsl: {
scheme: 'https',
preferredExternalPort: 443,
addXForwardedHeaders: null,
},
secure: {
ssl: false,
},
},
},
hostInfo: {
id: 'start-os-ui-host',
kind: 'multi',
hostnames: hosts,
},
}
return {
...serviceInterface,
addresses: getAddresses(serviceInterface),
}
}),
)
}

View File

@@ -1,5 +1,5 @@
import { Config } from '@start9labs/start-sdk/lib/config/builder/config' import { Config } from '@start9labs/start-sdk/cjs/sdk/lib/config/builder/config'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value' import { Value } from '@start9labs/start-sdk/cjs/sdk/lib/config/builder/value'
import { TuiDialogOptions } from '@taiga-ui/core' import { TuiDialogOptions } from '@taiga-ui/core'
import { TuiPromptData } from '@taiga-ui/kit' import { TuiPromptData } from '@taiga-ui/kit'
@@ -27,8 +27,6 @@ export const wireguardSpec = Config.of({
}) })
export type WireguardSpec = typeof wireguardSpec.validator._TYPE export type WireguardSpec = typeof wireguardSpec.validator._TYPE
export type ProxyUpdate = Partial<{ export type ProxyUpdate = {
name: string name: string
primaryInbound: true }
primaryOutbound: true
}>

View File

@@ -6,7 +6,6 @@ import {
Input, Input,
} from '@angular/core' } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared' import { ErrorService, LoadingService } from '@start9labs/shared'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
import { TuiButtonModule } from '@taiga-ui/experimental' import { TuiButtonModule } from '@taiga-ui/experimental'
import { import {
TuiDataListModule, TuiDataListModule,
@@ -25,6 +24,7 @@ import { Proxy } from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service' import { FormDialogService } from 'src/app/services/form-dialog.service'
import { DELETE_OPTIONS, ProxyUpdate } from './constants' import { DELETE_OPTIONS, ProxyUpdate } from './constants'
import { Value } from '@start9labs/start-sdk/cjs/sdk/lib/config/builder/value'
@Component({ @Component({
selector: 'proxies-menu', selector: 'proxies-menu',
@@ -45,23 +45,6 @@ import { DELETE_OPTIONS, ProxyUpdate } from './constants'
</tui-hosted-dropdown> </tui-hosted-dropdown>
<ng-template #dropdown> <ng-template #dropdown>
<tui-data-list> <tui-data-list>
<button
*ngIf="!proxy.primaryInbound && proxy.type === 'inbound-outbound'"
tuiOption
(click)="update({ primaryInbound: true })"
>
Make Primary Inbound
</button>
<button
*ngIf="
!proxy.primaryOutbound &&
(proxy.type === 'inbound-outbound' || proxy.type === 'outbound')
"
tuiOption
(click)="update({ primaryOutbound: true })"
>
Make Primary Outbound
</button>
<button tuiOption (click)="rename()">Rename</button> <button tuiOption (click)="rename()">Rename</button>
<tui-opt-group> <tui-opt-group>
<button tuiOption (click)="delete()">Delete</button> <button tuiOption (click)="delete()">Delete</button>
@@ -105,20 +88,6 @@ export class ProxiesMenuComponent {
}) })
} }
async update(value: ProxyUpdate): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.updateProxy(value)
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
async rename() { async rename() {
const spec = { name: 'Name', required: { default: this.proxy.name } } const spec = { name: 'Name', required: { default: this.proxy.name } }
const name = await Value.text(spec).build({} as any) const name = await Value.text(spec).build({} as any)
@@ -137,4 +106,18 @@ export class ProxiesMenuComponent {
this.formDialog.open(FormComponent, options) this.formDialog.open(FormComponent, options)
} }
private async update(value: ProxyUpdate): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.updateProxy(value)
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
} }

View File

@@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared' import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core' import { TuiDialogOptions } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental' import { TuiButtonModule } from '@taiga-ui/experimental'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { import {
@@ -42,7 +42,7 @@ export class SettingsProxiesComponent {
private readonly formDialog = inject(FormDialogService) private readonly formDialog = inject(FormDialogService)
readonly proxies$ = inject(PatchDB<DataModel>).watch$( readonly proxies$ = inject(PatchDB<DataModel>).watch$(
'server-info', 'serverInfo',
'network', 'network',
'proxies', 'proxies',
) )

View File

@@ -20,7 +20,6 @@ import { ProxiesMenuComponent } from './menu.component'
<th>Name</th> <th>Name</th>
<th>Created</th> <th>Created</th>
<th>Type</th> <th>Type</th>
<th>Primary</th>
<th>Used By</th> <th>Used By</th>
<th></th> <th></th>
</tr> </tr>
@@ -28,21 +27,8 @@ import { ProxiesMenuComponent } from './menu.component'
<tbody> <tbody>
<tr *ngFor="let proxy of proxies"> <tr *ngFor="let proxy of proxies">
<td>{{ proxy.name }}</td> <td>{{ proxy.name }}</td>
<td>{{ proxy.createdAt | date : 'short' }}</td> <td>{{ proxy.createdAt | date: 'short' }}</td>
<td>{{ proxy.type }}</td> <td>{{ proxy.type }}</td>
<td>
<tui-badge
*ngIf="proxy.primaryInbound"
appearance="success"
size="m"
[style.margin-right.rem]="0.25"
>
Inbound
</tui-badge>
<tui-badge *ngIf="proxy.primaryOutbound" appearance="info" size="m">
Outbound
</tui-badge>
</td>
<td> <td>
<button <button
*ngIf="getLength(proxy); else unused" *ngIf="getLength(proxy); else unused"

View File

@@ -1,14 +1,16 @@
import { Pipe, PipeTransform } from '@angular/core' import { Pipe, PipeTransform } from '@angular/core'
import { IpInfo } from 'src/app/services/patch-db/data-model' import { HostnameInfo } from '@start9labs/start-sdk/cjs/sdk/lib/types'
@Pipe({ @Pipe({
standalone: true, standalone: true,
name: 'primaryIp', name: 'primaryIp',
}) })
export class PrimaryIpPipe implements PipeTransform { export class PrimaryIpPipe implements PipeTransform {
transform(ipInfo: IpInfo): string { transform(hostnames: HostnameInfo[]): string {
return Object.values(ipInfo) return (
.filter(iface => iface.ipv4) hostnames.map(
.sort((a, b) => (a.wireless ? -1 : 1))[0].ipv4! h => h.kind === 'ip' && h.hostname.kind === 'ipv4' && h.hostname.value,
)[0] || ''
)
} }
} }

View File

@@ -12,7 +12,7 @@ import { RouterPortComponent } from './table.component'
<ng-container *ngIf="server$ | async as server"> <ng-container *ngIf="server$ | async as server">
<router-info [enabled]="!server.network.wanConfig.upnp" /> <router-info [enabled]="!server.network.wanConfig.upnp" />
<table <table
*ngIf="server.ui.ipInfo | primaryIp as ip" *ngIf="server.ui | primaryIp as ip"
tuiTextfieldAppearance="unstyled" tuiTextfieldAppearance="unstyled"
tuiTextfieldSize="m" tuiTextfieldSize="m"
[tuiTextfieldLabelOutside]="true" [tuiTextfieldLabelOutside]="true"
@@ -65,5 +65,5 @@ import { RouterPortComponent } from './table.component'
], ],
}) })
export class SettingsRouterComponent { export class SettingsRouterComponent {
readonly server$ = inject(PatchDB<DataModel>).watch$('server-info') readonly server$ = inject(PatchDB<DataModel>).watch$('serverInfo')
} }

View File

@@ -5,7 +5,6 @@ import {
inject, inject,
Input, Input,
OnChanges, OnChanges,
SimpleChanges,
} from '@angular/core' } from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { CopyService, ErrorService, LoadingService } from '@start9labs/shared' import { CopyService, ErrorService, LoadingService } from '@start9labs/shared'

View File

@@ -58,8 +58,8 @@ export class SettingsSessionsComponent {
})) }))
.sort( .sort(
(a, b) => (a, b) =>
new Date(b['last-active']).valueOf() - new Date(b.lastActive).valueOf() -
new Date(a['last-active']).valueOf(), new Date(a.lastActive).valueOf(),
), ),
), ),
), ),

View File

@@ -49,13 +49,13 @@ import { FormsModule } from '@angular/forms'
[ngModel]="selected$.value.includes(session)" [ngModel]="selected$.value.includes(session)"
(ngModelChange)="onToggle(session)" (ngModelChange)="onToggle(session)"
/> />
{{ session['user-agent'] }} {{ session.userAgent }}
</td> </td>
<td *ngIf="session.metadata.platforms | platformInfo as info"> <td *ngIf="session.metadata.platforms | platformInfo as info">
<tui-icon [icon]="info.icon"></tui-icon> <tui-icon [icon]="info.icon"></tui-icon>
{{ info.name }} {{ info.name }}
</td> </td>
<td>{{ session['last-active'] }}</td> <td>{{ session.lastActive }}</td>
</tr> </tr>
<ng-template #loading> <ng-template #loading>
<tr *ngFor="let _ of single ? [''] : ['', '']"> <tr *ngFor="let _ of single ? [''] : ['', '']">

View File

@@ -35,7 +35,7 @@ import { TuiForModule } from '@taiga-ui/cdk'
<tbody> <tbody>
<tr *ngFor="let key of keys; else: loading"> <tr *ngFor="let key of keys; else: loading">
<td>{{ key.hostname }}</td> <td>{{ key.hostname }}</td>
<td>{{ key['created-at'] | date: 'medium' }}</td> <td>{{ key.createdAt | date: 'medium' }}</td>
<td>{{ key.alg }}</td> <td>{{ key.alg }}</td>
<td>{{ key.fingerprint }}</td> <td>{{ key.fingerprint }}</td>
<td> <td>

View File

@@ -1,4 +1,4 @@
import { ValueSpecObject } from '@start9labs/start-sdk/lib/config/configTypes' import { ValueSpecObject } from '@start9labs/start-sdk/cjs/sdk/lib/config/configTypes'
import { AvailableWifi } from 'src/app/services/api/api.types' import { AvailableWifi } from 'src/app/services/api/api.types'
import { RR } from 'src/app/services/api/api.types' import { RR } from 'src/app/services/api/api.types'
@@ -18,7 +18,7 @@ export interface WifiData {
export function parseWifi(res: RR.GetWifiRes): WifiData { export function parseWifi(res: RR.GetWifiRes): WifiData {
return { return {
available: res['available-wifi'], available: res.availableWifi,
known: Object.entries(res.ssids).map(([ssid, strength]) => ({ known: Object.entries(res.ssids).map(([ssid, strength]) => ({
ssid, ssid,
strength, strength,

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