feat: Implement real-time stats and metrics for platform services with WebSocket integration
This commit is contained in:
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: () =>
|
||||||
|
|||||||
@@ -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`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user