diff --git a/web/projects/shared/styles/taiga.scss b/web/projects/shared/styles/taiga.scss
index ed89a5be9..5adcc6739 100644
--- a/web/projects/shared/styles/taiga.scss
+++ b/web/projects/shared/styles/taiga.scss
@@ -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);
+}
diff --git a/web/projects/ui/src/app/apps/portal/components/drawer/drawer-item.directive.ts b/web/projects/ui/src/app/apps/portal/components/drawer/drawer-item.directive.ts
deleted file mode 100644
index 6e29ff0ca..000000000
--- a/web/projects/ui/src/app/apps/portal/components/drawer/drawer-item.directive.ts
+++ /dev/null
@@ -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)
- }
-}
diff --git a/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.html b/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.html
deleted file mode 100644
index 35a941eba..000000000
--- a/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.html
+++ /dev/null
@@ -1,57 +0,0 @@
-
-
-
- Enter service name
-
-
- System Utilities
-
- @for (
- item of system | keyvalue | tuiFilter: bySearch : search;
- track $index
- ) {
-
- } @empty {
- Nothing found
- }
-
- Installed services
-
- @for (
- item of (services$ | async) || [] | tuiFilter: bySearch : search;
- track $index
- ) {
-
- } @empty {
- Nothing found
- }
-
-
-
diff --git a/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.scss b/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.scss
deleted file mode 100644
index 49dd3e7ee..000000000
--- a/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.scss
+++ /dev/null
@@ -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;
-}
diff --git a/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts b/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts
deleted file mode 100644
index 5f2cc0b78..000000000
--- a/web/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts
+++ /dev/null
@@ -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)
- }
-}
diff --git a/web/projects/ui/src/app/apps/portal/components/header/header.component.ts b/web/projects/ui/src/app/apps/portal/components/header/header.component.ts
index 3da3472d6..b12380702 100644
--- a/web/projects/ui/src/app/apps/portal/components/header/header.component.ts
+++ b/web/projects/ui/src/app/apps/portal/components/header/header.component.ts
@@ -18,7 +18,7 @@ import { BreadcrumbsService } from '../../services/breadcrumbs.service'
@Component({
selector: 'header[appHeader]',
template: `
-
+
@for (item of breadcrumbs$ | async; track $index) {
diff --git a/web/projects/ui/src/app/apps/portal/components/header/mobile.component.ts b/web/projects/ui/src/app/apps/portal/components/header/mobile.component.ts
index e47815656..391fb6141 100644
--- a/web/projects/ui/src/app/apps/portal/components/header/mobile.component.ts
+++ b/web/projects/ui/src/app/apps/portal/components/header/mobile.component.ts
@@ -62,7 +62,7 @@ export class HeaderMobileComponent {
get back() {
return (
this.headerMobile?.[this.headerMobile?.length - 2]?.routerLink ||
- '/portal/desktop'
+ '/portal/dashboard'
)
}
}
diff --git a/web/projects/ui/src/app/apps/portal/components/skeleton-list.component.ts b/web/projects/ui/src/app/apps/portal/components/skeleton-list.component.ts
deleted file mode 100644
index b3fca5e06..000000000
--- a/web/projects/ui/src/app/apps/portal/components/skeleton-list.component.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { Component, Input } from '@angular/core'
-import { TuiRepeatTimesModule } from '@taiga-ui/cdk'
-
-@Component({
- selector: 'skeleton-list',
- template: `
-
- `,
- standalone: true,
- imports: [TuiRepeatTimesModule],
-})
-export class SkeletonListComponent {
- @Input() rows = 3
- @Input() showAvatar = false
-}
diff --git a/web/projects/ui/src/app/apps/portal/constants/system-utilities.ts b/web/projects/ui/src/app/apps/portal/constants/system-utilities.ts
index b71ee8e01..e29bc1059 100644
--- a/web/projects/ui/src/app/apps/portal/constants/system-utilities.ts
+++ b/web/projects/ui/src/app/apps/portal/constants/system-utilities.ts
@@ -24,10 +24,6 @@ export const SYSTEM_UTILITIES: Record =
icon: 'tuiIconTool',
title: 'Settings',
},
- '/portal/system/snek': {
- icon: 'assets/img/icon_transparent.png',
- title: 'Snek',
- },
'/portal/system/notifications': {
icon: 'tuiIconBell',
title: 'Notifications',
diff --git a/web/projects/ui/src/app/apps/portal/routes/service/components/config-dep.component.ts b/web/projects/ui/src/app/apps/portal/modals/config-dep.component.ts
similarity index 100%
rename from web/projects/ui/src/app/apps/portal/routes/service/components/config-dep.component.ts
rename to web/projects/ui/src/app/apps/portal/modals/config-dep.component.ts
diff --git a/web/projects/ui/src/app/apps/portal/routes/service/modals/config.component.ts b/web/projects/ui/src/app/apps/portal/modals/config.component.ts
similarity index 95%
rename from web/projects/ui/src/app/apps/portal/routes/service/modals/config.component.ts
rename to web/projects/ui/src/app/apps/portal/modals/config.component.ts
index 43f874c47..d56e0e6f8 100644
--- a/web/projects/ui/src/app/apps/portal/routes/service/modals/config.component.ts
+++ b/web/projects/ui/src/app/apps/portal/modals/config.component.ts
@@ -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: `
-
+
+ />
No config options for {{ pkg.manifest.title }}
diff --git a/web/projects/ui/src/app/apps/portal/pipes/to-navigation-item.ts b/web/projects/ui/src/app/apps/portal/pipes/to-navigation-item.ts
deleted file mode 100644
index 39d1d91c7..000000000
--- a/web/projects/ui/src/app/apps/portal/pipes/to-navigation-item.ts
+++ /dev/null
@@ -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,
- id: string,
- ): NavigationItem | null {
- return id ? toNavigationItem(id, packages) : null
- }
-}
diff --git a/web/projects/ui/src/app/apps/portal/portal.component.ts b/web/projects/ui/src/app/apps/portal/portal.component.ts
index b609af43e..6592e2320 100644
--- a/web/projects/ui/src/app/apps/portal/portal.component.ts
+++ b/web/projects/ui/src/app/apps/portal/portal.component.ts
@@ -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: `
-
`,
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({
diff --git a/web/projects/ui/src/app/apps/portal/portal.routes.ts b/web/projects/ui/src/app/apps/portal/portal.routes.ts
index 20db07d63..57f1a6476 100644
--- a/web/projects/ui/src/app/apps/portal/portal.routes.ts
+++ b/web/projects/ui/src/app/apps/portal/portal.routes.ts
@@ -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,
),
},
{
diff --git a/web/projects/ui/src/app/apps/portal/routes/dashboard/controls.component.ts b/web/projects/ui/src/app/apps/portal/routes/dashboard/controls.component.ts
new file mode 100644
index 000000000..ed037df29
--- /dev/null
+++ b/web/projects/ui/src/app/apps/portal/routes/dashboard/controls.component.ts
@@ -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) {
+
+
+
+ } @else {
+
+
+
+ }
+
+
+ `,
+ 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)
+ }
+}
diff --git a/web/projects/ui/src/app/apps/portal/routes/dashboard/dashboard.component.ts b/web/projects/ui/src/app/apps/portal/routes/dashboard/dashboard.component.ts
new file mode 100644
index 000000000..531112839
--- /dev/null
+++ b/web/projects/ui/src/app/apps/portal/routes/dashboard/dashboard.component.ts
@@ -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: `
+
+
+
+
+ Metrics
+
+
+
+
+
+
+ Utilities
+
+
+
+
+
+
+ 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())))
+}
diff --git a/web/projects/ui/src/app/apps/portal/routes/dashboard/metrics.component.ts b/web/projects/ui/src/app/apps/portal/routes/dashboard/metrics.component.ts
new file mode 100644
index 000000000..017a6322c
--- /dev/null
+++ b/web/projects/ui/src/app/apps/portal/routes/dashboard/metrics.component.ts
@@ -0,0 +1,42 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core'
+
+@Component({
+ standalone: true,
+ selector: 'app-metrics',
+ template: `
+
+
+ `,
+ 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 {}
diff --git a/web/projects/ui/src/app/apps/portal/routes/dashboard/service.component.ts b/web/projects/ui/src/app/apps/portal/routes/dashboard/service.component.ts
new file mode 100644
index 000000000..299578bc8
--- /dev/null
+++ b/web/projects/ui/src/app/apps/portal/routes/dashboard/service.component.ts
@@ -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: `
+ ![logo]() |
+
+ {{ appService.manifest.title }}
+ |
+ {{ appService.manifest.version }} |
+ |
+
+
+ |
+ `,
+ 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)
+ }
+}
diff --git a/web/projects/ui/src/app/apps/portal/routes/dashboard/services.component.ts b/web/projects/ui/src/app/apps/portal/routes/dashboard/services.component.ts
new file mode 100644
index 000000000..8769ae101
--- /dev/null
+++ b/web/projects/ui/src/app/apps/portal/routes/dashboard/services.component.ts
@@ -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: `
+
+
+
+
+ |
+ Name |
+ Version |
+ Status |
+ Controls |
+
+
+
+ @if (errors$ | async; as errors) {
+ @for (service of services$ | async; track $index) {
+
+ } @empty {
+
+ | No services installed |
+
+ }
+ }
+
+
+ `,
+ 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$
+}
diff --git a/web/projects/ui/src/app/apps/portal/routes/dashboard/status.component.ts b/web/projects/ui/src/app/apps/portal/routes/dashboard/status.component.ts
new file mode 100644
index 000000000..d398385e9
--- /dev/null
+++ b/web/projects/ui/src/app/apps/portal/routes/dashboard/status.component.ts
@@ -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) {
+
+ } @else {
+ @if (healthy) {
+
+ } @else {
+
+ }
+ }
+ {{ status }}
+ `,
+ 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)'
+ }
+ }
+}
diff --git a/web/projects/ui/src/app/apps/portal/routes/dashboard/ui.component.ts b/web/projects/ui/src/app/apps/portal/routes/dashboard/ui.component.ts
new file mode 100644
index 000000000..265f799bf
--- /dev/null
+++ b/web/projects/ui/src/app/apps/portal/routes/dashboard/ui.component.ts
@@ -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) {
+
+
+
+
+ @for (interface of interfaces; track $index) {
+
+ {{ interface.name }}
+
+ }
+
+
+
+ } @else {
+
+ {{ interfaces[0]?.name }}
+
+ }
+ `,
+ 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
+ }
+}
diff --git a/web/projects/ui/src/app/apps/portal/routes/dashboard/utilities.component.ts b/web/projects/ui/src/app/apps/portal/routes/dashboard/utilities.component.ts
new file mode 100644
index 000000000..d2f7b4539
--- /dev/null
+++ b/web/projects/ui/src/app/apps/portal/routes/dashboard/utilities.component.ts
@@ -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: `
+
+
+ `,
+ 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),
+ }))
+}
diff --git a/web/projects/ui/src/app/apps/portal/routes/desktop/dektop-loading.service.ts b/web/projects/ui/src/app/apps/portal/routes/desktop/dektop-loading.service.ts
deleted file mode 100644
index f0e50f0f2..000000000
--- a/web/projects/ui/src/app/apps/portal/routes/desktop/dektop-loading.service.ts
+++ /dev/null
@@ -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 {
- private readonly desktop = inject(DesktopService)
- private readonly patch = inject(PatchDB)
- 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))
- }
-}
diff --git a/web/projects/ui/src/app/apps/portal/routes/desktop/desktop-item.directive.ts b/web/projects/ui/src/app/apps/portal/routes/desktop/desktop-item.directive.ts
deleted file mode 100644
index f54fc39fa..000000000
--- a/web/projects/ui/src/app/apps/portal/routes/desktop/desktop-item.directive.ts
+++ /dev/null
@@ -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
- }
-}
diff --git a/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.html b/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.html
deleted file mode 100644
index 0945fda7e..000000000
--- a/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.html
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
-
-
diff --git a/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.scss b/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.scss
deleted file mode 100644
index dd8bfe078..000000000
--- a/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.scss
+++ /dev/null
@@ -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);
- }
-}
diff --git a/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.ts b/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.ts
deleted file mode 100644
index 589146a7e..000000000
--- a/web/projects/ui/src/app/apps/portal/routes/desktop/desktop.component.ts
+++ /dev/null
@@ -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 = EMPTY_QUERY
-
- readonly desktop = inject(DesktopService)
- readonly loading$ = inject(DektopLoadingService)
- readonly packages$ = inject(PatchDB).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) {
- this.desktop.reorder(order)
- }
-}
diff --git a/web/projects/ui/src/app/apps/portal/routes/service/components/actions.component.ts b/web/projects/ui/src/app/apps/portal/routes/service/components/actions.component.ts
index 5a3582b31..f385cd0b3 100644
--- a/web/projects/ui/src/app/apps/portal/routes/service/components/actions.component.ts
+++ b/web/projects/ui/src/app/apps/portal/routes/service/components/actions.component.ts
@@ -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: `
-
+ @if (isRunning) {
+
-
+
+ }
-
+ @if (isStopped && isConfigured) {
+
+ }
-
+ @if (!isConfigured) {
+
+ }
`,
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 {
- 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(ServiceConfigModal, {
- label: `${this.service.pkg.manifest.title} configuration`,
- data: { pkgId: this.id },
- })
- }
-
- async tryStart(): Promise {
- 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 {
- 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 {
- 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 {
- const loader = this.loader.open(`Starting...`).subscribe()
-
- try {
- await this.embassyApi.startPackage({ id: this.id })
- } catch (e: any) {
- this.errorService.handleError(e)
- } finally {
- loader.unsubscribe()
- }
- }
-
- private async stop(): Promise {
- const loader = this.loader.open(`Stopping...`).subscribe()
-
- try {
- await this.embassyApi.stopPackage({ id: this.id })
- } catch (e: any) {
- this.errorService.handleError(e)
- } finally {
- loader.unsubscribe()
- }
- }
-
- private async restart(): Promise {
- const loader = this.loader.open(`Restarting...`).subscribe()
-
- try {
- await this.embassyApi.restartPackage({ id: this.id })
- } catch (e: any) {
- this.errorService.handleError(e)
- } finally {
- loader.unsubscribe()
- }
- }
-
- private async presentAlertStart(content: string): Promise {
- return new Promise(async resolve => {
- this.dialogs
- .open(TUI_PROMPT, {
- label: 'Warning',
- size: 's',
- data: {
- content,
- yes: 'Continue',
- no: 'Cancel',
- },
- })
- .subscribe(response => resolve(response))
- })
+ @tuiPure
+ hasUnmet(dependencies: readonly DependencyInfo[]): boolean {
+ return dependencies.some(dep => !!dep.errorText)
}
}
diff --git a/web/projects/ui/src/app/apps/portal/routes/service/components/interface.component.ts b/web/projects/ui/src/app/apps/portal/routes/service/components/interface.component.ts
index 45ded6d6d..81da0b63f 100644
--- a/web/projects/ui/src/app/apps/portal/routes/service/components/interface.component.ts
+++ b/web/projects/ui/src/app/apps/portal/routes/service/components/interface.component.ts
@@ -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'
{{ info.description }}
{{ info.typeDetail }}
-
+ [attr.href]="href"
+ (click.stop)="(0)"
+ >
`,
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)
}
}
diff --git a/web/projects/ui/src/app/apps/portal/routes/service/components/status.component.ts b/web/projects/ui/src/app/apps/portal/routes/service/components/status.component.ts
index db021af59..2fce9e78e 100644
--- a/web/projects/ui/src/app/apps/portal/routes/service/components/status.component.ts
+++ b/web/projects/ui/src/app/apps/portal/routes/service/components/status.component.ts
@@ -12,18 +12,17 @@ import { InstallProgressPipe } from '../pipes/install-progress.pipe'
@Component({
selector: 'service-status',
template: `
-
+ @if (installProgress) {
+
+ Installing
+
+ {{ installProgress | installProgress }}
+
+ } @else {
{{ connected ? rendering.display : 'Unknown' }}
-
-
-
- Installing
-
- {{ progress }}
-
-
+ }
`,
styles: [
`
diff --git a/web/projects/ui/src/app/apps/portal/routes/service/pipes/to-menu.pipe.ts b/web/projects/ui/src/app/apps/portal/routes/service/pipes/to-menu.pipe.ts
index 12e35d2b8..33e5e06fd 100644
--- a/web/projects/ui/src/app/apps/portal/routes/service/pipes/to-menu.pipe.ts
+++ b/web/projects/ui/src/app/apps/portal/routes/service/pipes/to-menu.pipe.ts
@@ -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 {
diff --git a/web/projects/ui/src/app/apps/portal/routes/service/routes/actions.component.ts b/web/projects/ui/src/app/apps/portal/routes/service/routes/actions.component.ts
index 52e6371da..8d1e9beae 100644
--- a/web/projects/ui/src/app/apps/portal/routes/service/routes/actions.component.ts
+++ b/web/projects/ui/src/app/apps/portal/routes/service/routes/actions.component.ts
@@ -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,
private readonly formDialog: FormDialogService,
- private readonly desktop: DesktopService,
) {}
async handleAction(action: WithId) {
@@ -162,8 +160,7 @@ export class ServiceActionsRoute {
this.embassyApi
.setDbValue(['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 {
diff --git a/web/projects/ui/src/app/apps/portal/routes/service/routes/outlet.component.ts b/web/projects/ui/src/app/apps/portal/routes/service/routes/outlet.component.ts
index 6dcd7a26a..733f796f2 100644
--- a/web/projects/ui/src/app/apps/portal/routes/service/routes/outlet.component.ts
+++ b/web/projects/ui/src/app/apps/portal/routes/service/routes/outlet.component.ts
@@ -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'])
}
}),
)
diff --git a/web/projects/ui/src/app/apps/portal/routes/service/routes/service.component.ts b/web/projects/ui/src/app/apps/portal/routes/service/routes/service.component.ts
index 21f6ca148..152767ede 100644
--- a/web/projects/ui/src/app/apps/portal/routes/service/routes/service.component.ts
+++ b/web/projects/ui/src/app/apps/portal/routes/service/routes/service.component.ts
@@ -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: `
-
-
-
+ @if (service$ | async; as service) {
+ @if (showProgress(service.pkg)) {
+ @if (service.pkg | progressData; as progress) {
Downloading
Validating
Unpacking
-
-
-
-
+ }
+ } @else {
Status
-
-
-
-
-
+ }
+
+ @if (isInstalled(service.pkg) && !isBackingUp(service.status)) {
+
+
+ @if (isRunning(service.status) && (health$ | async); as checks) {
+
+ }
+
+ @if (service.dependencies.length) {
+
+ }
+
-
-
-
+ }
+ }
+ }
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
@@ -105,16 +109,21 @@ const STATES = [
})
export class ServiceRoute {
private readonly patch = inject(PatchDB)
- 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(
diff --git a/web/projects/ui/src/app/apps/portal/routes/service/service.module.ts b/web/projects/ui/src/app/apps/portal/routes/service/service.module.ts
index fb2d6b8a1..af231e987 100644
--- a/web/projects/ui/src/app/apps/portal/routes/service/service.module.ts
+++ b/web/projects/ui/src/app/apps/portal/routes/service/service.module.ts
@@ -33,7 +33,7 @@ const ROUTES: Routes = [
{
path: '',
pathMatch: 'full',
- redirectTo: '/portal/desktop',
+ redirectTo: '/portal/dashboard',
},
],
},
diff --git a/web/projects/ui/src/app/apps/portal/routes/service/types/package-config-data.ts b/web/projects/ui/src/app/apps/portal/routes/service/types/package-config-data.ts
deleted file mode 100644
index 3981773fc..000000000
--- a/web/projects/ui/src/app/apps/portal/routes/service/types/package-config-data.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { DependentInfo } from 'src/app/types/dependent-info'
-
-export interface PackageConfigData {
- readonly pkgId: string
- readonly dependentInfo?: DependentInfo
-}
diff --git a/web/projects/ui/src/app/apps/portal/routes/system/backups/services/restore.service.ts b/web/projects/ui/src/app/apps/portal/routes/system/backups/services/restore.service.ts
index 72c37c877..449558eaf 100644
--- a/web/projects/ui/src/app/apps/portal/routes/system/backups/services/restore.service.ts
+++ b/web/projects/ui/src/app/apps/portal/routes/system/backups/services/restore.service.ts
@@ -58,7 +58,7 @@ export class BackupsRestoreService {
),
)
.subscribe(() => {
- this.router.navigate(['/portal/desktop'])
+ this.router.navigate(['/portal/dashboard'])
})
}
diff --git a/web/projects/ui/src/app/apps/portal/routes/system/settings/components/menu.component.ts b/web/projects/ui/src/app/apps/portal/routes/system/settings/components/menu.component.ts
index 66a13c4d5..adc738603 100644
--- a/web/projects/ui/src/app/apps/portal/routes/system/settings/components/menu.component.ts
+++ b/web/projects/ui/src/app/apps/portal/routes/system/settings/components/menu.component.ts
@@ -17,6 +17,10 @@ import { SettingsUpdateComponent } from './update.component'
{{ cat.key }}
+
-
diff --git a/web/projects/ui/src/app/apps/portal/services/actions.service.ts b/web/projects/ui/src/app/apps/portal/services/actions.service.ts
new file mode 100644
index 000000000..7272cf2d5
--- /dev/null
+++ b/web/projects/ui/src/app/apps/portal/services/actions.service.ts
@@ -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
(ServiceConfigModal, {
+ label: `${manifest.title} configuration`,
+ data: { pkgId: manifest.id },
+ })
+ }
+
+ async start({ manifest }: PackageDataEntry, unmet: boolean): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ return firstValueFrom(
+ this.dialogs
+ .open(TUI_PROMPT, getOptions(content))
+ .pipe(defaultIfEmpty(false)),
+ )
+ }
+}
+
+function getOptions(
+ content: string,
+ yes = 'Continue',
+): Partial> {
+ return {
+ label: 'Warning',
+ size: 's',
+ data: {
+ content,
+ yes,
+ no: 'Cancel',
+ },
+ }
+}
diff --git a/web/projects/ui/src/app/apps/portal/services/badge.service.ts b/web/projects/ui/src/app/apps/portal/services/badge.service.ts
index e8ff3982f..2039e4db9 100644
--- a/web/projects/ui/src/app/apps/portal/services/badge.service.ts
+++ b/web/projects/ui/src/app/apps/portal/services/badge.service.ts
@@ -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)
+ 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(),
).size,
),
+ shareReplay(1),
)
getCount(id: string): Observable {
switch (id) {
case '/portal/system/updates':
- return this.updateCount$
+ return this.updates$
+ case '/portal/system/settings':
+ return this.settings$
default:
return EMPTY
}
diff --git a/web/projects/ui/src/app/apps/portal/services/desktop.service.ts b/web/projects/ui/src/app/apps/portal/services/desktop.service.ts
deleted file mode 100644
index ac63cfbbd..000000000
--- a/web/projects/ui/src/app/apps/portal/services/desktop.service.ts
+++ /dev/null
@@ -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) {
- this.order = order
-
- const items: string[] = [...this.items]
-
- Array.from(this.order.entries()).forEach(([index, order]) => {
- items[order] = this.items[index]
- })
-
- this.save(items)
- }
-}
diff --git a/web/projects/ui/src/app/apps/portal/services/services.service.ts b/web/projects/ui/src/app/apps/portal/services/services.service.ts
index 8afc6f43e..458e83227 100644
--- a/web/projects/ui/src/app/apps/portal/services/services.service.ts
+++ b/web/projects/ui/src/app/apps/portal/services/services.service.ts
@@ -13,15 +13,8 @@ export class ServicesService extends Observable {
private readonly services$ = inject(PatchDB)
.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,
diff --git a/web/projects/ui/src/app/apps/portal/types/navigation-item.ts b/web/projects/ui/src/app/apps/portal/types/navigation-item.ts
deleted file mode 100644
index ea912247c..000000000
--- a/web/projects/ui/src/app/apps/portal/types/navigation-item.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export interface NavigationItem {
- readonly routerLink: string
- readonly icon: string
- readonly title: string
-}
diff --git a/web/projects/ui/src/app/apps/portal/utils/to-navigation-item.ts b/web/projects/ui/src/app/apps/portal/utils/to-navigation-item.ts
index b33afe3a4..d4109d496 100644
--- a/web/projects/ui/src/app/apps/portal/utils/to-navigation-item.ts
+++ b/web/projects/ui/src/app/apps/portal/utils/to-navigation-item.ts
@@ -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 = {},
diff --git a/web/projects/ui/src/app/common/svg-definitions.component.ts b/web/projects/ui/src/app/common/svg-definitions.component.ts
index 36d6462b9..b73909485 100644
--- a/web/projects/ui/src/app/common/svg-definitions.component.ts
+++ b/web/projects/ui/src/app/common/svg-definitions.component.ts
@@ -6,6 +6,20 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'
template: `