mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -62,7 +62,7 @@ export class HeaderMobileComponent {
|
||||
get back() {
|
||||
return (
|
||||
this.headerMobile?.[this.headerMobile?.length - 2]?.routerLink ||
|
||||
'/portal/desktop'
|
||||
'/portal/dashboard'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())))
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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$
|
||||
}
|
||||
@@ -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)'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'])
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -33,7 +33,7 @@ const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
redirectTo: '/portal/desktop',
|
||||
redirectTo: '/portal/dashboard',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { DependentInfo } from 'src/app/types/dependent-info'
|
||||
|
||||
export interface PackageConfigData {
|
||||
readonly pkgId: string
|
||||
readonly dependentInfo?: DependentInfo
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export class BackupsRestoreService {
|
||||
),
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['/portal/desktop'])
|
||||
this.router.navigate(['/portal/dashboard'])
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
138
web/projects/ui/src/app/apps/portal/services/actions.service.ts
Normal file
138
web/projects/ui/src/app/apps/portal/services/actions.service.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface NavigationItem {
|
||||
readonly routerLink: string
|
||||
readonly icon: string
|
||||
readonly title: string
|
||||
}
|
||||
@@ -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> = {},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user