feat(ui.dashboard): Add Resource Usage card to dashboard and make dashboard cards full-height; add VSCode launch/tasks/config

This commit is contained in:
2025-11-27 09:50:06 +00:00
parent 9470c7911d
commit 7b159a3486
7 changed files with 294 additions and 7 deletions

View File

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

View File

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

View File

@@ -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: `
<ui-card>
<ui-card class="h-full">
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Certificates</ui-card-title>
<ui-card-description>SSL/TLS certificate status</ui-card-description>

View File

@@ -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: `
<div class="space-y-6">
@@ -123,7 +125,10 @@ import { CertificatesCardComponent } from './certificates-card.component';
</ui-card>
</div>
<!-- Row 2: Traffic & Platform Services (2-column) -->
<!-- Row 2: Resource Usage (full width) -->
<app-resource-usage-card />
<!-- Row 3: Traffic & Platform Services (2-column) -->
<div class="grid gap-4 md:grid-cols-2">
<!-- Traffic Overview -->
<app-traffic-card />
@@ -132,7 +137,7 @@ import { CertificatesCardComponent } from './certificates-card.component';
<app-platform-services-card [services]="status()!.platformServices" />
</div>
<!-- Row 3: Certificates & System Status (3-column) -->
<!-- Row 4: Certificates & System Status (3-column) -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Certificates Health -->
<app-certificates-card [health]="status()!.certificateHealth" />
@@ -186,7 +191,7 @@ import { CertificatesCardComponent } from './certificates-card.component';
</ui-card>
</div>
<!-- Row 4: Quick Actions -->
<!-- Row 5: Quick Actions -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Quick Actions</ui-card-title>

View File

@@ -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: `
<ui-card>
<ui-card class="h-full">
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Platform Services</ui-card-title>
<ui-card-description>Infrastructure status</ui-card-description>

View File

@@ -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: `
<ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<ui-card-title>Resource Usage</ui-card-title>
<ui-card-description>Aggregated across {{ aggregated().serviceCount }} services</ui-card-description>
</div>
<a routerLink="/services" class="text-xs text-muted-foreground hover:text-primary transition-colors">
View All
</a>
</ui-card-header>
<ui-card-content class="space-y-4">
@if (aggregated().serviceCount === 0) {
<div class="text-sm text-muted-foreground">No running services</div>
} @else {
<!-- CPU Usage -->
<div class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">CPU</span>
<span class="font-medium" [class.text-warning]="aggregated().totalCpuPercent > 70" [class.text-destructive]="aggregated().totalCpuPercent > 90">
{{ aggregated().totalCpuPercent.toFixed(1) }}%
</span>
</div>
<div class="h-2 rounded-full bg-muted overflow-hidden">
<div
class="h-full transition-all duration-300"
[class.bg-success]="aggregated().totalCpuPercent <= 70"
[class.bg-warning]="aggregated().totalCpuPercent > 70 && aggregated().totalCpuPercent <= 90"
[class.bg-destructive]="aggregated().totalCpuPercent > 90"
[style.width.%]="Math.min(aggregated().totalCpuPercent, 100)">
</div>
</div>
</div>
<!-- Memory Usage -->
<div class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Memory</span>
<span class="font-medium" [class.text-warning]="aggregated().memoryPercent > 70" [class.text-destructive]="aggregated().memoryPercent > 90">
{{ formatBytes(aggregated().totalMemoryUsed) }} / {{ formatBytes(aggregated().totalMemoryLimit) }}
</span>
</div>
<div class="h-2 rounded-full bg-muted overflow-hidden">
<div
class="h-full transition-all duration-300"
[class.bg-success]="aggregated().memoryPercent <= 70"
[class.bg-warning]="aggregated().memoryPercent > 70 && aggregated().memoryPercent <= 90"
[class.bg-destructive]="aggregated().memoryPercent > 90"
[style.width.%]="Math.min(aggregated().memoryPercent, 100)">
</div>
</div>
</div>
<!-- Network -->
<div class="flex items-center justify-between text-sm pt-1 border-t">
<span class="text-muted-foreground">Network</span>
<div class="flex items-center gap-3">
<span class="flex items-center gap-1">
<svg class="h-3 w-3 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
{{ formatBytesRate(aggregated().networkRxRate) }}
</span>
<span class="flex items-center gap-1">
<svg class="h-3 w-3 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" />
</svg>
{{ formatBytesRate(aggregated().networkTxRate) }}
</span>
</div>
</div>
<!-- Top Consumers -->
@if (aggregated().topCpuServices.length > 0 || aggregated().topMemoryServices.length > 0) {
<div class="pt-2 border-t">
<div class="text-xs text-muted-foreground mb-1">Top consumers</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs">
@for (svc of aggregated().topCpuServices.slice(0, 2); track svc.name) {
<span>
<span class="text-muted-foreground">{{ svc.name }}:</span>
<span class="font-medium"> {{ svc.value.toFixed(1) }}% CPU</span>
</span>
}
@for (svc of aggregated().topMemoryServices.slice(0, 2); track svc.name) {
<span>
<span class="text-muted-foreground">{{ svc.name }}:</span>
<span class="font-medium"> {{ formatBytes(svc.value) }}</span>
</span>
}
</div>
</div>
}
}
</ui-card-content>
</ui-card>
`,
})
export class ResourceUsageCardComponent implements OnDestroy {
private ws = inject(WebSocketService);
// Store stats per service
private serviceStats = new Map<string, IServiceStats>();
private cleanupInterval: any;
// Expose Math for template
Math = Math;
aggregated = signal<IAggregatedStats>({
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';
}
}

View File

@@ -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: `
<ui-card>
<ui-card class="h-full">
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Traffic (Last Hour)</ui-card-title>
<ui-card-description>Request metrics from access logs</ui-card-description>