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

@@ -28,7 +28,7 @@
"license": "MIT", "license": "MIT",
"repository": { "repository": {
"type": "git", "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", "homepage": "https://code.foss.global/serve.zone/onebox",
"bugs": { "bugs": {

View File

@@ -21,7 +21,9 @@ export class OneboxDaemon {
private smartdaemon: plugins.smartdaemon.SmartDaemon | null = null; private smartdaemon: plugins.smartdaemon.SmartDaemon | null = null;
private running = false; private running = false;
private monitoringInterval: number | null = null; 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 pidFilePath: string = PID_FILE_PATH;
private lastDomainSync = 0; // Timestamp of last Cloudflare domain sync private lastDomainSync = 0; // Timestamp of last Cloudflare domain sync
private domainSyncInterval = 6 * 60 * 60 * 1000; // 6 hours private domainSyncInterval = 6 * 60 * 60 * 1000; // 6 hours
@@ -184,6 +186,11 @@ export class OneboxDaemon {
await this.monitoringTick(); await this.monitoringTick();
}, this.metricsInterval); }, this.metricsInterval);
// Start stats broadcasting loop (faster for real-time UI)
this.statsInterval = setInterval(async () => {
await this.broadcastStats();
}, this.statsBroadcastInterval);
// Run first tick immediately // Run first tick immediately
this.monitoringTick(); this.monitoringTick();
} }
@@ -195,8 +202,12 @@ export class OneboxDaemon {
if (this.monitoringInterval !== null) { if (this.monitoringInterval !== null) {
clearInterval(this.monitoringInterval); clearInterval(this.monitoringInterval);
this.monitoringInterval = null; 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<void> {
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 * Check SSL certificate expiration
*/ */

View File

@@ -8,7 +8,7 @@ import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts'; import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts'; import { getErrorMessage } from '../utils/error.ts';
import type { Onebox } from './onebox.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 { export class OneboxHttpServer {
private oneboxRef: Onebox; private oneboxRef: Onebox;
@@ -245,6 +245,13 @@ export class OneboxHttpServer {
} else if (path.match(/^\/api\/services\/[^/]+\/logs$/) && method === 'GET') { } else if (path.match(/^\/api\/services\/[^/]+\/logs$/) && method === 'GET') {
const name = path.split('/')[3]; const name = path.split('/')[3];
return await this.handleGetLogsRequest(name); 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') { } else if (path === '/api/ssl/obtain' && method === 'POST') {
return await this.handleObtainCertificateRequest(req); return await this.handleObtainCertificateRequest(req);
} else if (path === '/api/ssl/list' && method === 'GET') { } 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') { } else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq|caddy)\/stop$/) && method === 'POST') {
const type = path.split('/')[3] as TPlatformServiceType; const type = path.split('/')[3] as TPlatformServiceType;
return await this.handleStopPlatformServiceRequest(type); 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') { } else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
const serviceName = path.split('/')[3]; const serviceName = path.split('/')[3];
return await this.handleGetServicePlatformResourcesRequest(serviceName); return await this.handleGetServicePlatformResourcesRequest(serviceName);
@@ -506,6 +516,51 @@ export class OneboxHttpServer {
} }
} }
private async handleGetServiceStatsRequest(name: string): Promise<Response> {
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<Response> {
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<Response> { private async handleGetSettingsRequest(): Promise<Response> {
const settings = this.oneboxRef.database.getAllSettings(); const settings = this.oneboxRef.database.getAllSettings();
return this.jsonResponse({ success: true, data: settings }); 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 ============ // ============ Platform Services Endpoints ============
private async handleListPlatformServicesRequest(): Promise<Response> { private async handleListPlatformServicesRequest(): Promise<Response> {
@@ -1263,11 +1330,19 @@ export class OneboxHttpServer {
const service = platformServices.find((s) => s.type === provider.type); const service = platformServices.find((s) => s.type === provider.type);
// Check if provider has isCore property (like CaddyProvider) // Check if provider has isCore property (like CaddyProvider)
const isCore = 'isCore' in provider && (provider as any).isCore === true; 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 { return {
type: provider.type, type: provider.type,
displayName: provider.displayName, displayName: provider.displayName,
resourceTypes: provider.resourceTypes, resourceTypes: provider.resourceTypes,
status: service?.status || 'not-deployed', status,
containerId: service?.containerId, containerId: service?.containerId,
isCore, isCore,
createdAt: service?.createdAt, createdAt: service?.createdAt,
@@ -1302,13 +1377,20 @@ export class OneboxHttpServer {
? this.oneboxRef.database.getPlatformResourcesByPlatformService(service.id) ? 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({ return this.jsonResponse({
success: true, success: true,
data: { data: {
type: provider.type, type: provider.type,
displayName: provider.displayName, displayName: provider.displayName,
resourceTypes: provider.resourceTypes, resourceTypes: provider.resourceTypes,
status: service?.status || 'not-deployed', status,
containerId: service?.containerId, containerId: service?.containerId,
config: provider.getDefaultConfig(), config: provider.getDefaultConfig(),
resourceCount: allResources.length, resourceCount: allResources.length,
@@ -1391,6 +1473,52 @@ export class OneboxHttpServer {
} }
} }
private async handleGetPlatformServiceStatsRequest(type: TPlatformServiceType): Promise<Response> {
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<Response> { private async handleGetServicePlatformResourcesRequest(serviceName: string): Promise<Response> {
try { try {
const service = this.oneboxRef.services.getService(serviceName); const service = this.oneboxRef.services.getService(serviceName);

View File

@@ -237,7 +237,7 @@ export class Onebox {
}, },
ssl: { ssl: {
configured: sslConfigured, configured: sslConfigured,
certbotInstalled: await this.ssl.isCertbotInstalled(), certificateCount: this.ssl.listCertificates().length,
}, },
services: { services: {
total: totalServices, total: totalServices,

View File

@@ -44,6 +44,13 @@ export const routes: Routes = [
(m) => m.ServiceCreateComponent (m) => m.ServiceCreateComponent
), ),
}, },
{
path: 'platform/:type',
loadComponent: () =>
import('./features/services/platform-service-detail.component').then(
(m) => m.PlatformServiceDetailComponent
),
},
{ {
path: ':name', path: ':name',
loadComponent: () => loadComponent: () =>

View File

@@ -22,6 +22,8 @@ import {
TPlatformServiceType, TPlatformServiceType,
INetworkTarget, INetworkTarget,
INetworkStats, INetworkStats,
IContainerStats,
IMetric,
} from '../types/api.types'; } from '../types/api.types';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -70,6 +72,15 @@ export class ApiService {
return firstValueFrom(this.http.get<IApiResponse<string>>(`/api/services/${name}/logs`)); return firstValueFrom(this.http.get<IApiResponse<string>>(`/api/services/${name}/logs`));
} }
async getServiceStats(name: string): Promise<IApiResponse<IContainerStats>> {
return firstValueFrom(this.http.get<IApiResponse<IContainerStats>>(`/api/services/${name}/stats`));
}
async getServiceMetrics(name: string, limit?: number): Promise<IApiResponse<IMetric[]>> {
const params = limit ? `?limit=${limit}` : '';
return firstValueFrom(this.http.get<IApiResponse<IMetric[]>>(`/api/services/${name}/metrics${params}`));
}
// Registries // Registries
async getRegistries(): Promise<IApiResponse<IRegistry[]>> { async getRegistries(): Promise<IApiResponse<IRegistry[]>> {
return firstValueFrom(this.http.get<IApiResponse<IRegistry[]>>('/api/registries')); return firstValueFrom(this.http.get<IApiResponse<IRegistry[]>>('/api/registries'));
@@ -177,6 +188,10 @@ export class ApiService {
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/platform-services/${type}/stop`, {})); return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/platform-services/${type}/stop`, {}));
} }
async getPlatformServiceStats(type: TPlatformServiceType): Promise<IApiResponse<IContainerStats>> {
return firstValueFrom(this.http.get<IApiResponse<IContainerStats>>(`/api/platform-services/${type}/stats`));
}
async getServicePlatformResources(serviceName: string): Promise<IApiResponse<IPlatformResource[]>> { async getServicePlatformResources(serviceName: string): Promise<IApiResponse<IPlatformResource[]>> {
return firstValueFrom(this.http.get<IApiResponse<IPlatformResource[]>>(`/api/services/${serviceName}/platform-resources`)); return firstValueFrom(this.http.get<IApiResponse<IPlatformResource[]>>(`/api/services/${serviceName}/platform-resources`));
} }

View File

@@ -1,5 +1,5 @@
import { Injectable, signal, computed, effect, inject } from '@angular/core'; 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'; import { AuthService } from './auth.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -29,6 +29,11 @@ export class WebSocketService {
return msg?.type === 'system_status' ? msg : null; 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() { constructor() {
// Auto-connect when authenticated // Auto-connect when authenticated
effect(() => { effect(() => {

View File

@@ -77,7 +77,7 @@ export interface ISystemStatus {
routes: number; routes: number;
}; };
dns: { configured: boolean }; dns: { configured: boolean };
ssl: { configured: boolean; certbotInstalled: boolean }; ssl: { configured: boolean; certificateCount: number };
services: { total: number; running: number; stopped: number }; services: { total: number; running: number; stopped: number };
platformServices: Array<{ type: TPlatformServiceType; status: TPlatformServiceStatus }>; platformServices: Array<{ type: TPlatformServiceType; status: TPlatformServiceStatus }>;
} }
@@ -195,10 +195,11 @@ export interface ISettings {
} }
export interface IWebSocketMessage { 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'; action?: 'created' | 'updated' | 'deleted' | 'started' | 'stopped';
serviceName?: string; serviceName?: string;
status?: string; status?: string;
stats?: IContainerStats;
data?: any; data?: any;
message?: string; message?: string;
timestamp: number; timestamp: number;
@@ -289,3 +290,33 @@ export interface INetworkLogMessage {
data?: ICaddyAccessLog; data?: ICaddyAccessLog;
timestamp: number; 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;
}

View File

@@ -175,10 +175,8 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
</ui-badge> </ui-badge>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm">Certbot</span> <span class="text-sm">Certificates</span>
<ui-badge [variant]="status()!.ssl.certbotInstalled ? 'success' : 'secondary'"> <span class="text-sm font-medium">{{ status()!.ssl.certificateCount }} managed</span>
{{ status()!.ssl.certbotInstalled ? 'Installed' : 'Not installed' }}
</ui-badge>
</div> </div>
</ui-card-content> </ui-card-content>
</ui-card> </ui-card>

View File

@@ -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: `
<div class="space-y-6">
<!-- Header -->
<div>
<a routerLink="/services" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to Services
</a>
@if (loading() && !service()) {
<ui-skeleton class="h-9 w-48" />
} @else if (service()) {
<div class="flex items-center gap-4">
<h1 class="text-3xl font-bold tracking-tight">{{ service()!.displayName }}</h1>
<ui-badge [variant]="getStatusVariant(service()!.status)">{{ service()!.status }}</ui-badge>
@if (service()!.isCore) {
<ui-badge variant="outline">Core Service</ui-badge>
}
</div>
}
</div>
@if (loading() && !service()) {
<div class="grid gap-6 md:grid-cols-2">
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-skeleton class="h-6 w-32" />
</ui-card-header>
<ui-card-content class="space-y-4">
@for (_ of [1,2,3]; track $index) {
<ui-skeleton class="h-4 w-full" />
}
</ui-card-content>
</ui-card>
</div>
} @else if (service()) {
<div class="grid gap-6 md:grid-cols-2">
<!-- Service Details -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Service Details</ui-card-title>
<ui-card-description>Platform service information</ui-card-description>
</ui-card-header>
<ui-card-content>
<dl class="space-y-4">
<div>
<dt class="text-sm font-medium text-muted-foreground">Type</dt>
<dd class="text-sm">{{ service()!.type }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-muted-foreground">Resource Types</dt>
<dd class="text-sm">
<div class="flex flex-wrap gap-1 mt-1">
@for (type of service()!.resourceTypes; track type) {
<ui-badge variant="outline">{{ type }}</ui-badge>
}
</div>
</dd>
</div>
@if (service()!.containerId) {
<div>
<dt class="text-sm font-medium text-muted-foreground">Container ID</dt>
<dd class="text-sm font-mono">{{ service()!.containerId?.slice(0, 12) }}</dd>
</div>
}
@if (service()!.createdAt) {
<div>
<dt class="text-sm font-medium text-muted-foreground">Created</dt>
<dd class="text-sm">{{ formatDate(service()!.createdAt!) }}</dd>
</div>
}
</dl>
</ui-card-content>
</ui-card>
<!-- Actions -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Actions</ui-card-title>
<ui-card-description>Manage platform service state</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-4">
@if (service()!.isCore) {
<p class="text-sm text-muted-foreground">
This is a core service managed by Onebox. It cannot be stopped manually.
</p>
} @else {
<div class="flex flex-wrap gap-2">
@if (service()!.status === 'stopped' || service()!.status === 'not-deployed' || service()!.status === 'failed') {
<button uiButton (click)="startService()" [disabled]="actionLoading()">
@if (actionLoading()) {
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
}
Start Service
</button>
}
@if (service()!.status === 'running') {
<button uiButton variant="outline" (click)="stopService()" [disabled]="actionLoading()">
Stop Service
</button>
}
</div>
}
</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>
}
<!-- Service Description -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>About {{ service()!.displayName }}</ui-card-title>
</ui-card-header>
<ui-card-content>
<p class="text-sm text-muted-foreground">{{ getServiceDescription(service()!.type) }}</p>
</ui-card-content>
</ui-card>
</div>
}
</div>
`,
})
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<IPlatformService | null>(null);
stats = signal<IContainerStats | null>(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<void> {
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<void> {
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<TPlatformServiceType, string> = {
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<void> {
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<void> {
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);
}
}
}

View File

@@ -4,7 +4,8 @@ import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/services/api.service'; import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service'; import { ToastService } from '../../core/services/toast.service';
import { LogStreamService } from '../../core/services/log-stream.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 { import {
CardComponent, CardComponent,
CardHeaderComponent, CardHeaderComponent,
@@ -190,6 +191,65 @@ import {
</ui-card-content> </ui-card-content>
</ui-card> </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 --> <!-- Environment Variables -->
@if (service()!.envVars && getEnvKeys(service()!.envVars).length > 0) { @if (service()!.envVars && getEnvKeys(service()!.envVars).length > 0) {
<ui-card> <ui-card>
@@ -386,12 +446,15 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
private router = inject(Router); private router = inject(Router);
private api = inject(ApiService); private api = inject(ApiService);
private toast = inject(ToastService); private toast = inject(ToastService);
private ws = inject(WebSocketService);
logStream = inject(LogStreamService); logStream = inject(LogStreamService);
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>; @ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
service = signal<IService | null>(null); service = signal<IService | null>(null);
platformResources = signal<IPlatformResource[]>([]); platformResources = signal<IPlatformResource[]>([]);
stats = signal<IContainerStats | null>(null);
metrics = signal<IMetric[]>([]);
loading = signal(false); loading = signal(false);
actionLoading = signal(false); actionLoading = signal(false);
editMode = signal(false); editMode = signal(false);
@@ -411,6 +474,15 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
}, 0); }, 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 { ngOnInit(): void {
@@ -440,6 +512,17 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
if (response.data.platformRequirements) { if (response.data.platformRequirements) {
this.loadPlatformResources(name); 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 { } else {
this.toast.error(response.error || 'Service not found'); this.toast.error(response.error || 'Service not found');
this.router.navigate(['/services']); 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 { startLogStream(): void {
const name = this.service()?.name; const name = this.service()?.name;
if (name) { if (name) {
@@ -492,6 +597,18 @@ 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);
} }

View File

@@ -227,7 +227,9 @@ type TServicesTab = 'user' | 'system';
<ui-table-row> <ui-table-row>
<ui-table-cell> <ui-table-cell>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-medium">{{ service.displayName }}</span> <a [routerLink]="['/services/platform', service.type]" class="font-medium hover:underline">
{{ service.displayName }}
</a>
@if (service.isCore) { @if (service.isCore) {
<ui-badge variant="outline">Core</ui-badge> <ui-badge variant="outline">Core</ui-badge>
} }