feat: implement metrics on the dashboard

This commit is contained in:
waterplea
2024-04-18 13:39:49 +07:00
parent 2693b9a42d
commit 8b89e03999
9 changed files with 528 additions and 17 deletions

View File

@@ -0,0 +1,81 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
@Component({
standalone: true,
selector: 'app-cpu',
template: `
<div class="meter"></div>
<div class="arrow" [style.transform]="transform"></div>
<div class="percent">{{ percent }}%</div>
`,
styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
position: relative;
margin: 1rem auto;
width: 80%;
aspect-ratio: 1;
background: var(--tui-clear);
border-radius: 100%;
}
.meter {
position: absolute;
inset: 7%;
mask: url(/assets/img/meter.svg);
background: conic-gradient(
from 180deg,
var(--tui-success-fill) 30%,
var(--tui-warning-fill),
var(--tui-error-fill) 70%
);
}
.percent {
position: absolute;
bottom: 10%;
width: 100%;
text-align: center;
}
.arrow {
@include transition(transform);
position: absolute;
top: 50%;
left: 50%;
&:before {
content: '';
position: absolute;
inset: -0.5rem;
background: var(--tui-base-02);
border-radius: 100%;
}
&:after {
content: '';
position: absolute;
width: 0.25rem;
height: 2rem;
border-radius: 1rem;
background: var(--tui-text-01);
transform: translate(-0.125rem, -0.125rem);
clip-path: polygon(0 0, 100% 0, 60% 100%, 40% 100%);
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CpuComponent {
@Input()
value = 0
get percent(): string {
return (100 * this.value).toFixed(1)
}
get transform(): string {
return `rotate(${60 + 300 * this.value}deg)`
}
}

View File

@@ -1,8 +1,10 @@
import { DatePipe } from '@angular/common'
import { ChangeDetectionStrategy, Component } from '@angular/core'
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, timer } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { MetricsComponent } from './metrics.component'
import { ServicesComponent } from './services.component'
import { UtilitiesComponent } from './utilities.component'
@@ -11,7 +13,7 @@ import { UtilitiesComponent } from './utilities.component'
standalone: true,
template: `
<time>{{ date() | date: 'medium' }}</time>
<app-metrics>
<app-metrics [metrics]="metrics$ | async">
<h2>
<tui-icon icon="tuiIconActivity" />
Metrics
@@ -60,6 +62,7 @@ import { UtilitiesComponent } from './utilities.component'
left: 22%;
font-weight: bold;
line-height: 1.75rem;
text-shadow: 0 0 0.25rem #000;
}
h2 {
@@ -78,10 +81,11 @@ import { UtilitiesComponent } from './utilities.component'
}
:host-context(tui-root._mobile) {
height: calc(100vh - 7rem);
height: calc(100vh - 7.375rem);
display: block;
margin: 0;
border-top: 0;
border-bottom: 0;
app-metrics,
app-utilities,
@@ -121,9 +125,12 @@ import { UtilitiesComponent } from './utilities.component'
UtilitiesComponent,
TuiIconModule,
DatePipe,
RouterLink,
AsyncPipe,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardComponent {
readonly date = toSignal(timer(0, 1000).pipe(map(() => new Date())))
readonly metrics$ = inject(ApiService).openMetricsWebsocket$({ url: '' })
}

View File

@@ -0,0 +1,48 @@
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-clear);
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-clear);
&::before {
position: absolute;
top: 0;
left: 100%;
content: '';
border-left: 1rem solid var(--tui-clear);
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,11 +1,74 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
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'
@Component({
standalone: true,
selector: 'app-metrics',
template: `
<ng-content />
<section>TODO</section>
<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>
-:-:-:-
<div>Days : Hrs : Mins : Secs</div>
</label>
</app-metric>
<app-metric label="Temperature">
<app-temperature [value]="temperature" />
</app-metric>
</aside>
</section>
`,
styles: `
:host {
@@ -28,20 +91,126 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'
1.25rem calc(100% - 2rem),
0 calc(100% - 4rem)
);
}
section {
height: 80%;
display: flex;
align-items: center;
justify-content: center;
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;
height: 100%;
min-height: 100%;
section {
flex-wrap: wrap;
padding: 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,
],
})
export class MetricsComponent {}
export class MetricsComponent {
@Input({ required: true })
metrics: Metrics | null = null
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

@@ -0,0 +1,89 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
@Component({
standalone: true,
selector: 'app-temperature',
template: `
<svg viewBox="0 0 43 95" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M31.7543 56.2935C31.5285 56.1505 31.3425 55.9529 31.2135 55.7188C31.0846 55.4848 31.0168 55.2219 31.0165 54.9547V12.2071C31.0165 9.68211 30.0134 7.26051 28.228 5.47505C26.4425 3.68959 24.0209 2.68652 21.4959 2.68652C18.9708 2.68652 16.5492 3.68959 14.7638 5.47505C12.9783 7.26051 11.9753 9.68211 11.9753 12.2071V54.9547C11.9748 55.2214 11.9072 55.4837 11.7786 55.7174C11.65 55.951 11.4645 56.1485 11.2394 56.2915C8.41934 58.1285 6.12747 60.6696 4.59028 63.6636C3.05309 66.6576 2.32379 70.001 2.47448 73.3632C2.70143 78.3319 4.86361 83.0146 8.49859 86.4098C12.1336 89.8049 16.9527 91.6429 21.9254 91.5307C26.8981 91.4185 31.6294 89.365 35.1076 85.8094C38.5857 82.2537 40.5345 77.4783 40.5371 72.5043C40.5385 69.2847 39.7359 66.1157 38.2022 63.2848C36.6684 60.4539 34.4521 58.0508 31.7543 56.2935Z"
stroke="var(--tui-clear)"
stroke-width="4"
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"
width="7"
height="60"
rx="3"
fill="var(--tui-clear)"
/>
<rect
x="18"
y="10"
width="7"
height="60"
rx="3"
class="bar"
fill="url(#temperature)"
/>
<defs>
<linearGradient
id="temperature"
x1="21"
y1="13"
x2="21"
y2="66"
gradientUnits="userSpaceOnUse"
>
<stop offset="0" stop-color="#EC2E34" />
<stop offset="0.35" stop-color="#C48510" />
<stop offset="0.65" stop-color="#00A030" />
<stop offset="1" stop-color="#325CE3" />
</linearGradient>
</defs>
</svg>
<span>{{ value || '-' }} C°</span>
`,
styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
height: 100%;
display: flex;
align-items: center;
margin: auto;
}
svg {
width: auto;
height: 75%;
}
span {
width: 4rem;
flex-shrink: 0;
white-space: nowrap;
text-align: center;
}
.bar {
@include transition(clip-path);
clip-path: inset(var(--fill) 0 0 0);
}
`,
host: {
'[style.--fill.%]': '100 - value',
},
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TemperatureComponent {
@Input()
value = 0
}

View File

@@ -100,10 +100,12 @@ import { BadgeService } from 'src/app/services/badge.service'
export class UtilitiesComponent {
private readonly badge = inject(BadgeService)
readonly items = Object.keys(SYSTEM_UTILITIES)
.filter(key => key !== '/portal/system/notifications')
.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

@@ -41,9 +41,6 @@ function toBreadcrumbs(
id: string,
packages: Record<string, PackageDataEntry> = {},
): Breadcrumb[] {
const item = SYSTEM_UTILITIES[id]
const routerLink = toRouterLink(id)
if (id.startsWith('/portal/system/')) {
const [page, ...path] = id.replace('/portal/system/', '').split('/')
const service = `/portal/system/${page}`

View File

@@ -4,6 +4,10 @@ export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
icon: 'tuiIconSave',
title: 'Backups',
},
'/portal/system/metrics': {
icon: 'tuiIconActivity',
title: 'Metrics',
},
'/portal/system/logs': {
icon: 'tuiIconFileText',
title: 'Logs',