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:
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
271
ui/src/app/features/dashboard/resource-usage-card.component.ts
Normal file
271
ui/src/app/features/dashboard/resource-usage-card.component.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user