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: `
-
-
- `,
- 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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
`,
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('-:-:-:-'),
)
}