{{ action.name }}
{{ action.description }}
diff --git a/web/projects/ui/src/app/routes/portal/routes/service/components/actions.component.ts b/web/projects/ui/src/app/routes/portal/routes/service/components/actions.component.ts
index 4c286e2c1..9714aa464 100644
--- a/web/projects/ui/src/app/routes/portal/routes/service/components/actions.component.ts
+++ b/web/projects/ui/src/app/routes/portal/routes/service/components/actions.component.ts
@@ -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;
}
`,
diff --git a/web/projects/ui/src/app/routes/portal/routes/service/components/status.component.ts b/web/projects/ui/src/app/routes/portal/routes/service/components/status.component.ts
index 0e699a6d2..16fc7e9bd 100644
--- a/web/projects/ui/src/app/routes/portal/routes/service/components/status.component.ts
+++ b/web/projects/ui/src/app/routes/portal/routes/service/components/status.component.ts
@@ -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) {
+
Installing
{{ installingInfo.progress.overall | installingProgressString }}
} @else {
+
{{ connected ? rendering.display : 'Unknown' }}
-
-
30">
- . This may take a while
-
-
-
+ @if (rendering.showDots) {
+
+ }
+ @if (sigtermTimeout && (sigtermTimeout | durationToSeconds) > 30) {
+
This may take a while
+ }
}
`,
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'
}
}
}
diff --git a/web/projects/ui/src/app/routes/portal/routes/service/routes/service.component.ts b/web/projects/ui/src/app/routes/portal/routes/service/routes/service.component.ts
index 1452fc6fe..6e2c97ed7 100644
--- a/web/projects/ui/src/app/routes/portal/routes/service/routes/service.component.ts
+++ b/web/projects/ui/src/app/routes/portal/routes/service/routes/service.component.ts
@@ -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%);
diff --git a/web/projects/ui/src/app/routes/portal/routes/dashboard/cpu.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/metrics/cpu.component.ts
similarity index 100%
rename from web/projects/ui/src/app/routes/portal/routes/dashboard/cpu.component.ts
rename to web/projects/ui/src/app/routes/portal/routes/system/metrics/cpu.component.ts
diff --git a/web/projects/ui/src/app/routes/portal/routes/dashboard/metric.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/metrics/metric.component.ts
similarity index 100%
rename from web/projects/ui/src/app/routes/portal/routes/dashboard/metric.component.ts
rename to web/projects/ui/src/app/routes/portal/routes/system/metrics/metric.component.ts
diff --git a/web/projects/ui/src/app/routes/portal/routes/system/metrics/metrics.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/metrics/metrics.component.ts
index d45a7dbc4..95c4d8de0 100644
--- a/web/projects/ui/src/app/routes/portal/routes/system/metrics/metrics.component.ts
+++ b/web/projects/ui/src/app/routes/portal/routes/system/metrics/metrics.component.ts
@@ -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: `
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ 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)
+ }
}
diff --git a/web/projects/ui/src/app/services/metrics.service.ts b/web/projects/ui/src/app/routes/portal/routes/system/metrics/metrics.service.ts
similarity index 100%
rename from web/projects/ui/src/app/services/metrics.service.ts
rename to web/projects/ui/src/app/routes/portal/routes/system/metrics/metrics.service.ts
diff --git a/web/projects/ui/src/app/routes/portal/routes/dashboard/temperature.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/metrics/temperature.component.ts
similarity index 100%
rename from web/projects/ui/src/app/routes/portal/routes/dashboard/temperature.component.ts
rename to web/projects/ui/src/app/routes/portal/routes/system/metrics/temperature.component.ts
diff --git a/web/projects/ui/src/app/routes/portal/routes/system/settings/settings.service.ts b/web/projects/ui/src/app/routes/portal/routes/system/settings/settings.service.ts
index 06caafb17..c15290d75 100644
--- a/web/projects/ui/src/app/routes/portal/routes/system/settings/settings.service.ts
+++ b/web/projects/ui/src/app/routes/portal/routes/system/settings/settings.service.ts
@@ -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
)
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> {
+ 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',
+ },
+ }
+}
diff --git a/web/projects/ui/src/app/services/badge.service.ts b/web/projects/ui/src/app/services/badge.service.ts
index e9aa1149f..c71163292 100644
--- a/web/projects/ui/src/app/services/badge.service.ts
+++ b/web/projects/ui/src/app/services/badge.service.ts
@@ -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)
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
}
diff --git a/web/projects/ui/src/app/utils/resources.ts b/web/projects/ui/src/app/utils/resources.ts
new file mode 100644
index 000000000..fab5f36ef
--- /dev/null
+++ b/web/projects/ui/src/app/utils/resources.ts
@@ -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',
+ },
+]
diff --git a/web/projects/ui/src/app/utils/system-utilities.ts b/web/projects/ui/src/app/utils/system-utilities.ts
index 07a988226..20032354a 100644
--- a/web/projects/ui/src/app/utils/system-utilities.ts
+++ b/web/projects/ui/src/app/utils/system-utilities.ts
@@ -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 =
{
+ '/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 =
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 =
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 }),
+ }))
+}