feat: add new dashboard (#2574)

* feat: add new dashboard

* chore: comments

* fix duplicate

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
Alex Inkin
2024-03-19 22:56:16 +08:00
committed by GitHub
parent a5b1b4e103
commit f4fadd366e
47 changed files with 1093 additions and 1016 deletions

View File

@@ -195,3 +195,9 @@ tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] {
backdrop-filter: blur(1rem);
background: rgb(34 34 34 / 80%);
}
// TODO: Move to Taiga UI
a[tuiIconButton]:not([href]) {
pointer-events: none;
opacity: var(--tui-disabled-opacity);
}

View File

@@ -1,96 +0,0 @@
import {
Directive,
ElementRef,
HostListener,
inject,
Input,
} from '@angular/core'
import { tuiGetActualTarget, tuiIsElement, tuiPx } from '@taiga-ui/cdk'
import { DrawerComponent } from './drawer.component'
import { DesktopService } from '../../services/desktop.service'
import { TuiAlertService } from '@taiga-ui/core'
/**
* This directive is responsible for drag and drop of the drawer item.
* It saves item to desktop when dropped.
*/
@Directive({
selector: '[drawerItem]',
standalone: true,
host: {
'[style.userSelect]': '"none"',
'[style.touchAction]': '"none"',
},
})
export class DrawerItemDirective {
private readonly alerts = inject(TuiAlertService)
private readonly desktop = inject(DesktopService)
private readonly drawer = inject(DrawerComponent)
private readonly element: HTMLElement = inject(ElementRef).nativeElement
private x = NaN
private y = NaN
@Input()
drawerItem = ''
@HostListener('pointerdown.silent', ['$event'])
onStart(event: PointerEvent): void {
const target = tuiGetActualTarget(event)
const { x, y, pointerId } = event
const { left, top } = this.element.getBoundingClientRect()
if (tuiIsElement(target)) {
target.releasePointerCapture(pointerId)
}
this.drawer.open = false
this.onPointer(x - left, y - top)
}
@HostListener('document:pointerup.silent')
onPointer(x = NaN, y = NaN): void {
// Some other element is dragged
if (Number.isNaN(this.x) && Number.isNaN(x)) return
this.x = x
this.y = y
this.process(NaN, NaN)
}
@HostListener('document:pointermove.silent', ['$event.x', '$event.y'])
onMove(x: number, y: number): void {
// This element is not dragged
if (Number.isNaN(this.x)) return
// This element is already on the desktop
if (this.desktop.items.includes(this.drawerItem)) {
this.onPointer()
this.alerts
.open('This item is already added', { status: 'warning' })
.subscribe()
return
}
this.process(x, y)
this.desktop.add('')
}
private process(x: number, y: number) {
const { style } = this.element
const { items } = this.desktop
const dragged = !Number.isNaN(this.x + x)
style.pointerEvents = dragged ? 'none' : ''
style.position = dragged ? 'fixed' : ''
style.top = dragged ? tuiPx(y - this.y) : ''
style.left = dragged ? tuiPx(x - this.x) : ''
if (dragged || !items.includes('')) {
return
}
this.desktop.items = items.map(item => item || this.drawerItem)
this.desktop.reorder(this.desktop.order)
}
}

View File

@@ -1,57 +0,0 @@
<div class="content" (tuiActiveZoneChange)="open = $event">
<button class="toggle" (click)="open = !open" (mousedown.prevent)="(0)">
<tui-icon icon="tuiIconArrowUpCircle" class="icon" />
Toggle drawer
</button>
<tui-input
class="search"
tuiTextfieldAppearance="drawer"
tuiTextfieldSize="m"
tuiTextfieldIconLeft="tuiIconSearchLarge"
[tuiTextfieldLabelOutside]="true"
[(ngModel)]="search"
>
Enter service name
</tui-input>
<tui-scrollbar class="scrollbar">
<h2 class="title">System Utilities</h2>
<div class="items">
@for (
item of system | keyvalue | tuiFilter: bySearch : search;
track $index
) {
<a
appCard
[badge]="item.key | toBadge | async"
[drawerItem]="item.key"
[id]="item.key"
[title]="item.value.title"
[icon]="item.value.icon"
[routerLink]="item.key"
(click)="open = false"
></a>
} @empty {
Nothing found
}
</div>
<h2 class="title">Installed services</h2>
<div class="items">
@for (
item of (services$ | async) || [] | tuiFilter: bySearch : search;
track $index
) {
<a
appCard
[drawerItem]="item.manifest.id"
[id]="item.manifest.id"
[icon]="item.icon"
[title]="item.manifest.title"
[routerLink]="getLink(item.manifest.id)"
(click)="open = false"
></a>
} @empty {
Nothing found
}
</div>
</tui-scrollbar>
</div>

View File

@@ -1,77 +0,0 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
@include transition(top);
position: absolute;
top: 100%;
left: 0;
width: 100%;
height: calc(100% - 10.25rem);
display: flex;
flex-direction: column;
// TODO: Theme
background: #2d2d2d;
color: #fff;
&._open {
top: 10.25rem;
}
}
.content {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
background: inherit;
}
.toggle {
position: absolute;
top: -2.5rem;
height: 2.5rem;
width: 25rem;
max-width: 100vw;
left: 50%;
background: inherit;
color: inherit;
text-align: center;
font-size: 0;
transform: translateX(-50%);
border-top-left-radius: var(--tui-radius-xl);
border-top-right-radius: var(--tui-radius-xl);
}
.icon {
@include transition(transform);
:host._open & {
transform: rotate(180deg);
}
}
.scrollbar {
margin-top: 1rem;
}
.search {
width: 25rem;
margin: 6rem auto 0;
}
.title {
margin: 4rem 0 1.25rem;
text-align: center;
text-transform: uppercase;
font: var(--tui-font-text-xl);
}
.items {
display: flex;
gap: 2rem;
padding: 2rem;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}

View File

@@ -1,67 +0,0 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
HostBinding,
inject,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import {
TUI_DEFAULT_MATCHER,
TuiActiveZoneModule,
TuiFilterPipeModule,
TuiForModule,
} from '@taiga-ui/cdk'
import {
TuiScrollbarModule,
TuiTextfieldControllerModule,
} from '@taiga-ui/core'
import { TuiIconModule } from '@taiga-ui/experimental'
import { TuiInputModule } from '@taiga-ui/kit'
import { CardComponent } from '../card.component'
import { ServicesService } from '../../services/services.service'
import { toRouterLink } from '../../utils/to-router-link'
import { DrawerItemDirective } from './drawer-item.directive'
import { SYSTEM_UTILITIES } from '../../constants/system-utilities'
import { ToBadgePipe } from '../../pipes/to-badge'
@Component({
selector: 'app-drawer',
templateUrl: 'drawer.component.html',
styleUrls: ['drawer.component.scss'],
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
FormsModule,
RouterLink,
TuiScrollbarModule,
TuiActiveZoneModule,
TuiInputModule,
TuiTextfieldControllerModule,
TuiForModule,
TuiFilterPipeModule,
CardComponent,
DrawerItemDirective,
ToBadgePipe,
TuiIconModule,
],
})
export class DrawerComponent {
@HostBinding('class._open')
open = false
search = ''
readonly system = SYSTEM_UTILITIES
readonly services$ = inject(ServicesService)
readonly bySearch = (item: any, search: string): boolean =>
search.length < 2 ||
TUI_DEFAULT_MATCHER(item.manifest?.title || item.value?.title || '', search)
getLink(id: string): string {
return toRouterLink(id)
}
}

View File

@@ -18,7 +18,7 @@ import { BreadcrumbsService } from '../../services/breadcrumbs.service'
@Component({
selector: 'header[appHeader]',
template: `
<a headerHome routerLink="/portal/desktop" routerLinkActive="active">
<a headerHome routerLink="/portal/dashboard" routerLinkActive="active">
<div class="plaque"></div>
</a>
@for (item of breadcrumbs$ | async; track $index) {

View File

@@ -62,7 +62,7 @@ export class HeaderMobileComponent {
get back() {
return (
this.headerMobile?.[this.headerMobile?.length - 2]?.routerLink ||
'/portal/desktop'
'/portal/dashboard'
)
}
}

View File

@@ -1,26 +0,0 @@
import { Component, Input } from '@angular/core'
import { TuiRepeatTimesModule } from '@taiga-ui/cdk'
@Component({
selector: 'skeleton-list',
template: `
<div *tuiRepeatTimes="let index of rows" class="g-action">
<div
class="tui-skeleton"
style="--tui-skeleton-radius: 100%; width: 2.5rem; height: 2.5rem"
[hidden]="!showAvatar"
></div>
<div class="tui-skeleton" style="width: 12rem; height: 0.75rem"></div>
<div
class="tui-skeleton"
style="width: 5rem; height: 0.75rem; margin-left: auto"
></div>
</div>
`,
standalone: true,
imports: [TuiRepeatTimesModule],
})
export class SkeletonListComponent {
@Input() rows = 3
@Input() showAvatar = false
}

View File

@@ -24,10 +24,6 @@ export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
icon: 'tuiIconTool',
title: 'Settings',
},
'/portal/system/snek': {
icon: 'assets/img/icon_transparent.png',
title: 'Snek',
},
'/portal/system/notifications': {
icon: 'tuiIconBell',
title: 'Notifications',

View File

@@ -20,6 +20,7 @@ import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { compare, Operation } from 'fast-json-patch'
import { PatchDB } from 'patch-db-client'
import { endWith, firstValueFrom, Subscription } from 'rxjs'
import { ConfigDepComponent } from 'src/app/apps/portal/modals/config-dep.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import {
DataModel,
@@ -33,16 +34,16 @@ import {
ActionButton,
FormComponent,
} from 'src/app/apps/portal/components/form.component'
import { PackageConfigData } from '../types/package-config-data'
import { ConfigDepComponent } from '../components/config-dep.component'
import { DependentInfo } from 'src/app/types/dependent-info'
export interface PackageConfigData {
readonly pkgId: string
readonly dependentInfo?: DependentInfo
}
@Component({
template: `
<tui-loader
*ngIf="loadingText"
size="l"
[textContent]="loadingText"
></tui-loader>
<tui-loader *ngIf="loadingText" size="l" [textContent]="loadingText" />
<tui-notification
*ngIf="!loadingText && (loadingError || !pkg)"
@@ -63,7 +64,7 @@ import { ConfigDepComponent } from '../components/config-dep.component'
[dep]="dependentInfo.title"
[original]="original"
[value]="value"
></config-dep>
/>
<tui-notification *ngIf="!pkg.installed?.['has-config']" status="warning">
No config options for {{ pkg.manifest.title }}

View File

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

View File

@@ -7,7 +7,6 @@ import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { HeaderComponent } from './components/header/header.component'
import { DrawerComponent } from './components/drawer/drawer.component'
import { BreadcrumbsService } from './services/breadcrumbs.service'
@Component({
@@ -15,7 +14,6 @@ import { BreadcrumbsService } from './services/breadcrumbs.service'
template: `
<header appHeader>{{ name$ | async }}</header>
<main><router-outlet /></main>
<app-drawer />
`,
styles: [
`
@@ -32,7 +30,7 @@ import { BreadcrumbsService } from './services/breadcrumbs.service'
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterOutlet, HeaderComponent, DrawerComponent],
imports: [CommonModule, RouterOutlet, HeaderComponent],
providers: [
// TODO: Move to global
tuiDropdownOptionsProvider({

View File

@@ -7,15 +7,15 @@ const ROUTES: Routes = [
component: PortalComponent,
children: [
{
redirectTo: 'desktop',
redirectTo: 'dashboard',
pathMatch: 'full',
path: '',
},
{
path: 'desktop',
path: 'dashboard',
loadComponent: () =>
import('./routes/desktop/desktop.component').then(
m => m.DesktopComponent,
import('./routes/dashboard/dashboard.component').then(
m => m.DashboardComponent,
),
},
{

View File

@@ -0,0 +1,100 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { TuiLetModule, tuiPure } from '@taiga-ui/cdk'
import {
TuiButtonModule,
tuiButtonOptionsProvider,
} from '@taiga-ui/experimental'
import { map, of } from 'rxjs'
import { UIComponent } from 'src/app/apps/portal/routes/dashboard/ui.component'
import { ActionsService } from 'src/app/apps/portal/services/actions.service'
import { DepErrorService } from 'src/app/services/dep-error.service'
import {
PackageDataEntry,
PackageMainStatus,
} from 'src/app/services/patch-db/data-model'
@Component({
standalone: true,
selector: 'fieldset[appControls]',
template: `
@if (isRunning) {
<button
tuiIconButton
iconLeft="tuiIconSquare"
(click)="actions.stop(appControls)"
>
Stop
</button>
<button
tuiIconButton
iconLeft="tuiIconRotateCw"
(click)="actions.restart(appControls)"
>
Restart
</button>
} @else {
<button
*tuiLet="hasUnmet(appControls) | async as hasUnmet"
tuiIconButton
iconLeft="tuiIconPlay"
[disabled]="!isConfigured"
(click)="actions.start(appControls, !!hasUnmet)"
>
Start
</button>
<button
tuiIconButton
iconLeft="tuiIconTool"
(click)="actions.configure(appControls)"
>
Configure
</button>
}
<app-ui [pkg]="appControls" />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButtonModule, UIComponent, TuiLetModule, AsyncPipe],
providers: [tuiButtonOptionsProvider({ size: 's', appearance: 'none' })],
})
export class ControlsComponent {
private readonly errors = inject(DepErrorService)
@Input()
appControls!: PackageDataEntry
readonly actions = inject(ActionsService)
get isRunning(): boolean {
return (
this.appControls.installed?.status.main.status ===
PackageMainStatus.Running
)
}
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

@@ -0,0 +1,89 @@
import { DatePipe } from '@angular/common'
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { TuiIconModule } from '@taiga-ui/experimental'
import { map, timer } from 'rxjs'
import { MetricsComponent } from './metrics.component'
import { ServicesComponent } from './services.component'
import { UtilitiesComponent } from './utilities.component'
@Component({
standalone: true,
template: `
<time>{{ date() | date: 'medium' }}</time>
<app-metrics>
<h2>
<tui-icon icon="tuiIconActivity" />
Metrics
</h2>
<div class="g-plaque"></div>
</app-metrics>
<app-utilities>
<h2>
<tui-icon icon="tuiIconSettings" />
Utilities
</h2>
<div class="g-plaque"></div>
</app-utilities>
<app-services>
<h2>
<tui-icon icon="tuiIconGrid" />
Services
</h2>
<div class="g-plaque"></div>
</app-services>
`,
styles: `
:host {
position: relative;
max-width: 64rem;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1rem;
margin: 2rem auto;
border: 0.375rem solid transparent;
}
app-metrics,
app-utilities,
app-services {
position: relative;
clip-path: var(--clip-path);
backdrop-filter: blur(1rem);
font-size: 1rem;
}
time {
position: absolute;
left: 22%;
font-weight: bold;
line-height: 1.75rem;
}
h2 {
height: 2rem;
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
padding: 0 2rem;
font-weight: bold;
font-size: 1rem;
tui-icon {
font-size: 1rem;
}
}
`,
imports: [
ServicesComponent,
MetricsComponent,
UtilitiesComponent,
TuiIconModule,
DatePipe,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardComponent {
readonly date = toSignal(timer(0, 1000).pipe(map(() => new Date())))
}

View File

@@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
standalone: true,
selector: 'app-metrics',
template: `
<ng-content />
<section>TODO</section>
`,
styles: `
:host {
grid-column: 1/3;
--clip-path: polygon(
0 2rem,
1.25rem 0,
8.75rem 0,
calc(10rem + 0.1em) calc(2rem - 0.1em),
11rem 2rem,
calc(65% - 0.2em) 2rem,
calc(65% + 1.25rem) 0,
calc(100% - 1.25rem) 0,
100% 2rem,
100% calc(100% - 2rem),
calc(100% - 1.25rem) 100%,
10.5rem 100%,
calc(9.25rem - 0.1em) calc(100% - 2rem + 0.1em),
1.25rem calc(100% - 2rem),
0 calc(100% - 4rem)
);
section {
height: 80%;
display: flex;
align-items: center;
justify-content: center;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MetricsComponent {}

View File

@@ -0,0 +1,79 @@
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { tuiPure } from '@taiga-ui/cdk'
import { ControlsComponent } from 'src/app/apps/portal/routes/dashboard/controls.component'
import { StatusComponent } from 'src/app/apps/portal/routes/dashboard/status.component'
import { ConnectionService } from 'src/app/services/connection.service'
import { PkgDependencyErrors } from 'src/app/services/dep-error.service'
import {
PackageDataEntry,
PackageState,
} from 'src/app/services/patch-db/data-model'
import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
@Component({
standalone: true,
selector: 'tr[appService]',
template: `
<td><img alt="logo" [src]="appService.icon" /></td>
<td>
<a [routerLink]="routerLink">{{ appService.manifest.title }}</a>
</td>
<td>{{ appService.manifest.version }}</td>
<td
[appStatus]="appService"
[appStatusError]="hasError(appServiceError)"
></td>
<td [style.text-align]="'center'">
<fieldset
[disabled]="!installed || !(connected$ | async)"
[appControls]="appService"
></fieldset>
</td>
`,
styles: `
img {
height: 2rem;
width: 2rem;
border-radius: 100%;
}
td {
padding: 0.5rem;
}
a {
color: var(--tui-text-01);
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [RouterLink, AsyncPipe, StatusComponent, ControlsComponent],
})
export class ServiceComponent {
@Input()
appService!: PackageDataEntry
@Input()
appServiceError?: PkgDependencyErrors
readonly connected$ = inject(ConnectionService).connected$
get routerLink() {
return `/portal/service/${this.appService.manifest.id}`
}
get installed(): boolean {
return this.appService.state === PackageState.Installed
}
@tuiPure
hasError(errors: PkgDependencyErrors = {}): boolean {
return Object.values(errors).some(Boolean)
}
}

View File

@@ -0,0 +1,86 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ServiceComponent } from 'src/app/apps/portal/routes/dashboard/service.component'
import { ServicesService } from 'src/app/apps/portal/services/services.service'
import { DepErrorService } from 'src/app/services/dep-error.service'
@Component({
standalone: true,
selector: 'app-services',
template: `
<ng-content />
<table>
<thead>
<tr>
<th [style.width.rem]="3"></th>
<th>Name</th>
<th>Version</th>
<th [style.width.rem]="12">Status</th>
<th [style.width.rem]="8" [style.text-align]="'center'">Controls</th>
</tr>
</thead>
<tbody>
@if (errors$ | async; as errors) {
@for (service of services$ | async; track $index) {
<tr
[appService]="service"
[appServiceError]="errors[service.manifest.id]"
></tr>
} @empty {
<tr>
<td colspan="5">No services installed</td>
</tr>
}
}
</tbody>
</table>
`,
styles: `
:host {
grid-column: 1/4;
margin-top: -2rem;
--clip-path: polygon(
0 2rem,
1.25rem 0,
8.75rem 0,
calc(10rem + 0.1em) calc(2rem - 0.1em),
calc(100% - 1.25rem) 2rem,
100% 4rem,
100% calc(100% - 2rem),
calc(100% - 1.25rem) 100%,
1.25rem 100%,
0 calc(100% - 2rem)
);
}
table {
width: calc(100% - 4rem);
margin: 2rem;
}
tr:not(:last-child) {
box-shadow: inset 0 -1px var(--tui-clear);
}
th {
text-transform: uppercase;
color: var(--tui-text-02);
font: var(--tui-font-text-s);
font-weight: bold;
text-align: left;
padding: 0 0.5rem;
}
td {
text-align: center;
padding: 1rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServiceComponent, AsyncPipe],
})
export class ServicesComponent {
readonly services$ = inject(ServicesService)
readonly errors$ = inject(DepErrorService).depErrors$
}

View File

@@ -0,0 +1,128 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { tuiPure } from '@taiga-ui/cdk'
import { TuiLoaderModule } from '@taiga-ui/core'
import { TuiIconModule } from '@taiga-ui/experimental'
import {
PackageDataEntry,
PackageState,
} from 'src/app/services/patch-db/data-model'
import {
HealthStatus,
PrimaryStatus,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { packageLoadingProgress } from 'src/app/util/package-loading-progress'
@Component({
standalone: true,
selector: 'td[appStatus]',
template: `
@if (loading) {
<tui-loader size="s" />
} @else {
@if (healthy) {
<tui-icon icon="tuiIconCheck" class="g-success" />
} @else {
<tui-icon icon="tuiIconAlertTriangle" class="g-warning" />
}
}
<b [style.color]="color">{{ status }}</b>
`,
styles: `
:host {
display: flex;
align-items: center;
gap: 0.5rem;
height: 3rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIconModule, TuiLoaderModule],
})
export class StatusComponent {
@Input()
appStatus!: PackageDataEntry
@Input()
appStatusError = false
get healthy(): boolean {
const status = this.getStatus(this.appStatus)
return (
!this.appStatusError && // no deps error
!!this.appStatus.installed?.status.configured && // no config needed
status.primary !== PackageState.NeedsUpdate && // no update needed
status.health !== HealthStatus.Failure // no health issues
)
}
get loading(): boolean {
return (
!!this.appStatus['install-progress'] ||
this.color === 'var(--tui-info-fill)'
)
}
@tuiPure
getStatus(pkg: PackageDataEntry) {
return renderPkgStatus(pkg, {})
}
get status(): string {
if (this.appStatus['install-progress']) {
return `Installing... ${packageLoadingProgress(this.appStatus['install-progress'])?.totalProgress || 0}%`
}
switch (this.getStatus(this.appStatus).primary) {
case PrimaryStatus.Running:
return 'Running'
case PrimaryStatus.Stopped:
return 'Stopped'
case PackageState.NeedsUpdate:
return 'Needs Update'
case PrimaryStatus.NeedsConfig:
return 'Needs Config'
case PrimaryStatus.Updating:
return 'Updating...'
case PrimaryStatus.Stopping:
return 'Stopping...'
case PrimaryStatus.Starting:
return 'Starting...'
case PrimaryStatus.BackingUp:
return 'Backing Up...'
case PrimaryStatus.Restarting:
return 'Restarting...'
case PrimaryStatus.Removing:
return 'Removing...'
case PrimaryStatus.Restoring:
return 'Restoring...'
default:
return 'Unknown'
}
}
get color(): string {
if (this.appStatus['install-progress']) {
return 'var(--tui-info-fill)'
}
switch (this.getStatus(this.appStatus).primary) {
case PrimaryStatus.Running:
return 'var(--tui-success-fill)'
case PackageState.NeedsUpdate:
case PrimaryStatus.NeedsConfig:
return 'var(--tui-warning-fill)'
case PrimaryStatus.Updating:
case PrimaryStatus.Stopping:
case PrimaryStatus.Starting:
case PrimaryStatus.BackingUp:
case PrimaryStatus.Restarting:
case PrimaryStatus.Removing:
case PrimaryStatus.Restoring:
return 'var(--tui-info-fill)'
default:
return 'var(--tui-text-02)'
}
}
}

View File

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

View File

@@ -0,0 +1,93 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import {
TuiBadgeNotificationModule,
TuiIconModule,
} from '@taiga-ui/experimental'
import { SYSTEM_UTILITIES } from 'src/app/apps/portal/constants/system-utilities'
import { BadgeService } from 'src/app/apps/portal/services/badge.service'
@Component({
standalone: true,
selector: 'app-utilities',
template: `
<ng-content />
<div class="links">
@for (item of items; track $index) {
<a class="link" [routerLink]="item.routerLink">
<tui-icon [icon]="item.icon" />
{{ item.title }}
@if (item.notification$ | async; as value) {
<tui-badge-notification>{{ value }}</tui-badge-notification>
}
</a>
}
</div>
`,
styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
--clip-path: polygon(
0 2rem,
1.25rem 0,
8.75rem 0,
calc(10rem + 0.1em) calc(2rem - 0.1em),
calc(100% - 1.25rem) 2rem,
100% 4rem,
100% calc(100% - 2rem),
calc(100% - 1.25rem) 100%,
1.25rem 100%,
0 calc(100% - 2rem)
);
}
.links {
display: grid;
grid-template: 1fr 1fr / 1fr 1fr 1fr;
gap: 0.75rem;
padding: 1.5rem;
font-size: min(0.75rem, 1.25vw);
}
.link {
@include transition(background);
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
aspect-ratio: 1/1;
border-radius: 0.25rem;
border: 1px solid var(--tui-clear);
tui-icon {
width: 50%;
height: 50%;
}
tui-badge-notification {
position: absolute;
top: 10%;
right: 10%;
}
&:hover {
background: var(--tui-clear);
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiIconModule, RouterLink, TuiBadgeNotificationModule, AsyncPipe],
})
export class UtilitiesComponent {
private readonly badge = inject(BadgeService)
readonly items = Object.keys(SYSTEM_UTILITIES)
.filter(key => key !== '/portal/system/notifications')
.map(key => ({
...SYSTEM_UTILITIES[key],
routerLink: key,
notification$: this.badge.getCount(key),
}))
}

View File

@@ -1,37 +0,0 @@
import { inject, Injectable } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import {
endWith,
ignoreElements,
Observable,
shareReplay,
startWith,
take,
tap,
} from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { DesktopService } from '../../services/desktop.service'
/**
* This service loads initial values for desktop items
* and is used to show loading indicator.
*/
@Injectable({
providedIn: 'root',
})
export class DektopLoadingService extends Observable<boolean> {
private readonly desktop = inject(DesktopService)
private readonly patch = inject(PatchDB<DataModel>)
private readonly loading = this.patch.watch$('ui', 'desktop').pipe(
take(1),
tap(items => (this.desktop.items = items.filter(Boolean))),
ignoreElements(),
startWith(true),
endWith(false),
shareReplay(1),
)
constructor() {
super(subscriber => this.loading.subscribe(subscriber))
}
}

View File

@@ -1,34 +0,0 @@
import {
Directive,
ElementRef,
HostBinding,
inject,
Input,
OnInit,
} from '@angular/core'
import { TuiTilesComponent } from '@taiga-ui/kit'
/**
* This directive is responsible for creating empty placeholder
* on the desktop when item is dragged from the drawer
*/
@Directive({
selector: '[desktopItem]',
standalone: true,
})
export class DesktopItemDirective implements OnInit {
private readonly element: Element = inject(ElementRef).nativeElement
private readonly tiles = inject(TuiTilesComponent)
@Input()
desktopItem = ''
@HostBinding('class._empty')
get empty(): boolean {
return !this.desktopItem
}
ngOnInit() {
if (this.empty) this.tiles.element = this.element
}
}

View File

@@ -1,44 +0,0 @@
<tui-loader
*ngIf="loading$ | async; else data"
size="xl"
class="loader"
></tui-loader>
<ng-template #data>
<tui-svg
*ngIf="tile?.element"
class="remove"
src="tuiIconTrash2Large"
@tuiFadeIn
@tuiScaleIn
(pointerup)="onRemove()"
></tui-svg>
<div dragScroller tuiFade="vertical" class="fade">
<tui-tiles
*ngIf="packages$ | async as packages"
class="tiles"
@tuiParentStop
[debounce]="500"
[order]="desktop.order"
(orderChange)="onReorder($event)"
>
<tui-tile
*ngFor="let item of desktop.items; let index = index"
class="item"
[style.order]="desktop.order.get(index)"
[desktopItem]="item"
>
<a
*ngIf="packages | toNavigationItem : item as navigationItem"
tuiTileHandle
appCard
@tuiFadeIn
[id]="item"
[badge]="item | toBadge | async"
[title]="navigationItem.title"
[icon]="navigationItem.icon"
[routerLink]="navigationItem.routerLink"
></a>
</tui-tile>
</tui-tiles>
</div>
</ng-template>

View File

@@ -1,68 +0,0 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: flex;
align-items: center;
align-content: center;
justify-content: center;
height: 100%;
max-width: calc(100vw - 4rem);
margin: 0 auto;
}
.loader {
height: 10rem;
width: 10rem;
}
.fade {
@include scrollbar-hidden();
width: 100%;
height: calc(100% - 4rem);
display: flex;
flex-direction: column;
overflow: auto;
}
.tiles {
width: 100%;
justify-content: center;
grid-template-columns: repeat(auto-fit, 12.5rem);
grid-auto-rows: min-content;
gap: 2rem;
margin: auto;
&::after {
content: '';
grid-column: 1;
height: 1rem;
order: 999;
}
}
.remove {
@include transition(background);
position: fixed;
bottom: 0;
left: calc(50% - 3rem);
width: 6rem;
height: 6rem;
border-radius: 100%;
background: var(--tui-base-02);
z-index: 10;
&:hover {
background: var(--tui-base-01);
}
}
.item {
height: 5.5rem;
&._dragged,
&._empty {
border-radius: var(--tui-radius-l);
box-shadow: inset 0 0 0 0.5rem var(--tui-clear-active);
}
}

View File

@@ -1,77 +0,0 @@
import { CommonModule } from '@angular/common'
import {
Component,
ElementRef,
inject,
QueryList,
ViewChild,
ViewChildren,
} from '@angular/core'
import { RouterModule } from '@angular/router'
import { DragScrollerDirective } from '@start9labs/shared'
import { EMPTY_QUERY, TUI_PARENT_STOP } from '@taiga-ui/cdk'
import {
tuiFadeIn,
TuiLoaderModule,
tuiScaleIn,
TuiSvgModule,
} from '@taiga-ui/core'
import { TuiFadeModule } from '@taiga-ui/experimental'
import {
TuiTileComponent,
TuiTilesComponent,
TuiTilesModule,
} from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { DesktopService } from '../../services/desktop.service'
import { DektopLoadingService } from './dektop-loading.service'
import { CardComponent } from '../../components/card.component'
import { DesktopItemDirective } from './desktop-item.directive'
import { ToNavigationItemPipe } from '../../pipes/to-navigation-item'
import { ToBadgePipe } from '../../pipes/to-badge'
@Component({
standalone: true,
templateUrl: 'desktop.component.html',
styleUrls: ['desktop.component.scss'],
animations: [TUI_PARENT_STOP, tuiScaleIn, tuiFadeIn],
imports: [
CommonModule,
RouterModule,
CardComponent,
DesktopItemDirective,
TuiSvgModule,
TuiLoaderModule,
TuiTilesModule,
ToNavigationItemPipe,
TuiFadeModule,
DragScrollerDirective,
ToBadgePipe,
],
})
export class DesktopComponent {
@ViewChildren(TuiTileComponent, { read: ElementRef })
private readonly tiles: QueryList<ElementRef> = EMPTY_QUERY
readonly desktop = inject(DesktopService)
readonly loading$ = inject(DektopLoadingService)
readonly packages$ = inject(PatchDB<DataModel>).watch$('package-data')
@ViewChild(TuiTilesComponent)
readonly tile?: TuiTilesComponent
onRemove() {
const element = this.tile?.element
const index = this.tiles
.toArray()
.map(({ nativeElement }) => nativeElement)
.indexOf(element)
this.desktop.remove(this.desktop.items[index])
}
onReorder(order: Map<number, number>) {
this.desktop.reorder(order)
}
}

View File

@@ -1,228 +1,94 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { filter } from 'rxjs'
import {
InterfaceInfo,
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { tuiPure } from '@taiga-ui/cdk'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { DependencyInfo } from 'src/app/apps/portal/routes/service/types/dependency-info'
import { ActionsService } from 'src/app/apps/portal/services/actions.service'
import {
PackageDataEntry,
PackageMainStatus,
PackagePlus,
} from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { ServiceConfigModal } from '../modals/config.component'
import { PackageConfigData } from '../types/package-config-data'
@Component({
selector: 'service-actions',
template: `
<button
*ngIf="isRunning"
tuiButton
appearance="secondary-destructive"
icon="tuiIconSquare"
(click)="tryStop()"
>
Stop
</button>
@if (isRunning) {
<button
tuiButton
appearance="secondary-destructive"
iconLeft="tuiIconSquare"
(click)="actions.stop(service)"
>
Stop
</button>
<button
*ngIf="isRunning"
tuiButton
appearance="secondary"
icon="tuiIconRotateCw"
(click)="tryRestart()"
>
Restart
</button>
<button
tuiButton
appearance="secondary"
iconLeft="tuiIconRotateCw"
(click)="actions.restart(service)"
>
Restart
</button>
}
<button
*ngIf="isStopped && isConfigured"
tuiButton
icon="tuiIconPlay"
(click)="tryStart()"
>
Start
</button>
@if (isStopped && isConfigured) {
<button
tuiButton
iconLeft="tuiIconPlay"
(click)="actions.start(service, hasUnmet(dependencies))"
>
Start
</button>
}
<button
*ngIf="!isConfigured"
tuiButton
appearance="secondary-warning"
icon="tuiIconTool"
(click)="presentModalConfig()"
>
Configure
</button>
@if (!isConfigured) {
<button
tuiButton
appearance="secondary-warning"
iconLeft="tuiIconTool"
(click)="actions.configure(service)"
>
Configure
</button>
}
`,
styles: [':host { display: flex; gap: 1rem }'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiButtonModule],
imports: [TuiButtonModule],
})
export class ServiceActionsComponent {
@Input({ required: true })
service!: PackagePlus
service!: PackageDataEntry
constructor(
private readonly dialogs: TuiDialogService,
private readonly errorService: ErrorService,
private readonly loader: LoadingService,
private readonly embassyApi: ApiService,
private readonly formDialog: FormDialogService,
) {}
@Input({ required: true })
dependencies: readonly DependencyInfo[] = []
private get id(): string {
return this.service.pkg.manifest.id
}
get interfaceInfo(): Record<string, InterfaceInfo> {
return this.service.pkg.installed!['interfaceInfo']
}
readonly actions = inject(ActionsService)
get isConfigured(): boolean {
return this.service.pkg.installed!.status.configured
return this.service.installed!.status.configured
}
get isRunning(): boolean {
return (
this.service.pkg.installed?.status.main.status ===
PackageMainStatus.Running
this.service.installed?.status.main.status === PackageMainStatus.Running
)
}
get isStopped(): boolean {
return (
this.service.pkg.installed?.status.main.status ===
PackageMainStatus.Stopped
this.service.installed?.status.main.status === PackageMainStatus.Stopped
)
}
presentModalConfig(): void {
this.formDialog.open<PackageConfigData>(ServiceConfigModal, {
label: `${this.service.pkg.manifest.title} configuration`,
data: { pkgId: this.id },
})
}
async tryStart(): Promise<void> {
const pkg = this.service.pkg
if (Object.values(this.service.dependencies).some(dep => !!dep.errorText)) {
const depErrMsg = `${pkg.manifest.title} has unmet dependencies. It will not work as expected.`
const proceed = await this.presentAlertStart(depErrMsg)
if (!proceed) return
}
const alertMsg = pkg.manifest.alerts.start
if (alertMsg) {
const proceed = await this.presentAlertStart(alertMsg)
if (!proceed) return
}
this.start()
}
async tryStop(): Promise<void> {
const { title, alerts } = this.service.pkg.manifest
let content = alerts.stop || ''
if (hasCurrentDeps(this.service.pkg)) {
const depMessage = `Services that depend on ${title} will no longer work properly and may crash`
content = content ? `${content}.\n\n${depMessage}` : depMessage
}
if (content) {
this.dialogs
.open(TUI_PROMPT, {
label: 'Warning',
size: 's',
data: {
content,
yes: 'Stop',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.stop())
} else {
this.stop()
}
}
async tryRestart(): Promise<void> {
if (hasCurrentDeps(this.service.pkg)) {
this.dialogs
.open(TUI_PROMPT, {
label: 'Warning',
size: 's',
data: {
content: `Services that depend on ${this.service.pkg.manifest} may temporarily experiences issues`,
yes: 'Restart',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(() => this.restart())
} else {
this.restart()
}
}
private async start(): Promise<void> {
const loader = this.loader.open(`Starting...`).subscribe()
try {
await this.embassyApi.startPackage({ id: this.id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async stop(): Promise<void> {
const loader = this.loader.open(`Stopping...`).subscribe()
try {
await this.embassyApi.stopPackage({ id: this.id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async restart(): Promise<void> {
const loader = this.loader.open(`Restarting...`).subscribe()
try {
await this.embassyApi.restartPackage({ id: this.id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async presentAlertStart(content: string): Promise<boolean> {
return new Promise(async resolve => {
this.dialogs
.open<boolean>(TUI_PROMPT, {
label: 'Warning',
size: 's',
data: {
content,
yes: 'Continue',
no: 'Cancel',
},
})
.subscribe(response => resolve(response))
})
@tuiPure
hasUnmet(dependencies: readonly DependencyInfo[]): boolean {
return dependencies.some(dep => !!dep.errorText)
}
}

View File

@@ -1,4 +1,4 @@
import { DOCUMENT, CommonModule } from '@angular/common'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
@@ -8,7 +8,6 @@ import {
import { TuiSvgModule } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { ConfigService } from 'src/app/services/config.service'
import { InterfaceInfo } from 'src/app/services/patch-db/data-model'
import { ExtendedInterfaceInfo } from '../pipes/interface-info.pipe'
@Component({
@@ -20,22 +19,23 @@ import { ExtendedInterfaceInfo } from '../pipes/interface-info.pipe'
<div>{{ info.description }}</div>
<div [style.color]="info.color">{{ info.typeDetail }}</div>
</div>
<button
<a
*ngIf="info.type === 'ui'"
tuiIconButton
appearance="flat"
iconLeft="tuiIconExternalLinkLarge"
target="_blank"
rel="noreferrer"
[style.border-radius.%]="100"
(click.stop.prevent)="launchUI(info)"
[disabled]="disabled"
></button>
[attr.href]="href"
(click.stop)="(0)"
></a>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiButtonModule, CommonModule, TuiSvgModule],
})
export class ServiceInterfaceComponent {
private readonly document = inject(DOCUMENT)
private readonly config = inject(ConfigService)
@Input({ required: true, alias: 'serviceInterface' })
@@ -44,11 +44,7 @@ export class ServiceInterfaceComponent {
@Input()
disabled = false
launchUI(info: InterfaceInfo) {
this.document.defaultView?.open(
this.config.launchableAddress(info),
'_blank',
'noreferrer',
)
get href(): string | null {
return this.disabled ? null : this.config.launchableAddress(this.info)
}
}

View File

@@ -12,18 +12,17 @@ import { InstallProgressPipe } from '../pipes/install-progress.pipe'
@Component({
selector: 'service-status',
template: `
<strong *ngIf="!installProgress; else installing">
@if (installProgress) {
<strong>
Installing
<span class="loading-dots"></span>
{{ installProgress | installProgress }}
</strong>
} @else {
{{ connected ? rendering.display : 'Unknown' }}
<!-- @TODO should show 'this may take a while' if sigtermTimeout is > 30s -->
<span *ngIf="rendering.showDots" class="loading-dots"></span>
</strong>
<ng-template #installing>
<strong *ngIf="installProgress | installProgress as progress">
Installing
<span class="loading-dots"></span>
{{ progress }}
</strong>
</ng-template>
}
`,
styles: [
`

View File

@@ -1,19 +1,21 @@
import { inject, Pipe, PipeTransform, Type } from '@angular/core'
import { inject, Pipe, PipeTransform } from '@angular/core'
import { Params } from '@angular/router'
import { Manifest } from '@start9labs/marketplace'
import { MarkdownComponent } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { from } from 'rxjs'
import {
PackageConfigData,
ServiceConfigModal,
} from 'src/app/apps/portal/modals/config.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import {
InstalledPackageInfo,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { ProxyService } from 'src/app/services/proxy.service'
import { PackageConfigData } from '../types/package-config-data'
import { ServiceConfigModal } from '../modals/config.component'
import { ServiceCredentialsModal } from '../modals/credentials.component'
export interface ServiceMenu {

View File

@@ -25,7 +25,6 @@ import { hasCurrentDeps } from 'src/app/util/has-deps'
import { FormDialogService } from 'src/app/services/form-dialog.service'
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'
@Component({
@@ -84,7 +83,6 @@ export class ServiceActionsRoute {
private readonly router: Router,
private readonly patch: PatchDB<DataModel>,
private readonly formDialog: FormDialogService,
private readonly desktop: DesktopService,
) {}
async handleAction(action: WithId<Action>) {
@@ -162,8 +160,7 @@ export class ServiceActionsRoute {
this.embassyApi
.setDbValue<boolean>(['ack-instructions', this.id], false)
.catch(e => console.error('Failed to mark instructions as unseen', e))
this.desktop.remove(this.id)
this.router.navigate(['portal', 'desktop'])
this.router.navigate(['./portal/dashboard'])
} catch (e: any) {
this.errorService.handleError(e)
} finally {

View File

@@ -28,7 +28,7 @@ export class ServiceOutletComponent {
tap(pkg => {
// if package disappears, navigate to list page
if (!pkg) {
this.router.navigate(['./portal/desktop'])
this.router.navigate(['./portal/dashboard'])
}
}),
)

View File

@@ -1,9 +1,17 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'
import { Manifest } from '@start9labs/marketplace'
import { getPkgId, isEmptyObject } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { combineLatest, map } from 'rxjs'
import { combineLatest, map, switchMap } from 'rxjs'
import { ConnectionService } from 'src/app/services/connection.service'
import {
DependencyErrorType,
DepErrorService,
PkgDependencyErrors,
} from 'src/app/services/dep-error.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import {
DataModel,
HealthCheckResult,
@@ -16,31 +24,24 @@ import {
PackageStatus,
PrimaryRendering,
PrimaryStatus,
StatusRendering,
renderPkgStatus,
StatusRendering,
} from 'src/app/services/pkg-status-rendering.service'
import { ConnectionService } from 'src/app/services/connection.service'
import { DependentInfo } from 'src/app/types/dependent-info'
import { ServiceActionsComponent } from '../components/actions.component'
import { ServiceAdditionalComponent } from '../components/additional.component'
import { ServiceDependenciesComponent } from '../components/dependencies.component'
import { ServiceHealthChecksComponent } from '../components/health-checks.component'
import { ServiceInterfacesComponent } from '../components/interfaces.component'
import { ServiceMenuComponent } from '../components/menu.component'
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 {
DepErrorService,
DependencyErrorType,
PkgDependencyErrors,
} from 'src/app/services/dep-error.service'
PackageConfigData,
ServiceConfigModal,
} from 'src/app/apps/portal/modals/config.component'
import { ProgressDataPipe } from '../pipes/progress-data.pipe'
import { DependencyInfo } from '../types/dependency-info'
import { Manifest } from '@start9labs/marketplace'
import { toRouterLink } from '../../../utils/to-router-link'
import { PackageConfigData } from '../types/package-config-data'
import { ServiceConfigModal } from '../modals/config.component'
import { DependentInfo } from 'src/app/types/dependent-info'
import { FormDialogService } from 'src/app/services/form-dialog.service'
const STATES = [
PackageState.Installing,
@@ -50,41 +51,44 @@ const STATES = [
@Component({
template: `
<ng-container *ngIf="service$ | async as service">
<ng-container *ngIf="showProgress(service.pkg); else installed">
<ng-container *ngIf="service.pkg | progressData as progress">
@if (service$ | async; as service) {
@if (showProgress(service.pkg)) {
@if (service.pkg | 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>
}
} @else {
<h3 class="g-title">Status</h3>
<service-status
[connected]="!!(connected$ | async)"
[installProgress]="service.pkg['install-progress']"
[rendering]="$any(getRendering(service.status))"
/>
<service-actions
*ngIf="isInstalled(service.pkg) && (connected$ | async)"
[service]="service"
/>
<ng-container
*ngIf="isInstalled(service.pkg) && !isBackingUp(service.status)"
>
<service-interfaces [service]="service" />
<service-health-checks
*ngIf="isRunning(service.status) && (health$ | async) as checks"
[checks]="checks"
@if (isInstalled(service.pkg) && (connected$ | async)) {
<service-actions
[service]="service.pkg"
[dependencies]="service.dependencies"
/>
<service-dependencies [dependencies]="service.dependencies" />
}
@if (isInstalled(service.pkg) && !isBackingUp(service.status)) {
<service-interfaces [service]="service" />
@if (isRunning(service.status) && (health$ | async); as checks) {
<service-health-checks [checks]="checks" />
}
@if (service.dependencies.length) {
<service-dependencies [dependencies]="service.dependencies" />
}
<service-menu [service]="service.pkg" />
<service-additional [service]="service.pkg" />
</ng-container>
</ng-template>
</ng-container>
}
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
@@ -105,16 +109,21 @@ const STATES = [
})
export class ServiceRoute {
private readonly patch = inject(PatchDB<DataModel>)
private readonly pkgId = getPkgId(inject(ActivatedRoute))
private readonly pkgId$ = inject(ActivatedRoute).paramMap.pipe(
map(params => params.get('pkgId')!),
)
private readonly depErrorService = inject(DepErrorService)
private readonly router = inject(Router)
private readonly formDialog = inject(FormDialogService)
readonly connected$ = inject(ConnectionService).connected$
readonly service$ = combineLatest([
this.patch.watch$('package-data', this.pkgId),
this.depErrorService.getPkgDepErrors$(this.pkgId),
]).pipe(
readonly service$ = this.pkgId$.pipe(
switchMap(pkgId =>
combineLatest([
this.patch.watch$('package-data', pkgId),
this.depErrorService.getPkgDepErrors$(pkgId),
]),
),
map(([pkg, depErrors]) => {
return {
pkg,
@@ -123,9 +132,12 @@ export class ServiceRoute {
}
}),
)
readonly health$ = this.patch
.watch$('package-data', this.pkgId, 'installed', 'status', 'main')
.pipe(map(toHealthCheck))
readonly health$ = this.pkgId$.pipe(
switchMap(pkgId =>
this.patch.watch$('package-data', pkgId, 'installed', 'status', 'main'),
),
map(toHealthCheck),
)
getRendering({ primary }: PackageStatus): StatusRendering {
return PrimaryRendering[primary]
@@ -148,20 +160,16 @@ export class ServiceRoute {
}
private getDepInfo(
pkg: PackageDataEntry,
{ installed, manifest }: PackageDataEntry,
depErrors: PkgDependencyErrors,
): DependencyInfo[] {
const pkgInstalled = pkg.installed
if (!pkgInstalled) return []
const pkgManifest = pkg.manifest
return Object.keys(pkgInstalled['current-dependencies'])
.filter(depId => !!pkg.manifest.dependencies[depId])
.map(depId =>
this.getDepValues(pkgInstalled, pkgManifest, depId, depErrors),
)
return installed
? Object.keys(installed['current-dependencies'])
.filter(depId => !!manifest.dependencies[depId])
.map(depId =>
this.getDepValues(installed, manifest, depId, depErrors),
)
: []
}
private getDepValues(

View File

@@ -33,7 +33,7 @@ const ROUTES: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: '/portal/desktop',
redirectTo: '/portal/dashboard',
},
],
},

View File

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

View File

@@ -58,7 +58,7 @@ export class BackupsRestoreService {
),
)
.subscribe(() => {
this.router.navigate(['/portal/desktop'])
this.router.navigate(['/portal/dashboard'])
})
}

View File

@@ -17,6 +17,10 @@ import { SettingsUpdateComponent } from './update.component'
<settings-sync *ngIf="!server['ntp-synced']" />
<section *ngFor="let cat of service.settings | keyvalue: asIsOrder">
<h3 class="g-title" (click)="addClick(cat.key)">{{ cat.key }}</h3>
<settings-update
*ngIf="cat.key === 'General'"
[updated]="server['status-info'].updated"
/>
<ng-container *ngFor="let btn of cat.value">
<settings-button [button]="btn">
<div
@@ -37,10 +41,6 @@ import { SettingsUpdateComponent } from './update.component'
}}
</div>
</settings-button>
<settings-update
*ngIf="btn.title === 'About'"
[updated]="server['status-info'].updated"
/>
</ng-container>
</section>
</ng-container>

View File

@@ -0,0 +1,138 @@
import { inject, Injectable } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { defaultIfEmpty, filter, firstValueFrom } from 'rxjs'
import {
PackageConfigData,
ServiceConfigModal,
} from 'src/app/apps/portal/modals/config.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { hasCurrentDeps } from 'src/app/util/has-deps'
@Injectable({
providedIn: 'root',
})
export class ActionsService {
private readonly dialogs = inject(TuiDialogService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
configure({ manifest }: PackageDataEntry): void {
this.formDialog.open<PackageConfigData>(ServiceConfigModal, {
label: `${manifest.title} configuration`,
data: { pkgId: manifest.id },
})
}
async start({ manifest }: PackageDataEntry, unmet: boolean): Promise<void> {
const deps = `${manifest.title} has unmet dependencies. It will not work as expected.`
if (
(!unmet || (await this.alert(deps))) &&
(!manifest.alerts.start || (await this.alert(manifest.alerts.start)))
) {
this.doStart(manifest.id)
}
}
stop(pkg: PackageDataEntry): void {
const { title, alerts } = pkg.manifest
let content = alerts.stop || ''
if (hasCurrentDeps(pkg)) {
const depMessage = `Services that depend on ${title} will no longer work properly and may crash`
content = content ? `${content}.\n\n${depMessage}` : depMessage
}
if (content) {
this.dialogs
.open(TUI_PROMPT, getOptions(content, 'Stop'))
.pipe(filter(Boolean))
.subscribe(() => this.doStop(pkg.manifest.id))
} else {
this.doStop(pkg.manifest.id)
}
}
restart(pkg: PackageDataEntry): void {
if (hasCurrentDeps(pkg)) {
this.dialogs
.open(
TUI_PROMPT,
getOptions(
`Services that depend on ${pkg.manifest} may temporarily experiences issues`,
'Restart',
),
)
.pipe(filter(Boolean))
.subscribe(() => this.doRestart(pkg.manifest.id))
} else {
this.doRestart(pkg.manifest.id)
}
}
private async doStart(id: string): Promise<void> {
const loader = this.loader.open(`Starting...`).subscribe()
try {
await this.api.startPackage({ id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async doStop(id: string): Promise<void> {
const loader = this.loader.open(`Stopping...`).subscribe()
try {
await this.api.stopPackage({ id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async doRestart(id: string): Promise<void> {
const loader = this.loader.open(`Restarting...`).subscribe()
try {
await this.api.restartPackage({ id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private alert(content: string): Promise<boolean> {
return firstValueFrom(
this.dialogs
.open<boolean>(TUI_PROMPT, getOptions(content))
.pipe(defaultIfEmpty(false)),
)
}
}
function getOptions(
content: string,
yes = 'Continue',
): Partial<TuiDialogOptions<TuiPromptData>> {
return {
label: 'Warning',
size: 's',
data: {
content,
yes,
no: 'Cancel',
},
}
}

View File

@@ -10,9 +10,11 @@ import {
map,
Observable,
pairwise,
shareReplay,
startWith,
switchMap,
} from 'rxjs'
import { EOSService } from 'src/app/services/eos.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { ConnectionService } from 'src/app/services/connection.service'
@@ -23,6 +25,10 @@ import { ConnectionService } from 'src/app/services/connection.service'
export class BadgeService {
private readonly emver = inject(Emver)
private readonly patch = inject(PatchDB<DataModel>)
private readonly settings$ = combineLatest([
this.patch.watch$('server-info', 'ntp-synced'),
inject(EOSService).updateAvailable$,
]).pipe(map(([synced, update]) => Number(!synced) + Number(update)))
private readonly marketplace = inject(
AbstractMarketplaceService,
) as MarketplaceService
@@ -47,7 +53,7 @@ export class BadgeService {
),
)
private readonly updateCount$ = combineLatest([
private readonly updates$ = combineLatest([
this.marketplace.getMarketplace$(true),
this.local$,
]).pipe(
@@ -65,12 +71,15 @@ export class BadgeService {
new Set<string>(),
).size,
),
shareReplay(1),
)
getCount(id: string): Observable<number> {
switch (id) {
case '/portal/system/updates':
return this.updateCount$
return this.updates$
case '/portal/system/settings':
return this.settings$
default:
return EMPTY
}

View File

@@ -1,53 +0,0 @@
import { inject, Injectable } from '@angular/core'
import { TuiAlertService } from '@taiga-ui/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Injectable({
providedIn: 'root',
})
export class DesktopService {
private readonly alerts = inject(TuiAlertService)
private readonly api = inject(ApiService)
order = new Map()
items: readonly string[] = []
add(id: string) {
if (this.items.includes(id)) return
this.items = this.items.concat(id)
this.save(this.items)
}
remove(id: string) {
if (!this.items.includes(id)) return
this.items = this.items.filter(x => x !== id)
this.save(this.items)
}
save(ids: readonly string[] = []) {
this.api
.setDbValue(['desktop'], Array.from(new Set(ids)))
.catch(() =>
this.alerts
.open(
'Desktop might be out of sync. Please refresh the page to fix it.',
{ status: 'warning' },
)
.subscribe(),
)
}
reorder(order: Map<number, number>) {
this.order = order
const items: string[] = [...this.items]
Array.from(this.order.entries()).forEach(([index, order]) => {
items[order] = this.items[index]
})
this.save(items)
}
}

View File

@@ -13,15 +13,8 @@ export class ServicesService extends Observable<readonly PackageDataEntry[]> {
private readonly services$ = inject(PatchDB<DataModel>)
.watch$('package-data')
.pipe(
map(pkgs => Object.values(pkgs)),
startWith([]),
pairwise(),
filter(([prev, next]) => {
const length = next.length
return !length || prev.length !== length
}),
map(([_, pkgs]) =>
pkgs.sort((a, b) =>
map(pkgs =>
Object.values(pkgs).sort((a, b) =>
b.manifest.title.toLowerCase() > a.manifest.title.toLowerCase()
? -1
: 1,

View File

@@ -1,5 +0,0 @@
export interface NavigationItem {
readonly routerLink: string
readonly icon: string
readonly title: string
}

View File

@@ -1,8 +1,13 @@
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { SYSTEM_UTILITIES } from '../constants/system-utilities'
import { NavigationItem } from '../types/navigation-item'
import { toRouterLink } from './to-router-link'
export interface NavigationItem {
readonly routerLink: string
readonly icon: string
readonly title: string
}
export function toNavigationItem(
id: string,
packages: Record<string, PackageDataEntry> = {},

View File

@@ -6,6 +6,20 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'
template: `
<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="bevel-light">
<feFlood flood-color="white" flood-opacity="0.1" />
<feComposite in2="SourceAlpha" operator="out" />
<feGaussianBlur stdDeviation="0.5" result="blur" />
<feOffset dx="-1" dy="1" />
<feComposite operator="atop" in2="SourceGraphic" />
</filter>
<filter id="bevel-dark">
<feFlood flood-color="black" flood-opacity="0.3" />
<feComposite in2="SourceAlpha" operator="out" />
<feGaussianBlur stdDeviation="0.5" result="blur" />
<feOffset dx="1" dy="-1" />
<feComposite operator="atop" in2="SourceGraphic" />
</filter>
<filter id="round-corners">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
<feColorMatrix

View File

@@ -72,16 +72,16 @@ export class ConfigService {
return window.isSecureContext || this.isTor()
}
launchableAddress(info: InterfaceInfo): string {
launchableAddress({ addressInfo }: InterfaceInfo): string {
return this.isTor()
? info.addressInfo.torHostname
? addressInfo.torHostname
: this.isLocalhost()
? `https://${info.addressInfo.lanHostname}`
? `https://${addressInfo.lanHostname}`
: this.isLocal() || this.isIpv4() || this.isIpv6()
? `https://${this.hostname}`
: info.addressInfo.domainInfo?.subdomain
? `https://${info.addressInfo.domainInfo.subdomain}${info.addressInfo.domainInfo.domain}`
: `https://${info.addressInfo.domainInfo?.domain}`
: addressInfo.domainInfo?.subdomain
? `https://${addressInfo.domainInfo.subdomain}${addressInfo.domainInfo.domain}`
: `https://${addressInfo.domainInfo?.domain}`
}
getHost(): string {

View File

@@ -408,30 +408,25 @@ ul {
0% calc(100% - 2rem),
0% 3rem
);
}
.g-plaque {
@include transition(opacity);
.g-plaque {
@include transition(opacity);
position: absolute;
inset: 0;
z-index: -1;
filter: url(#round-corners) url(#bevel-light) url(#bevel-dark);
font-size: 0;
opacity: 0.75;
&::before {
@include transition(clip-path);
content: '';
position: absolute;
inset: 0;
z-index: -1;
filter: url(#round-corners);
opacity: 0.75;
clip-path: var(--clip-path);
// TODO: Theme
box-shadow: inset 0 0 2rem white;
&::before {
@include transition(clip-path);
content: '';
position: absolute;
inset: 0;
clip-path: var(--clip-path);
// TODO: Theme
background: #333;
box-shadow:
inset 0 1px rgba(255, 255, 255, 0.2),
inset -1px 0 rgba(255, 255, 255, 0.1),
inset 1px 0 rgba(255, 255, 255, 0.1);
}
background: #333;
}
}
@@ -531,6 +526,22 @@ button.g-action {
overflow: auto !important;
}
.g-success {
color: var(--tui-success-fill);
}
.g-warning {
color: var(--tui-warning-fill);
}
.g-error {
color: var(--tui-error-fill);
}
.g-info {
color: var(--tui-info-fill);
}
ng-component {
display: block;
}