feat: Enhance container stats monitoring and UI integration with new ContainerStatsComponent
This commit is contained in:
@@ -285,21 +285,25 @@ export class OneboxDaemon {
|
|||||||
private async broadcastStats(): Promise<void> {
|
private async broadcastStats(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const services = this.oneboxRef.services.listServices();
|
const services = this.oneboxRef.services.listServices();
|
||||||
|
const runningServices = services.filter(s => s.status === 'running' && s.containerID);
|
||||||
|
|
||||||
for (const service of services) {
|
logger.info(`Broadcasting stats for ${runningServices.length} running services`);
|
||||||
if (service.status === 'running' && service.containerID) {
|
|
||||||
try {
|
for (const service of runningServices) {
|
||||||
const stats = await this.oneboxRef.docker.getContainerStats(service.containerID);
|
try {
|
||||||
if (stats) {
|
const stats = await this.oneboxRef.docker.getContainerStats(service.containerID!);
|
||||||
this.oneboxRef.httpServer.broadcastStatsUpdate(service.name, stats);
|
if (stats) {
|
||||||
}
|
logger.info(`Broadcasting stats for ${service.name}: CPU=${stats.cpuPercent.toFixed(1)}%, Mem=${Math.round(stats.memoryUsed / 1024 / 1024)}MB`);
|
||||||
} catch {
|
this.oneboxRef.httpServer.broadcastStatsUpdate(service.name, stats);
|
||||||
// Silently ignore - stats collection can fail transiently
|
} 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 {
|
} catch (error) {
|
||||||
// Silently ignore broadcast errors
|
logger.error(`Broadcast stats error: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -574,14 +574,23 @@ export class OneboxDockerManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get container stats (CPU, memory, network)
|
* Get container stats (CPU, memory, network)
|
||||||
|
* Handles both regular containers and Swarm services
|
||||||
*/
|
*/
|
||||||
async getContainerStats(containerID: string): Promise<IContainerStats | null> {
|
async getContainerStats(containerID: string): Promise<IContainerStats | null> {
|
||||||
try {
|
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) {
|
if (!container) {
|
||||||
// Container not found - this is expected for Swarm services where we have service ID instead of container ID
|
// Container/service not found
|
||||||
// Return null silently
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ApiService } from '../../core/services/api.service';
|
|||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
import { WebSocketService } from '../../core/services/websocket.service';
|
import { WebSocketService } from '../../core/services/websocket.service';
|
||||||
import { IPlatformService, IContainerStats, TPlatformServiceType } from '../../core/types/api.types';
|
import { IPlatformService, IContainerStats, TPlatformServiceType } from '../../core/types/api.types';
|
||||||
|
import { ContainerStatsComponent } from '../../shared/components/container-stats/container-stats.component';
|
||||||
import {
|
import {
|
||||||
CardComponent,
|
CardComponent,
|
||||||
CardHeaderComponent,
|
CardHeaderComponent,
|
||||||
@@ -28,6 +29,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
|||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
BadgeComponent,
|
BadgeComponent,
|
||||||
SkeletonComponent,
|
SkeletonComponent,
|
||||||
|
ContainerStatsComponent,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@@ -141,62 +143,8 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
|||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
<!-- Resource Stats (only shown when service is running) -->
|
<!-- Resource Stats (only shown when service is running) -->
|
||||||
@if (service()!.status === 'running' && stats()) {
|
@if (service()!.status === 'running') {
|
||||||
<ui-card>
|
<app-container-stats [stats]="stats()" [showLiveIndicator]="true" />
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<!-- Service Description -->
|
<!-- Service Description -->
|
||||||
@@ -309,18 +257,6 @@ export class PlatformServiceDetailComponent implements OnInit {
|
|||||||
return new Date(timestamp).toLocaleString();
|
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 {
|
getServiceDescription(type: TPlatformServiceType): string {
|
||||||
const descriptions: Record<TPlatformServiceType, string> = {
|
const descriptions: Record<TPlatformServiceType, string> = {
|
||||||
mongodb: 'MongoDB is a document-oriented NoSQL database used for high volume data storage. It stores data in flexible, JSON-like documents.',
|
mongodb: 'MongoDB is a document-oriented NoSQL database used for high volume data storage. It stores data in flexible, JSON-like documents.',
|
||||||
|
|||||||
@@ -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 { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ApiService } from '../../core/services/api.service';
|
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 { LogStreamService } from '../../core/services/log-stream.service';
|
||||||
import { WebSocketService } from '../../core/services/websocket.service';
|
import { WebSocketService } from '../../core/services/websocket.service';
|
||||||
import { IService, IServiceUpdate, IPlatformResource, IContainerStats, IMetric } from '../../core/types/api.types';
|
import { IService, IServiceUpdate, IPlatformResource, IContainerStats, IMetric } from '../../core/types/api.types';
|
||||||
|
import { ContainerStatsComponent } from '../../shared/components/container-stats/container-stats.component';
|
||||||
import {
|
import {
|
||||||
CardComponent,
|
CardComponent,
|
||||||
CardHeaderComponent,
|
CardHeaderComponent,
|
||||||
@@ -51,6 +52,7 @@ import {
|
|||||||
DialogTitleComponent,
|
DialogTitleComponent,
|
||||||
DialogDescriptionComponent,
|
DialogDescriptionComponent,
|
||||||
DialogFooterComponent,
|
DialogFooterComponent,
|
||||||
|
ContainerStatsComponent,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@@ -192,62 +194,8 @@ import {
|
|||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
<!-- Resource Stats (only shown when service is running) -->
|
<!-- Resource Stats (only shown when service is running) -->
|
||||||
@if (service()!.status === 'running' && stats()) {
|
@if (service()!.status === 'running') {
|
||||||
<ui-card>
|
<app-container-stats [stats]="stats()" [showLiveIndicator]="true" />
|
||||||
<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 -->
|
<!-- Environment Variables -->
|
||||||
@@ -352,6 +300,38 @@ import {
|
|||||||
</ui-card-content>
|
</ui-card-content>
|
||||||
</ui-card>
|
</ui-card>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- External Registry Info (shown when NOT using Onebox Registry) -->
|
||||||
|
@if (!service()!.useOneboxRegistry) {
|
||||||
|
<ui-card>
|
||||||
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
|
<ui-card-title>Image Source</ui-card-title>
|
||||||
|
<ui-card-description>External container registry</ui-card-description>
|
||||||
|
</ui-card-header>
|
||||||
|
<ui-card-content>
|
||||||
|
<dl class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-muted-foreground">Registry</dt>
|
||||||
|
<dd class="text-sm">{{ imageInfo().registry }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-muted-foreground">Repository</dt>
|
||||||
|
<dd class="text-sm font-mono">{{ imageInfo().repository }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-muted-foreground">Tag</dt>
|
||||||
|
<dd class="text-sm">
|
||||||
|
<ui-badge variant="outline">{{ imageInfo().tag }}</ui-badge>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="pt-2 border-t">
|
||||||
|
<dt class="text-sm font-medium text-muted-foreground">Full Image Reference</dt>
|
||||||
|
<dd class="text-sm font-mono text-muted-foreground break-all">{{ service()!.image }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs Section -->
|
<!-- Logs Section -->
|
||||||
@@ -463,6 +443,13 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
editForm: IServiceUpdate = {};
|
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() {
|
constructor() {
|
||||||
// Auto-scroll when new logs arrive
|
// Auto-scroll when new logs arrive
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -597,22 +584,41 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
|||||||
return new Date(timestamp).toLocaleString();
|
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[] {
|
getEnvKeys(envVars: Record<string, string>): string[] {
|
||||||
return Object.keys(envVars);
|
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 {
|
cancelEdit(): void {
|
||||||
this.editMode.set(false);
|
this.editMode.set(false);
|
||||||
if (this.service()) {
|
if (this.service()) {
|
||||||
|
|||||||
@@ -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: `
|
||||||
|
<!-- Live indicator -->
|
||||||
|
@if (showLiveIndicator() && stats()) {
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
<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>
|
||||||
|
<span class="text-sm text-muted-foreground">Live stats</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||||
|
<!-- CPU -->
|
||||||
|
<ui-card>
|
||||||
|
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<ui-card-title class="text-sm font-medium">CPU</ui-card-title>
|
||||||
|
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
|
</svg>
|
||||||
|
</ui-card-header>
|
||||||
|
<ui-card-content>
|
||||||
|
@if (stats()) {
|
||||||
|
<div class="text-2xl font-bold">{{ formatPercent(stats()!.cpuPercent) }}</div>
|
||||||
|
} @else {
|
||||||
|
<ui-skeleton class="h-8 w-16" />
|
||||||
|
}
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
|
||||||
|
<!-- Memory -->
|
||||||
|
<ui-card>
|
||||||
|
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<ui-card-title class="text-sm font-medium">Memory</ui-card-title>
|
||||||
|
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||||
|
</svg>
|
||||||
|
</ui-card-header>
|
||||||
|
<ui-card-content>
|
||||||
|
@if (stats()) {
|
||||||
|
<div class="text-2xl font-bold">{{ formatBytes(stats()!.memoryUsed) }}</div>
|
||||||
|
<p class="text-xs text-muted-foreground">of {{ formatBytes(stats()!.memoryLimit) }}</p>
|
||||||
|
} @else {
|
||||||
|
<ui-skeleton class="h-8 w-20" />
|
||||||
|
<ui-skeleton class="h-3 w-16 mt-1" />
|
||||||
|
}
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
|
||||||
|
<!-- Network In -->
|
||||||
|
<ui-card>
|
||||||
|
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<ui-card-title class="text-sm font-medium">Network In</ui-card-title>
|
||||||
|
<svg class="h-4 w-4 text-muted-foreground" 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>
|
||||||
|
</ui-card-header>
|
||||||
|
<ui-card-content>
|
||||||
|
@if (stats()) {
|
||||||
|
<div class="text-2xl font-bold">{{ formatBytes(stats()!.networkRx) }}</div>
|
||||||
|
} @else {
|
||||||
|
<ui-skeleton class="h-8 w-16" />
|
||||||
|
}
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
|
||||||
|
<!-- Network Out -->
|
||||||
|
<ui-card>
|
||||||
|
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<ui-card-title class="text-sm font-medium">Network Out</ui-card-title>
|
||||||
|
<svg class="h-4 w-4 text-muted-foreground" 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>
|
||||||
|
</ui-card-header>
|
||||||
|
<ui-card-content>
|
||||||
|
@if (stats()) {
|
||||||
|
<div class="text-2xl font-bold">{{ formatBytes(stats()!.networkTx) }}</div>
|
||||||
|
} @else {
|
||||||
|
<ui-skeleton class="h-8 w-16" />
|
||||||
|
}
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class ContainerStatsComponent {
|
||||||
|
stats = input<IContainerStats | null>(null);
|
||||||
|
showLiveIndicator = input<boolean>(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) + '%';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user