mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
Merge pull request #2629 from Start9Labs/navigation
refactor: change navigation
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" focusable="false" height="1em" width="1em">
|
||||
<g id="tuiIconCheckCircle" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg x="50%" y="50%" fill="none" height="1em" overflow="visible" viewBox="0 0 16 16" width="1em">
|
||||
<svg
|
||||
x="-8"
|
||||
y="-8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
vector-effect="non-scaling-stroke"
|
||||
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"
|
||||
/>
|
||||
<path vector-effect="non-scaling-stroke" d="M15.5 9.5l-4.5 5-2.5-2.273" />
|
||||
</svg>
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 988 B |
@@ -178,26 +178,36 @@ tui-hint[data-appearance='onDark'] {
|
||||
|
||||
tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] {
|
||||
border: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(0.25rem);
|
||||
box-shadow: 0 0.25rem 0.25rem rgb(0 0 0 / 25%);
|
||||
border-radius: 0.325rem;
|
||||
// TODO: Replace --tui-elevation-02 when Taiga UI is updated
|
||||
background: rgb(63 63 63 / 95%);
|
||||
background: rgb(63 63 63 / 80%);
|
||||
|
||||
tui-opt-group {
|
||||
&::before {
|
||||
background: var(--tui-clear);
|
||||
box-shadow:
|
||||
1rem 0 var(--tui-clear),
|
||||
-1rem 0 var(--tui-clear);
|
||||
padding-top: 0.25rem !important;
|
||||
padding-bottom: 0 !important;
|
||||
margin: 0.25rem;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[tuiOption] {
|
||||
border-radius: 0.1875rem !important;
|
||||
transition-property: background, box-shadow;
|
||||
|
||||
&:focus,
|
||||
&._with-dropdown {
|
||||
box-shadow:
|
||||
inset 0 -1px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px rgba(255, 255, 255, 0.1),
|
||||
inset 0 -3rem 4rem -2rem rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[tuiSidebar] > div.t-wrapper {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NgModule } from '@angular/core'
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
|
||||
import { ServiceWorkerModule } from '@angular/service-worker'
|
||||
import { LoadingModule } from '@start9labs/shared'
|
||||
import { TuiSheetDialogModule } from '@taiga-ui/addon-mobile'
|
||||
import {
|
||||
TuiAlertModule,
|
||||
TuiDialogModule,
|
||||
@@ -27,6 +28,7 @@ import { RoutingModule } from './routing.module'
|
||||
ToastContainerComponent,
|
||||
TuiRootModule,
|
||||
TuiDialogModule,
|
||||
TuiSheetDialogModule,
|
||||
TuiAlertModule,
|
||||
TuiModeModule,
|
||||
TuiThemeNightModule,
|
||||
|
||||
@@ -1,54 +1,52 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { TuiSidebarModule } from '@taiga-ui/addon-mobile'
|
||||
import { tuiContainsOrAfter, tuiIsElement, TuiLetModule } from '@taiga-ui/cdk'
|
||||
import { TuiLetModule } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TUI_ANIMATION_OPTIONS,
|
||||
tuiFadeIn,
|
||||
tuiScaleIn,
|
||||
tuiWidthCollapse,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiBadgedContentModule,
|
||||
TuiBadgeNotificationModule,
|
||||
TuiButtonModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { Subject } from 'rxjs'
|
||||
import { HeaderMenuComponent } from './menu.component'
|
||||
import { HeaderNotificationsComponent } from './notifications.component'
|
||||
import { SidebarDirective } from 'src/app/components/sidebar-host.component'
|
||||
import { NotificationService } from 'src/app/services/notification.service'
|
||||
import { getMenu } from 'src/app/utils/system-utilities'
|
||||
import { HeaderMenuComponent } from './menu.component'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'header-corner',
|
||||
template: `
|
||||
<ng-content />
|
||||
<tui-badged-content
|
||||
*tuiLet="notificationService.unreadCount$ | async as unread"
|
||||
[style.--tui-radius.%]="50"
|
||||
>
|
||||
<tui-badge-notification *ngIf="unread" tuiSlot="top" size="s">
|
||||
{{ unread }}
|
||||
</tui-badge-notification>
|
||||
<button
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconBellLarge"
|
||||
appearance="icon"
|
||||
size="s"
|
||||
[style.color]="'var(--tui-text-01)'"
|
||||
(click)="handleNotificationsClick(unread || 0)"
|
||||
>
|
||||
Notifications
|
||||
</button>
|
||||
</tui-badged-content>
|
||||
@for (item of utils; track $index) {
|
||||
@if (item.badge(); as badge) {
|
||||
<tui-badged-content
|
||||
[style.--tui-radius.%]="50"
|
||||
[@tuiFadeIn]="animation"
|
||||
[@tuiWidthCollapse]="animation"
|
||||
[@tuiScaleIn]="animation"
|
||||
>
|
||||
<tui-badge-notification tuiSlot="top" size="s">
|
||||
{{ badge }}
|
||||
</tui-badge-notification>
|
||||
<a
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
size="s"
|
||||
[iconLeft]="item.icon"
|
||||
[routerLink]="item.routerLink"
|
||||
[style.color]="'var(--tui-text-01)'"
|
||||
>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
</tui-badged-content>
|
||||
}
|
||||
}
|
||||
<header-menu></header-menu>
|
||||
<header-notifications
|
||||
(onEmpty)="this.open$.next(false)"
|
||||
*tuiSidebar="!!(open$ | async); direction: 'right'; autoWidth: true"
|
||||
/>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
@@ -65,48 +63,20 @@ import { NotificationService } from 'src/app/services/notification.service'
|
||||
}
|
||||
`,
|
||||
],
|
||||
animations: [tuiFadeIn, tuiWidthCollapse, tuiScaleIn],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
HeaderMenuComponent,
|
||||
HeaderNotificationsComponent,
|
||||
SidebarDirective,
|
||||
TuiBadgeNotificationModule,
|
||||
TuiBadgedContentModule,
|
||||
TuiButtonModule,
|
||||
TuiLetModule,
|
||||
TuiSidebarModule,
|
||||
RouterLink,
|
||||
],
|
||||
})
|
||||
export class HeaderCornerComponent {
|
||||
private readonly router = inject(Router)
|
||||
readonly notificationService = inject(NotificationService)
|
||||
|
||||
@ViewChild(HeaderNotificationsComponent, { read: ElementRef })
|
||||
private readonly panel?: ElementRef<HTMLElement>
|
||||
|
||||
private readonly _ = this.router.events.subscribe(() => {
|
||||
this.open$.next(false)
|
||||
})
|
||||
|
||||
readonly open$ = new Subject<boolean>()
|
||||
|
||||
@HostListener('document:click.capture', ['$event.target'])
|
||||
onClick(target: EventTarget | null) {
|
||||
if (
|
||||
tuiIsElement(target) &&
|
||||
this.panel?.nativeElement &&
|
||||
!tuiContainsOrAfter(this.panel.nativeElement, target)
|
||||
) {
|
||||
this.open$.next(false)
|
||||
}
|
||||
}
|
||||
|
||||
handleNotificationsClick(unread: number) {
|
||||
if (unread) {
|
||||
this.open$.next(true)
|
||||
} else {
|
||||
this.router.navigateByUrl('/portal/system/notifications')
|
||||
}
|
||||
}
|
||||
readonly animation = inject(TUI_ANIMATION_OPTIONS)
|
||||
readonly utils = getMenu()
|
||||
}
|
||||
|
||||
@@ -64,10 +64,11 @@ import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service'
|
||||
margin-left: -1.25rem;
|
||||
backdrop-filter: blur(1rem);
|
||||
clip-path: var(--clip-path);
|
||||
}
|
||||
|
||||
&:active {
|
||||
backdrop-filter: blur(2rem) brightness(0.75) saturate(0.75);
|
||||
}
|
||||
> a:active,
|
||||
> button:active {
|
||||
backdrop-filter: blur(2rem) brightness(0.75) saturate(0.75);
|
||||
}
|
||||
|
||||
&:has([data-connection='error']) {
|
||||
|
||||
@@ -1,65 +1,86 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { TuiActiveZoneModule } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiDataListModule,
|
||||
TuiDialogOptions,
|
||||
TuiDialogService,
|
||||
TuiDropdownModule,
|
||||
TuiHostedDropdownModule,
|
||||
TuiSvgModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiButtonModule, TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||
import { filter } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import {
|
||||
TuiBadgeNotificationModule,
|
||||
TuiButtonModule,
|
||||
TuiIconModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { TuiDataListDropdownManagerModule } from '@taiga-ui/kit'
|
||||
import { RESOURCES } from 'src/app/utils/resources'
|
||||
import { getMenu } from 'src/app/utils/system-utilities'
|
||||
import { ABOUT } from './about.component'
|
||||
import { HeaderConnectionComponent } from './connection.component'
|
||||
|
||||
@Component({
|
||||
selector: 'header-menu',
|
||||
template: `
|
||||
<tui-hosted-dropdown [content]="content" [tuiDropdownMaxHeight]="9999">
|
||||
<tui-hosted-dropdown
|
||||
[content]="content"
|
||||
[(open)]="open"
|
||||
[tuiDropdownMaxHeight]="9999"
|
||||
>
|
||||
<button tuiIconButton appearance="">
|
||||
<img style="max-width: 62%" src="assets/img/icon.png" alt="StartOS" />
|
||||
<img [style.max-width.%]="50" src="assets/img/icon.png" alt="StartOS" />
|
||||
</button>
|
||||
<ng-template #content>
|
||||
<tui-data-list>
|
||||
<ng-template #content let-zone>
|
||||
<tui-data-list tuiDataListDropdownManager [tuiActiveZoneParent]="zone">
|
||||
<header-connection class="status">
|
||||
<h3 class="title">StartOS</h3>
|
||||
</header-connection>
|
||||
<button tuiOption class="item" (click)="about()">
|
||||
<tui-icon icon="tuiIconInfo" />
|
||||
About this server
|
||||
@for (link of utils; track $index) {
|
||||
<a
|
||||
tuiOption
|
||||
class="item"
|
||||
[routerLink]="link.routerLink"
|
||||
(click)="open = false"
|
||||
>
|
||||
<tui-icon [icon]="link.icon" />
|
||||
{{ link.name }}
|
||||
@if (link.badge(); as badge) {
|
||||
<tui-badge-notification>{{ badge }}</tui-badge-notification>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
<button
|
||||
tuiOption
|
||||
class="item"
|
||||
tuiDropdownSided
|
||||
[tuiDropdown]="dropdown"
|
||||
[tuiDropdownOffset]="12"
|
||||
[tuiDropdownManual]="false"
|
||||
>
|
||||
<tui-icon icon="tuiIconHelpCircle" />
|
||||
Resources
|
||||
<ng-template #dropdown>
|
||||
<tui-data-list [tuiActiveZoneParent]="zone">
|
||||
<button tuiOption class="item" (click)="about()">
|
||||
<tui-icon icon="tuiIconInfo" />
|
||||
About this server
|
||||
</button>
|
||||
@for (link of links; track $index) {
|
||||
<a
|
||||
tuiOption
|
||||
class="item"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
[href]="link.href"
|
||||
>
|
||||
<tui-icon [icon]="link.icon" />
|
||||
{{ link.name }}
|
||||
<tui-icon class="external" icon="tuiIconExternalLink" />
|
||||
</a>
|
||||
}
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
</button>
|
||||
<tui-opt-group>
|
||||
@for (link of links; track $index) {
|
||||
<a
|
||||
tuiOption
|
||||
class="item"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
[href]="link.href"
|
||||
>
|
||||
<tui-icon [icon]="link.icon" />
|
||||
{{ link.name }}
|
||||
<tui-icon class="external" icon="tuiIconArrowUpRight" />
|
||||
</a>
|
||||
}
|
||||
</tui-opt-group>
|
||||
<tui-opt-group>
|
||||
@for (item of system; track $index) {
|
||||
<button tuiOption class="item" (click)="prompt(item.action)">
|
||||
<tui-icon [icon]="item.icon" />
|
||||
{{ item.action }}
|
||||
</button>
|
||||
}
|
||||
</tui-opt-group>
|
||||
<tui-opt-group>
|
||||
<button tuiOption class="item" (click)="logout()">
|
||||
<tui-icon icon="tuiIconLogOut" />
|
||||
Logout
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
</tui-hosted-dropdown>
|
||||
@@ -70,9 +91,22 @@ import { HeaderConnectionComponent } from './connection.component'
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
tui-hosted-dropdown {
|
||||
margin: 0 -0.5rem;
|
||||
|
||||
[tuiIconButton] {
|
||||
height: calc(var(--tui-height-m) + 0.375rem);
|
||||
width: calc(var(--tui-height-m) + 0.625rem);
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
|
||||
::ng-deep tui-svg {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
@@ -80,7 +114,7 @@ import { HeaderConnectionComponent } from './connection.component'
|
||||
font-size: 0;
|
||||
padding: 0 0.5rem;
|
||||
height: 2rem;
|
||||
width: 14rem;
|
||||
width: 13rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -104,95 +138,22 @@ import { HeaderConnectionComponent } from './connection.component'
|
||||
TuiButtonModule,
|
||||
TuiIconModule,
|
||||
HeaderConnectionComponent,
|
||||
RouterLink,
|
||||
TuiBadgeNotificationModule,
|
||||
TuiDropdownModule,
|
||||
TuiDataListDropdownManagerModule,
|
||||
TuiActiveZoneModule,
|
||||
],
|
||||
})
|
||||
export class HeaderMenuComponent {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly auth = inject(AuthService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
readonly links = [
|
||||
{
|
||||
name: 'User Manual',
|
||||
icon: 'tuiIconBookOpen',
|
||||
href: 'https://docs.start9.com/0.3.5.x/user-manual',
|
||||
},
|
||||
{
|
||||
name: 'Contact Support',
|
||||
icon: 'tuiIconHeadphones',
|
||||
href: 'https://start9.com/contact',
|
||||
},
|
||||
{
|
||||
name: 'Donate to Start9',
|
||||
icon: 'tuiIconDollarSign',
|
||||
href: 'https://donate.start9.com',
|
||||
},
|
||||
]
|
||||
open = false
|
||||
|
||||
readonly system = [
|
||||
{
|
||||
icon: 'tuiIconRefreshCw',
|
||||
action: 'Restart',
|
||||
},
|
||||
{
|
||||
icon: 'tuiIconPower',
|
||||
action: 'Shutdown',
|
||||
},
|
||||
] as const
|
||||
readonly utils = getMenu()
|
||||
readonly links = RESOURCES
|
||||
|
||||
about() {
|
||||
this.dialogs.open(ABOUT, { label: 'About this server' }).subscribe()
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.api.logout({}).catch(e => console.error('Failed to log out', e))
|
||||
this.auth.setUnverified()
|
||||
}
|
||||
|
||||
async prompt(action: 'Restart' | 'Shutdown') {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, getOptions(action))
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const loader = this.loader.open(`Beginning ${action}...`).subscribe()
|
||||
|
||||
try {
|
||||
await this.api[
|
||||
action === 'Restart' ? 'restartServer' : 'shutdownServer'
|
||||
]({})
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getOptions(
|
||||
operation: 'Restart' | 'Shutdown',
|
||||
): Partial<TuiDialogOptions<TuiPromptData>> {
|
||||
return operation === 'Restart'
|
||||
? {
|
||||
label: 'Restart',
|
||||
size: 's',
|
||||
data: {
|
||||
content:
|
||||
'Are you sure you want to restart your server? It can take several minutes to come back online.',
|
||||
yes: 'Restart',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}
|
||||
: {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content:
|
||||
'Are you sure you want to power down your server? This can take several minutes, and your server will not come back online automatically. To power on again, You will need to physically unplug your server and plug it back in',
|
||||
yes: 'Shutdown',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule, TuiTitleModule } from '@taiga-ui/experimental'
|
||||
import { TuiLineClampModule } from '@taiga-ui/kit'
|
||||
import { ServerNotification } from 'src/app/services/api/api.types'
|
||||
import { NotificationService } from 'src/app/services/notification.service'
|
||||
|
||||
@Component({
|
||||
selector: 'header-notification',
|
||||
template: `
|
||||
<tui-svg
|
||||
style="align-self: flex-start; margin: 0.25rem 0;"
|
||||
[style.color]="color"
|
||||
[src]="icon"
|
||||
></tui-svg>
|
||||
<div tuiTitle>
|
||||
<div tuiSubtitle><ng-content></ng-content></div>
|
||||
<div [style.color]="color">
|
||||
{{ notification.title }}
|
||||
</div>
|
||||
<tui-line-clamp
|
||||
tuiSubtitle
|
||||
style="pointer-events: none"
|
||||
[linesLimit]="4"
|
||||
[lineHeight]="16"
|
||||
[content]="notification.message"
|
||||
(overflownChange)="overflow = $event"
|
||||
/>
|
||||
<div style="display: flex; gap: 0.5rem; padding-top: 0.5rem;">
|
||||
<button
|
||||
*ngIf="notification.code === 1"
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
size="xs"
|
||||
(click)="service.viewReport(notification)"
|
||||
>
|
||||
View Report
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="overflow"
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
size="xs"
|
||||
(click)="service.viewFull(notification)"
|
||||
>
|
||||
View full
|
||||
</button>
|
||||
<ng-content select="a"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
<ng-content select="button"></ng-content>
|
||||
`,
|
||||
styles: [':host { box-shadow: 0 1px var(--tui-clear); }'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiSvgModule,
|
||||
TuiTitleModule,
|
||||
TuiButtonModule,
|
||||
TuiLineClampModule,
|
||||
],
|
||||
})
|
||||
export class HeaderNotificationComponent<T extends number> {
|
||||
readonly service = inject(NotificationService)
|
||||
|
||||
@Input({ required: true }) notification!: ServerNotification<T>
|
||||
|
||||
overflow = false
|
||||
|
||||
get color(): string {
|
||||
return this.service.getColor(this.notification)
|
||||
}
|
||||
|
||||
get icon(): string {
|
||||
return this.service.getIcon(this.notification)
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Output,
|
||||
inject,
|
||||
EventEmitter,
|
||||
} from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { TuiForModule } from '@taiga-ui/cdk'
|
||||
import { TuiScrollbarModule } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiAvatarStackModule,
|
||||
TuiButtonModule,
|
||||
TuiCellModule,
|
||||
TuiTitleModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { Subject, first, tap } from 'rxjs'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { HeaderNotificationComponent } from './notification.component'
|
||||
import { toRouterLink } from 'src/app/utils/to-router-link'
|
||||
import {
|
||||
ServerNotification,
|
||||
ServerNotifications,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import { NotificationService } from 'src/app/services/notification.service'
|
||||
import { ToManifestPipe } from '../../pipes/to-manifest'
|
||||
|
||||
@Component({
|
||||
selector: 'header-notifications',
|
||||
template: `
|
||||
<ng-container *ngIf="notifications$ | async as notifications">
|
||||
<h3 class="g-title" style="padding: 0 1rem">
|
||||
Notifications
|
||||
<a
|
||||
*ngIf="notifications.length"
|
||||
style="margin-left: auto; text-transform: none; font-size: 0.9rem; font-weight: 600;"
|
||||
(click)="markAllSeen(notifications[0].id)"
|
||||
>
|
||||
Mark All Seen
|
||||
</a>
|
||||
</h3>
|
||||
<tui-scrollbar *ngIf="packageData$ | async as packageData">
|
||||
<header-notification
|
||||
*ngFor="let not of notifications; let i = index"
|
||||
tuiCell
|
||||
[notification]="not"
|
||||
>
|
||||
<ng-container *ngIf="not.packageId as pkgId">
|
||||
{{
|
||||
packageData[pkgId]
|
||||
? (packageData[pkgId] | toManifest).title
|
||||
: pkgId
|
||||
}}
|
||||
</ng-container>
|
||||
<button
|
||||
style="align-self: flex-start; flex-shrink: 0;"
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
iconLeft="tuiIconMinusCircle"
|
||||
(click)="markSeen(notifications, not)"
|
||||
></button>
|
||||
<a
|
||||
*ngIf="not.packageId && packageData[not.packageId]"
|
||||
tuiButton
|
||||
size="xs"
|
||||
appearance="secondary"
|
||||
[routerLink]="getLink(not.packageId || '')"
|
||||
>
|
||||
View Service
|
||||
</a>
|
||||
</header-notification>
|
||||
</tui-scrollbar>
|
||||
<a
|
||||
style="margin: 2rem; text-align: center; font-size: 0.9rem; font-weight: 600;"
|
||||
[routerLink]="'/portal/system/notifications'"
|
||||
>
|
||||
View All
|
||||
</a>
|
||||
</ng-container>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 22rem;
|
||||
max-width: 80vw;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
TuiForModule,
|
||||
TuiScrollbarModule,
|
||||
TuiButtonModule,
|
||||
HeaderNotificationComponent,
|
||||
TuiCellModule,
|
||||
TuiAvatarStackModule,
|
||||
TuiTitleModule,
|
||||
ToManifestPipe,
|
||||
],
|
||||
})
|
||||
export class HeaderNotificationsComponent {
|
||||
private readonly patch = inject(PatchDB<DataModel>)
|
||||
private readonly service = inject(NotificationService)
|
||||
|
||||
readonly packageData$ = this.patch.watch$('packageData').pipe(first())
|
||||
|
||||
readonly notifications$ = new Subject<ServerNotifications>()
|
||||
|
||||
@Output() onEmpty = new EventEmitter()
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.patch
|
||||
.watch$('serverInfo', 'unreadNotifications', 'recent')
|
||||
.pipe(
|
||||
tap(recent => this.notifications$.next(recent)),
|
||||
first(),
|
||||
)
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
markSeen(
|
||||
current: ServerNotifications,
|
||||
notification: ServerNotification<number>,
|
||||
) {
|
||||
this.notifications$.next(current.filter(c => c.id !== notification.id))
|
||||
|
||||
if (current.length === 1) this.onEmpty.emit()
|
||||
|
||||
this.service.markSeen([notification])
|
||||
}
|
||||
|
||||
markAllSeen(latestId: number) {
|
||||
this.notifications$.next([])
|
||||
|
||||
this.service.markSeenAll(latestId)
|
||||
|
||||
this.onEmpty.emit()
|
||||
}
|
||||
|
||||
getLink(id: string) {
|
||||
return toRouterLink(id)
|
||||
}
|
||||
}
|
||||
@@ -1,84 +1,170 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
TemplateRef,
|
||||
viewChildren,
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router'
|
||||
import { TuiTabBarModule } from '@taiga-ui/addon-mobile'
|
||||
import { combineLatest, map, startWith } from 'rxjs'
|
||||
import { SYSTEM_UTILITIES } from 'src/app/utils/system-utilities'
|
||||
import { TuiSheetDialogService, TuiTabBarModule } from '@taiga-ui/addon-mobile'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiBadgeNotificationModule,
|
||||
TuiIconModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { ABOUT } from 'src/app/routes/portal/components/header/about.component'
|
||||
import { BadgeService } from 'src/app/services/badge.service'
|
||||
import { NotificationService } from 'src/app/services/notification.service'
|
||||
import { RESOURCES } from 'src/app/utils/resources'
|
||||
import { getMenu } from 'src/app/utils/system-utilities'
|
||||
|
||||
const FILTER = ['/portal/system/settings', '/portal/system/marketplace']
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-tabs',
|
||||
template: `
|
||||
<nav tuiTabBar>
|
||||
<nav tuiTabBar [(activeItemIndex)]="index">
|
||||
<a
|
||||
tuiTabBarItem
|
||||
icon="tuiIconGrid"
|
||||
routerLink="/portal/dashboard"
|
||||
routerLinkActive
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
(isActiveChange)="update()"
|
||||
>
|
||||
Services
|
||||
</a>
|
||||
<a
|
||||
tuiTabBarItem
|
||||
icon="tuiIconActivity"
|
||||
routerLink="/portal/system/metrics"
|
||||
icon="tuiIconShoppingCart"
|
||||
routerLink="/portal/system/marketplace"
|
||||
routerLinkActive
|
||||
(isActiveChange)="update()"
|
||||
>
|
||||
Metrics
|
||||
Marketplace
|
||||
</a>
|
||||
<a
|
||||
tuiTabBarItem
|
||||
icon="tuiIconSettings"
|
||||
routerLink="/portal/dashboard"
|
||||
routerLink="/portal/system/settings"
|
||||
routerLinkActive
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
[queryParams]="{ tab: 'utilities' }"
|
||||
[badge]="(utils$ | async) || 0"
|
||||
[badge]="badge()"
|
||||
(isActiveChange)="update()"
|
||||
>
|
||||
Utilities
|
||||
Settings
|
||||
</a>
|
||||
<a
|
||||
<button
|
||||
tuiTabBarItem
|
||||
routerLinkActive
|
||||
routerLink="/portal/system/notifications"
|
||||
icon="tuiIconBell"
|
||||
[badge]="(notification$ | async) || 0"
|
||||
icon="tuiIconMoreHorizontal"
|
||||
(click)="more(content)"
|
||||
[badge]="all()"
|
||||
>
|
||||
Notifications
|
||||
</a>
|
||||
More
|
||||
<ng-template #content let-observer>
|
||||
@for (item of menu; track $index) {
|
||||
<a
|
||||
class="item"
|
||||
routerLinkActive="item_active"
|
||||
[routerLink]="item.routerLink"
|
||||
(click)="observer.complete()"
|
||||
>
|
||||
<tui-icon [icon]="item.icon" />
|
||||
{{ item.name }}
|
||||
@if (item.badge(); as badge) {
|
||||
<tui-badge-notification>{{ badge }}</tui-badge-notification>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
<button class="item" (click)="about()">
|
||||
<tui-icon icon="tuiIconInfo" />
|
||||
About this server
|
||||
</button>
|
||||
@for (link of resources; track $index) {
|
||||
<a class="item" target="_blank" rel="noreferrer" [href]="link.href">
|
||||
<tui-icon [icon]="link.icon" />
|
||||
{{ link.name }}
|
||||
<tui-icon
|
||||
icon="tuiIconExternalLink"
|
||||
[style.margin-inline-start]="'auto'"
|
||||
/>
|
||||
</a>
|
||||
}
|
||||
</ng-template>
|
||||
</button>
|
||||
</nav>
|
||||
`,
|
||||
styles: `
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
display: none;
|
||||
backdrop-filter: blur(1rem);
|
||||
// TODO: Theme
|
||||
--tui-elevation-01: #333;
|
||||
--tui-base-01: #fff;
|
||||
--tui-base-04: var(--tui-clear);
|
||||
--tui-error-fill: #f52222;
|
||||
backdrop-filter: blur(1rem);
|
||||
}
|
||||
|
||||
[tuiTabBar]::before {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.item {
|
||||
@include clearbtn();
|
||||
|
||||
display: flex;
|
||||
padding: 0.75rem 0.25rem;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
|
||||
&_active {
|
||||
color: var(--tui-link);
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
imports: [AsyncPipe, RouterLink, RouterLinkActive, TuiTabBarModule],
|
||||
imports: [
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
TuiTabBarModule,
|
||||
TuiBadgeNotificationModule,
|
||||
TuiIconModule,
|
||||
],
|
||||
})
|
||||
export class TabsComponent {
|
||||
private readonly badge = inject(BadgeService)
|
||||
private readonly sheets = inject(TuiSheetDialogService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly links = viewChildren(RouterLinkActive)
|
||||
|
||||
readonly utils$ = combineLatest(
|
||||
Object.keys(SYSTEM_UTILITIES)
|
||||
.filter(key => key !== '/portal/system/notifications')
|
||||
.map(key => this.badge.getCount(key).pipe(startWith(0))),
|
||||
).pipe(map(values => values.reduce((acc, value) => acc + value, 0)))
|
||||
readonly notification$ = inject(NotificationService).unreadCount$
|
||||
index = 3
|
||||
|
||||
readonly resources = RESOURCES
|
||||
readonly menu = getMenu().filter(item => !FILTER.includes(item.routerLink))
|
||||
readonly badge = toSignal(
|
||||
inject(BadgeService).getCount('/portal/system/settings'),
|
||||
{ initialValue: 0 },
|
||||
)
|
||||
|
||||
readonly all = computed(() =>
|
||||
this.menu.reduce((acc, item) => acc + item.badge(), 0),
|
||||
)
|
||||
|
||||
about() {
|
||||
this.dialogs.open(ABOUT, { label: 'About this server' }).subscribe()
|
||||
}
|
||||
|
||||
more(content: TemplateRef<any>) {
|
||||
this.sheets.open(content, { label: 'Start OS' }).subscribe({
|
||||
complete: () => this.update(),
|
||||
})
|
||||
}
|
||||
|
||||
update() {
|
||||
const index = this.links().findIndex(link => link.isActive)
|
||||
this.index = index === -1 ? 3 : index
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
||||
import {
|
||||
ActivatedRoute,
|
||||
NavigationEnd,
|
||||
Router,
|
||||
RouterOutlet,
|
||||
} from '@angular/router'
|
||||
import { NavigationEnd, Router, RouterOutlet } from '@angular/router'
|
||||
import { TuiScrollbarModule } from '@taiga-ui/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter, map } from 'rxjs'
|
||||
import { filter } from 'rxjs'
|
||||
import { TabsComponent } from 'src/app/routes/portal/components/tabs.component'
|
||||
import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
@@ -19,7 +14,7 @@ import { HeaderComponent } from './components/header/header.component'
|
||||
standalone: true,
|
||||
template: `
|
||||
<header appHeader>{{ name$ | async }}</header>
|
||||
<main [attr.data-dashboard]="tab$ | async">
|
||||
<main>
|
||||
<tui-scrollbar [style.max-height.%]="100">
|
||||
<router-outlet />
|
||||
</tui-scrollbar>
|
||||
@@ -64,7 +59,4 @@ export class PortalComponent {
|
||||
})
|
||||
|
||||
readonly name$ = inject(PatchDB<DataModel>).watch$('ui', 'name')
|
||||
readonly tab$ = inject(ActivatedRoute).queryParams.pipe(
|
||||
map(params => params['tab']),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,69 +1,68 @@
|
||||
import { AsyncPipe, DatePipe } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { map } from 'rxjs'
|
||||
import { MetricsService } from 'src/app/services/metrics.service'
|
||||
import { TimeService } from 'src/app/services/time.service'
|
||||
import { MetricsComponent } from './metrics.component'
|
||||
import { ServicesComponent } from './services.component'
|
||||
import { UtilitiesComponent } from './utilities.component'
|
||||
import { ToManifestPipe } from 'src/app/routes/portal/pipes/to-manifest'
|
||||
import { ServiceComponent } from 'src/app/routes/portal/routes/dashboard/service.component'
|
||||
import { ServicesService } from 'src/app/routes/portal/routes/dashboard/services.service'
|
||||
import { DepErrorService } from 'src/app/services/dep-error.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<time>{{ date() | date: 'medium' }}</time>
|
||||
<app-metrics [metrics]="metrics$ | async">
|
||||
<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>
|
||||
<h2>
|
||||
<tui-icon icon="tuiIconGrid" />
|
||||
Services
|
||||
</h2>
|
||||
<div class="g-plaque"></div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th [style.width.rem]="3"></th>
|
||||
<th>Name</th>
|
||||
<th>Version</th>
|
||||
<th [style.width.rem]="13">Status</th>
|
||||
<th [style.width.rem]="8" [style.text-indent.rem]="1">Controls</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (pkg of services(); track $index) {
|
||||
<tr
|
||||
appService
|
||||
[pkg]="pkg"
|
||||
[depErrors]="errors()?.[(pkg | toManifest).id]"
|
||||
></tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
{{ services() ? 'No services installed' : 'Loading...' }}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
height: calc(100vh - 6rem);
|
||||
position: relative;
|
||||
max-width: 64rem;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin: 2rem auto 0;
|
||||
border: 0.375rem solid transparent;
|
||||
}
|
||||
|
||||
app-metrics,
|
||||
app-utilities,
|
||||
app-services {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
clip-path: var(--clip-path);
|
||||
backdrop-filter: blur(1rem);
|
||||
font-size: 1rem;
|
||||
}
|
||||
overflow: hidden;
|
||||
|
||||
time {
|
||||
position: absolute;
|
||||
left: 22%;
|
||||
font-weight: bold;
|
||||
line-height: 1.75rem;
|
||||
text-shadow: 0 0 0.25rem #000;
|
||||
--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)
|
||||
);
|
||||
}
|
||||
|
||||
h2 {
|
||||
@@ -81,59 +80,49 @@ import { UtilitiesComponent } from './utilities.component'
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
height: calc(100vh - 7.375rem);
|
||||
display: block;
|
||||
margin: 0;
|
||||
border-top: 0;
|
||||
border-bottom: 0;
|
||||
margin: 0 0.375rem;
|
||||
--clip-path: none !important;
|
||||
|
||||
app-metrics,
|
||||
app-utilities,
|
||||
app-services {
|
||||
display: none;
|
||||
table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
time,
|
||||
thead,
|
||||
h2 {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile [data-dashboard='metrics']) {
|
||||
app-metrics {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile [data-dashboard='utilities']) {
|
||||
app-utilities {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile main:not([data-dashboard])) {
|
||||
app-services {
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
ServicesComponent,
|
||||
MetricsComponent,
|
||||
UtilitiesComponent,
|
||||
TuiIconModule,
|
||||
DatePipe,
|
||||
RouterLink,
|
||||
AsyncPipe,
|
||||
],
|
||||
imports: [TuiIconModule, ServiceComponent, ToManifestPipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DashboardComponent {
|
||||
readonly metrics$ = inject(MetricsService)
|
||||
readonly date = toSignal(
|
||||
inject(TimeService).now$.pipe(map(({ now }) => new Date(now))),
|
||||
)
|
||||
readonly services = toSignal(inject(ServicesService))
|
||||
readonly errors = toSignal(inject(DepErrorService).depErrors$)
|
||||
}
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { TuiProgressModule } from '@taiga-ui/kit'
|
||||
import { CpuComponent } from 'src/app/routes/portal/routes/dashboard/cpu.component'
|
||||
import { MetricComponent } from 'src/app/routes/portal/routes/dashboard/metric.component'
|
||||
import { TemperatureComponent } from 'src/app/routes/portal/routes/dashboard/temperature.component'
|
||||
import { Metrics } from 'src/app/services/api/api.types'
|
||||
import { TimeService } from 'src/app/services/time.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-metrics',
|
||||
template: `
|
||||
<ng-content />
|
||||
<section>
|
||||
<app-metric class="wide" label="Storage" [style.max-height.%]="85">
|
||||
<progress
|
||||
tuiProgressBar
|
||||
[max]="100"
|
||||
[attr.value]="metrics?.disk?.percentageUsed?.value"
|
||||
></progress>
|
||||
<footer>
|
||||
<div>
|
||||
<span [attr.data-unit]="metrics?.disk?.used?.unit">
|
||||
{{ getValue(metrics?.disk?.used?.value) }}
|
||||
</span>
|
||||
Used
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<span [attr.data-unit]="metrics?.disk?.available?.unit">
|
||||
{{ getValue(metrics?.disk?.available?.value) }}
|
||||
</span>
|
||||
Available
|
||||
</div>
|
||||
</footer>
|
||||
</app-metric>
|
||||
<app-metric label="CPU">
|
||||
<app-cpu [value]="cpu" />
|
||||
</app-metric>
|
||||
<app-metric label="Memory">
|
||||
<label tuiProgressLabel>
|
||||
<tui-progress-circle size="l" [max]="100" [value]="memory" />
|
||||
{{ metrics?.memory?.percentageUsed?.value || ' - ' }}%
|
||||
</label>
|
||||
<footer>
|
||||
<div>
|
||||
<span [attr.data-unit]="metrics?.memory?.used?.unit">
|
||||
{{ getValue(metrics?.memory?.used?.value) }}
|
||||
</span>
|
||||
Used
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<span [attr.data-unit]="metrics?.memory?.available?.unit">
|
||||
{{ getValue(metrics?.memory?.available?.value) }}
|
||||
</span>
|
||||
Available
|
||||
</div>
|
||||
</footer>
|
||||
</app-metric>
|
||||
<aside>
|
||||
<app-metric label="Uptime" [style.flex]="'unset'">
|
||||
<label>
|
||||
{{ uptime() }}
|
||||
<div>Days : Hrs : Mins : Secs</div>
|
||||
</label>
|
||||
</app-metric>
|
||||
<app-metric label="Temperature">
|
||||
<app-temperature [value]="temperature" />
|
||||
</app-metric>
|
||||
</aside>
|
||||
</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;
|
||||
padding: 1rem 1.5rem 0.5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
aside {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: -1.5rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
background: var(--tui-clear);
|
||||
}
|
||||
|
||||
label {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
padding: 0.375rem 0;
|
||||
}
|
||||
|
||||
progress {
|
||||
height: 1.5rem;
|
||||
width: 80%;
|
||||
margin: auto;
|
||||
border-radius: 0;
|
||||
clip-path: none;
|
||||
mask: linear-gradient(to right, #000 80%, transparent 80%);
|
||||
mask-size: 5% 100%;
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
margin: 0;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
color: var(--tui-text-02);
|
||||
font-size: 0.5rem;
|
||||
line-height: 1rem;
|
||||
|
||||
span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: var(--tui-text-01);
|
||||
padding-top: 0.4rem;
|
||||
|
||||
&:after {
|
||||
content: attr(data-unit);
|
||||
font-size: 0.5rem;
|
||||
font-weight: normal;
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
--clip-path: none !important;
|
||||
min-height: 100%;
|
||||
|
||||
section {
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
margin: 1rem -1rem;
|
||||
}
|
||||
|
||||
aside {
|
||||
order: -1;
|
||||
flex-direction: row;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
app-metric {
|
||||
min-width: calc(50% - 0.5rem);
|
||||
|
||||
&.wide {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
TuiProgressModule,
|
||||
MetricComponent,
|
||||
TemperatureComponent,
|
||||
CpuComponent,
|
||||
AsyncPipe,
|
||||
],
|
||||
})
|
||||
export class MetricsComponent {
|
||||
@Input({ required: true })
|
||||
metrics: Metrics | null = null
|
||||
|
||||
readonly uptime = toSignal(inject(TimeService).uptime$)
|
||||
|
||||
get cpu(): number {
|
||||
return Number(this.metrics?.cpu.percentageUsed.value || 0) / 100
|
||||
}
|
||||
|
||||
get temperature(): number {
|
||||
return Number(this.metrics?.general.temperature?.value || 0)
|
||||
}
|
||||
|
||||
get memory(): number {
|
||||
return Number(this.metrics?.memory?.percentageUsed?.value) || 0
|
||||
}
|
||||
|
||||
getValue(value?: string | null): number | string | undefined {
|
||||
return value == null ? '-' : Number.parseInt(value)
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { TuiScrollbarModule } from '@taiga-ui/core'
|
||||
import { ToManifestPipe } from 'src/app/routes/portal/pipes/to-manifest'
|
||||
import { ServiceComponent } from 'src/app/routes/portal/routes/dashboard/service.component'
|
||||
import { ServicesService } from 'src/app/routes/portal/routes/dashboard/services.service'
|
||||
import { DepErrorService } from 'src/app/services/dep-error.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-services',
|
||||
template: `
|
||||
<ng-content />
|
||||
<tui-scrollbar [style.max-height.%]="100">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th [style.width.rem]="3"></th>
|
||||
<th>Name</th>
|
||||
<th>Version</th>
|
||||
<th [style.width.rem]="13">Status</th>
|
||||
<th [style.width.rem]="8" [style.text-indent.rem]="1">Controls</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (errors$ | async; as errors) {
|
||||
@for (pkg of services$ | async; track $index) {
|
||||
<tr
|
||||
appService
|
||||
[pkg]="pkg"
|
||||
[depErrors]="errors[(pkg | toManifest).id]"
|
||||
></tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5">No services installed</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</tui-scrollbar>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
grid-column: 1/4;
|
||||
margin-top: -2rem;
|
||||
overflow: hidden;
|
||||
|
||||
--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;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
--clip-path: none !important;
|
||||
height: 100%;
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [ServiceComponent, AsyncPipe, ToManifestPipe, TuiScrollbarModule],
|
||||
})
|
||||
export class ServicesComponent {
|
||||
readonly services$ = inject(ServicesService)
|
||||
readonly errors$ = inject(DepErrorService).depErrors$
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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/utils/system-utilities'
|
||||
import { BadgeService } from 'src/app/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 {
|
||||
height: fit-content;
|
||||
--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 {
|
||||
width: 100%;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
--clip-path: none !important;
|
||||
height: 100%;
|
||||
|
||||
.links {
|
||||
grid-template: 1fr 1fr/1fr 1fr;
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 1rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiIconModule, RouterLink, TuiBadgeNotificationModule, AsyncPipe],
|
||||
})
|
||||
export class UtilitiesComponent {
|
||||
private readonly badge = inject(BadgeService)
|
||||
readonly items = Object.keys(SYSTEM_UTILITIES)
|
||||
.filter(key => !SKIPPED.includes(key))
|
||||
.map(key => ({
|
||||
...SYSTEM_UTILITIES[key],
|
||||
routerLink: key,
|
||||
notification$: this.badge.getCount(key),
|
||||
}))
|
||||
}
|
||||
|
||||
const SKIPPED = ['/portal/system/notifications', '/portal/system/metrics']
|
||||
@@ -11,7 +11,7 @@ interface ActionItem {
|
||||
@Component({
|
||||
selector: '[action]',
|
||||
template: `
|
||||
<tui-icon [icon]="action.icon"></tui-icon>
|
||||
<tui-icon [icon]="action.icon" />
|
||||
<div>
|
||||
<strong>{{ action.name }}</strong>
|
||||
<div>{{ action.description }}</div>
|
||||
|
||||
@@ -63,7 +63,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
:host {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
HostBinding,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { TuiLoaderModule } from '@taiga-ui/core'
|
||||
import { TuiIconModule } from '@taiga-ui/experimental'
|
||||
import { StatusRendering } from 'src/app/services/pkg-status-rendering.service'
|
||||
import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
|
||||
import { InstallingInfo } from 'src/app/services/patch-db/data-model'
|
||||
@@ -15,18 +17,20 @@ import { UnitConversionPipesModule } from '@start9labs/shared'
|
||||
template: `
|
||||
@if (installingInfo) {
|
||||
<strong>
|
||||
<tui-loader size="s" [inheritColor]="true" />
|
||||
Installing
|
||||
<span class="loading-dots"></span>
|
||||
{{ installingInfo.progress.overall | installingProgressString }}
|
||||
</strong>
|
||||
} @else {
|
||||
<tui-icon [icon]="icon" [style.margin-bottom.rem]="0.25" />
|
||||
{{ connected ? rendering.display : 'Unknown' }}
|
||||
|
||||
<span *ngIf="sigtermTimeout && (sigtermTimeout | durationToSeconds) > 30">
|
||||
. This may take a while
|
||||
</span>
|
||||
|
||||
<span *ngIf="rendering.showDots" class="loading-dots"></span>
|
||||
@if (rendering.showDots) {
|
||||
<span class="loading-dots"></span>
|
||||
}
|
||||
@if (sigtermTimeout && (sigtermTimeout | durationToSeconds) > 30) {
|
||||
<div>This may take a while</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
@@ -36,7 +40,20 @@ import { UnitConversionPipesModule } from '@start9labs/shared'
|
||||
font-size: x-large;
|
||||
white-space: nowrap;
|
||||
margin: auto 0;
|
||||
height: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
|
||||
tui-loader {
|
||||
display: inline-flex;
|
||||
vertical-align: bottom;
|
||||
margin: 0 0.25rem -0.125rem 0;
|
||||
}
|
||||
|
||||
div {
|
||||
font-size: 1rem;
|
||||
color: var(--tui-text-02);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
`,
|
||||
],
|
||||
@@ -46,6 +63,8 @@ import { UnitConversionPipesModule } from '@start9labs/shared'
|
||||
CommonModule,
|
||||
InstallingProgressDisplayPipe,
|
||||
UnitConversionPipesModule,
|
||||
TuiIconModule,
|
||||
TuiLoaderModule,
|
||||
],
|
||||
})
|
||||
export class ServiceStatusComponent {
|
||||
@@ -60,21 +79,38 @@ export class ServiceStatusComponent {
|
||||
|
||||
@Input() sigtermTimeout?: string | null = null
|
||||
|
||||
@HostBinding('style.color')
|
||||
get color(): string {
|
||||
if (!this.connected) return 'var(--tui-text-02)'
|
||||
@HostBinding('class')
|
||||
get class(): string | null {
|
||||
if (!this.connected) return null
|
||||
|
||||
switch (this.rendering.color) {
|
||||
case 'danger':
|
||||
return 'var(--tui-error-fill)'
|
||||
return 'g-error'
|
||||
case 'warning':
|
||||
return 'var(--tui-warning-fill)'
|
||||
return 'g-warning'
|
||||
case 'success':
|
||||
return 'var(--tui-success-fill)'
|
||||
return 'g-success'
|
||||
case 'primary':
|
||||
return 'var(--tui-info-fill)'
|
||||
return 'g-info'
|
||||
default:
|
||||
return 'var(--tui-text-02)'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
get icon(): string {
|
||||
if (!this.connected) return 'tuiIconCircle'
|
||||
|
||||
switch (this.rendering.color) {
|
||||
case 'danger':
|
||||
return 'tuiIconXCircle'
|
||||
case 'warning':
|
||||
return 'tuiIconAlertCircle'
|
||||
case 'success':
|
||||
return 'tuiIconCheckCircle'
|
||||
case 'primary':
|
||||
return 'tuiIconMinusCircle'
|
||||
default:
|
||||
return 'tuiIconCircle'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ import { DependencyInfo } from '../types/dependency-info'
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--tui-clear);
|
||||
box-shadow: inset 0 7rem 0 -4rem var(--tui-clear);
|
||||
clip-path: polygon(0 1.5rem, 1.5rem 0, 100% 0, 100% 100%, 0 100%);
|
||||
|
||||
@@ -1,17 +1,193 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { MetricsComponent } from 'src/app/routes/portal/routes/dashboard/metrics.component'
|
||||
import { MetricsService } from 'src/app/services/metrics.service'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { TuiProgressModule } from '@taiga-ui/kit'
|
||||
import { CpuComponent } from 'src/app/routes/portal/routes/system/metrics/cpu.component'
|
||||
import { TemperatureComponent } from 'src/app/routes/portal/routes/system/metrics/temperature.component'
|
||||
import { MetricComponent } from 'src/app/routes/portal/routes/system/metrics/metric.component'
|
||||
import { MetricsService } from 'src/app/routes/portal/routes/system/metrics/metrics.service'
|
||||
import { TimeService } from 'src/app/services/time.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-metrics',
|
||||
template: `
|
||||
<app-metrics [metrics]="metrics$ | async" />
|
||||
<section>
|
||||
<app-metric class="wide" label="Storage" [style.max-height.%]="85">
|
||||
<progress
|
||||
tuiProgressBar
|
||||
[max]="100"
|
||||
[attr.value]="metrics()?.disk?.percentageUsed?.value"
|
||||
></progress>
|
||||
<footer>
|
||||
<div>
|
||||
<span [attr.data-unit]="metrics()?.disk?.used?.unit">
|
||||
{{ getValue(metrics()?.disk?.used?.value) }}
|
||||
</span>
|
||||
Used
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<span [attr.data-unit]="metrics()?.disk?.available?.unit">
|
||||
{{ getValue(metrics()?.disk?.available?.value) }}
|
||||
</span>
|
||||
Available
|
||||
</div>
|
||||
</footer>
|
||||
</app-metric>
|
||||
<app-metric label="CPU">
|
||||
<app-cpu [value]="cpu" />
|
||||
</app-metric>
|
||||
<app-metric label="Memory">
|
||||
<label tuiProgressLabel>
|
||||
<tui-progress-circle size="l" [max]="100" [value]="memory" />
|
||||
{{ metrics()?.memory?.percentageUsed?.value || ' - ' }}%
|
||||
</label>
|
||||
<footer>
|
||||
<div>
|
||||
<span [attr.data-unit]="metrics()?.memory?.used?.unit">
|
||||
{{ getValue(metrics()?.memory?.used?.value) }}
|
||||
</span>
|
||||
Used
|
||||
</div>
|
||||
<hr />
|
||||
<div>
|
||||
<span [attr.data-unit]="metrics()?.memory?.available?.unit">
|
||||
{{ getValue(metrics()?.memory?.available?.value) }}
|
||||
</span>
|
||||
Available
|
||||
</div>
|
||||
</footer>
|
||||
</app-metric>
|
||||
<aside>
|
||||
<app-metric label="Uptime" [style.flex]="'unset'">
|
||||
<label>
|
||||
{{ uptime() }}
|
||||
<div>Days : Hrs : Mins : Secs</div>
|
||||
</label>
|
||||
</app-metric>
|
||||
<app-metric label="Temperature">
|
||||
<app-temperature [value]="temperature" />
|
||||
</app-metric>
|
||||
</aside>
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
section {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1rem 0;
|
||||
}
|
||||
|
||||
aside {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
white-space: nowrap;
|
||||
background: var(--tui-clear);
|
||||
}
|
||||
|
||||
label {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
padding: 0.375rem 0;
|
||||
}
|
||||
|
||||
progress {
|
||||
height: 1.5rem;
|
||||
width: 80%;
|
||||
margin: auto;
|
||||
border-radius: 0;
|
||||
clip-path: none;
|
||||
mask: linear-gradient(to right, #000 80%, transparent 80%);
|
||||
mask-size: 5% 100%;
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 100%;
|
||||
width: 1px;
|
||||
margin: 0;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
color: var(--tui-text-02);
|
||||
font-size: 0.5rem;
|
||||
line-height: 1rem;
|
||||
|
||||
span {
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: var(--tui-text-01);
|
||||
padding-top: 0.4rem;
|
||||
|
||||
&:after {
|
||||
content: attr(data-unit);
|
||||
font-size: 0.5rem;
|
||||
font-weight: normal;
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
section {
|
||||
min-height: 100%;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -2rem -2rem;
|
||||
}
|
||||
|
||||
aside {
|
||||
order: -1;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
app-metric {
|
||||
min-width: calc(50% - 0.5rem);
|
||||
|
||||
&.wide {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
host: { class: 'g-page' },
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [MetricsComponent, AsyncPipe],
|
||||
imports: [
|
||||
TuiProgressModule,
|
||||
MetricComponent,
|
||||
TemperatureComponent,
|
||||
CpuComponent,
|
||||
AsyncPipe,
|
||||
],
|
||||
})
|
||||
export default class SystemMetricsComponent {
|
||||
readonly metrics$ = inject(MetricsService)
|
||||
readonly metrics = toSignal(inject(MetricsService))
|
||||
readonly uptime = toSignal(inject(TimeService).uptime$)
|
||||
|
||||
get cpu(): number {
|
||||
return Number(this.metrics()?.cpu.percentageUsed.value || 0) / 100
|
||||
}
|
||||
|
||||
get temperature(): number {
|
||||
return Number(this.metrics()?.general.temperature?.value || 0)
|
||||
}
|
||||
|
||||
get memory(): number {
|
||||
return Number(this.metrics()?.memory?.percentageUsed?.value) || 0
|
||||
}
|
||||
|
||||
getValue(value?: string | null): number | string | undefined {
|
||||
return value == null ? '-' : Number.parseInt(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,16 +5,25 @@ import {
|
||||
Injectable,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TuiAlertService, TuiDialogService } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiAlertService,
|
||||
TuiDialogOptions,
|
||||
TuiDialogService,
|
||||
} from '@taiga-ui/core'
|
||||
import * as argon2 from '@start9labs/argon2'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TUI_PROMPT, TuiCheckboxLabeledModule } from '@taiga-ui/kit'
|
||||
import {
|
||||
TUI_PROMPT,
|
||||
TuiCheckboxLabeledModule,
|
||||
TuiPromptData,
|
||||
} from '@taiga-ui/kit'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter, firstValueFrom, from, take } from 'rxjs'
|
||||
import { switchMap } from 'rxjs/operators'
|
||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import { ProxyService } from 'src/app/services/proxy.service'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
import { getServerInfo } from 'src/app/utils/get-server-info'
|
||||
@@ -35,6 +44,7 @@ export class SettingsService {
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly patch = inject(PatchDB<DataModel>)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly auth = inject(AuthService)
|
||||
private readonly isTor = inject(ConfigService).isTor()
|
||||
|
||||
wipe = false
|
||||
@@ -100,6 +110,27 @@ export class SettingsService {
|
||||
icon: 'tuiIconMonitor',
|
||||
routerLink: 'ui',
|
||||
},
|
||||
{
|
||||
title: 'Restart',
|
||||
icon: 'tuiIconRefreshCw',
|
||||
description: 'Restart Start OS server',
|
||||
action: () => this.promptPower('Restart'),
|
||||
},
|
||||
{
|
||||
title: 'Shutdown',
|
||||
icon: 'tuiIconPower',
|
||||
description: 'Turn Start OS server off',
|
||||
action: () => this.promptPower('Shutdown'),
|
||||
},
|
||||
{
|
||||
title: 'Logout',
|
||||
icon: 'tuiIconLogOut',
|
||||
description: 'Log off from Start OS',
|
||||
action: () => {
|
||||
this.api.logout({}).catch(e => console.error('Failed to log out', e))
|
||||
this.auth.setUnverified()
|
||||
},
|
||||
},
|
||||
],
|
||||
'Privacy and Security': [
|
||||
{
|
||||
@@ -146,6 +177,25 @@ export class SettingsService {
|
||||
.subscribe(() => this.resetTor(this.wipe))
|
||||
}
|
||||
|
||||
private async promptPower(action: 'Restart' | 'Shutdown') {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, getOptions(action))
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const loader = this.loader.open(`Beginning ${action}...`).subscribe()
|
||||
|
||||
try {
|
||||
await this.api[
|
||||
action === 'Restart' ? 'restartServer' : 'shutdownServer'
|
||||
]({})
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async resetTor(wipeState: boolean) {
|
||||
const loader = this.loader.open('Resetting Tor...').subscribe()
|
||||
|
||||
@@ -294,3 +344,29 @@ class WipeComponent {
|
||||
readonly isTor = inject(ConfigService).isTor()
|
||||
readonly service = inject(SettingsService)
|
||||
}
|
||||
|
||||
function getOptions(
|
||||
operation: 'Restart' | 'Shutdown',
|
||||
): Partial<TuiDialogOptions<TuiPromptData>> {
|
||||
return operation === 'Restart'
|
||||
? {
|
||||
label: 'Restart',
|
||||
size: 's',
|
||||
data: {
|
||||
content:
|
||||
'Are you sure you want to restart your server? It can take several minutes to come back online.',
|
||||
yes: 'Restart',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}
|
||||
: {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content:
|
||||
'Are you sure you want to power down your server? This can take several minutes, and your server will not come back online automatically. To power on again, You will need to physically unplug your server and plug it back in',
|
||||
yes: 'Shutdown',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
switchMap,
|
||||
} from 'rxjs'
|
||||
import { EOSService } from 'src/app/services/eos.service'
|
||||
import { NotificationService } from 'src/app/services/notification.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'
|
||||
@@ -24,6 +25,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class BadgeService {
|
||||
private readonly notifications = inject(NotificationService)
|
||||
private readonly emver = inject(Emver)
|
||||
private readonly patch = inject(PatchDB<DataModel>)
|
||||
private readonly settings$ = combineLatest([
|
||||
@@ -85,6 +87,8 @@ export class BadgeService {
|
||||
return this.updates$
|
||||
case '/portal/system/settings':
|
||||
return this.settings$
|
||||
case '/portal/system/notifications':
|
||||
return this.notifications.unreadCount$
|
||||
default:
|
||||
return EMPTY
|
||||
}
|
||||
|
||||
17
web/projects/ui/src/app/utils/resources.ts
Normal file
17
web/projects/ui/src/app/utils/resources.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const RESOURCES = [
|
||||
{
|
||||
name: 'User Manual',
|
||||
icon: 'tuiIconBookOpen',
|
||||
href: 'https://docs.start9.com/0.3.5.x/user-manual',
|
||||
},
|
||||
{
|
||||
name: 'Contact Support',
|
||||
icon: 'tuiIconHeadphones',
|
||||
href: 'https://start9.com/contact',
|
||||
},
|
||||
{
|
||||
name: 'Donate to Start9',
|
||||
icon: 'tuiIconDollarSign',
|
||||
href: 'https://donate.start9.com',
|
||||
},
|
||||
]
|
||||
@@ -1,5 +1,21 @@
|
||||
import { inject } from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { BadgeService } from 'src/app/services/badge.service'
|
||||
|
||||
export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
|
||||
{
|
||||
'/portal/system/notifications': {
|
||||
icon: 'tuiIconBell',
|
||||
title: 'Notifications',
|
||||
},
|
||||
'/portal/system/marketplace': {
|
||||
icon: 'tuiIconShoppingCart',
|
||||
title: 'Marketplace',
|
||||
},
|
||||
'/portal/system/updates': {
|
||||
icon: 'tuiIconGlobe',
|
||||
title: 'Updates',
|
||||
},
|
||||
'/portal/system/backups': {
|
||||
icon: 'tuiIconSave',
|
||||
title: 'Backups',
|
||||
@@ -12,14 +28,6 @@ export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
|
||||
icon: 'tuiIconFileText',
|
||||
title: 'Logs',
|
||||
},
|
||||
'/portal/system/marketplace': {
|
||||
icon: 'tuiIconShoppingCart',
|
||||
title: 'Marketplace',
|
||||
},
|
||||
'/portal/system/updates': {
|
||||
icon: 'tuiIconGlobe',
|
||||
title: 'Updates',
|
||||
},
|
||||
'/portal/system/sideload': {
|
||||
icon: 'tuiIconUpload',
|
||||
title: 'Sideload',
|
||||
@@ -28,8 +36,15 @@ export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
|
||||
icon: 'tuiIconTool',
|
||||
title: 'Settings',
|
||||
},
|
||||
'/portal/system/notifications': {
|
||||
icon: 'tuiIconBell',
|
||||
title: 'Notifications',
|
||||
},
|
||||
}
|
||||
|
||||
export function getMenu() {
|
||||
const badge = inject(BadgeService)
|
||||
|
||||
return Object.keys(SYSTEM_UTILITIES).map(key => ({
|
||||
name: SYSTEM_UTILITIES[key].title,
|
||||
icon: SYSTEM_UTILITIES[key].icon,
|
||||
routerLink: key,
|
||||
badge: toSignal(badge.getCount(key), { initialValue: 0 }),
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user