272 lines
9.3 KiB
TypeScript
272 lines
9.3 KiB
TypeScript
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';
|
|
}
|
|
}
|