diff --git a/package.json b/package.json index deb2f2f..47ddfb0 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "license": "MIT", "repository": { "type": "git", - "url": "https://code.foss.global/serve.zone/onebox" + "url": "https://code.foss.global/serve.zone/onebox.git" }, "homepage": "https://code.foss.global/serve.zone/onebox", "bugs": { diff --git a/ts/classes/daemon.ts b/ts/classes/daemon.ts index c7dc34d..10c8a7c 100644 --- a/ts/classes/daemon.ts +++ b/ts/classes/daemon.ts @@ -21,7 +21,9 @@ export class OneboxDaemon { private smartdaemon: plugins.smartdaemon.SmartDaemon | null = null; private running = false; private monitoringInterval: number | null = null; - private metricsInterval = 60000; // 1 minute + private statsInterval: number | null = null; + private metricsInterval = 60000; // 1 minute (for DB storage) + private statsBroadcastInterval = 5000; // 5 seconds (for real-time WebSocket) private pidFilePath: string = PID_FILE_PATH; private lastDomainSync = 0; // Timestamp of last Cloudflare domain sync private domainSyncInterval = 6 * 60 * 60 * 1000; // 6 hours @@ -184,6 +186,11 @@ export class OneboxDaemon { await this.monitoringTick(); }, this.metricsInterval); + // Start stats broadcasting loop (faster for real-time UI) + this.statsInterval = setInterval(async () => { + await this.broadcastStats(); + }, this.statsBroadcastInterval); + // Run first tick immediately this.monitoringTick(); } @@ -195,8 +202,12 @@ export class OneboxDaemon { if (this.monitoringInterval !== null) { clearInterval(this.monitoringInterval); this.monitoringInterval = null; - logger.debug('Monitoring loop stopped'); } + if (this.statsInterval !== null) { + clearInterval(this.statsInterval); + this.statsInterval = null; + } + logger.debug('Monitoring loops stopped'); } /** @@ -268,6 +279,30 @@ export class OneboxDaemon { } } + /** + * Broadcast stats to WebSocket clients (real-time updates) + */ + private async broadcastStats(): Promise { + try { + const services = this.oneboxRef.services.listServices(); + + for (const service of services) { + if (service.status === 'running' && service.containerID) { + try { + const stats = await this.oneboxRef.docker.getContainerStats(service.containerID); + if (stats) { + this.oneboxRef.httpServer.broadcastStatsUpdate(service.name, stats); + } + } catch { + // Silently ignore - stats collection can fail transiently + } + } + } + } catch { + // Silently ignore broadcast errors + } + } + /** * Check SSL certificate expiration */ diff --git a/ts/classes/httpserver.ts b/ts/classes/httpserver.ts index 1f1d3dc..c37ee5e 100644 --- a/ts/classes/httpserver.ts +++ b/ts/classes/httpserver.ts @@ -8,7 +8,7 @@ import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; import type { Onebox } from './onebox.ts'; -import type { IApiResponse, ICreateRegistryTokenRequest, IRegistryTokenView, TPlatformServiceType } from '../types.ts'; +import type { IApiResponse, ICreateRegistryTokenRequest, IRegistryTokenView, TPlatformServiceType, IContainerStats } from '../types.ts'; export class OneboxHttpServer { private oneboxRef: Onebox; @@ -245,6 +245,13 @@ export class OneboxHttpServer { } else if (path.match(/^\/api\/services\/[^/]+\/logs$/) && method === 'GET') { const name = path.split('/')[3]; return await this.handleGetLogsRequest(name); + } else if (path.match(/^\/api\/services\/[^/]+\/stats$/) && method === 'GET') { + const name = path.split('/')[3]; + return await this.handleGetServiceStatsRequest(name); + } else if (path.match(/^\/api\/services\/[^/]+\/metrics$/) && method === 'GET') { + const name = path.split('/')[3]; + const limit = new URL(req.url).searchParams.get('limit'); + return await this.handleGetServiceMetricsRequest(name, limit ? parseInt(limit, 10) : 60); } else if (path === '/api/ssl/obtain' && method === 'POST') { return await this.handleObtainCertificateRequest(req); } else if (path === '/api/ssl/list' && method === 'GET') { @@ -293,6 +300,9 @@ export class OneboxHttpServer { } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stop$/) && method === 'POST') { const type = path.split('/')[3] as TPlatformServiceType; return await this.handleStopPlatformServiceRequest(type); + } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stats$/) && method === 'GET') { + const type = path.split('/')[3] as TPlatformServiceType; + return await this.handleGetPlatformServiceStatsRequest(type); } else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') { const serviceName = path.split('/')[3]; return await this.handleGetServicePlatformResourcesRequest(serviceName); @@ -506,6 +516,51 @@ export class OneboxHttpServer { } } + private async handleGetServiceStatsRequest(name: string): Promise { + try { + const service = this.oneboxRef.services.getService(name); + if (!service) { + return this.jsonResponse({ success: false, error: 'Service not found' }, 404); + } + + if (!service.containerID) { + return this.jsonResponse({ success: false, error: 'Service has no container' }, 400); + } + + // Get live container stats + const stats = await this.oneboxRef.docker.getContainerStats(service.containerID); + if (!stats) { + return this.jsonResponse({ success: false, error: 'Could not retrieve container stats' }, 500); + } + + return this.jsonResponse({ success: true, data: stats }); + } catch (error) { + logger.error(`Failed to get stats for service ${name}: ${getErrorMessage(error)}`); + return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to get stats' }, 500); + } + } + + private async handleGetServiceMetricsRequest(name: string, limit: number): Promise { + try { + const service = this.oneboxRef.services.getService(name); + if (!service) { + return this.jsonResponse({ success: false, error: 'Service not found' }, 404); + } + + if (!service.id) { + return this.jsonResponse({ success: false, error: 'Service has no ID' }, 400); + } + + // Get historical metrics from database + const metrics = this.oneboxRef.database.getMetrics(service.id, limit); + + return this.jsonResponse({ success: true, data: metrics }); + } catch (error) { + logger.error(`Failed to get metrics for service ${name}: ${getErrorMessage(error)}`); + return this.jsonResponse({ success: false, error: getErrorMessage(error) || 'Failed to get metrics' }, 500); + } + } + private async handleGetSettingsRequest(): Promise { const settings = this.oneboxRef.database.getAllSettings(); return this.jsonResponse({ success: true, data: settings }); @@ -1251,6 +1306,18 @@ export class OneboxHttpServer { }); } + /** + * Broadcast stats update for a service + */ + broadcastStatsUpdate(serviceName: string, stats: IContainerStats): void { + this.broadcast({ + type: 'stats_update', + serviceName, + stats, + timestamp: Date.now(), + }); + } + // ============ Platform Services Endpoints ============ private async handleListPlatformServicesRequest(): Promise { @@ -1263,11 +1330,19 @@ export class OneboxHttpServer { const service = platformServices.find((s) => s.type === provider.type); // Check if provider has isCore property (like CaddyProvider) const isCore = 'isCore' in provider && (provider as any).isCore === true; + + // For Caddy, check actual runtime status since it starts without a DB record + let status = service?.status || 'not-deployed'; + if (provider.type === 'caddy') { + const proxyStatus = this.oneboxRef.reverseProxy.getStatus(); + status = proxyStatus.http.running ? 'running' : 'stopped'; + } + return { type: provider.type, displayName: provider.displayName, resourceTypes: provider.resourceTypes, - status: service?.status || 'not-deployed', + status, containerId: service?.containerId, isCore, createdAt: service?.createdAt, @@ -1302,13 +1377,20 @@ export class OneboxHttpServer { ? this.oneboxRef.database.getPlatformResourcesByPlatformService(service.id) : []; + // For Caddy, check actual runtime status since it starts without a DB record + let status = service?.status || 'not-deployed'; + if (type === 'caddy') { + const proxyStatus = this.oneboxRef.reverseProxy.getStatus(); + status = proxyStatus.http.running ? 'running' : 'stopped'; + } + return this.jsonResponse({ success: true, data: { type: provider.type, displayName: provider.displayName, resourceTypes: provider.resourceTypes, - status: service?.status || 'not-deployed', + status, containerId: service?.containerId, config: provider.getDefaultConfig(), resourceCount: allResources.length, @@ -1391,6 +1473,52 @@ export class OneboxHttpServer { } } + private async handleGetPlatformServiceStatsRequest(type: TPlatformServiceType): Promise { + try { + const provider = this.oneboxRef.platformServices.getProvider(type); + if (!provider) { + return this.jsonResponse({ + success: false, + error: `Unknown platform service type: ${type}`, + }, 404); + } + + // For Caddy, return proxy stats instead of container stats + if (type === 'caddy') { + const proxyStatus = this.oneboxRef.reverseProxy.getStatus(); + return this.jsonResponse({ + success: true, + data: { + type: 'caddy', + running: proxyStatus.http.running, + httpPort: proxyStatus.http.port, + httpsPort: proxyStatus.https.port, + routes: proxyStatus.routes, + certificates: proxyStatus.https.certificates, + }, + }); + } + + const service = this.oneboxRef.database.getPlatformServiceByType(type); + if (!service || !service.containerId) { + return this.jsonResponse({ success: false, error: 'Platform service has no container' }, 400); + } + + const stats = await this.oneboxRef.docker.getContainerStats(service.containerId); + if (!stats) { + return this.jsonResponse({ success: false, error: 'Could not retrieve container stats' }, 500); + } + + return this.jsonResponse({ success: true, data: stats }); + } catch (error) { + logger.error(`Failed to get stats for platform service ${type}: ${getErrorMessage(error)}`); + return this.jsonResponse({ + success: false, + error: getErrorMessage(error) || 'Failed to get platform service stats', + }, 500); + } + } + private async handleGetServicePlatformResourcesRequest(serviceName: string): Promise { try { const service = this.oneboxRef.services.getService(serviceName); diff --git a/ts/classes/onebox.ts b/ts/classes/onebox.ts index 18a11bf..221c6ac 100644 --- a/ts/classes/onebox.ts +++ b/ts/classes/onebox.ts @@ -237,7 +237,7 @@ export class Onebox { }, ssl: { configured: sslConfigured, - certbotInstalled: await this.ssl.isCertbotInstalled(), + certificateCount: this.ssl.listCertificates().length, }, services: { total: totalServices, diff --git a/ui/src/app/app.routes.ts b/ui/src/app/app.routes.ts index 7123e2d..d9ec738 100644 --- a/ui/src/app/app.routes.ts +++ b/ui/src/app/app.routes.ts @@ -44,6 +44,13 @@ export const routes: Routes = [ (m) => m.ServiceCreateComponent ), }, + { + path: 'platform/:type', + loadComponent: () => + import('./features/services/platform-service-detail.component').then( + (m) => m.PlatformServiceDetailComponent + ), + }, { path: ':name', loadComponent: () => diff --git a/ui/src/app/core/services/api.service.ts b/ui/src/app/core/services/api.service.ts index 19acb7b..1cfed90 100644 --- a/ui/src/app/core/services/api.service.ts +++ b/ui/src/app/core/services/api.service.ts @@ -22,6 +22,8 @@ import { TPlatformServiceType, INetworkTarget, INetworkStats, + IContainerStats, + IMetric, } from '../types/api.types'; @Injectable({ providedIn: 'root' }) @@ -70,6 +72,15 @@ export class ApiService { return firstValueFrom(this.http.get>(`/api/services/${name}/logs`)); } + async getServiceStats(name: string): Promise> { + return firstValueFrom(this.http.get>(`/api/services/${name}/stats`)); + } + + async getServiceMetrics(name: string, limit?: number): Promise> { + const params = limit ? `?limit=${limit}` : ''; + return firstValueFrom(this.http.get>(`/api/services/${name}/metrics${params}`)); + } + // Registries async getRegistries(): Promise> { return firstValueFrom(this.http.get>('/api/registries')); @@ -177,6 +188,10 @@ export class ApiService { return firstValueFrom(this.http.post>(`/api/platform-services/${type}/stop`, {})); } + async getPlatformServiceStats(type: TPlatformServiceType): Promise> { + return firstValueFrom(this.http.get>(`/api/platform-services/${type}/stats`)); + } + async getServicePlatformResources(serviceName: string): Promise> { return firstValueFrom(this.http.get>(`/api/services/${serviceName}/platform-resources`)); } diff --git a/ui/src/app/core/services/websocket.service.ts b/ui/src/app/core/services/websocket.service.ts index 39ab4eb..b3b40f9 100644 --- a/ui/src/app/core/services/websocket.service.ts +++ b/ui/src/app/core/services/websocket.service.ts @@ -1,5 +1,5 @@ import { Injectable, signal, computed, effect, inject } from '@angular/core'; -import { IWebSocketMessage } from '../types/api.types'; +import { IWebSocketMessage, IStatsUpdateMessage, IContainerStats } from '../types/api.types'; import { AuthService } from './auth.service'; @Injectable({ providedIn: 'root' }) @@ -29,6 +29,11 @@ export class WebSocketService { return msg?.type === 'system_status' ? msg : null; }); + statsUpdate = computed(() => { + const msg = this.lastMessage(); + return msg?.type === 'stats_update' ? (msg as unknown as IStatsUpdateMessage) : null; + }); + constructor() { // Auto-connect when authenticated effect(() => { diff --git a/ui/src/app/core/types/api.types.ts b/ui/src/app/core/types/api.types.ts index 88619e1..89f0efe 100644 --- a/ui/src/app/core/types/api.types.ts +++ b/ui/src/app/core/types/api.types.ts @@ -77,7 +77,7 @@ export interface ISystemStatus { routes: number; }; dns: { configured: boolean }; - ssl: { configured: boolean; certbotInstalled: boolean }; + ssl: { configured: boolean; certificateCount: number }; services: { total: number; running: number; stopped: number }; platformServices: Array<{ type: TPlatformServiceType; status: TPlatformServiceStatus }>; } @@ -195,10 +195,11 @@ export interface ISettings { } export interface IWebSocketMessage { - type: 'connected' | 'service_update' | 'service_status' | 'system_status'; + type: 'connected' | 'service_update' | 'service_status' | 'system_status' | 'stats_update'; action?: 'created' | 'updated' | 'deleted' | 'started' | 'stopped'; serviceName?: string; status?: string; + stats?: IContainerStats; data?: any; message?: string; timestamp: number; @@ -289,3 +290,33 @@ export interface INetworkLogMessage { data?: ICaddyAccessLog; timestamp: number; } + +// Container stats (live) +export interface IContainerStats { + cpuPercent: number; + memoryUsed: number; + memoryLimit: number; + memoryPercent: number; + networkRx: number; + networkTx: number; +} + +// Historical metrics +export interface IMetric { + id?: number; + serviceId: number; + timestamp: number; + cpuPercent: number; + memoryUsed: number; + memoryLimit: number; + networkRxBytes: number; + networkTxBytes: number; +} + +// Stats update WebSocket message +export interface IStatsUpdateMessage { + type: 'stats_update'; + serviceName: string; + stats: IContainerStats; + timestamp: number; +} diff --git a/ui/src/app/features/dashboard/dashboard.component.ts b/ui/src/app/features/dashboard/dashboard.component.ts index 714d948..4793d49 100644 --- a/ui/src/app/features/dashboard/dashboard.component.ts +++ b/ui/src/app/features/dashboard/dashboard.component.ts @@ -175,10 +175,8 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
- Certbot - - {{ status()!.ssl.certbotInstalled ? 'Installed' : 'Not installed' }} - + Certificates + {{ status()!.ssl.certificateCount }} managed
diff --git a/ui/src/app/features/services/platform-service-detail.component.ts b/ui/src/app/features/services/platform-service-detail.component.ts new file mode 100644 index 0000000..52286bf --- /dev/null +++ b/ui/src/app/features/services/platform-service-detail.component.ts @@ -0,0 +1,381 @@ +import { Component, inject, signal, OnInit, effect } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ApiService } from '../../core/services/api.service'; +import { ToastService } from '../../core/services/toast.service'; +import { WebSocketService } from '../../core/services/websocket.service'; +import { IPlatformService, IContainerStats, TPlatformServiceType } from '../../core/types/api.types'; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardDescriptionComponent, + CardContentComponent, +} from '../../ui/card/card.component'; +import { ButtonComponent } from '../../ui/button/button.component'; +import { BadgeComponent } from '../../ui/badge/badge.component'; +import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; + +@Component({ + selector: 'app-platform-service-detail', + standalone: true, + imports: [ + RouterLink, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardDescriptionComponent, + CardContentComponent, + ButtonComponent, + BadgeComponent, + SkeletonComponent, + ], + template: ` +
+ +
+ + + + + Back to Services + + + @if (loading() && !service()) { + + } @else if (service()) { +
+

{{ service()!.displayName }}

+ {{ service()!.status }} + @if (service()!.isCore) { + Core Service + } +
+ } +
+ + @if (loading() && !service()) { +
+ + + + + + @for (_ of [1,2,3]; track $index) { + + } + + +
+ } @else if (service()) { +
+ + + + Service Details + Platform service information + + +
+
+
Type
+
{{ service()!.type }}
+
+
+
Resource Types
+
+
+ @for (type of service()!.resourceTypes; track type) { + {{ type }} + } +
+
+
+ @if (service()!.containerId) { +
+
Container ID
+
{{ service()!.containerId?.slice(0, 12) }}
+
+ } + @if (service()!.createdAt) { +
+
Created
+
{{ formatDate(service()!.createdAt!) }}
+
+ } +
+
+
+ + + + + Actions + Manage platform service state + + + @if (service()!.isCore) { +

+ This is a core service managed by Onebox. It cannot be stopped manually. +

+ } @else { +
+ @if (service()!.status === 'stopped' || service()!.status === 'not-deployed' || service()!.status === 'failed') { + + } + @if (service()!.status === 'running') { + + } +
+ } +
+
+ + + @if (service()!.status === 'running' && stats()) { + + + Resource Usage + + + + + + + Live stats + + + + +
+ +
+
+ CPU + {{ formatPercent(stats()!.cpuPercent) }} +
+
+
+
+
+ + +
+
+ Memory + {{ formatBytes(stats()!.memoryUsed) }} / {{ formatBytes(stats()!.memoryLimit) }} +
+
+
+
+
+ + +
+
+ Network In + {{ formatBytes(stats()!.networkRx) }} +
+
+ + +
+
+ Network Out + {{ formatBytes(stats()!.networkTx) }} +
+
+
+
+
+ } + + + + + About {{ service()!.displayName }} + + +

{{ getServiceDescription(service()!.type) }}

+
+
+
+ } +
+ `, +}) +export class PlatformServiceDetailComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private api = inject(ApiService); + private toast = inject(ToastService); + private ws = inject(WebSocketService); + + service = signal(null); + stats = signal(null); + loading = signal(false); + actionLoading = signal(false); + + private statsInterval: any; + + constructor() { + // Listen for WebSocket stats updates for platform services + effect(() => { + const update = this.ws.statsUpdate(); + const currentService = this.service(); + // Platform services use "onebox-{type}" as service name in WebSocket + if (update && currentService && update.serviceName === `onebox-${currentService.type}`) { + this.stats.set(update.stats); + } + }); + } + + ngOnInit(): void { + const type = this.route.snapshot.paramMap.get('type') as TPlatformServiceType; + if (type) { + this.loadService(type); + } + } + + async loadService(type: TPlatformServiceType): Promise { + this.loading.set(true); + try { + const response = await this.api.getPlatformService(type); + if (response.success && response.data) { + this.service.set(response.data); + // Load stats if service is running + if (response.data.status === 'running') { + this.loadStats(type); + // Start polling stats every 5 seconds + this.startStatsPolling(type); + } + } else { + this.toast.error(response.error || 'Platform service not found'); + this.router.navigate(['/services']); + } + } catch { + this.toast.error('Failed to load platform service'); + } finally { + this.loading.set(false); + } + } + + async loadStats(type: TPlatformServiceType): Promise { + try { + const response = await this.api.getPlatformServiceStats(type); + if (response.success && response.data) { + this.stats.set(response.data); + } + } catch { + // Silent fail - stats are optional + } + } + + startStatsPolling(type: TPlatformServiceType): void { + // Clear existing interval if any + if (this.statsInterval) { + clearInterval(this.statsInterval); + } + // Poll every 5 seconds + this.statsInterval = setInterval(() => { + if (this.service()?.status === 'running') { + this.loadStats(type); + } + }, 5000); + } + + getStatusVariant(status: string): 'success' | 'destructive' | 'warning' | 'secondary' { + switch (status) { + case 'running': return 'success'; + case 'stopped': + case 'not-deployed': return 'secondary'; + case 'failed': return 'destructive'; + case 'starting': + case 'stopping': return 'warning'; + default: return 'secondary'; + } + } + + formatDate(timestamp: number): string { + 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) + '%'; + } + + getServiceDescription(type: TPlatformServiceType): string { + const descriptions: Record = { + mongodb: 'MongoDB is a document-oriented NoSQL database used for high volume data storage. It stores data in flexible, JSON-like documents.', + minio: 'MinIO is a high-performance, S3-compatible object storage service. Use it to store unstructured data like photos, videos, log files, and backups.', + redis: 'Redis is an in-memory data structure store, used as a distributed cache, message broker, and key-value database.', + postgresql: 'PostgreSQL is a powerful, open-source object-relational database system with over 35 years of active development.', + rabbitmq: 'RabbitMQ is a message broker that enables applications to communicate with each other using messages through queues.', + caddy: 'Caddy is a powerful, enterprise-ready, open-source web server with automatic HTTPS. It serves as the reverse proxy for Onebox.', + }; + return descriptions[type] || 'A platform service managed by Onebox.'; + } + + async startService(): Promise { + const type = this.service()?.type; + if (!type) return; + + this.actionLoading.set(true); + try { + const response = await this.api.startPlatformService(type); + if (response.success) { + this.toast.success('Platform service started'); + this.loadService(type); + } else { + this.toast.error(response.error || 'Failed to start platform service'); + } + } catch { + this.toast.error('Failed to start platform service'); + } finally { + this.actionLoading.set(false); + } + } + + async stopService(): Promise { + const type = this.service()?.type; + if (!type) return; + + this.actionLoading.set(true); + try { + const response = await this.api.stopPlatformService(type); + if (response.success) { + this.toast.success('Platform service stopped'); + // Clear stats and stop polling + this.stats.set(null); + if (this.statsInterval) { + clearInterval(this.statsInterval); + this.statsInterval = null; + } + this.loadService(type); + } else { + this.toast.error(response.error || 'Failed to stop platform service'); + } + } catch { + this.toast.error('Failed to stop platform service'); + } finally { + this.actionLoading.set(false); + } + } +} diff --git a/ui/src/app/features/services/service-detail.component.ts b/ui/src/app/features/services/service-detail.component.ts index 7ed4323..7a36024 100644 --- a/ui/src/app/features/services/service-detail.component.ts +++ b/ui/src/app/features/services/service-detail.component.ts @@ -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 { + + @if (service()!.status === 'running' && stats()) { + + + Resource Usage + + + + + + + Live stats + + + + +
+ +
+
+ CPU + {{ formatPercent(stats()!.cpuPercent) }} +
+
+
+
+
+ + +
+
+ Memory + {{ formatBytes(stats()!.memoryUsed) }} / {{ formatBytes(stats()!.memoryLimit) }} +
+
+
+
+
+ + +
+
+ Network In + {{ formatBytes(stats()!.networkRx) }} +
+
+ + +
+
+ Network Out + {{ formatBytes(stats()!.networkTx) }} +
+
+
+
+
+ } + @if (service()!.envVars && getEnvKeys(service()!.envVars).length > 0) { @@ -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; service = signal(null); platformResources = signal([]); + stats = signal(null); + metrics = signal([]); 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 { + 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 { + 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[] { return Object.keys(envVars); } diff --git a/ui/src/app/features/services/services-list.component.ts b/ui/src/app/features/services/services-list.component.ts index a6782fc..6c0d412 100644 --- a/ui/src/app/features/services/services-list.component.ts +++ b/ui/src/app/features/services/services-list.component.ts @@ -227,7 +227,9 @@ type TServicesTab = 'user' | 'system';
- {{ service.displayName }} + + {{ service.displayName }} + @if (service.isCore) { Core }