diff --git a/changelog.md b/changelog.md index e5e63ef..ba8cd93 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-11-27 - 1.6.0 - feat(ui.dashboard) +Add Resource Usage card to dashboard and make dashboard cards full-height; add VSCode launch/tasks/config + +- Introduce ResourceUsageCardComponent and include it as a full-width row in the dashboard layout. +- Make several dashboard card components (Certificates, Traffic, Platform Services) full-height by adding host classes and applying h-full to ui-card elements for consistent card sizing. +- Reflow dashboard rows (insert Resource Usage as a dedicated row and update row numbering) to improve visual layout. +- Add VSCode workspace configuration: recommended Angular extension, launch configurations for ng serve/ng test, and npm tasks to run/start the UI in development. + ## 2025-11-27 - 1.5.0 - feat(network) Add traffic stats endpoint and dashboard UI; enhance platform services and certificate health reporting diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index de072af..379c1b5 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/onebox', - version: '1.5.0', + version: '1.6.0', description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers' } diff --git a/ui/src/app/features/dashboard/certificates-card.component.ts b/ui/src/app/features/dashboard/certificates-card.component.ts index 4f6448c..81812b2 100644 --- a/ui/src/app/features/dashboard/certificates-card.component.ts +++ b/ui/src/app/features/dashboard/certificates-card.component.ts @@ -18,6 +18,7 @@ interface ICertificateHealth { @Component({ selector: 'app-certificates-card', standalone: true, + host: { class: 'block h-full' }, imports: [ RouterLink, CardComponent, @@ -27,7 +28,7 @@ interface ICertificateHealth { CardContentComponent, ], template: ` - + Certificates SSL/TLS certificate status diff --git a/ui/src/app/features/dashboard/dashboard.component.ts b/ui/src/app/features/dashboard/dashboard.component.ts index 0649d20..680f18c 100644 --- a/ui/src/app/features/dashboard/dashboard.component.ts +++ b/ui/src/app/features/dashboard/dashboard.component.ts @@ -17,6 +17,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; import { TrafficCardComponent } from './traffic-card.component'; import { PlatformServicesCardComponent } from './platform-services-card.component'; import { CertificatesCardComponent } from './certificates-card.component'; +import { ResourceUsageCardComponent } from './resource-usage-card.component'; @Component({ selector: 'app-dashboard', @@ -34,6 +35,7 @@ import { CertificatesCardComponent } from './certificates-card.component'; TrafficCardComponent, PlatformServicesCardComponent, CertificatesCardComponent, + ResourceUsageCardComponent, ], template: `
@@ -123,7 +125,10 @@ import { CertificatesCardComponent } from './certificates-card.component';
- + + + +
@@ -132,7 +137,7 @@ import { CertificatesCardComponent } from './certificates-card.component';
- +
@@ -186,7 +191,7 @@ import { CertificatesCardComponent } from './certificates-card.component';
- + Quick Actions diff --git a/ui/src/app/features/dashboard/platform-services-card.component.ts b/ui/src/app/features/dashboard/platform-services-card.component.ts index b017eca..d4358fb 100644 --- a/ui/src/app/features/dashboard/platform-services-card.component.ts +++ b/ui/src/app/features/dashboard/platform-services-card.component.ts @@ -19,6 +19,7 @@ interface IPlatformServiceSummary { @Component({ selector: 'app-platform-services-card', standalone: true, + host: { class: 'block h-full' }, imports: [ RouterLink, CardComponent, @@ -28,7 +29,7 @@ interface IPlatformServiceSummary { CardContentComponent, ], template: ` - + Platform Services Infrastructure status diff --git a/ui/src/app/features/dashboard/resource-usage-card.component.ts b/ui/src/app/features/dashboard/resource-usage-card.component.ts new file mode 100644 index 0000000..113c43c --- /dev/null +++ b/ui/src/app/features/dashboard/resource-usage-card.component.ts @@ -0,0 +1,271 @@ +import { Component, inject, signal, effect, OnDestroy } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { WebSocketService } from '../../core/services/websocket.service'; +import { IContainerStats } from '../../core/types/api.types'; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardDescriptionComponent, + CardContentComponent, +} from '../../ui/card/card.component'; + +interface IServiceStats { + name: string; + stats: IContainerStats; + timestamp: number; +} + +interface IAggregatedStats { + totalCpuPercent: number; + totalMemoryUsed: number; + totalMemoryLimit: number; + memoryPercent: number; + networkRxRate: number; + networkTxRate: number; + serviceCount: number; + topCpuServices: { name: string; value: number }[]; + topMemoryServices: { name: string; value: number }[]; +} + +@Component({ + selector: 'app-resource-usage-card', + standalone: true, + host: { class: 'block' }, + imports: [ + RouterLink, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardDescriptionComponent, + CardContentComponent, + ], + template: ` + + +
+ Resource Usage + Aggregated across {{ aggregated().serviceCount }} services +
+ + View All + +
+ + @if (aggregated().serviceCount === 0) { +
No running services
+ } @else { + +
+
+ CPU + + {{ aggregated().totalCpuPercent.toFixed(1) }}% + +
+
+
+
+
+
+ + +
+
+ Memory + + {{ formatBytes(aggregated().totalMemoryUsed) }} / {{ formatBytes(aggregated().totalMemoryLimit) }} + +
+
+
+
+
+
+ + +
+ Network +
+ + + + + {{ formatBytesRate(aggregated().networkRxRate) }} + + + + + + {{ formatBytesRate(aggregated().networkTxRate) }} + +
+
+ + + @if (aggregated().topCpuServices.length > 0 || aggregated().topMemoryServices.length > 0) { +
+
Top consumers
+
+ @for (svc of aggregated().topCpuServices.slice(0, 2); track svc.name) { + + {{ svc.name }}: + {{ svc.value.toFixed(1) }}% CPU + + } + @for (svc of aggregated().topMemoryServices.slice(0, 2); track svc.name) { + + {{ svc.name }}: + {{ formatBytes(svc.value) }} + + } +
+
+ } + } +
+
+ `, +}) +export class ResourceUsageCardComponent implements OnDestroy { + private ws = inject(WebSocketService); + + // Store stats per service + private serviceStats = new Map(); + private cleanupInterval: any; + + // Expose Math for template + Math = Math; + + aggregated = signal({ + totalCpuPercent: 0, + totalMemoryUsed: 0, + totalMemoryLimit: 0, + memoryPercent: 0, + networkRxRate: 0, + networkTxRate: 0, + serviceCount: 0, + topCpuServices: [], + topMemoryServices: [], + }); + + constructor() { + // Listen for stats updates + effect(() => { + const update = this.ws.statsUpdate(); + if (update) { + this.serviceStats.set(update.serviceName, { + name: update.serviceName, + stats: update.stats, + timestamp: update.timestamp, + }); + this.recalculateAggregated(); + } + }); + + // Clean up stale entries every 30 seconds + this.cleanupInterval = setInterval(() => { + const now = Date.now(); + const staleThreshold = 60000; // 60 seconds + let changed = false; + + for (const [name, entry] of this.serviceStats.entries()) { + if (now - entry.timestamp > staleThreshold) { + this.serviceStats.delete(name); + changed = true; + } + } + + if (changed) { + this.recalculateAggregated(); + } + }, 30000); + } + + ngOnDestroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + } + + private recalculateAggregated(): void { + const entries = Array.from(this.serviceStats.values()); + + if (entries.length === 0) { + this.aggregated.set({ + totalCpuPercent: 0, + totalMemoryUsed: 0, + totalMemoryLimit: 0, + memoryPercent: 0, + networkRxRate: 0, + networkTxRate: 0, + serviceCount: 0, + topCpuServices: [], + topMemoryServices: [], + }); + return; + } + + let totalCpu = 0; + let totalMemUsed = 0; + let totalMemLimit = 0; + let totalNetRx = 0; + let totalNetTx = 0; + + for (const entry of entries) { + totalCpu += entry.stats.cpuPercent; + totalMemUsed += entry.stats.memoryUsed; + totalMemLimit += entry.stats.memoryLimit; + totalNetRx += entry.stats.networkRx; + totalNetTx += entry.stats.networkTx; + } + + // Sort by CPU usage for top consumers + const sortedByCpu = [...entries] + .filter(e => e.stats.cpuPercent > 0) + .sort((a, b) => b.stats.cpuPercent - a.stats.cpuPercent) + .slice(0, 3) + .map(e => ({ name: e.name, value: e.stats.cpuPercent })); + + // Sort by memory usage for top consumers + const sortedByMem = [...entries] + .filter(e => e.stats.memoryUsed > 0) + .sort((a, b) => b.stats.memoryUsed - a.stats.memoryUsed) + .slice(0, 3) + .map(e => ({ name: e.name, value: e.stats.memoryUsed })); + + this.aggregated.set({ + totalCpuPercent: totalCpu, + totalMemoryUsed: totalMemUsed, + totalMemoryLimit: totalMemLimit, + memoryPercent: totalMemLimit > 0 ? (totalMemUsed / totalMemLimit) * 100 : 0, + networkRxRate: totalNetRx, + networkTxRate: totalNetTx, + serviceCount: entries.length, + topCpuServices: sortedByCpu, + topMemoryServices: sortedByMem, + }); + } + + formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + } + + formatBytesRate(bytes: number): string { + return this.formatBytes(bytes) + '/s'; + } +} diff --git a/ui/src/app/features/dashboard/traffic-card.component.ts b/ui/src/app/features/dashboard/traffic-card.component.ts index 4ad69d5..82efaa8 100644 --- a/ui/src/app/features/dashboard/traffic-card.component.ts +++ b/ui/src/app/features/dashboard/traffic-card.component.ts @@ -13,6 +13,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; @Component({ selector: 'app-traffic-card', standalone: true, + host: { class: 'block h-full' }, imports: [ CardComponent, CardHeaderComponent, @@ -22,7 +23,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; SkeletonComponent, ], template: ` - + Traffic (Last Hour) Request metrics from access logs