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

@@ -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<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
*/

View File

@@ -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<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> {
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<Response> {
@@ -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<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> {
try {
const service = this.oneboxRef.services.getService(serviceName);

View File

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