Merge pull request #2629 from Start9Labs/navigation

refactor: change navigation
This commit is contained in:
Matt Hill
2024-05-28 21:18:44 -06:00
committed by GitHub
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'] {
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 {

View File

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

View File

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

View File

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

View File

@@ -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',
},
}
}

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 { 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
}
}

View File

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

View File

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

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({
selector: '[action]',
template: `
<tui-icon [icon]="action.icon"></tui-icon>
<tui-icon [icon]="action.icon" />
<div>
<strong>{{ action.name }}</strong>
<div>{{ action.description }}</div>

View File

@@ -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;
}
`,

View File

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

View File

@@ -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%);

View File

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

View File

@@ -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',
},
}
}

View File

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

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 }> =
{
'/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 }),
}))
}