From 9de32cd00d85fd1cb081db334abb011d55e93ee8 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 26 Nov 2025 16:36:01 +0000 Subject: [PATCH] feat: Enhance container stats monitoring and UI integration with new ContainerStatsComponent --- ts/classes/daemon.ts | 26 ++-- ts/classes/docker.ts | 15 +- .../platform-service-detail.component.ts | 72 +-------- .../services/service-detail.component.ts | 144 +++++++++--------- .../container-stats.component.ts | 122 +++++++++++++++ 5 files changed, 228 insertions(+), 151 deletions(-) create mode 100644 ui/src/app/shared/components/container-stats/container-stats.component.ts diff --git a/ts/classes/daemon.ts b/ts/classes/daemon.ts index 10c8a7c..667f27d 100644 --- a/ts/classes/daemon.ts +++ b/ts/classes/daemon.ts @@ -285,21 +285,25 @@ export class OneboxDaemon { private async broadcastStats(): Promise { try { const services = this.oneboxRef.services.listServices(); + const runningServices = services.filter(s => s.status === 'running' && s.containerID); - 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 + logger.info(`Broadcasting stats for ${runningServices.length} running services`); + + for (const service of runningServices) { + try { + const stats = await this.oneboxRef.docker.getContainerStats(service.containerID!); + if (stats) { + logger.info(`Broadcasting stats for ${service.name}: CPU=${stats.cpuPercent.toFixed(1)}%, Mem=${Math.round(stats.memoryUsed / 1024 / 1024)}MB`); + this.oneboxRef.httpServer.broadcastStatsUpdate(service.name, stats); + } else { + logger.warn(`No stats returned for ${service.name} (containerID: ${service.containerID})`); } + } catch (error) { + logger.warn(`Stats collection failed for ${service.name}: ${getErrorMessage(error)}`); } } - } catch { - // Silently ignore broadcast errors + } catch (error) { + logger.error(`Broadcast stats error: ${getErrorMessage(error)}`); } } diff --git a/ts/classes/docker.ts b/ts/classes/docker.ts index 6569264..2983503 100644 --- a/ts/classes/docker.ts +++ b/ts/classes/docker.ts @@ -574,14 +574,23 @@ export class OneboxDockerManager { /** * Get container stats (CPU, memory, network) + * Handles both regular containers and Swarm services */ async getContainerStats(containerID: string): Promise { try { - const container = await this.dockerClient!.getContainerById(containerID); + // Try to get container directly first + let container = await this.dockerClient!.getContainerById(containerID); + + // If not found, it might be a service ID - try to get the actual container ID + if (!container) { + const serviceContainerId = await this.getContainerIdForService(containerID); + if (serviceContainerId) { + container = await this.dockerClient!.getContainerById(serviceContainerId); + } + } if (!container) { - // Container not found - this is expected for Swarm services where we have service ID instead of container ID - // Return null silently + // Container/service not found return null; } diff --git a/ui/src/app/features/services/platform-service-detail.component.ts b/ui/src/app/features/services/platform-service-detail.component.ts index 52286bf..ce37c37 100644 --- a/ui/src/app/features/services/platform-service-detail.component.ts +++ b/ui/src/app/features/services/platform-service-detail.component.ts @@ -4,6 +4,7 @@ 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 { ContainerStatsComponent } from '../../shared/components/container-stats/container-stats.component'; import { CardComponent, CardHeaderComponent, @@ -28,6 +29,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; ButtonComponent, BadgeComponent, SkeletonComponent, + ContainerStatsComponent, ], template: `
@@ -141,62 +143,8 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; - @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()!.status === 'running') { + } @@ -309,18 +257,6 @@ export class PlatformServiceDetailComponent implements OnInit { 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.', diff --git a/ui/src/app/features/services/service-detail.component.ts b/ui/src/app/features/services/service-detail.component.ts index 7a36024..79fa5b6 100644 --- a/ui/src/app/features/services/service-detail.component.ts +++ b/ui/src/app/features/services/service-detail.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, signal, OnInit, OnDestroy, ViewChild, ElementRef, effect } from '@angular/core'; +import { Component, inject, signal, computed, OnInit, OnDestroy, ViewChild, ElementRef, effect } from '@angular/core'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { ApiService } from '../../core/services/api.service'; @@ -6,6 +6,7 @@ import { ToastService } from '../../core/services/toast.service'; import { LogStreamService } from '../../core/services/log-stream.service'; import { WebSocketService } from '../../core/services/websocket.service'; import { IService, IServiceUpdate, IPlatformResource, IContainerStats, IMetric } from '../../core/types/api.types'; +import { ContainerStatsComponent } from '../../shared/components/container-stats/container-stats.component'; import { CardComponent, CardHeaderComponent, @@ -51,6 +52,7 @@ import { DialogTitleComponent, DialogDescriptionComponent, DialogFooterComponent, + ContainerStatsComponent, ], template: `
@@ -192,62 +194,8 @@ 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()!.status === 'running') { + } @@ -352,6 +300,38 @@ import { } + + + @if (!service()!.useOneboxRegistry) { + + + Image Source + External container registry + + +
+
+
Registry
+
{{ imageInfo().registry }}
+
+
+
Repository
+
{{ imageInfo().repository }}
+
+
+
Tag
+
+ {{ imageInfo().tag }} +
+
+
+
Full Image Reference
+
{{ service()!.image }}
+
+
+
+
+ }
@@ -463,6 +443,13 @@ export class ServiceDetailComponent implements OnInit, OnDestroy { editForm: IServiceUpdate = {}; + // Computed signal for parsed image information (external registries) + imageInfo = computed(() => { + const svc = this.service(); + if (!svc) return { registry: '', repository: '', tag: '' }; + return this.parseImageInfo(svc.image, svc.registry); + }); + constructor() { // Auto-scroll when new logs arrive effect(() => { @@ -597,22 +584,41 @@ 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); } + parseImageInfo(image: string, registry?: string): { registry: string; repository: string; tag: string } { + // Handle digest format: image@sha256:... + let imageWithoutDigest = image; + if (image.includes('@')) { + imageWithoutDigest = image.split('@')[0]; + } + + // Split tag: image:tag + const tagIndex = imageWithoutDigest.lastIndexOf(':'); + let repository = imageWithoutDigest; + let tag = 'latest'; + + if (tagIndex > 0 && !imageWithoutDigest.substring(tagIndex).includes('/')) { + repository = imageWithoutDigest.substring(0, tagIndex); + tag = imageWithoutDigest.substring(tagIndex + 1); + } + + // Parse registry from repository + let parsedRegistry = registry || 'Docker Hub'; + if (!registry && repository.includes('/')) { + const firstPart = repository.split('/')[0]; + // If first part looks like a registry (contains . or :) + if (firstPart.includes('.') || firstPart.includes(':')) { + parsedRegistry = firstPart; + repository = repository.substring(firstPart.length + 1); + } + } + + return { registry: parsedRegistry, repository, tag }; + } + cancelEdit(): void { this.editMode.set(false); if (this.service()) { diff --git a/ui/src/app/shared/components/container-stats/container-stats.component.ts b/ui/src/app/shared/components/container-stats/container-stats.component.ts new file mode 100644 index 0000000..0fab681 --- /dev/null +++ b/ui/src/app/shared/components/container-stats/container-stats.component.ts @@ -0,0 +1,122 @@ +import { Component, input, computed } from '@angular/core'; +import { IContainerStats } from '../../../core/types/api.types'; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardContentComponent, +} from '../../../ui/card/card.component'; +import { SkeletonComponent } from '../../../ui/skeleton/skeleton.component'; + +@Component({ + selector: 'app-container-stats', + standalone: true, + imports: [ + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardContentComponent, + SkeletonComponent, + ], + template: ` + + @if (showLiveIndicator() && stats()) { +
+ + + + + Live stats +
+ } + + +
+ + + + CPU + + + + + + @if (stats()) { +
{{ formatPercent(stats()!.cpuPercent) }}
+ } @else { + + } +
+
+ + + + + Memory + + + + + + @if (stats()) { +
{{ formatBytes(stats()!.memoryUsed) }}
+

of {{ formatBytes(stats()!.memoryLimit) }}

+ } @else { + + + } +
+
+ + + + + Network In + + + + + + @if (stats()) { +
{{ formatBytes(stats()!.networkRx) }}
+ } @else { + + } +
+
+ + + + + Network Out + + + + + + @if (stats()) { +
{{ formatBytes(stats()!.networkTx) }}
+ } @else { + + } +
+
+
+ `, +}) +export class ContainerStatsComponent { + stats = input(null); + showLiveIndicator = input(true); + + 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) + '%'; + } +}