feat(network): Add traffic stats endpoint and dashboard UI; enhance platform services and certificate health reporting

This commit is contained in:
2025-11-27 09:26:04 +00:00
parent ff5b51072f
commit 3d7727c304
11 changed files with 659 additions and 33 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/onebox',
version: '1.4.0',
version: '1.5.0',
description: 'Self-hosted container platform with automatic SSL and DNS - a mini Heroku for single servers'
}

View File

@@ -79,8 +79,84 @@ export class CaddyLogReceiver {
private recentLogs: ICaddyAccessLog[] = [];
private maxRecentLogs = 100;
// Traffic stats aggregation (hourly rolling window)
private trafficStats: {
timestamp: number;
requestCount: number;
errorCount: number; // 4xx + 5xx
totalDuration: number; // microseconds
totalSize: number; // bytes
statusCounts: Record<string, number>; // "2xx", "3xx", "4xx", "5xx"
}[] = [];
private maxStatsAge = 3600 * 1000; // 1 hour in ms
private statsInterval = 60 * 1000; // 1 minute buckets
constructor(port = 9999) {
this.port = port;
// Initialize first stats bucket
this.trafficStats.push(this.createStatsBucket());
}
/**
* Create a new stats bucket
*/
private createStatsBucket(): typeof this.trafficStats[0] {
return {
timestamp: Math.floor(Date.now() / this.statsInterval) * this.statsInterval,
requestCount: 0,
errorCount: 0,
totalDuration: 0,
totalSize: 0,
statusCounts: { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 },
};
}
/**
* Get current stats bucket, creating new one if needed
*/
private getCurrentStatsBucket(): typeof this.trafficStats[0] {
const now = Date.now();
const currentBucketTime = Math.floor(now / this.statsInterval) * this.statsInterval;
// Get or create current bucket
let bucket = this.trafficStats[this.trafficStats.length - 1];
if (!bucket || bucket.timestamp !== currentBucketTime) {
bucket = this.createStatsBucket();
this.trafficStats.push(bucket);
// Clean up old buckets
const cutoff = now - this.maxStatsAge;
while (this.trafficStats.length > 0 && this.trafficStats[0].timestamp < cutoff) {
this.trafficStats.shift();
}
}
return bucket;
}
/**
* Record a request in traffic stats
*/
private recordTrafficStats(log: ICaddyAccessLog): void {
const bucket = this.getCurrentStatsBucket();
bucket.requestCount++;
bucket.totalDuration += log.duration;
bucket.totalSize += log.size || 0;
// Categorize status code
const statusCategory = Math.floor(log.status / 100);
if (statusCategory === 2) {
bucket.statusCounts['2xx']++;
} else if (statusCategory === 3) {
bucket.statusCounts['3xx']++;
} else if (statusCategory === 4) {
bucket.statusCounts['4xx']++;
bucket.errorCount++;
} else if (statusCategory === 5) {
bucket.statusCounts['5xx']++;
bucket.errorCount++;
}
}
/**
@@ -181,6 +257,9 @@ export class CaddyLogReceiver {
return;
}
// Always record traffic stats (before sampling) for accurate aggregation
this.recordTrafficStats(log);
// Update adaptive sampling
this.updateSampling();
@@ -414,4 +493,57 @@ export class CaddyLogReceiver {
recentLogsCount: this.recentLogs.length,
};
}
/**
* Get aggregated traffic stats for the specified time range
* @param minutes Number of minutes to aggregate (default: 60)
*/
getTrafficStats(minutes = 60): {
requestCount: number;
errorCount: number;
avgResponseTime: number; // in milliseconds
totalBytes: number;
statusCounts: Record<string, number>;
requestsPerMinute: number;
errorRate: number; // percentage
} {
const now = Date.now();
const cutoff = now - (minutes * 60 * 1000);
// Aggregate all buckets within the time range
let requestCount = 0;
let errorCount = 0;
let totalDuration = 0;
let totalBytes = 0;
const statusCounts: Record<string, number> = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0 };
for (const bucket of this.trafficStats) {
if (bucket.timestamp >= cutoff) {
requestCount += bucket.requestCount;
errorCount += bucket.errorCount;
totalDuration += bucket.totalDuration;
totalBytes += bucket.totalSize;
for (const [status, count] of Object.entries(bucket.statusCounts)) {
statusCounts[status] = (statusCounts[status] || 0) + count;
}
}
}
// Calculate averages
const avgResponseTime = requestCount > 0
? (totalDuration / requestCount) / 1000 // Convert from microseconds to milliseconds
: 0;
const requestsPerMinute = requestCount / Math.max(minutes, 1);
const errorRate = requestCount > 0 ? (errorCount / requestCount) * 100 : 0;
return {
requestCount,
errorCount,
avgResponseTime: Math.round(avgResponseTime * 100) / 100, // Round to 2 decimal places
totalBytes,
statusCounts,
requestsPerMinute: Math.round(requestsPerMinute * 100) / 100,
errorRate: Math.round(errorRate * 100) / 100,
};
}
}

View File

@@ -317,6 +317,8 @@ export class OneboxHttpServer {
return await this.handleGetNetworkTargetsRequest();
} else if (path === '/api/network/stats' && method === 'GET') {
return await this.handleGetNetworkStatsRequest();
} else if (path === '/api/network/traffic-stats' && method === 'GET') {
return await this.handleGetTrafficStatsRequest(new URL(req.url));
} else {
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
}
@@ -1365,6 +1367,37 @@ export class OneboxHttpServer {
}
}
/**
* Get traffic stats from Caddy access logs
*/
private async handleGetTrafficStatsRequest(url: URL): Promise<Response> {
try {
// Get minutes parameter (default: 60)
const minutesParam = url.searchParams.get('minutes');
const minutes = minutesParam ? parseInt(minutesParam, 10) : 60;
if (isNaN(minutes) || minutes < 1 || minutes > 60) {
return this.jsonResponse({
success: false,
error: 'Invalid minutes parameter. Must be between 1 and 60.',
}, 400);
}
const trafficStats = this.oneboxRef.caddyLogReceiver.getTrafficStats(minutes);
return this.jsonResponse({
success: true,
data: trafficStats,
});
} catch (error) {
logger.error(`Failed to get traffic stats: ${getErrorMessage(error)}`);
return this.jsonResponse({
success: false,
error: getErrorMessage(error) || 'Failed to get traffic stats',
}, 500);
}
}
/**
* Broadcast message to all connected WebSocket clients
*/

View File

@@ -219,12 +219,51 @@ export class Onebox {
const runningServices = services.filter((s) => s.status === 'running').length;
const totalServices = services.length;
// Get platform services status
// Get platform services status with resource counts
const platformServices = this.platformServices.getAllPlatformServices();
const platformServicesStatus = platformServices.map((ps) => ({
type: ps.type,
status: ps.status,
}));
const providers = this.platformServices.getAllProviders();
const platformServicesStatus = providers.map((provider) => {
const service = platformServices.find((s) => s.type === provider.type);
// For Caddy, check actual runtime status since it starts without a DB record
let status = service?.status || 'not-deployed';
if (provider.type === 'caddy') {
status = proxyStatus.http.running ? 'running' : 'stopped';
}
// Count resources for this platform service
const resourceCount = service?.id
? this.database.getPlatformResourcesByPlatformService(service.id).length
: 0;
return {
type: provider.type,
displayName: provider.displayName,
status,
resourceCount,
};
});
// Get certificate health summary
const certificates = this.ssl.listCertificates();
const now = Date.now();
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
let validCount = 0;
let expiringCount = 0;
let expiredCount = 0;
const expiringDomains: { domain: string; daysRemaining: number }[] = [];
for (const cert of certificates) {
if (cert.expiryDate <= now) {
expiredCount++;
} else if (cert.expiryDate <= now + thirtyDaysMs) {
expiringCount++;
const daysRemaining = Math.floor((cert.expiryDate - now) / (24 * 60 * 60 * 1000));
expiringDomains.push({ domain: cert.domain, daysRemaining });
} else {
validCount++;
}
}
// Sort expiring domains by days remaining (ascending)
expiringDomains.sort((a, b) => a.daysRemaining - b.daysRemaining);
return {
docker: {
@@ -245,6 +284,12 @@ export class Onebox {
stopped: totalServices - runningServices,
},
platformServices: platformServicesStatus,
certificateHealth: {
valid: validCount,
expiringSoon: expiringCount,
expired: expiredCount,
expiringDomains: expiringDomains.slice(0, 5), // Top 5 expiring
},
};
} catch (error) {
logger.error(`Failed to get system status: ${getErrorMessage(error)}`);