diff --git a/changelog.md b/changelog.md index 8804bb4..e5e63ef 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,16 @@ # Changelog +## 2025-11-27 - 1.5.0 - feat(network) +Add traffic stats endpoint and dashboard UI; enhance platform services and certificate health reporting + +- Add /api/network/traffic-stats GET endpoint to the HTTP API with an optional minutes query parameter (validated, 1-60). +- Implement traffic statistics aggregation in CaddyLogReceiver using rolling per-minute buckets (requestCount, errorCount, avgResponseTime, totalBytes, statusCounts, requestsPerMinute, errorRate). +- Expose getTrafficStats(minutes?) in the Angular ApiService and add ITrafficStats type to the client API types. +- Add dashboard UI components: TrafficCard, PlatformServicesCard, CertificatesCard and integrate them into the main Dashboard (including links to Platform Services). +- Enhance system status data: platformServices entries now include displayName and resourceCount; add certificateHealth summary (valid, expiringSoon, expired, expiringDomains) returned by Onebox status. +- Platform services manager and Onebox code updated to surface provider information and resource counts for the UI. +- Add VSCode workspace launch/tasks recommendations for the UI development environment. + ## 2025-11-26 - 1.4.0 - feat(platform-services) Add ClickHouse platform service support and improve related healthchecks and tooling diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 828f57b..de072af 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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' } diff --git a/ts/classes/caddy-log-receiver.ts b/ts/classes/caddy-log-receiver.ts index 34bb1b8..deb2b0e 100644 --- a/ts/classes/caddy-log-receiver.ts +++ b/ts/classes/caddy-log-receiver.ts @@ -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; // "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; + 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 = { '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, + }; + } } diff --git a/ts/classes/httpserver.ts b/ts/classes/httpserver.ts index e3f31a5..96cd464 100644 --- a/ts/classes/httpserver.ts +++ b/ts/classes/httpserver.ts @@ -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 { + 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 */ diff --git a/ts/classes/onebox.ts b/ts/classes/onebox.ts index 221c6ac..834d55a 100644 --- a/ts/classes/onebox.ts +++ b/ts/classes/onebox.ts @@ -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)}`); diff --git a/ui/src/app/core/services/api.service.ts b/ui/src/app/core/services/api.service.ts index 1cfed90..8438327 100644 --- a/ui/src/app/core/services/api.service.ts +++ b/ui/src/app/core/services/api.service.ts @@ -24,6 +24,7 @@ import { INetworkStats, IContainerStats, IMetric, + ITrafficStats, } from '../types/api.types'; @Injectable({ providedIn: 'root' }) @@ -204,4 +205,9 @@ export class ApiService { async getNetworkStats(): Promise> { return firstValueFrom(this.http.get>('/api/network/stats')); } + + async getTrafficStats(minutes?: number): Promise> { + const params = minutes ? `?minutes=${minutes}` : ''; + return firstValueFrom(this.http.get>(`/api/network/traffic-stats${params}`)); + } } diff --git a/ui/src/app/core/types/api.types.ts b/ui/src/app/core/types/api.types.ts index fdac3f3..64992d0 100644 --- a/ui/src/app/core/types/api.types.ts +++ b/ui/src/app/core/types/api.types.ts @@ -81,7 +81,18 @@ export interface ISystemStatus { dns: { configured: boolean }; ssl: { configured: boolean; certificateCount: number }; services: { total: number; running: number; stopped: number }; - platformServices: Array<{ type: TPlatformServiceType; status: TPlatformServiceStatus }>; + platformServices: Array<{ + type: TPlatformServiceType; + displayName: string; + status: TPlatformServiceStatus; + resourceCount: number; + }>; + certificateHealth: { + valid: number; + expiringSoon: number; + expired: number; + expiringDomains: Array<{ domain: string; daysRemaining: number }>; + }; } export interface IDomain { @@ -322,3 +333,14 @@ export interface IStatsUpdateMessage { stats: IContainerStats; timestamp: number; } + +// Traffic stats from Caddy access logs +export interface ITrafficStats { + requestCount: number; + errorCount: number; + avgResponseTime: number; // milliseconds + totalBytes: number; + statusCounts: Record; // '2xx', '3xx', '4xx', '5xx' + requestsPerMinute: number; + errorRate: number; // percentage +} diff --git a/ui/src/app/features/dashboard/certificates-card.component.ts b/ui/src/app/features/dashboard/certificates-card.component.ts new file mode 100644 index 0000000..4f6448c --- /dev/null +++ b/ui/src/app/features/dashboard/certificates-card.component.ts @@ -0,0 +1,98 @@ +import { Component, Input } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardDescriptionComponent, + CardContentComponent, +} from '../../ui/card/card.component'; + +interface ICertificateHealth { + valid: number; + expiringSoon: number; + expired: number; + expiringDomains: Array<{ domain: string; daysRemaining: number }>; +} + +@Component({ + selector: 'app-certificates-card', + standalone: true, + imports: [ + RouterLink, + CardComponent, + CardHeaderComponent, + CardTitleComponent, + CardDescriptionComponent, + CardContentComponent, + ], + template: ` + + + Certificates + SSL/TLS certificate status + + + +
+ @if (health.valid > 0) { +
+ + + + {{ health.valid }} valid +
+ } + + @if (health.expiringSoon > 0) { +
+ + + + {{ health.expiringSoon }} expiring soon +
+ } + + @if (health.expired > 0) { +
+ + + + {{ health.expired }} expired +
+ } + + @if (health.valid === 0 && health.expiringSoon === 0 && health.expired === 0) { +
No certificates
+ } +
+ + + @if (health.expiringDomains.length > 0) { +
+ @for (item of health.expiringDomains; track item.domain) { + + {{ item.domain }} + + {{ item.daysRemaining }}d + + + } +
+ } +
+
+ `, +}) +export class CertificatesCardComponent { + @Input() health: ICertificateHealth = { + valid: 0, + expiringSoon: 0, + expired: 0, + expiringDomains: [], + }; +} diff --git a/ui/src/app/features/dashboard/dashboard.component.ts b/ui/src/app/features/dashboard/dashboard.component.ts index 4793d49..0649d20 100644 --- a/ui/src/app/features/dashboard/dashboard.component.ts +++ b/ui/src/app/features/dashboard/dashboard.component.ts @@ -14,6 +14,9 @@ import { import { ButtonComponent } from '../../ui/button/button.component'; import { BadgeComponent } from '../../ui/badge/badge.component'; import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; +import { TrafficCardComponent } from './traffic-card.component'; +import { PlatformServicesCardComponent } from './platform-services-card.component'; +import { CertificatesCardComponent } from './certificates-card.component'; @Component({ selector: 'app-dashboard', @@ -28,6 +31,9 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; ButtonComponent, BadgeComponent, SkeletonComponent, + TrafficCardComponent, + PlatformServicesCardComponent, + CertificatesCardComponent, ], template: `
@@ -63,7 +69,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; }
} @else if (status()) { - +
@@ -117,8 +123,20 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
- + +
+ + + + + +
+ +
+ + + @@ -139,56 +157,42 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
- Certificates - {{ status()!.reverseProxy.https.certificates }} + Routes + {{ status()!.reverseProxy.routes }}
- + - DNS - DNS configuration status + DNS & SSL + Configuration status - +
- Cloudflare + Cloudflare DNS {{ status()!.dns.configured ? 'Configured' : 'Not configured' }}
-
-
- - - - - SSL/TLS - Certificate management - -
- ACME + ACME (Let's Encrypt) {{ status()!.ssl.configured ? 'Configured' : 'Not configured' }}
-
- Certificates - {{ status()!.ssl.certificateCount }} managed -
- + Quick Actions Common tasks and shortcuts - +