feat: use routes for service sections (#2502)

* feat: use routes for service sections

* chore: fix comment
This commit is contained in:
Alex Inkin
2023-11-09 23:23:58 +04:00
committed by GitHub
parent 06207145af
commit c491dfdd3a
34 changed files with 637 additions and 443 deletions

View File

@@ -4,7 +4,7 @@
{{ badge }}
</tui-badge-notification>
<tui-svg
*ngIf="icon.startsWith('tuiIcon'); else url"
*ngIf="icon?.startsWith('tuiIcon'); else url"
class="icon"
[src]="icon"
></tui-svg>

View File

@@ -11,7 +11,6 @@
#rla="routerLinkActive"
class="tab"
routerLinkActive="tab_active"
[routerLinkActiveOptions]="{ exact: true }"
[routerLink]="tab.routerLink"
>
<tui-svg
@@ -28,7 +27,7 @@
iconLeft="tuiIconClose"
appearance="icon"
class="close"
(click.stop.prevent)="removeTab(tab, rla.isActive)"
(click.stop.prevent)="removeTab(tab.routerLink, rla.isActive)"
>
Close
</button>

View File

@@ -20,9 +20,9 @@ export class NavigationComponent {
readonly tabs$ = this.navigation.getTabs()
removeTab(tab: NavigationItem, active: boolean) {
this.navigation.removeTab(tab)
removeTab(routerLink: string, active: boolean) {
this.navigation.removeTab(routerLink)
if (active) this.router.navigate(['./portal/desktop'])
if (active) this.router.navigate(['/portal/desktop'])
}
}

View File

@@ -1,17 +1,17 @@
import { Pipe, PipeTransform } from '@angular/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { NavigationItem } from '../types/navigation-item'
import { toDesktopItem } from '../utils/to-desktop-item'
import { toNavigationItem } from '../utils/to-navigation-item'
@Pipe({
name: 'toDesktopItem',
name: 'toNavigationItem',
standalone: true,
})
export class ToDesktopItemPipe implements PipeTransform {
export class ToNavigationItemPipe implements PipeTransform {
transform(
packages: Record<string, PackageDataEntry>,
id: string,
): NavigationItem | null {
return id ? toDesktopItem(id, packages) : null
return id ? toNavigationItem(id, packages) : null
}
}

View File

@@ -28,15 +28,15 @@
[desktopItem]="item"
>
<a
*ngIf="packages | toDesktopItem : item as desktopItem"
*ngIf="packages | toNavigationItem : item as navigationItem"
tuiTileHandle
appCard
@tuiFadeIn
[id]="item"
[badge]="item | toNotifications | async"
[title]="desktopItem.title"
[icon]="desktopItem.icon"
[routerLink]="desktopItem.routerLink"
[title]="navigationItem.title"
[icon]="navigationItem.icon"
[routerLink]="navigationItem.routerLink"
></a>
</tui-tile>
</tui-tiles>

View File

@@ -7,7 +7,7 @@ import { TuiFadeModule } from '@taiga-ui/experimental'
import { TuiTilesModule } from '@taiga-ui/kit'
import { DesktopComponent } from './desktop.component'
import { CardComponent } from '../../components/card/card.component'
import { ToDesktopItemPipe } from '../../pipes/to-desktop-item'
import { ToNavigationItemPipe } from '../../pipes/to-navigation-item'
import { ToNotificationsPipe } from '../../pipes/to-notifications'
import { DesktopItemDirective } from './desktop-item.directive'
@@ -26,7 +26,7 @@ const ROUTES: Routes = [
TuiSvgModule,
TuiLoaderModule,
TuiTilesModule,
ToDesktopItemPipe,
ToNavigationItemPipe,
RouterModule.forChild(ROUTES),
TuiFadeModule,
DragScrollerDirective,

View File

@@ -119,7 +119,7 @@ export class ServiceActionsComponent {
}
async tryStart(): Promise<void> {
if (this.dependencies.transform(this.service).some(d => !!d.errorText)) {
if (this.dependencies.transform(this.service)?.some(d => !!d.errorText)) {
const depErrMsg = `${this.service.manifest.title} has unmet dependencies. It will not work as expected.`
const proceed = await this.presentAlertStart(depErrMsg)

View File

@@ -0,0 +1,47 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiSvgModule } from '@taiga-ui/core'
import { AdditionalItem, FALLBACK_URL } from '../pipes/to-additional.pipe'
@Component({
selector: '[additionalItem]',
template: `
<div [style.flex]="1">
<strong>{{ additionalItem.name }}</strong>
<div>{{ additionalItem.description }}</div>
</div>
<tui-svg *ngIf="icon" [src]="icon"></tui-svg>
`,
styles: [
`
:host._disabled {
pointer-events: none;
opacity: var(--tui-disabled-opacity);
}
`,
],
host: {
rel: 'noreferrer',
target: '_blank',
'[class._disabled]': 'disabled',
'[attr.href]':
'additionalItem.description.startsWith("http") ? additionalItem.description : null',
},
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiSvgModule],
})
export class ServiceAdditionalItemComponent {
@Input({ required: true })
additionalItem!: AdditionalItem
get disabled(): boolean {
return this.additionalItem.description === FALLBACK_URL
}
get icon(): string | undefined {
return this.additionalItem.description.startsWith('http')
? 'tuiIconExternalLinkLarge'
: this.additionalItem.icon
}
}

View File

@@ -1,46 +1,34 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiSvgModule } from '@taiga-ui/core'
import { AdditionalItem, FALLBACK_URL } from '../pipes/to-additional.pipe'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { ToAdditionalPipe } from '../pipes/to-additional.pipe'
import { ServiceAdditionalItemComponent } from './additional-item.component'
@Component({
selector: '[additional]',
selector: 'service-additional',
template: `
<div [style.flex]="1">
<strong>{{ additional.name }}</strong>
<div>{{ additional.description }}</div>
</div>
<tui-svg *ngIf="icon" [src]="icon"></tui-svg>
<h3 class="g-title">Additional Info</h3>
<ng-container *ngFor="let additional of service | toAdditional">
<a
*ngIf="additional.description.startsWith('http'); else button"
class="g-action"
[additionalItem]="additional"
></a>
<ng-template #button>
<button
class="g-action"
[style.pointer-events]="!additional.icon ? 'none' : null"
[additionalItem]="additional"
(click)="additional.action?.()"
></button>
</ng-template>
</ng-container>
`,
styles: [
`
:host._disabled {
pointer-events: none;
opacity: var(--tui-disabled-opacity);
}
`,
],
host: {
'[attr.href]': 'additional.description',
'[class._disabled]': 'disabled',
target: '_blank',
rel: 'noreferrer',
},
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiSvgModule],
imports: [CommonModule, ToAdditionalPipe, ServiceAdditionalItemComponent],
})
export class ServiceAdditionalComponent {
@Input({ required: true })
additional!: AdditionalItem
get disabled(): boolean {
return this.additional.description === FALLBACK_URL
}
get icon(): string | undefined {
return this.additional.description.startsWith('http')
? 'tuiIconExternalLinkLarge'
: this.additional.icon
}
service!: PackageDataEntry
}

View File

@@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { ServiceDependencyComponent } from './dependency.component'
import { DependencyInfo } from '../types/dependency-info'
@Component({
selector: 'service-dependencies',
template: `
<h3 class="g-title">Dependencies</h3>
<button
*ngFor="let dep of dependencies"
class="g-action"
[serviceDependency]="dep"
(click)="dep.action()"
></button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, ServiceDependencyComponent],
})
export class ServiceDependenciesComponent {
@Input({ required: true })
dependencies: readonly DependencyInfo[] = []
}

View File

@@ -0,0 +1,32 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { HealthCheckResult } from 'src/app/services/patch-db/data-model'
import { ConnectionService } from 'src/app/services/connection.service'
import { ServiceHealthCheckComponent } from './health-check.component'
@Component({
selector: 'service-health-checks',
template: `
<h3 class="g-title">Health Checks</h3>
<service-health-check
*ngFor="let check of checks"
class="g-action"
[check]="check"
[connected]="!!(connected$ | async)"
></service-health-check>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, ServiceHealthCheckComponent],
})
export class ServiceHealthChecksComponent {
@Input({ required: true })
checks: readonly HealthCheckResult[] = []
readonly connected$ = inject(ConnectionService).connected$
}

View File

@@ -12,7 +12,7 @@ import { InterfaceInfo } from 'src/app/services/patch-db/data-model'
import { ExtendedInterfaceInfo } from '../pipes/interface-info.pipe'
@Component({
selector: 'button[serviceInterface]',
selector: 'a[serviceInterface]',
template: `
<tui-svg [src]="info.icon" [style.color]="info.color"></tui-svg>
<div [style.flex]="1">

View File

@@ -0,0 +1,42 @@
import { NgForOf } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { PackageDataEntry } 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 { ToStatusPipe } from '../pipes/to-status.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 | interfaceInfo"
class="g-action"
[serviceInterface]="info"
[disabled]="!isRunning(service | toStatus)"
[routerLink]="info.routerLink"
></a>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
NgForOf,
RouterLink,
InterfaceInfoPipe,
ServiceInterfaceComponent,
ToStatusPipe,
],
})
export class ServiceInterfacesComponent {
@Input({ required: true })
service!: PackageDataEntry
isRunning({ primary }: PackageStatus): boolean {
return primary === PrimaryStatus.Running
}
}

View File

@@ -0,0 +1,25 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiSvgModule } from '@taiga-ui/core'
import { ServiceMenu } from '../pipes/to-menu.pipe'
@Component({
selector: '[serviceMenuItem]',
template: `
<tui-svg [src]="menu.icon"></tui-svg>
<div [style.flex]="1">
<strong>{{ menu.name }}</strong>
<div>
{{ menu.description }}
<ng-content></ng-content>
</div>
</div>
<tui-svg src="tuiIconChevronRightLarge"></tui-svg>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiSvgModule],
})
export class ServiceMenuItemComponent {
@Input({ required: true, alias: 'serviceMenuItem' })
menu!: ServiceMenu
}

View File

@@ -1,25 +1,46 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiSvgModule } from '@taiga-ui/core'
import { ServiceMenu } from '../pipes/to-menu.pipe'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { ToMenuPipe } from '../pipes/to-menu.pipe'
import { ServiceMenuItemComponent } from './menu-item.component'
@Component({
selector: '[serviceMenu]',
selector: 'service-menu',
template: `
<tui-svg [src]="menu.icon"></tui-svg>
<div [style.flex]="1">
<strong>{{ menu.name }}</strong>
<div>
{{ menu.description }}
<ng-content></ng-content>
<h3 class="g-title">Menu</h3>
<button
*ngFor="let menu of service | toMenu"
class="g-action"
[serviceMenuItem]="menu"
(click)="menu.action()"
>
<div *ngIf="menu.name === 'Outbound Proxy'" [style.color]="color">
{{ proxy }}
</div>
</div>
<tui-svg src="tuiIconChevronRightLarge"></tui-svg>
</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiSvgModule],
imports: [CommonModule, ToMenuPipe, ServiceMenuItemComponent],
})
export class ServiceMenuComponent {
@Input({ required: true, alias: 'serviceMenu' })
menu!: ServiceMenu
@Input({ required: true })
service!: PackageDataEntry
get color(): string {
return this.service.installed?.outboundProxy
? 'var(--tui-success-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

@@ -24,6 +24,15 @@ import { InstallProgressPipeModule } from 'src/app/common/install-progress/insta
</strong>
</ng-template>
`,
styles: [
`
:host {
font-size: x-large;
margin: 1em 0;
display: block;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, InstallProgressPipeModule],

View File

@@ -1,18 +1,15 @@
import { inject, Pipe, PipeTransform } from '@angular/core'
import { TuiDialogService } from '@taiga-ui/core'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { Pipe, PipeTransform } from '@angular/core'
import {
InterfaceInfo,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { ServiceInterfaceModal } from '../modals/interface.component'
export interface ExtendedInterfaceInfo extends InterfaceInfo {
id: string
icon: string
color: string
typeDetail: string
action: () => void
routerLink: string
}
@Pipe({
@@ -20,12 +17,7 @@ export interface ExtendedInterfaceInfo extends InterfaceInfo {
standalone: true,
})
export class InterfaceInfoPipe implements PipeTransform {
private readonly dialogs = inject(TuiDialogService)
transform({
manifest,
installed,
}: PackageDataEntry): ExtendedInterfaceInfo[] {
transform({ installed }: PackageDataEntry): ExtendedInterfaceInfo[] {
return Object.entries(installed!.interfaceInfo).map(([id, val]) => {
let color: string
let icon: string
@@ -60,17 +52,7 @@ export class InterfaceInfoPipe implements PipeTransform {
color,
icon,
typeDetail,
action: () =>
this.dialogs
.open(new PolymorpheusComponent(ServiceInterfaceModal), {
label: val.name,
size: 'l',
data: {
packageId: manifest.id,
interfaceId: id,
},
})
.subscribe(),
routerLink: `./interface/${id}`,
}
})
}

View File

@@ -10,6 +10,8 @@ import { FormDialogService } from 'src/app/services/form-dialog.service'
import { ServiceConfigModal } from '../modals/config.component'
import { DependencyInfo } from '../types/dependency-info'
import { PackageConfigData } from '../types/package-config-data'
import { NavigationService } from '../../../services/navigation.service'
import { toRouterLink } from '../../../utils/to-router-link'
@Pipe({
name: 'toDependencies',
@@ -19,23 +21,32 @@ export class ToDependenciesPipe implements PipeTransform {
constructor(
private readonly router: Router,
private readonly formDialog: FormDialogService,
private readonly navigation: NavigationService,
) {}
transform(pkg: PackageDataEntry): DependencyInfo[] {
if (!pkg.installed) return []
transform(pkg: PackageDataEntry): DependencyInfo[] | null {
if (!pkg.installed) return null
return Object.keys(pkg.installed['current-dependencies'])
.filter(depId => !!pkg.manifest.dependencies[depId])
const deps = Object.keys(pkg.installed['current-dependencies'])
.filter(depId => pkg.manifest.dependencies[depId])
.map(depId => this.setDepValues(pkg, depId))
return deps.length ? deps : null
}
private setDepValues(pkg: PackageDataEntry, depId: string): DependencyInfo {
private setDepValues(pkg: PackageDataEntry, id: string): DependencyInfo {
const error = pkg.installed!.status['dependency-errors'][id]
const depInfo = pkg.installed!['dependency-info'][id]
const version = pkg.manifest.dependencies[id].version
const title = depInfo?.title || id
const icon = depInfo?.icon || ''
let errorText = ''
let actionText = 'View'
let action = (): unknown =>
this.router.navigate([`portal`, `service`, depId])
const error = pkg.installed!.status['dependency-errors'][depId]
let action = () => {
this.navigation.addTab({ icon, title, routerLink: toRouterLink(id) })
this.router.navigate([`portal`, `service`, id])
}
if (error) {
// health checks failed
@@ -45,12 +56,12 @@ export class ToDependenciesPipe implements PipeTransform {
} else if (error.type === DependencyErrorType.NotInstalled) {
errorText = 'Not installed'
actionText = 'Install'
action = () => this.fixDep(pkg, 'install', depId)
action = () => this.fixDep(pkg, 'install', id)
// incorrect version
} else if (error.type === DependencyErrorType.IncorrectVersion) {
errorText = 'Incorrect version'
actionText = 'Update'
action = () => this.fixDep(pkg, 'update', depId)
action = () => this.fixDep(pkg, 'update', id)
// not running
} else if (error.type === DependencyErrorType.NotRunning) {
errorText = 'Not running'
@@ -59,24 +70,14 @@ export class ToDependenciesPipe implements PipeTransform {
} else if (error.type === DependencyErrorType.ConfigUnsatisfied) {
errorText = 'Config not satisfied'
actionText = 'Auto config'
action = () => this.fixDep(pkg, 'configure', depId)
action = () => this.fixDep(pkg, 'configure', id)
} else if (error.type === DependencyErrorType.Transitive) {
errorText = 'Dependency has a dependency issue'
}
errorText = `${errorText}. ${pkg.manifest.title} will not work as expected.`
}
const depInfo = pkg.installed!['dependency-info'][depId]
return {
id: depId,
version: pkg.manifest.dependencies[depId].version,
title: depInfo?.title || depId,
icon: depInfo?.icon || '',
errorText,
actionText,
action,
}
return { id, icon, title, version, errorText, actionText, action }
}
async fixDep(

View File

@@ -14,9 +14,7 @@ import { FormDialogService } from 'src/app/services/form-dialog.service'
import { ProxyService } from 'src/app/services/proxy.service'
import { PackageConfigData } from '../types/package-config-data'
import { ServiceConfigModal } from '../modals/config.component'
import { ServiceLogsModal } from '../modals/logs.component'
import { ServiceCredentialsModal } from '../modals/credentials.component'
import { ServiceActionsModal } from '../modals/actions.component'
export interface ServiceMenu {
icon: string
@@ -29,7 +27,7 @@ export interface ServiceMenu {
name: 'toMenu',
standalone: true,
})
export class ToMenusPipe implements PipeTransform {
export class ToMenuPipe implements PipeTransform {
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
private readonly formDialog = inject(FormDialogService)
@@ -69,11 +67,9 @@ export class ToMenusPipe implements PipeTransform {
name: 'Actions',
description: `Uninstall and other commands specific to ${manifest.title}`,
action: () =>
this.showDialog(
`${manifest.title} credentials`,
manifest.id,
ServiceActionsModal,
),
this.router.navigate(['actions'], {
relativeTo: this.route,
}),
},
{
icon: 'tuiIconShieldLarge',
@@ -86,11 +82,9 @@ export class ToMenusPipe implements PipeTransform {
name: 'Logs',
description: `Raw, unfiltered logs`,
action: () =>
this.showDialog(
`${manifest.title} logs`,
manifest.id,
ServiceLogsModal,
),
this.router.navigate(['logs'], {
relativeTo: this.route,
}),
},
url
? {
@@ -99,7 +93,6 @@ export class ToMenusPipe implements PipeTransform {
description: `View ${manifest.title} on the Marketplace`,
action: () =>
this.router.navigate(['marketplace', manifest.id], {
relativeTo: this.route,
queryParams: { url },
}),
}

View File

@@ -1,18 +1,16 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { Router } from '@angular/router'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import {
isEmptyObject,
WithId,
ErrorService,
LoadingService,
getPkgId,
} from '@start9labs/shared'
import { TuiDialogContext, TuiDialogService } from '@taiga-ui/core'
import { TuiDialogService } from '@taiga-ui/core'
import { TUI_PROMPT } from '@taiga-ui/kit'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@tinkoff/ng-polymorpheus'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { PatchDB } from 'patch-db-client'
import { filter, switchMap, timer } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@@ -27,7 +25,11 @@ import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormPage } from 'src/app/apps/ui/modals/form/form.page'
import { ServiceActionComponent } from '../components/action.component'
import { ServiceActionSuccessComponent } from '../components/action-success.component'
import { DesktopService } from '../../../services/desktop.service'
import { GroupActionsPipe } from '../pipes/group-actions.pipe'
import { updateTab } from '../utils/update-tab'
import { NavigationService } from '../../../services/navigation.service'
import { toRouterLink } from '../../../utils/to-router-link'
@Component({
template: `
@@ -41,7 +43,9 @@ import { GroupActionsPipe } from '../pipes/group-actions.pipe'
></button>
</section>
<ng-container *ngIf="pkg.actions | groupActions as actionGroups">
<h3>Actions for {{ pkg.manifest.title }}</h3>
<h3 *ngIf="actionGroups.length" class="g-title">
Actions for {{ pkg.manifest.title }}
</h3>
<div *ngFor="let group of actionGroups">
<button
*ngFor="let action of group"
@@ -61,9 +65,11 @@ import { GroupActionsPipe } from '../pipes/group-actions.pipe'
standalone: true,
imports: [CommonModule, ServiceActionComponent, GroupActionsPipe],
})
export class ServiceActionsModal {
export class ServiceActionsRoute {
private readonly id = getPkgId(inject(ActivatedRoute))
readonly pkg$ = this.patch
.watch$('package-data', this.context.data)
.watch$('package-data', this.id)
.pipe(filter(pkg => pkg.state === PackageState.Installed))
readonly action = {
@@ -74,8 +80,6 @@ export class ServiceActionsModal {
}
constructor(
@Inject(POLYMORPHEUS_CONTEXT)
private readonly context: TuiDialogContext<void, string>,
private readonly embassyApi: ApiService,
private readonly dialogs: TuiDialogService,
private readonly errorService: ErrorService,
@@ -83,7 +87,11 @@ export class ServiceActionsModal {
private readonly router: Router,
private readonly patch: PatchDB<DataModel>,
private readonly formDialog: FormDialogService,
) {}
private readonly desktop: DesktopService,
private readonly navigation: NavigationService,
) {
updateTab('/actions')
}
async handleAction(action: WithId<Action>) {
if (action.disabled) {
@@ -156,12 +164,13 @@ export class ServiceActionsModal {
const loader = this.loader.open(`Beginning uninstall...`).subscribe()
try {
await this.embassyApi.uninstallPackage({ id: this.context.data })
await this.embassyApi.uninstallPackage({ id: this.id })
this.embassyApi
.setDbValue<boolean>(['ack-instructions', this.context.data], false)
.setDbValue<boolean>(['ack-instructions', this.id], false)
.catch(e => console.error('Failed to mark instructions as unseen', e))
this.navigation.removeTab(toRouterLink(this.id))
this.desktop.remove(this.id)
this.router.navigate(['portal', 'desktop'])
this.context.$implicit.complete()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -177,7 +186,7 @@ export class ServiceActionsModal {
try {
const data = await this.embassyApi.executePackageAction({
id: this.context.data,
id: this.id,
'action-id': actionId,
input,
})

View File

@@ -1,14 +1,11 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { PatchDB } from 'patch-db-client'
import { InterfaceAddressesComponentModule } from 'src/app/common/interface-addresses/interface-addresses.module'
import { DataModel } from 'src/app/services/patch-db/data-model'
interface Context {
packageId: string
interfaceId: string
}
import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
import { updateTab } from '../utils/update-tab'
@Component({
template: `
@@ -23,8 +20,13 @@ interface Context {
standalone: true,
imports: [CommonModule, InterfaceAddressesComponentModule],
})
export class ServiceInterfaceModal {
readonly context = inject<{ data: Context }>(POLYMORPHEUS_CONTEXT).data
export class ServiceInterfaceRoute {
private readonly route = inject(ActivatedRoute)
readonly context = {
packageId: getPkgId(this.route),
interfaceId: this.route.snapshot.paramMap.get('interfaceId') || '',
}
readonly interfaceInfo$ = inject(PatchDB<DataModel>).watch$(
'package-data',
@@ -33,4 +35,8 @@ export class ServiceInterfaceModal {
'interfaceInfo',
this.context.interfaceId,
)
constructor() {
updateTab(`/interface/${this.context.interfaceId}`)
}
}

View File

@@ -1,17 +1,19 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
import { LogsComponentModule } from 'src/app/common/logs/logs.component.module'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { RR } from 'src/app/services/api/api.types'
import { LogsComponentModule } from 'src/app/common/logs/logs.component.module'
import { updateTab } from '../utils/update-tab'
@Component({
template:
'<logs [fetchLogs]="fetch" [followLogs]="follow" [context]="id"></logs>',
template: '<logs [fetchLogs]="fetch" [followLogs]="follow" [context]="id" />',
styles: [
`
logs {
display: block;
height: 60vh;
height: calc(100% - 9rem);
min-height: 20rem;
margin-bottom: 5rem;
::ng-deep ion-header {
@@ -24,14 +26,18 @@ import { LogsComponentModule } from 'src/app/common/logs/logs.component.module'
standalone: true,
imports: [LogsComponentModule],
})
export class ServiceLogsModal {
export class ServiceLogsRoute {
private readonly api = inject(ApiService)
readonly id = inject<{ data: string }>(POLYMORPHEUS_CONTEXT).data
readonly id = getPkgId(inject(ActivatedRoute))
readonly follow = async (params: RR.FollowServerLogsReq) =>
this.api.followPackageLogs({ id: this.id, ...params })
readonly fetch = async (params: RR.GetServerLogsReq) =>
this.api.getPackageLogs({ id: this.id, ...params })
constructor() {
updateTab('/logs')
}
}

View File

@@ -0,0 +1,86 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import { TuiSvgModule } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { toRouterLink } from '../../../utils/to-router-link'
import { NavigationService } from '../../../services/navigation.service'
@Component({
template: `
<a
*ngIf="service$ | async as service"
routerLinkActive="_current"
[routerLinkActiveOptions]="{ exact: true }"
[routerLink]="getLink(service.manifest.id)"
(isActiveChange)="onActive(service, $event)"
>
<tui-svg src="tuiIconChevronLeftLarge" />
{{ service.manifest.title }}
</a>
<router-outlet></router-outlet>
`,
styles: [
`
a {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin: 1rem 0;
font-size: 1rem;
color: var(--tui-text-01);
}
._current {
display: none;
}
`,
],
host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, RouterModule, TuiSvgModule],
})
export class ServiceOutletComponent {
private readonly patch = inject(PatchDB<DataModel>)
private readonly route = inject(ActivatedRoute)
private readonly router = inject(Router)
private readonly navigation = inject(NavigationService)
readonly service$ = this.router.events.pipe(
map(() => this.route.firstChild?.snapshot.paramMap?.get('pkgId')),
filter(Boolean),
distinctUntilChanged(),
switchMap(id => this.patch.watch$('package-data', id)),
tap(pkg => {
// if package disappears, navigate to list page
if (!pkg) {
this.router.navigate(['./portal/desktop'])
} else {
this.onActive(
pkg,
!this.navigation.hasSubtab(this.getLink(pkg.manifest.id)),
)
}
}),
)
getLink(id: string): string {
return toRouterLink(id)
}
onActive({ icon, manifest }: PackageDataEntry, active: boolean): void {
if (!active) return
this.navigation.addTab({
icon,
title: manifest.title,
routerLink: this.getLink(manifest.id),
})
}
}

View File

@@ -0,0 +1,134 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { getPkgId, isEmptyObject } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import {
DataModel,
HealthCheckResult,
MainStatus,
PackageDataEntry,
PackageState,
} from 'src/app/services/patch-db/data-model'
import {
PackageStatus,
PrimaryRendering,
PrimaryStatus,
StatusRendering,
} from 'src/app/services/pkg-status-rendering.service'
import { ConnectionService } from 'src/app/services/connection.service'
import { ServiceProgressComponent } from '../components/progress.component'
import { ServiceStatusComponent } from '../components/status.component'
import { ServiceActionsComponent } from '../components/actions.component'
import { ServiceInterfacesComponent } from '../components/interfaces.component'
import { ServiceHealthChecksComponent } from '../components/health-checks.component'
import { ServiceDependenciesComponent } from '../components/dependencies.component'
import { ServiceMenuComponent } from '../components/menu.component'
import { ServiceAdditionalComponent } from '../components/additional.component'
import { ProgressDataPipe } from '../pipes/progress-data.pipe'
import { ToDependenciesPipe } from '../pipes/to-dependencies.pipe'
import { ToStatusPipe } from '../pipes/to-status.pipe'
const STATES = [
PackageState.Installing,
PackageState.Updating,
PackageState.Restoring,
]
@Component({
template: `
<ng-container *ngIf="service$ | async as service">
<ng-container *ngIf="showProgress(service); else installed">
<ng-container *ngIf="service | progressData as progress">
<p [progress]="progress.downloadProgress">Downloading</p>
<p [progress]="progress.validateProgress">Validating</p>
<p [progress]="progress.unpackProgress">Unpacking</p>
</ng-container>
</ng-container>
<ng-template #installed>
<ng-container *ngIf="service | toStatus as status">
<h3 class="g-title">Status</h3>
<service-status
[connected]="!!(connected$ | async)"
[installProgress]="service['install-progress']"
[rendering]="$any(getRendering(status))"
/>
<service-actions
*ngIf="isInstalled(service) && (connected$ | async)"
[service]="service"
/>
<ng-container *ngIf="isInstalled(service) && !isBackingUp(status)">
<service-interfaces [service]="service" />
<service-health-checks
*ngIf="isRunning(status) && (health$ | async) as checks"
[checks]="checks"
/>
<service-dependencies
*ngIf="service | toDependencies as dependencies"
[dependencies]="dependencies"
/>
<service-menu [service]="service" />
<service-additional [service]="service" />
</ng-container>
</ng-container>
</ng-template>
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
ServiceProgressComponent,
ServiceStatusComponent,
ServiceActionsComponent,
ServiceInterfacesComponent,
ServiceHealthChecksComponent,
ServiceDependenciesComponent,
ServiceMenuComponent,
ServiceAdditionalComponent,
ProgressDataPipe,
ToDependenciesPipe,
ToStatusPipe,
],
})
export class ServiceRoute {
private readonly patch = inject(PatchDB<DataModel>)
private readonly pkgId = getPkgId(inject(ActivatedRoute))
readonly connected$ = inject(ConnectionService).connected$
readonly service$ = this.patch.watch$('package-data', this.pkgId)
readonly health$ = this.patch
.watch$('package-data', this.pkgId, 'installed', 'status', 'main')
.pipe(map(toHealthCheck))
getRendering({ primary }: PackageStatus): StatusRendering {
return PrimaryRendering[primary]
}
isInstalled({ state }: PackageDataEntry): boolean {
return state === PackageState.Installed
}
isRunning({ primary }: PackageStatus): boolean {
return primary === PrimaryStatus.Running
}
isBackingUp({ primary }: PackageStatus): boolean {
return primary === PrimaryStatus.BackingUp
}
showProgress({ state }: PackageDataEntry): boolean {
return STATES.includes(state)
}
}
function toHealthCheck(main: MainStatus): HealthCheckResult[] | null {
return main.status !== 'running' || isEmptyObject(main.health)
? null
: Object.values(main.health)
}

View File

@@ -1,106 +0,0 @@
<ng-container *ngIf="service$ | async as service">
<ng-container *tuiLet="!!(connected$ | async) as connected">
<ng-container *ngIf="showProgress(service); else installed">
<ng-container *ngIf="service | progressData as progress">
<p [progress]="progress.downloadProgress">Downloading</p>
<p [progress]="progress.validateProgress">Validating</p>
<p [progress]="progress.unpackProgress">Unpacking</p>
</ng-container>
</ng-container>
<ng-template #installed>
<ng-container *ngIf="service | toStatus as status">
<section>
<h3 class="g-title">Status</h3>
<service-status
class="status"
[connected]="connected"
[installProgress]="service['install-progress']"
[rendering]="$any(getRendering(status))"
></service-status>
<service-actions
*ngIf="isInstalled(service) && connected"
[service]="service"
></service-actions>
</section>
<ng-container *ngIf="isInstalled(service) && !isBackingUp(status)">
<section>
<h3 class="g-title">Interfaces</h3>
<button
*ngFor="let info of service | interfaceInfo"
class="g-action"
[serviceInterface]="info"
[disabled]="!isRunning(status)"
(click)="info.action()"
></button>
</section>
<ng-container *ngIf="isRunning(status)">
<section *ngIf="health$ | async as checks">
<h3 class="g-title">Health Checks</h3>
<service-health-check
*ngFor="let check of checks"
class="g-action"
[check]="check"
[connected]="connected"
></service-health-check>
</section>
</ng-container>
<ng-container *ngIf="service | toDependencies as dependencies">
<section *ngIf="dependencies.length">
<h3 class="g-title">Dependencies</h3>
<button
*ngFor="let dep of dependencies"
class="g-action"
[serviceDependency]="dep"
(click)="dep.action()"
></button>
</section>
</ng-container>
<section>
<h3 class="g-title">Menu</h3>
<button
*ngFor="let menu of service | toMenu"
class="g-action"
[serviceMenu]="menu"
(click)="menu.action()"
>
<div
*ngIf="menu.name === 'Outbound Proxy'"
[style.color]="
service.installed?.outboundProxy
? 'var(--tui-success-fill)'
: 'var(--tui-warning-fill)'
"
>
{{ this.getProxy(service.installed?.outboundProxy) }}
</div>
</button>
</section>
<section>
<h3 class="g-title">Additional Info</h3>
<ng-container *ngFor="let additional of service | toAdditional">
<a
*ngIf="additional.description.startsWith('http'); else button"
class="g-action"
[additional]="additional"
></a>
<ng-template #button>
<button
class="g-action"
[class.g-action_static]="!additional.icon"
[additional]="additional"
(click)="additional.action?.()"
></button>
</ng-template>
</ng-container>
</section>
</ng-container>
</ng-container>
</ng-template>
</ng-container>
</ng-container>

View File

@@ -1,15 +0,0 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
.status {
font-size: x-large;
margin: 1em 0;
display: block;
}
.g-action_static {
cursor: default;
&:hover {
background: transparent;
}
}

View File

@@ -1,100 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { getPkgId, isEmptyObject } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { map, Observable, tap } from 'rxjs'
import {
DataModel,
HealthCheckResult,
PackageDataEntry,
PackageState,
ServiceOutboundProxy,
} from 'src/app/services/patch-db/data-model'
import {
PackageStatus,
PrimaryRendering,
PrimaryStatus,
StatusRendering,
} from 'src/app/services/pkg-status-rendering.service'
import { ConnectionService } from 'src/app/services/connection.service'
import { NavigationService } from '../../services/navigation.service'
import { toRouterLink } from '../../utils/to-router-link'
const STATES = [
PackageState.Installing,
PackageState.Updating,
PackageState.Restoring,
]
@Component({
templateUrl: 'service.component.html',
styleUrls: ['service.component.scss'],
host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ServiceComponent {
private readonly route = inject(ActivatedRoute)
private readonly router = inject(Router)
private readonly navigation = inject(NavigationService)
private readonly patch = inject(PatchDB<DataModel>)
readonly pkgId = getPkgId(this.route)
readonly connected$ = inject(ConnectionService).connected$
readonly service$ = this.patch.watch$('package-data', this.pkgId).pipe(
tap(pkg => {
// if package disappears, navigate to list page
if (!pkg) {
this.router.navigate(['..'], { relativeTo: this.route })
} else {
this.navigation.addTab({
icon: pkg.icon,
title: pkg.manifest.title,
routerLink: toRouterLink(pkg.manifest.id),
})
}
}),
)
readonly health$: Observable<HealthCheckResult[] | null> = this.patch
.watch$('package-data', this.pkgId, 'installed', 'status', 'main')
.pipe(
map(main =>
main.status !== 'running' || isEmptyObject(main.health)
? null
: Object.values(main.health),
),
)
getRendering({ primary }: PackageStatus): StatusRendering {
return PrimaryRendering[primary]
}
isInstalled({ state }: PackageDataEntry): boolean {
return state === PackageState.Installed
}
isRunning({ primary }: PackageStatus): boolean {
return primary === PrimaryStatus.Running
}
isBackingUp({ primary }: PackageStatus): boolean {
return primary === PrimaryStatus.BackingUp
}
showProgress({ state }: PackageDataEntry): boolean {
return STATES.includes(state)
}
getProxy(proxy?: ServiceOutboundProxy): string {
switch (proxy) {
case 'primary':
return 'System Primary'
case 'mirror':
return 'Mirror P2P'
default:
return proxy?.proxyId || 'None'
}
}
}

View File

@@ -1,56 +1,43 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { TuiLetModule } from '@taiga-ui/cdk'
import { ServiceComponent } from './service.component'
import { ServiceProgressComponent } from './components/progress.component'
import { ServiceStatusComponent } from './components/status.component'
import { ServiceActionsComponent } from './components/actions.component'
import { ServiceInterfaceComponent } from './components/interface.component'
import { ServiceHealthCheckComponent } from './components/health-check.component'
import { ServiceDependencyComponent } from './components/dependency.component'
import { ServiceMenuComponent } from './components/menu.component'
import { ServiceAdditionalComponent } from './components/additional.component'
import { ProgressDataPipe } from './pipes/progress-data.pipe'
import { ToDependenciesPipe } from './pipes/to-dependencies.pipe'
import { ToStatusPipe } from './pipes/to-status.pipe'
import { InterfaceInfoPipe } from './pipes/interface-info.pipe'
import { ToMenusPipe } from './pipes/to-menu.pipe'
import { ToAdditionalPipe } from './pipes/to-additional.pipe'
import { ServiceOutletComponent } from './routes/outlet.component'
import { ServiceRoute } from './routes/service.component'
const ROUTES: Routes = [
{
path: ':pkgId',
component: ServiceComponent,
path: '',
component: ServiceOutletComponent,
children: [
{
path: ':pkgId',
component: ServiceRoute,
},
{
path: ':pkgId/actions',
loadComponent: () =>
import('./routes/actions.component').then(m => m.ServiceActionsRoute),
},
{
path: ':pkgId/interface/:interfaceId',
loadComponent: () =>
import('./routes/interface.component').then(
m => m.ServiceInterfaceRoute,
),
},
{
path: ':pkgId/logs',
loadComponent: () =>
import('./routes/logs.component').then(m => m.ServiceLogsRoute),
},
{
path: '',
pathMatch: 'full',
redirectTo: '/portal/desktop',
},
],
},
]
@NgModule({
imports: [
CommonModule,
TuiLetModule,
ServiceProgressComponent,
ServiceStatusComponent,
ServiceActionsComponent,
ServiceInterfaceComponent,
ServiceHealthCheckComponent,
ServiceDependencyComponent,
ServiceMenuComponent,
ServiceAdditionalComponent,
ProgressDataPipe,
ToDependenciesPipe,
ToStatusPipe,
InterfaceInfoPipe,
ToMenusPipe,
ToAdditionalPipe,
RouterModule.forChild(ROUTES),
],
declarations: [ServiceComponent],
exports: [ServiceComponent],
})
@NgModule({ imports: [RouterModule.forChild(ROUTES)] })
export class ServiceModule {}

View File

@@ -0,0 +1,9 @@
import { inject } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
import { NavigationService } from 'src/app/apps/portal/services/navigation.service'
import { toRouterLink } from 'src/app/apps/portal/utils/to-router-link'
export function updateTab(path: string, id = getPkgId(inject(ActivatedRoute))) {
inject(NavigationService).updateTab(toRouterLink(id), toRouterLink(id) + path)
}

View File

@@ -22,7 +22,6 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ClientStorageService } from 'src/app/services/client-storage.service'
import { toDesktopItem } from '../../../utils/to-desktop-item'
import { NavigationService } from '../../../services/navigation.service'
import { SideloadDependenciesComponent } from './dependencies.component'
@@ -118,7 +117,7 @@ export class SideloadPackageComponent {
await this.api.uploadPackage(pkg, this.file)
await this.router.navigate(['/portal/service', manifest.id])
this.navigation.removeTab(toDesktopItem('/portal/system/sideload'))
this.navigation.removeTab('/portal/system/sideload')
this.alerts
.open('Package uploaded successfully', { status: 'success' })
.subscribe()

View File

@@ -1,7 +1,7 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { systemTabResolver } from '../../utils/system-tab-resolver'
import { toDesktopItem } from '../../utils/to-desktop-item'
import { toNavigationItem } from '../../utils/to-navigation-item'
const ROUTES: Routes = [
{
@@ -9,34 +9,30 @@ const ROUTES: Routes = [
path: 'backups',
loadComponent: () =>
import('./backups/backups.component').then(m => m.BackupsComponent),
data: toDesktopItem('/portal/system/backups'),
data: toNavigationItem('/portal/system/backups'),
},
{
title: systemTabResolver,
path: 'sideload',
loadComponent: () =>
import('./sideload/sideload.component').then(m => m.SideloadComponent),
data: toDesktopItem('/portal/system/sideload'),
data: toNavigationItem('/portal/system/sideload'),
},
{
title: systemTabResolver,
path: 'updates',
loadComponent: () =>
import('./updates/updates.component').then(m => m.UpdatesComponent),
data: toDesktopItem('/portal/system/updates'),
data: toNavigationItem('/portal/system/updates'),
},
{
title: systemTabResolver,
path: 'snek',
loadComponent: () =>
import('./snek/snek.component').then(m => m.SnekComponent),
data: toDesktopItem('/portal/system/snek'),
data: toNavigationItem('/portal/system/snek'),
},
]
@NgModule({
imports: [RouterModule.forChild(ROUTES)],
declarations: [],
exports: [],
})
@NgModule({ imports: [RouterModule.forChild(ROUTES)] })
export class SystemModule {}

View File

@@ -12,17 +12,37 @@ export class NavigationService {
return this.tabs
}
removeTab({ routerLink }: NavigationItem) {
this.tabs.next(this.tabs.value.filter(t => t.routerLink !== routerLink))
removeTab(routerLink: string) {
this.tabs.next(
this.tabs.value.filter(t => !t.routerLink.startsWith(routerLink)),
)
}
addTab(tab: NavigationItem) {
if (this.tabs.value.every(t => t.routerLink !== tab.routerLink)) {
this.tabs.next([...this.tabs.value, tab])
}
const current = this.tabs.value.find(t =>
t.routerLink.startsWith(tab.routerLink),
)
this.tabs.next(
current
? this.tabs.value.map(t => (t === current ? tab : t))
: this.tabs.value.concat(tab),
)
}
updateTab(old: string, routerLink: string) {
this.tabs.next(
this.tabs.value.map(t =>
t.routerLink === old ? { ...t, routerLink } : t,
),
)
}
hasTab(path: string): boolean {
return this.tabs.value.some(t => t.routerLink === path)
}
hasSubtab(path: string): boolean {
return this.tabs.value.some(t => t.routerLink.startsWith(path))
}
}

View File

@@ -3,7 +3,7 @@ import { SYSTEM_UTILITIES } from '../constants/system-utilities'
import { NavigationItem } from '../types/navigation-item'
import { toRouterLink } from './to-router-link'
export function toDesktopItem(
export function toNavigationItem(
id: string,
packages: Record<string, PackageDataEntry> = {},
): NavigationItem {

View File

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