feat: refactor metrics (#2861)

This commit is contained in:
Alex Inkin
2025-04-01 00:22:54 +04:00
committed by GitHub
parent 1883c9666e
commit f51dcf23d6
12 changed files with 395 additions and 248 deletions

View File

@@ -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: `
<div class="meter"></div>
<div class="arrow" [style.transform]="transform"></div>
<div class="percent">{{ percent }}%</div>
<div class="cpu">
<div class="meter"></div>
<div class="arrow" [style.transform]="transform()"></div>
<div class="percent">{{ value()?.percentageUsed?.value }}%</div>
</div>
<metrics-data [labels]="labels" [value]="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<ServerMetrics['cpu']>()
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
}

View File

@@ -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) {
<div tuiCell="m">
<span tuiTitle>{{ labels()[key] }}</span>
<span tuiTitle [attr.data-unit]="$any(value()?.[key])?.unit">
{{ $any(value()?.[key])?.value | value }}
</span>
</div>
}
`,
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<T extends ServerMetrics[keyof ServerMetrics]> {
readonly labels = input.required<Record<keyof T, string>>()
readonly value = input<T>()
readonly keys = computed(() => Object.keys(this.labels()) as Array<keyof T>)
}

View File

@@ -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: `
<label tuiProgressLabel>
<tui-progress-circle size="l" [max]="100" [value]="used()" />
{{ value()?.percentageUsed?.value | value }}%
</label>
<metrics-data [labels]="labels" [value]="value()" />
`,
styles: `
label {
margin: 2rem auto;
display: block;
width: fit-content;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [DataComponent, TuiProgress, ValuePipe],
})
export class MemoryComponent {
readonly value = input<ServerMetrics['memory']>()
readonly used = computed(
(value = this.value()?.percentageUsed.value || '0') =>
Number.parseFloat(value),
)
readonly labels = LABELS
}

View File

@@ -1,48 +0,0 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
@Component({
standalone: true,
selector: 'app-metric',
template: `
<header>{{ label }}</header>
<ng-content />
`,
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 = ''
}

View File

@@ -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: `
<ng-container *title>Metrics</ng-container>
<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>
<div>
<section class="g-card">
<header>System Time</header>
<metrics-time />
</section>
<section class="g-card">
<header>Uptime</header>
<metrics-uptime />
</section>
<section class="g-card">
<header>Temperature</header>
<metrics-temperature [value]="temperature()" />
</section>
<section class="g-card">
<header>CPU</header>
<metrics-cpu [value]="metrics()?.cpu" />
</section>
<section class="g-card">
<header>Memory</header>
<metrics-memory [value]="metrics()?.memory" />
</section>
<section class="g-card">
<header>Storage</header>
<metrics-storage [value]="metrics()?.disk" />
</section>
</div>
`,
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),
)
}

View File

@@ -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: `
<progress
tuiProgressBar
[max]="100"
[attr.value]="value()?.percentageUsed?.value"
></progress>
<metrics-data [labels]="labels" [value]="value()" />
`,
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<ServerMetrics['disk']>()
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
}

View File

@@ -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: `
<svg viewBox="0 0 43 95" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
@@ -12,10 +12,6 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
stroke-miterlimit="10"
stroke-linecap="round"
/>
<path
d="M21.5011 82.0256C26.7592 82.0256 31.0217 77.7631 31.0217 72.505C31.0217 67.2469 26.7592 62.9844 21.5011 62.9844C16.243 62.9844 11.9805 67.2469 11.9805 72.505C11.9805 77.7631 16.243 82.0256 21.5011 82.0256Z"
fill="#3853E3"
/>
<rect
x="18"
y="10"
@@ -33,6 +29,10 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
class="bar"
fill="url(#temperature)"
/>
<path
d="M21.5011 82.0256C26.7592 82.0256 31.0217 77.7631 31.0217 72.505C31.0217 67.2469 26.7592 62.9844 21.5011 62.9844C16.243 62.9844 11.9805 67.2469 11.9805 72.505C11.9805 77.7631 16.243 82.0256 21.5011 82.0256Z"
fill="#3853E3"
/>
<defs>
<linearGradient
id="temperature"
@@ -49,7 +49,7 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
</linearGradient>
</defs>
</svg>
<span>{{ value || '-' }} C°</span>
<b>{{ value() || '-' }} C°</b>
`,
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<number>()
}

View File

@@ -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) {
<tui-notification appearance="warning">
NTP not synced, time could be wrong
</tui-notification>
}
<div tuiTitle>
<div tuiSubtitle class="g-secondary">
{{ time.now | date: 'h:mm a z' : 'UTC' }}
</div>
<b>{{ time.now | date: 'MMMM d, y' : 'UTC' }}</b>
</div>
} @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$)
}

View File

@@ -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) {
<div>
<b>{{ time.days }}</b>
Days
</div>
<div>
<b>{{ time.hours }}</b>
Hours
</div>
<div>
<b>{{ time.minutes }}</b>
Minutes
</div>
<div>
<b>{{ time.seconds }}</b>
Seconds
</div>
} @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$)
}

View File

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

View File

@@ -123,6 +123,10 @@ export class MockApiService extends ApiService {
return from(this.initProgress()).pipe(
startWith(PROGRESS),
) as WebSocketSubject<T>
} else if (guid === 'metrics-guid') {
return interval(1000).pipe(
map(() => Mock.getMetrics()),
) as WebSocketSubject<T>
} else {
throw new Error('invalid guid type')
}
@@ -358,7 +362,7 @@ export class MockApiService extends ApiService {
): Promise<RR.FollowServerMetricsRes> {
await pauseFor(2000)
return {
guid: 'iqudh37um-i38u3-34-a51b-jkhd783ein',
guid: 'metrics-guid',
metrics: Mock.getMetrics(),
}
}

View File

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