feat: Implement real-time stats and metrics for platform services with WebSocket integration

This commit is contained in:
2025-11-26 14:12:20 +00:00
parent 3e8cd6e3d0
commit a14af4af9c
12 changed files with 735 additions and 16 deletions

View File

@@ -4,7 +4,8 @@ import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
import { LogStreamService } from '../../core/services/log-stream.service';
import { IService, IServiceUpdate, IPlatformResource } from '../../core/types/api.types';
import { WebSocketService } from '../../core/services/websocket.service';
import { IService, IServiceUpdate, IPlatformResource, IContainerStats, IMetric } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
@@ -190,6 +191,65 @@ import {
</ui-card-content>
</ui-card>
<!-- Resource Stats (only shown when service is running) -->
@if (service()!.status === 'running' && stats()) {
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Resource Usage</ui-card-title>
<ui-card-description>
<span class="flex items-center gap-2">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
Live stats
</span>
</ui-card-description>
</ui-card-header>
<ui-card-content>
<div class="grid grid-cols-2 gap-4">
<!-- CPU -->
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">CPU</span>
<span class="font-medium">{{ formatPercent(stats()!.cpuPercent) }}</span>
</div>
<div class="h-2 bg-muted rounded-full overflow-hidden">
<div class="h-full bg-primary transition-all duration-300" [style.width.%]="stats()!.cpuPercent"></div>
</div>
</div>
<!-- Memory -->
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Memory</span>
<span class="font-medium">{{ formatBytes(stats()!.memoryUsed) }} / {{ formatBytes(stats()!.memoryLimit) }}</span>
</div>
<div class="h-2 bg-muted rounded-full overflow-hidden">
<div class="h-full bg-primary transition-all duration-300" [style.width.%]="stats()!.memoryPercent"></div>
</div>
</div>
<!-- Network RX -->
<div class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Network In</span>
<span class="font-medium">{{ formatBytes(stats()!.networkRx) }}</span>
</div>
</div>
<!-- Network TX -->
<div class="space-y-1">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Network Out</span>
<span class="font-medium">{{ formatBytes(stats()!.networkTx) }}</span>
</div>
</div>
</div>
</ui-card-content>
</ui-card>
}
<!-- Environment Variables -->
@if (service()!.envVars && getEnvKeys(service()!.envVars).length > 0) {
<ui-card>
@@ -386,12 +446,15 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
private router = inject(Router);
private api = inject(ApiService);
private toast = inject(ToastService);
private ws = inject(WebSocketService);
logStream = inject(LogStreamService);
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
service = signal<IService | null>(null);
platformResources = signal<IPlatformResource[]>([]);
stats = signal<IContainerStats | null>(null);
metrics = signal<IMetric[]>([]);
loading = signal(false);
actionLoading = signal(false);
editMode = signal(false);
@@ -411,6 +474,15 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
}, 0);
}
});
// Listen for WebSocket stats updates
effect(() => {
const update = this.ws.statsUpdate();
const currentService = this.service();
if (update && currentService && update.serviceName === currentService.name) {
this.stats.set(update.stats);
}
});
}
ngOnInit(): void {
@@ -440,6 +512,17 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
if (response.data.platformRequirements) {
this.loadPlatformResources(name);
}
// Load initial stats and metrics if service is running
// (WebSocket will keep stats updated in real-time)
if (response.data.status === 'running') {
this.loadStats(name);
this.loadMetrics(name);
} else {
// Clear stats if service not running
this.stats.set(null);
this.metrics.set([]);
}
} else {
this.toast.error(response.error || 'Service not found');
this.router.navigate(['/services']);
@@ -462,6 +545,28 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
}
}
async loadStats(name: string): Promise<void> {
try {
const response = await this.api.getServiceStats(name);
if (response.success && response.data) {
this.stats.set(response.data);
}
} catch {
// Silent fail - stats are optional
}
}
async loadMetrics(name: string): Promise<void> {
try {
const response = await this.api.getServiceMetrics(name, 60);
if (response.success && response.data) {
this.metrics.set(response.data);
}
} catch {
// Silent fail - metrics are optional
}
}
startLogStream(): void {
const name = this.service()?.name;
if (name) {
@@ -492,6 +597,18 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
return new Date(timestamp).toLocaleString();
}
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];
}
formatPercent(value: number): string {
return value.toFixed(1) + '%';
}
getEnvKeys(envVars: Record<string, string>): string[] {
return Object.keys(envVars);
}