refactor: change navigation

Signed-off-by: waterplea <alexander@inkin.ru>
This commit is contained in:
waterplea
2024-05-28 13:04:01 +01:00
parent 9510c92288
commit f0ae9e21ae
27 changed files with 737 additions and 1058 deletions

View File

@@ -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

View File

@@ -178,26 +178,36 @@ tui-hint[data-appearance='onDark'] {
tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] { tui-dropdown[data-appearance='start-os'][data-appearance='start-os'] {
border: 0; border: 0;
border-top: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(0.25rem); backdrop-filter: blur(0.25rem);
box-shadow: 0 0.25rem 0.25rem rgb(0 0 0 / 25%); 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 // 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 { tui-opt-group {
&::before { &::before {
background: var(--tui-clear); background: var(--tui-clear);
box-shadow: height: 1px;
1rem 0 var(--tui-clear),
-1rem 0 var(--tui-clear);
padding-top: 0.25rem !important;
padding-bottom: 0 !important;
margin: 0.25rem;
} }
&::after { &::after {
display: none; 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 { [tuiSidebar] > div.t-wrapper {

View File

@@ -3,6 +3,7 @@ import { NgModule } from '@angular/core'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations' import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { ServiceWorkerModule } from '@angular/service-worker' import { ServiceWorkerModule } from '@angular/service-worker'
import { LoadingModule } from '@start9labs/shared' import { LoadingModule } from '@start9labs/shared'
import { TuiSheetDialogModule } from '@taiga-ui/addon-mobile'
import { import {
TuiAlertModule, TuiAlertModule,
TuiDialogModule, TuiDialogModule,
@@ -27,6 +28,7 @@ import { RoutingModule } from './routing.module'
ToastContainerComponent, ToastContainerComponent,
TuiRootModule, TuiRootModule,
TuiDialogModule, TuiDialogModule,
TuiSheetDialogModule,
TuiAlertModule, TuiAlertModule,
TuiModeModule, TuiModeModule,
TuiThemeNightModule, TuiThemeNightModule,

View File

@@ -1,54 +1,52 @@
import { CommonModule } from '@angular/common' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { import { RouterLink } from '@angular/router'
ChangeDetectionStrategy,
Component,
ElementRef,
HostListener,
inject,
ViewChild,
} from '@angular/core'
import { Router } from '@angular/router'
import { TuiSidebarModule } from '@taiga-ui/addon-mobile' 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 { import {
TuiBadgedContentModule, TuiBadgedContentModule,
TuiBadgeNotificationModule, TuiBadgeNotificationModule,
TuiButtonModule, TuiButtonModule,
} from '@taiga-ui/experimental' } 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 { 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({ @Component({
standalone: true, standalone: true,
selector: 'header-corner', selector: 'header-corner',
template: ` template: `
<ng-content /> <ng-content />
<tui-badged-content @for (item of utils; track $index) {
*tuiLet="notificationService.unreadCount$ | async as unread" @if (item.badge(); as badge) {
[style.--tui-radius.%]="50" <tui-badged-content
> [style.--tui-radius.%]="50"
<tui-badge-notification *ngIf="unread" tuiSlot="top" size="s"> [@tuiFadeIn]="animation"
{{ unread }} [@tuiWidthCollapse]="animation"
</tui-badge-notification> [@tuiScaleIn]="animation"
<button >
tuiIconButton <tui-badge-notification tuiSlot="top" size="s">
iconLeft="tuiIconBellLarge" {{ badge }}
appearance="icon" </tui-badge-notification>
size="s" <a
[style.color]="'var(--tui-text-01)'" tuiIconButton
(click)="handleNotificationsClick(unread || 0)" appearance="icon"
> size="s"
Notifications [iconLeft]="item.icon"
</button> [routerLink]="item.routerLink"
</tui-badged-content> [style.color]="'var(--tui-text-01)'"
>
{{ item.name }}
</a>
</tui-badged-content>
}
}
<header-menu></header-menu> <header-menu></header-menu>
<header-notifications
(onEmpty)="this.open$.next(false)"
*tuiSidebar="!!(open$ | async); direction: 'right'; autoWidth: true"
/>
`, `,
styles: [ styles: [
` `
@@ -65,48 +63,20 @@ import { NotificationService } from 'src/app/services/notification.service'
} }
`, `,
], ],
animations: [tuiFadeIn, tuiWidthCollapse, tuiScaleIn],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
CommonModule,
HeaderMenuComponent, HeaderMenuComponent,
HeaderNotificationsComponent,
SidebarDirective, SidebarDirective,
TuiBadgeNotificationModule, TuiBadgeNotificationModule,
TuiBadgedContentModule, TuiBadgedContentModule,
TuiButtonModule, TuiButtonModule,
TuiLetModule, TuiLetModule,
TuiSidebarModule, TuiSidebarModule,
RouterLink,
], ],
}) })
export class HeaderCornerComponent { export class HeaderCornerComponent {
private readonly router = inject(Router) readonly animation = inject(TUI_ANIMATION_OPTIONS)
readonly notificationService = inject(NotificationService) readonly utils = getMenu()
@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')
}
}
} }

View File

@@ -64,10 +64,11 @@ import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service'
margin-left: -1.25rem; margin-left: -1.25rem;
backdrop-filter: blur(1rem); backdrop-filter: blur(1rem);
clip-path: var(--clip-path); clip-path: var(--clip-path);
}
&:active { > a:active,
backdrop-filter: blur(2rem) brightness(0.75) saturate(0.75); > button:active {
} backdrop-filter: blur(2rem) brightness(0.75) saturate(0.75);
} }
&:has([data-connection='error']) { &:has([data-connection='error']) {

View File

@@ -1,65 +1,86 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' 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 { import {
TuiDataListModule, TuiDataListModule,
TuiDialogOptions,
TuiDialogService, TuiDialogService,
TuiDropdownModule,
TuiHostedDropdownModule, TuiHostedDropdownModule,
TuiSvgModule, TuiSvgModule,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import { TuiButtonModule, TuiIconModule } from '@taiga-ui/experimental' import {
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit' TuiBadgeNotificationModule,
import { filter } from 'rxjs' TuiButtonModule,
import { ApiService } from 'src/app/services/api/embassy-api.service' TuiIconModule,
import { AuthService } from 'src/app/services/auth.service' } 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 { ABOUT } from './about.component'
import { HeaderConnectionComponent } from './connection.component' import { HeaderConnectionComponent } from './connection.component'
@Component({ @Component({
selector: 'header-menu', selector: 'header-menu',
template: ` template: `
<tui-hosted-dropdown [content]="content" [tuiDropdownMaxHeight]="9999"> <tui-hosted-dropdown
[content]="content"
[(open)]="open"
[tuiDropdownMaxHeight]="9999"
>
<button tuiIconButton appearance=""> <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> </button>
<ng-template #content> <ng-template #content let-zone>
<tui-data-list> <tui-data-list tuiDataListDropdownManager [tuiActiveZoneParent]="zone">
<header-connection class="status"> <header-connection class="status">
<h3 class="title">StartOS</h3> <h3 class="title">StartOS</h3>
</header-connection> </header-connection>
<button tuiOption class="item" (click)="about()"> @for (link of utils; track $index) {
<tui-icon icon="tuiIconInfo" /> <a
About this server 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> </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> </tui-data-list>
</ng-template> </ng-template>
</tui-hosted-dropdown> </tui-hosted-dropdown>
@@ -70,9 +91,22 @@ import { HeaderConnectionComponent } from './connection.component'
font-size: 1rem; 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 { .item {
justify-content: flex-start; justify-content: flex-start;
gap: 0.75rem; gap: 0.75rem;
::ng-deep tui-svg {
margin-left: auto;
}
} }
.status { .status {
@@ -80,7 +114,7 @@ import { HeaderConnectionComponent } from './connection.component'
font-size: 0; font-size: 0;
padding: 0 0.5rem; padding: 0 0.5rem;
height: 2rem; height: 2rem;
width: 14rem; width: 13rem;
} }
.title { .title {
@@ -104,95 +138,22 @@ import { HeaderConnectionComponent } from './connection.component'
TuiButtonModule, TuiButtonModule,
TuiIconModule, TuiIconModule,
HeaderConnectionComponent, HeaderConnectionComponent,
RouterLink,
TuiBadgeNotificationModule,
TuiDropdownModule,
TuiDataListDropdownManagerModule,
TuiActiveZoneModule,
], ],
}) })
export class HeaderMenuComponent { 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) private readonly dialogs = inject(TuiDialogService)
readonly links = [ open = false
{
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',
},
]
readonly system = [ readonly utils = getMenu()
{ readonly links = RESOURCES
icon: 'tuiIconRefreshCw',
action: 'Restart',
},
{
icon: 'tuiIconPower',
action: 'Shutdown',
},
] as const
about() { about() {
this.dialogs.open(ABOUT, { label: 'About this server' }).subscribe() 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',
},
}
} }

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -1,84 +1,170 @@
import { AsyncPipe } from '@angular/common' import {
import { Component, inject } from '@angular/core' Component,
computed,
inject,
TemplateRef,
viewChildren,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { RouterLink, RouterLinkActive } from '@angular/router' import { RouterLink, RouterLinkActive } from '@angular/router'
import { TuiTabBarModule } from '@taiga-ui/addon-mobile' import { TuiSheetDialogService, TuiTabBarModule } from '@taiga-ui/addon-mobile'
import { combineLatest, map, startWith } from 'rxjs' import { TuiDialogService } from '@taiga-ui/core'
import { SYSTEM_UTILITIES } from 'src/app/utils/system-utilities' 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 { 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({ @Component({
standalone: true, standalone: true,
selector: 'app-tabs', selector: 'app-tabs',
template: ` template: `
<nav tuiTabBar> <nav tuiTabBar [(activeItemIndex)]="index">
<a <a
tuiTabBarItem tuiTabBarItem
icon="tuiIconGrid" icon="tuiIconGrid"
routerLink="/portal/dashboard" routerLink="/portal/dashboard"
routerLinkActive routerLinkActive
[routerLinkActiveOptions]="{ exact: true }" (isActiveChange)="update()"
> >
Services Services
</a> </a>
<a <a
tuiTabBarItem tuiTabBarItem
icon="tuiIconActivity" icon="tuiIconShoppingCart"
routerLink="/portal/system/metrics" routerLink="/portal/system/marketplace"
routerLinkActive routerLinkActive
(isActiveChange)="update()"
> >
Metrics Marketplace
</a> </a>
<a <a
tuiTabBarItem tuiTabBarItem
icon="tuiIconSettings" icon="tuiIconSettings"
routerLink="/portal/dashboard" routerLink="/portal/system/settings"
routerLinkActive routerLinkActive
[routerLinkActiveOptions]="{ exact: true }" [badge]="badge()"
[queryParams]="{ tab: 'utilities' }" (isActiveChange)="update()"
[badge]="(utils$ | async) || 0"
> >
Utilities Settings
</a> </a>
<a <button
tuiTabBarItem tuiTabBarItem
routerLinkActive icon="tuiIconMoreHorizontal"
routerLink="/portal/system/notifications" (click)="more(content)"
icon="tuiIconBell" [badge]="all()"
[badge]="(notification$ | async) || 0"
> >
Notifications More
</a> <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> </nav>
`, `,
styles: ` styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';
:host { :host {
display: none; display: none;
backdrop-filter: blur(1rem);
// TODO: Theme // TODO: Theme
--tui-elevation-01: #333; --tui-elevation-01: #333;
--tui-base-01: #fff; --tui-base-01: #fff;
--tui-base-04: var(--tui-clear); --tui-base-04: var(--tui-clear);
--tui-error-fill: #f52222; --tui-error-fill: #f52222;
backdrop-filter: blur(1rem);
} }
[tuiTabBar]::before { [tuiTabBar]::before {
opacity: 0.7; 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) { :host-context(tui-root._mobile) {
display: block; display: block;
} }
`, `,
imports: [AsyncPipe, RouterLink, RouterLinkActive, TuiTabBarModule], imports: [
RouterLink,
RouterLinkActive,
TuiTabBarModule,
TuiBadgeNotificationModule,
TuiIconModule,
],
}) })
export class TabsComponent { 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( index = 3
Object.keys(SYSTEM_UTILITIES)
.filter(key => key !== '/portal/system/notifications') readonly resources = RESOURCES
.map(key => this.badge.getCount(key).pipe(startWith(0))), readonly menu = getMenu().filter(item => !FILTER.includes(item.routerLink))
).pipe(map(values => values.reduce((acc, value) => acc + value, 0))) readonly badge = toSignal(
readonly notification$ = inject(NotificationService).unreadCount$ 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
}
} }

View File

@@ -1,15 +1,10 @@
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop' import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { import { NavigationEnd, Router, RouterOutlet } from '@angular/router'
ActivatedRoute,
NavigationEnd,
Router,
RouterOutlet,
} from '@angular/router'
import { TuiScrollbarModule } from '@taiga-ui/core' import { TuiScrollbarModule } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client' 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 { TabsComponent } from 'src/app/routes/portal/components/tabs.component'
import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service' import { BreadcrumbsService } from 'src/app/services/breadcrumbs.service'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -19,7 +14,7 @@ import { HeaderComponent } from './components/header/header.component'
standalone: true, standalone: true,
template: ` template: `
<header appHeader>{{ name$ | async }}</header> <header appHeader>{{ name$ | async }}</header>
<main [attr.data-dashboard]="tab$ | async"> <main>
<tui-scrollbar [style.max-height.%]="100"> <tui-scrollbar [style.max-height.%]="100">
<router-outlet /> <router-outlet />
</tui-scrollbar> </tui-scrollbar>
@@ -64,7 +59,4 @@ export class PortalComponent {
}) })
readonly name$ = inject(PatchDB<DataModel>).watch$('ui', 'name') readonly name$ = inject(PatchDB<DataModel>).watch$('ui', 'name')
readonly tab$ = inject(ActivatedRoute).queryParams.pipe(
map(params => params['tab']),
)
} }

View File

@@ -1,69 +1,68 @@
import { AsyncPipe, DatePipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { TuiIconModule } from '@taiga-ui/experimental' import { TuiIconModule } from '@taiga-ui/experimental'
import { map } from 'rxjs' import { ToManifestPipe } from 'src/app/routes/portal/pipes/to-manifest'
import { MetricsService } from 'src/app/services/metrics.service' import { ServiceComponent } from 'src/app/routes/portal/routes/dashboard/service.component'
import { TimeService } from 'src/app/services/time.service' import { ServicesService } from 'src/app/routes/portal/routes/dashboard/services.service'
import { MetricsComponent } from './metrics.component' import { DepErrorService } from 'src/app/services/dep-error.service'
import { ServicesComponent } from './services.component'
import { UtilitiesComponent } from './utilities.component'
@Component({ @Component({
standalone: true, standalone: true,
template: ` template: `
<time>{{ date() | date: 'medium' }}</time> <h2>
<app-metrics [metrics]="metrics$ | async"> <tui-icon icon="tuiIconGrid" />
<h2> Services
<tui-icon icon="tuiIconActivity" /> </h2>
Metrics <div class="g-plaque"></div>
</h2> <table>
<div class="g-plaque"></div> <thead>
</app-metrics> <tr>
<app-utilities> <th [style.width.rem]="3"></th>
<h2> <th>Name</th>
<tui-icon icon="tuiIconSettings" /> <th>Version</th>
Utilities <th [style.width.rem]="13">Status</th>
</h2> <th [style.width.rem]="8" [style.text-indent.rem]="1">Controls</th>
<div class="g-plaque"></div> </tr>
</app-utilities> </thead>
<app-services> <tbody>
<h2> @for (pkg of services(); track $index) {
<tui-icon icon="tuiIconGrid" /> <tr
Services appService
</h2> [pkg]="pkg"
<div class="g-plaque"></div> [depErrors]="errors()?.[(pkg | toManifest).id]"
</app-services> ></tr>
} @empty {
<tr>
<td colspan="5">
{{ services() ? 'No services installed' : 'Loading...' }}
</td>
</tr>
}
</tbody>
</table>
`, `,
styles: ` styles: `
:host { :host {
height: calc(100vh - 6rem);
position: relative; position: relative;
max-width: 64rem; max-width: 64rem;
display: grid; margin: 0 auto;
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;
clip-path: var(--clip-path); clip-path: var(--clip-path);
backdrop-filter: blur(1rem); backdrop-filter: blur(1rem);
font-size: 1rem; font-size: 1rem;
} overflow: hidden;
time { --clip-path: polygon(
position: absolute; 0 2rem,
left: 22%; 1.25rem 0,
font-weight: bold; 8.75rem 0,
line-height: 1.75rem; calc(10rem + 0.1em) calc(2rem - 0.1em),
text-shadow: 0 0 0.25rem #000; calc(100% - 1.25rem) 2rem,
100% 4rem,
100% calc(100% - 2rem),
calc(100% - 1.25rem) 100%,
1.25rem 100%,
0 calc(100% - 2rem)
);
} }
h2 { 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) { :host-context(tui-root._mobile) {
height: calc(100vh - 7.375rem); height: calc(100vh - 7.375rem);
display: block; margin: 0 0.375rem;
margin: 0; --clip-path: none !important;
border-top: 0;
border-bottom: 0;
app-metrics, table {
app-utilities, width: 100%;
app-services { margin: 0;
display: none;
} }
time, thead,
h2 { h2 {
display: none; 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: [ imports: [TuiIconModule, ServiceComponent, ToManifestPipe],
ServicesComponent,
MetricsComponent,
UtilitiesComponent,
TuiIconModule,
DatePipe,
RouterLink,
AsyncPipe,
],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class DashboardComponent { export class DashboardComponent {
readonly metrics$ = inject(MetricsService) readonly services = toSignal(inject(ServicesService))
readonly date = toSignal( readonly errors = toSignal(inject(DepErrorService).depErrors$)
inject(TimeService).now$.pipe(map(({ now }) => new Date(now))),
)
} }

View File

@@ -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)
}
}

View File

@@ -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$
}

View File

@@ -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']

View File

@@ -11,7 +11,7 @@ interface ActionItem {
@Component({ @Component({
selector: '[action]', selector: '[action]',
template: ` template: `
<tui-icon [icon]="action.icon"></tui-icon> <tui-icon [icon]="action.icon" />
<div> <div>
<strong>{{ action.name }}</strong> <strong>{{ action.name }}</strong>
<div>{{ action.description }}</div> <div>{{ action.description }}</div>

View File

@@ -63,7 +63,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
:host { :host {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 1rem; gap: 0.5rem;
padding-bottom: 1rem; padding-bottom: 1rem;
} }
`, `,

View File

@@ -5,6 +5,8 @@ import {
HostBinding, HostBinding,
Input, Input,
} from '@angular/core' } 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 { StatusRendering } from 'src/app/services/pkg-status-rendering.service'
import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe' import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
import { InstallingInfo } from 'src/app/services/patch-db/data-model' import { InstallingInfo } from 'src/app/services/patch-db/data-model'
@@ -15,18 +17,20 @@ import { UnitConversionPipesModule } from '@start9labs/shared'
template: ` template: `
@if (installingInfo) { @if (installingInfo) {
<strong> <strong>
<tui-loader size="s" [inheritColor]="true" />
Installing Installing
<span class="loading-dots"></span> <span class="loading-dots"></span>
{{ installingInfo.progress.overall | installingProgressString }} {{ installingInfo.progress.overall | installingProgressString }}
</strong> </strong>
} @else { } @else {
<tui-icon [icon]="icon" [style.margin-bottom.rem]="0.25" />
{{ connected ? rendering.display : 'Unknown' }} {{ connected ? rendering.display : 'Unknown' }}
@if (rendering.showDots) {
<span *ngIf="sigtermTimeout && (sigtermTimeout | durationToSeconds) > 30"> <span class="loading-dots"></span>
. This may take a while }
</span> @if (sigtermTimeout && (sigtermTimeout | durationToSeconds) > 30) {
<div>This may take a while</div>
<span *ngIf="rendering.showDots" class="loading-dots"></span> }
} }
`, `,
styles: [ styles: [
@@ -36,7 +40,20 @@ import { UnitConversionPipesModule } from '@start9labs/shared'
font-size: x-large; font-size: x-large;
white-space: nowrap; white-space: nowrap;
margin: auto 0; 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, CommonModule,
InstallingProgressDisplayPipe, InstallingProgressDisplayPipe,
UnitConversionPipesModule, UnitConversionPipesModule,
TuiIconModule,
TuiLoaderModule,
], ],
}) })
export class ServiceStatusComponent { export class ServiceStatusComponent {
@@ -60,21 +79,38 @@ export class ServiceStatusComponent {
@Input() sigtermTimeout?: string | null = null @Input() sigtermTimeout?: string | null = null
@HostBinding('style.color') @HostBinding('class')
get color(): string { get class(): string | null {
if (!this.connected) return 'var(--tui-text-02)' if (!this.connected) return null
switch (this.rendering.color) { switch (this.rendering.color) {
case 'danger': case 'danger':
return 'var(--tui-error-fill)' return 'g-error'
case 'warning': case 'warning':
return 'var(--tui-warning-fill)' return 'g-warning'
case 'success': case 'success':
return 'var(--tui-success-fill)' return 'g-success'
case 'primary': case 'primary':
return 'var(--tui-info-fill)' return 'g-info'
default: 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'
} }
} }
} }

View File

@@ -127,7 +127,7 @@ import { DependencyInfo } from '../types/dependency-info'
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
padding: 1rem 1.5rem 0.5rem; padding: 1rem 1.5rem 0.5rem;
border-radius: 1rem; border-radius: 0.5rem;
background: var(--tui-clear); background: var(--tui-clear);
box-shadow: inset 0 7rem 0 -4rem 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%); clip-path: polygon(0 1.5rem, 1.5rem 0, 100% 0, 100% 100%, 0 100%);

View File

@@ -1,17 +1,193 @@
import { AsyncPipe } from '@angular/common' import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core' import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { MetricsComponent } from 'src/app/routes/portal/routes/dashboard/metrics.component' import { toSignal } from '@angular/core/rxjs-interop'
import { MetricsService } from 'src/app/services/metrics.service' 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({ @Component({
standalone: true,
selector: 'app-metrics',
template: ` 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' }, host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true, imports: [
imports: [MetricsComponent, AsyncPipe], TuiProgressModule,
MetricComponent,
TemperatureComponent,
CpuComponent,
AsyncPipe,
],
}) })
export default class SystemMetricsComponent { 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)
}
} }

View File

@@ -5,16 +5,25 @@ import {
Injectable, Injectable,
} from '@angular/core' } from '@angular/core'
import { FormsModule } from '@angular/forms' 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 * as argon2 from '@start9labs/argon2'
import { ErrorService, LoadingService } from '@start9labs/shared' 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 { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { filter, firstValueFrom, from, take } from 'rxjs' import { filter, firstValueFrom, from, take } from 'rxjs'
import { switchMap } from 'rxjs/operators' import { switchMap } from 'rxjs/operators'
import { FormComponent } from 'src/app/routes/portal/components/form.component' import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { PROMPT } from 'src/app/routes/portal/modals/prompt.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 { ProxyService } from 'src/app/services/proxy.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { getServerInfo } from 'src/app/utils/get-server-info' import { getServerInfo } from 'src/app/utils/get-server-info'
@@ -35,6 +44,7 @@ export class SettingsService {
private readonly formDialog = inject(FormDialogService) private readonly formDialog = inject(FormDialogService)
private readonly patch = inject(PatchDB<DataModel>) private readonly patch = inject(PatchDB<DataModel>)
private readonly api = inject(ApiService) private readonly api = inject(ApiService)
private readonly auth = inject(AuthService)
private readonly isTor = inject(ConfigService).isTor() private readonly isTor = inject(ConfigService).isTor()
wipe = false wipe = false
@@ -100,6 +110,27 @@ export class SettingsService {
icon: 'tuiIconMonitor', icon: 'tuiIconMonitor',
routerLink: 'ui', 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': [ 'Privacy and Security': [
{ {
@@ -146,6 +177,25 @@ export class SettingsService {
.subscribe(() => this.resetTor(this.wipe)) .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) { private async resetTor(wipeState: boolean) {
const loader = this.loader.open('Resetting Tor...').subscribe() const loader = this.loader.open('Resetting Tor...').subscribe()
@@ -294,3 +344,29 @@ class WipeComponent {
readonly isTor = inject(ConfigService).isTor() readonly isTor = inject(ConfigService).isTor()
readonly service = inject(SettingsService) 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',
},
}
}

View File

@@ -15,6 +15,7 @@ import {
switchMap, switchMap,
} from 'rxjs' } from 'rxjs'
import { EOSService } from 'src/app/services/eos.service' 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 { DataModel } from 'src/app/services/patch-db/data-model'
import { MarketplaceService } from 'src/app/services/marketplace.service' import { MarketplaceService } from 'src/app/services/marketplace.service'
import { ConnectionService } from 'src/app/services/connection.service' import { ConnectionService } from 'src/app/services/connection.service'
@@ -24,6 +25,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
providedIn: 'root', providedIn: 'root',
}) })
export class BadgeService { export class BadgeService {
private readonly notifications = inject(NotificationService)
private readonly emver = inject(Emver) private readonly emver = inject(Emver)
private readonly patch = inject(PatchDB<DataModel>) private readonly patch = inject(PatchDB<DataModel>)
private readonly settings$ = combineLatest([ private readonly settings$ = combineLatest([
@@ -85,6 +87,8 @@ export class BadgeService {
return this.updates$ return this.updates$
case '/portal/system/settings': case '/portal/system/settings':
return this.settings$ return this.settings$
case '/portal/system/notifications':
return this.notifications.unreadCount$
default: default:
return EMPTY return EMPTY
} }

View 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',
},
]

View File

@@ -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 }> = 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': { '/portal/system/backups': {
icon: 'tuiIconSave', icon: 'tuiIconSave',
title: 'Backups', title: 'Backups',
@@ -12,14 +28,6 @@ export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
icon: 'tuiIconFileText', icon: 'tuiIconFileText',
title: 'Logs', title: 'Logs',
}, },
'/portal/system/marketplace': {
icon: 'tuiIconShoppingCart',
title: 'Marketplace',
},
'/portal/system/updates': {
icon: 'tuiIconGlobe',
title: 'Updates',
},
'/portal/system/sideload': { '/portal/system/sideload': {
icon: 'tuiIconUpload', icon: 'tuiIconUpload',
title: 'Sideload', title: 'Sideload',
@@ -28,8 +36,15 @@ export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
icon: 'tuiIconTool', icon: 'tuiIconTool',
title: 'Settings', 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 }),
}))
}