diff --git a/web/projects/ui/src/app/routes/portal/routes/metrics/cpu.component.ts b/web/projects/ui/src/app/routes/portal/routes/metrics/cpu.component.ts index 9345a3cf4..132dfdf79 100644 --- a/web/projects/ui/src/app/routes/portal/routes/metrics/cpu.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/metrics/cpu.component.ts @@ -1,23 +1,39 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core' +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core' +import { ServerMetrics } from 'src/app/services/api/api.types' +import { DataComponent } from './data.component' + +const LABELS = { + percentageUsed: 'Percentage Used', + userSpace: 'User Space', + kernelSpace: 'Kernel Space', + idle: 'Idle', + wait: 'I/O Wait', +} @Component({ standalone: true, - selector: 'app-cpu', + selector: 'metrics-cpu', template: ` -
-
-
{{ percent }}%
+
+
+
+
{{ value()?.percentageUsed?.value }}%
+
+ `, styles: ` @import '@taiga-ui/core/styles/taiga-ui-local'; - :host { + .cpu { position: relative; margin: 1rem auto; - width: 80%; + width: 7rem; aspect-ratio: 1; - background: var(--tui-background-neutral-1); - border-radius: 100%; } .meter { @@ -37,6 +53,7 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core' bottom: 10%; width: 100%; text-align: center; + font: var(--tui-font-text-l); } .arrow { @@ -66,16 +83,15 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core' } `, changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DataComponent], }) export class CpuComponent { - @Input() - value = 0 + readonly value = input() - get percent(): string { - return (100 * this.value).toFixed(1) - } + readonly transform = computed( + (value = this.value()?.percentageUsed?.value || '0') => + `rotate(${60 + (300 * Number.parseFloat(value)) / 100}deg)`, + ) - get transform(): string { - return `rotate(${60 + 300 * this.value}deg)` - } + readonly labels = LABELS } diff --git a/web/projects/ui/src/app/routes/portal/routes/metrics/data.component.ts b/web/projects/ui/src/app/routes/portal/routes/metrics/data.component.ts new file mode 100644 index 000000000..bd0c888c9 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/metrics/data.component.ts @@ -0,0 +1,50 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core' +import { TuiTitle } from '@taiga-ui/core' +import { TuiCell } from '@taiga-ui/layout' +import { ServerMetrics } from 'src/app/services/api/api.types' +import { ValuePipe } from './value.pipe' + +@Component({ + standalone: true, + selector: 'metrics-data', + template: ` + @for (key of keys(); track $index) { +
+ {{ labels()[key] }} + + {{ $any(value()?.[key])?.value | value }} + +
+ } + `, + styles: ` + [tuiTitle] { + &:first-child { + color: var(--tui-text-tertiary); + } + + &:last-child { + flex-direction: row; + gap: 0; + } + + &:after { + content: attr(data-unit); + font: var(--tui-font-text-ui-xs); + color: var(--tui-text-secondary); + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TuiCell, TuiTitle, ValuePipe], +}) +export class DataComponent { + readonly labels = input.required>() + readonly value = input() + readonly keys = computed(() => Object.keys(this.labels()) as Array) +} diff --git a/web/projects/ui/src/app/routes/portal/routes/metrics/memory.component.ts b/web/projects/ui/src/app/routes/portal/routes/metrics/memory.component.ts new file mode 100644 index 000000000..3ae3e8b9d --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/metrics/memory.component.ts @@ -0,0 +1,51 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core' +import { TuiProgress } from '@taiga-ui/kit' +import { ServerMetrics } from 'src/app/services/api/api.types' +import { DataComponent } from './data.component' +import { ValuePipe } from './value.pipe' + +const LABELS = { + percentageUsed: 'Percentage Used', + total: 'Total', + used: 'Used', + available: 'Available', + zramUsed: 'zram Used', + zramTotal: 'zram Total', + zramAvailable: 'zram Available', +} + +@Component({ + standalone: true, + selector: 'metrics-memory', + template: ` + + + `, + styles: ` + label { + margin: 2rem auto; + display: block; + width: fit-content; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DataComponent, TuiProgress, ValuePipe], +}) +export class MemoryComponent { + readonly value = input() + + readonly used = computed( + (value = this.value()?.percentageUsed.value || '0') => + Number.parseFloat(value), + ) + + readonly labels = LABELS +} diff --git a/web/projects/ui/src/app/routes/portal/routes/metrics/metric.component.ts b/web/projects/ui/src/app/routes/portal/routes/metrics/metric.component.ts deleted file mode 100644 index f9241724b..000000000 --- a/web/projects/ui/src/app/routes/portal/routes/metrics/metric.component.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core' - -@Component({ - standalone: true, - selector: 'app-metric', - template: ` -
{{ label }}
- - `, - changeDetection: ChangeDetectionStrategy.OnPush, - styles: ` - :host { - display: flex; - flex-direction: column; - flex: 1; - border: 1px solid var(--tui-background-neutral-1); - border-radius: 0 1rem 1rem 1rem; - box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.25); - overflow: hidden; - } - - header { - position: relative; - width: fit-content; - font-size: 0.75rem; - padding: 0.25rem 0.5rem; - background: var(--tui-background-neutral-1); - - &::before { - position: absolute; - top: 0; - left: 100%; - content: ''; - border-left: 1rem solid var(--tui-background-neutral-1); - border-bottom: 1.75rem solid transparent; - } - } - - :host-context(tui-root._mobile) { - min-height: 8rem; - min-width: 100%; - border-radius: 0; - } - `, -}) -export class MetricComponent { - @Input() label = '' -} diff --git a/web/projects/ui/src/app/routes/portal/routes/metrics/metrics.component.ts b/web/projects/ui/src/app/routes/portal/routes/metrics/metrics.component.ts index 3981b5991..254979b44 100644 --- a/web/projects/ui/src/app/routes/portal/routes/metrics/metrics.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/metrics/metrics.component.ts @@ -1,164 +1,84 @@ -import { AsyncPipe } from '@angular/common' -import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { + ChangeDetectionStrategy, + Component, + computed, + inject, +} from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' -import { TuiProgress } from '@taiga-ui/kit' -import { CpuComponent } from 'src/app/routes/portal/routes/metrics/cpu.component' import { TitleDirective } from 'src/app/services/title.service' -import { TemperatureComponent } from 'src/app/routes/portal/routes/metrics/temperature.component' -import { MetricComponent } from 'src/app/routes/portal/routes/metrics/metric.component' -import { MetricsService } from 'src/app/routes/portal/routes/metrics/metrics.service' -import { TimeService } from 'src/app/services/time.service' +import { CpuComponent } from './cpu.component' +import { MemoryComponent } from './memory.component' +import { MetricsService } from './metrics.service' +import { StorageComponent } from './storage.component' +import { TemperatureComponent } from './temperature.component' +import { TimeComponent } from './time.component' +import { UptimeComponent } from './uptime.component' @Component({ standalone: true, selector: 'app-metrics', template: ` Metrics -
- - -
-
- - {{ getValue(metrics()?.disk?.used?.value) }} - - Used -
-
-
- - {{ getValue(metrics()?.disk?.available?.value) }} - - Available -
-
-
- - - - - -
-
- - {{ getValue(metrics()?.memory?.used?.value) }} - - Used -
-
-
- - {{ getValue(metrics()?.memory?.available?.value) }} - - Available -
-
-
- -
+
+
+
System Time
+ +
+
+
Uptime
+ +
+
+
Temperature
+ +
+
+
CPU
+ +
+
+
Memory
+ +
+
+
Storage
+ +
+
`, styles: ` - section { - display: flex; - gap: 1rem; - padding: 1rem 1rem 0; - } + :host { + padding: 1rem; - aside { - display: flex; - flex: 1; - flex-direction: column; - gap: 1rem; - } + div { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + grid-auto-flow: dense; + gap: 1rem; + } - footer { - display: flex; - white-space: nowrap; - background: var(--tui-background-neutral-1); - } - - 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-secondary); - font-size: 0.5rem; - line-height: 1rem; - - span { - font-size: 0.75rem; - font-weight: bold; - color: var(--tui-text-primary); - padding-top: 0.4rem; - - &::after { - content: attr(data-unit); - font-size: 0.5rem; - font-weight: normal; - color: var(--tui-text-secondary); - } + header { + background: transparent; } } :host-context(tui-root._mobile) { - section { - min-height: 100%; - flex-wrap: wrap; - margin: 0 -2rem -2rem; - } + .g-card { + grid-column: span 3; - aside { - order: -1; - flex-direction: row; - } + &:nth-child(1), + &:nth-child(2) { + grid-column: span 2; + } - app-metric { - min-width: calc(50% - 0.5rem); + &:nth-child(3) { + grid-column: span 1; + grid-row: span 2; + padding-top: 0; - &.wide { - min-width: 100%; + header { + display: none; + } } } } @@ -166,30 +86,19 @@ import { TimeService } from 'src/app/services/time.service' host: { class: 'g-page' }, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - TuiProgress, - MetricComponent, TemperatureComponent, + StorageComponent, CpuComponent, TitleDirective, + MemoryComponent, + UptimeComponent, + TimeComponent, ], }) export default class SystemMetricsComponent { 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) - } + readonly temperature = computed(() => + Number(this.metrics()?.general.temperature?.value || 0), + ) } diff --git a/web/projects/ui/src/app/routes/portal/routes/metrics/storage.component.ts b/web/projects/ui/src/app/routes/portal/routes/metrics/storage.component.ts new file mode 100644 index 000000000..eb0d9172c --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/metrics/storage.component.ts @@ -0,0 +1,60 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core' +import { TuiProgress } from '@taiga-ui/kit' +import { ServerMetrics } from 'src/app/services/api/api.types' +import { DataComponent } from './data.component' + +const LABELS = { + percentageUsed: 'Percentage Used', + capacity: 'Capacity', + used: 'Used', + available: 'Available', +} + +@Component({ + standalone: true, + selector: 'metrics-storage', + template: ` + + + `, + styles: ` + progress { + height: 1.5rem; + width: 80%; + margin: 3.75rem auto; + border-radius: 0; + clip-path: none; + mask: linear-gradient(to right, #000 80%, transparent 80%); + mask-size: 5% 100%; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TuiProgress, DataComponent], +}) +export class StorageComponent { + readonly value = input() + + readonly used = computed( + ( + capacity = this.value()?.capacity?.value, + used = this.value()?.used?.value, + ) => + capacity && used + ? ( + (Number.parseFloat(used) / Number.parseFloat(capacity)) * + 100 + ).toFixed(1) + : '-', + ) + + readonly labels = LABELS +} diff --git a/web/projects/ui/src/app/routes/portal/routes/metrics/temperature.component.ts b/web/projects/ui/src/app/routes/portal/routes/metrics/temperature.component.ts index ef87abea7..8cb2d15b3 100644 --- a/web/projects/ui/src/app/routes/portal/routes/metrics/temperature.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/metrics/temperature.component.ts @@ -1,8 +1,8 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core' +import { ChangeDetectionStrategy, Component, input } from '@angular/core' @Component({ standalone: true, - selector: 'app-temperature', + selector: 'metrics-temperature', template: ` - + - {{ value || '-' }} C° + {{ value() || '-' }} C° `, styles: ` @import '@taiga-ui/core/styles/taiga-ui-local'; @@ -57,13 +57,16 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core' :host { height: 100%; display: flex; + flex-direction: column; align-items: center; + justify-content: center; margin: auto; } svg { width: auto; height: 75%; + max-width: 100%; } span { @@ -79,11 +82,10 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core' } `, host: { - '[style.--fill.%]': '100 - value', + '[style.--fill.%]': '100 - value()', }, changeDetection: ChangeDetectionStrategy.OnPush, }) export class TemperatureComponent { - @Input() - value = 0 + readonly value = input.required() } diff --git a/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts b/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts new file mode 100644 index 000000000..86001470e --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts @@ -0,0 +1,46 @@ +import { DatePipe } from '@angular/common' +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { TuiNotification, TuiTitle } from '@taiga-ui/core' +import { TimeService } from 'src/app/services/time.service' + +@Component({ + standalone: true, + selector: 'metrics-time', + template: ` + @if (now(); as time) { + @if (!time.synced) { + + NTP not synced, time could be wrong + + } +
+
+ {{ time.now | date: 'h:mm a z' : 'UTC' }} +
+ {{ time.now | date: 'MMMM d, y' : 'UTC' }} +
+ } @else { + Loading... + } + `, + styles: ` + :host { + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + gap: 1rem; + margin-bottom: 1.5rem; + } + + [tuiTitle] { + text-align: center; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TuiNotification, DatePipe, TuiTitle], +}) +export class TimeComponent { + readonly now = toSignal(inject(TimeService).now$) +} diff --git a/web/projects/ui/src/app/routes/portal/routes/metrics/uptime.component.ts b/web/projects/ui/src/app/routes/portal/routes/metrics/uptime.component.ts new file mode 100644 index 000000000..ca2937c17 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/metrics/uptime.component.ts @@ -0,0 +1,54 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { TimeService } from 'src/app/services/time.service' + +@Component({ + standalone: true, + selector: 'metrics-uptime', + template: ` + @if (uptime(); as time) { +
+ {{ time.days }} + Days +
+
+ {{ time.hours }} + Hours +
+
+ {{ time.minutes }} + Minutes +
+
+ {{ time.seconds }} + Seconds +
+ } @else { + Loading... + } + `, + styles: ` + :host { + height: 100%; + display: flex; + text-align: center; + justify-content: center; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + } + + b { + display: block; + font: var(--tui-font-heading-5); + } + + :host-context(tui-root._mobile) { + font: var(--tui-font-text-ui-xs); + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UptimeComponent { + readonly uptime = toSignal(inject(TimeService).uptime$) +} diff --git a/web/projects/ui/src/app/routes/portal/routes/metrics/value.pipe.ts b/web/projects/ui/src/app/routes/portal/routes/metrics/value.pipe.ts new file mode 100644 index 000000000..ada7354b8 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/metrics/value.pipe.ts @@ -0,0 +1,13 @@ +import { Pipe, PipeTransform } from '@angular/core' + +@Pipe({ + standalone: true, + name: 'value', +}) +export class ValuePipe implements PipeTransform { + readonly transform = getValue +} + +export function getValue(value?: string | null): number | string { + return value == null ? '-' : Number.parseFloat(value) +} diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 74d98417f..603acbe2a 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -123,6 +123,10 @@ export class MockApiService extends ApiService { return from(this.initProgress()).pipe( startWith(PROGRESS), ) as WebSocketSubject + } else if (guid === 'metrics-guid') { + return interval(1000).pipe( + map(() => Mock.getMetrics()), + ) as WebSocketSubject } else { throw new Error('invalid guid type') } @@ -358,7 +362,7 @@ export class MockApiService extends ApiService { ): Promise { await pauseFor(2000) return { - guid: 'iqudh37um-i38u3-34-a51b-jkhd783ein', + guid: 'metrics-guid', metrics: Mock.getMetrics(), } } diff --git a/web/projects/ui/src/app/services/time.service.ts b/web/projects/ui/src/app/services/time.service.ts index 2f30ae5f3..bd088242a 100644 --- a/web/projects/ui/src/app/services/time.service.ts +++ b/web/projects/ui/src/app/services/time.service.ts @@ -1,14 +1,6 @@ import { inject, Injectable } from '@angular/core' import { PatchDB } from 'patch-db-client' -import { - combineLatest, - defer, - map, - shareReplay, - startWith, - switchMap, - timer, -} from 'rxjs' +import { combineLatest, defer, map, shareReplay, switchMap, timer } from 'rxjs' import { ApiService } from 'src/app/services/api/embassy-api.service' import { DataModel } from 'src/app/services/patch-db/data-model' @@ -44,9 +36,7 @@ export class TimeService { const hoursSec = uptime % (60 * 60) const minutes = Math.floor(hoursSec / 60) const seconds = uptime % 60 - - return `${days}:${hours}:${minutes}:${seconds}` + return { days, hours, minutes, seconds } }), - startWith('-:-:-:-'), ) }