diff --git a/web/projects/shared/assets/img/meter.svg b/web/projects/shared/assets/img/meter.svg
new file mode 100644
index 000000000..1366ae2fb
--- /dev/null
+++ b/web/projects/shared/assets/img/meter.svg
@@ -0,0 +1,114 @@
+
+
diff --git a/web/projects/ui/src/app/routes/portal/routes/dashboard/cpu.component.ts b/web/projects/ui/src/app/routes/portal/routes/dashboard/cpu.component.ts
new file mode 100644
index 000000000..0b3ef27ae
--- /dev/null
+++ b/web/projects/ui/src/app/routes/portal/routes/dashboard/cpu.component.ts
@@ -0,0 +1,81 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
+
+@Component({
+ standalone: true,
+ selector: 'app-cpu',
+ template: `
+
+
+ {{ percent }}%
+ `,
+ 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)`
+ }
+}
diff --git a/web/projects/ui/src/app/routes/portal/routes/dashboard/dashboard.component.ts b/web/projects/ui/src/app/routes/portal/routes/dashboard/dashboard.component.ts
index 2b63042c5..2778c970c 100644
--- a/web/projects/ui/src/app/routes/portal/routes/dashboard/dashboard.component.ts
+++ b/web/projects/ui/src/app/routes/portal/routes/dashboard/dashboard.component.ts
@@ -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: `
-
+
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: '' })
}
diff --git a/web/projects/ui/src/app/routes/portal/routes/dashboard/metric.component.ts b/web/projects/ui/src/app/routes/portal/routes/dashboard/metric.component.ts
new file mode 100644
index 000000000..d8cb1e352
--- /dev/null
+++ b/web/projects/ui/src/app/routes/portal/routes/dashboard/metric.component.ts
@@ -0,0 +1,48 @@
+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-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 = ''
+}
diff --git a/web/projects/ui/src/app/routes/portal/routes/dashboard/metrics.component.ts b/web/projects/ui/src/app/routes/portal/routes/dashboard/metrics.component.ts
index ce38e16b1..5ee1a78e7 100644
--- a/web/projects/ui/src/app/routes/portal/routes/dashboard/metrics.component.ts
+++ b/web/projects/ui/src/app/routes/portal/routes/dashboard/metrics.component.ts
@@ -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: `
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`,
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)
+ }
+}
diff --git a/web/projects/ui/src/app/routes/portal/routes/dashboard/temperature.component.ts b/web/projects/ui/src/app/routes/portal/routes/dashboard/temperature.component.ts
new file mode 100644
index 000000000..4b0b130d7
--- /dev/null
+++ b/web/projects/ui/src/app/routes/portal/routes/dashboard/temperature.component.ts
@@ -0,0 +1,89 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
+
+@Component({
+ standalone: true,
+ selector: 'app-temperature',
+ template: `
+
+ {{ value || '-' }} C°
+ `,
+ 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
+}
diff --git a/web/projects/ui/src/app/routes/portal/routes/dashboard/utilities.component.ts b/web/projects/ui/src/app/routes/portal/routes/dashboard/utilities.component.ts
index 1e3c1871e..d18ffc89e 100644
--- a/web/projects/ui/src/app/routes/portal/routes/dashboard/utilities.component.ts
+++ b/web/projects/ui/src/app/routes/portal/routes/dashboard/utilities.component.ts
@@ -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']
diff --git a/web/projects/ui/src/app/services/breadcrumbs.service.ts b/web/projects/ui/src/app/services/breadcrumbs.service.ts
index 7a7346263..193ebf0c0 100644
--- a/web/projects/ui/src/app/services/breadcrumbs.service.ts
+++ b/web/projects/ui/src/app/services/breadcrumbs.service.ts
@@ -41,9 +41,6 @@ function toBreadcrumbs(
id: string,
packages: Record = {},
): 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}`
diff --git a/web/projects/ui/src/app/utils/system-utilities.ts b/web/projects/ui/src/app/utils/system-utilities.ts
index e29bc1059..07a988226 100644
--- a/web/projects/ui/src/app/utils/system-utilities.ts
+++ b/web/projects/ui/src/app/utils/system-utilities.ts
@@ -4,6 +4,10 @@ export const SYSTEM_UTILITIES: Record =
icon: 'tuiIconSave',
title: 'Backups',
},
+ '/portal/system/metrics': {
+ icon: 'tuiIconActivity',
+ title: 'Metrics',
+ },
'/portal/system/logs': {
icon: 'tuiIconFileText',
title: 'Logs',