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

@@ -1,5 +1,16 @@
# Changelog # 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) ## 2025-11-26 - 1.4.0 - feat(platform-services)
Add ClickHouse platform service support and improve related healthchecks and tooling Add ClickHouse platform service support and improve related healthchecks and tooling

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/onebox', 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' 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 recentLogs: ICaddyAccessLog[] = [];
private maxRecentLogs = 100; 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) { constructor(port = 9999) {
this.port = port; 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; return;
} }
// Always record traffic stats (before sampling) for accurate aggregation
this.recordTrafficStats(log);
// Update adaptive sampling // Update adaptive sampling
this.updateSampling(); this.updateSampling();
@@ -414,4 +493,57 @@ export class CaddyLogReceiver {
recentLogsCount: this.recentLogs.length, 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(); return await this.handleGetNetworkTargetsRequest();
} else if (path === '/api/network/stats' && method === 'GET') { } else if (path === '/api/network/stats' && method === 'GET') {
return await this.handleGetNetworkStatsRequest(); return await this.handleGetNetworkStatsRequest();
} else if (path === '/api/network/traffic-stats' && method === 'GET') {
return await this.handleGetTrafficStatsRequest(new URL(req.url));
} else { } else {
return this.jsonResponse({ success: false, error: 'Not found' }, 404); 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 * 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 runningServices = services.filter((s) => s.status === 'running').length;
const totalServices = services.length; const totalServices = services.length;
// Get platform services status // Get platform services status with resource counts
const platformServices = this.platformServices.getAllPlatformServices(); const platformServices = this.platformServices.getAllPlatformServices();
const platformServicesStatus = platformServices.map((ps) => ({ const providers = this.platformServices.getAllProviders();
type: ps.type, const platformServicesStatus = providers.map((provider) => {
status: ps.status, 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 { return {
docker: { docker: {
@@ -245,6 +284,12 @@ export class Onebox {
stopped: totalServices - runningServices, stopped: totalServices - runningServices,
}, },
platformServices: platformServicesStatus, platformServices: platformServicesStatus,
certificateHealth: {
valid: validCount,
expiringSoon: expiringCount,
expired: expiredCount,
expiringDomains: expiringDomains.slice(0, 5), // Top 5 expiring
},
}; };
} catch (error) { } catch (error) {
logger.error(`Failed to get system status: ${getErrorMessage(error)}`); logger.error(`Failed to get system status: ${getErrorMessage(error)}`);

View File

@@ -24,6 +24,7 @@ import {
INetworkStats, INetworkStats,
IContainerStats, IContainerStats,
IMetric, IMetric,
ITrafficStats,
} from '../types/api.types'; } from '../types/api.types';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -204,4 +205,9 @@ export class ApiService {
async getNetworkStats(): Promise<IApiResponse<INetworkStats>> { async getNetworkStats(): Promise<IApiResponse<INetworkStats>> {
return firstValueFrom(this.http.get<IApiResponse<INetworkStats>>('/api/network/stats')); return firstValueFrom(this.http.get<IApiResponse<INetworkStats>>('/api/network/stats'));
} }
async getTrafficStats(minutes?: number): Promise<IApiResponse<ITrafficStats>> {
const params = minutes ? `?minutes=${minutes}` : '';
return firstValueFrom(this.http.get<IApiResponse<ITrafficStats>>(`/api/network/traffic-stats${params}`));
}
} }

View File

@@ -81,7 +81,18 @@ export interface ISystemStatus {
dns: { configured: boolean }; dns: { configured: boolean };
ssl: { configured: boolean; certificateCount: number }; 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;
displayName: string;
status: TPlatformServiceStatus;
resourceCount: number;
}>;
certificateHealth: {
valid: number;
expiringSoon: number;
expired: number;
expiringDomains: Array<{ domain: string; daysRemaining: number }>;
};
} }
export interface IDomain { export interface IDomain {
@@ -322,3 +333,14 @@ export interface IStatsUpdateMessage {
stats: IContainerStats; stats: IContainerStats;
timestamp: number; timestamp: number;
} }
// Traffic stats from Caddy access logs
export interface ITrafficStats {
requestCount: number;
errorCount: number;
avgResponseTime: number; // milliseconds
totalBytes: number;
statusCounts: Record<string, number>; // '2xx', '3xx', '4xx', '5xx'
requestsPerMinute: number;
errorRate: number; // percentage
}

View File

@@ -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: `
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Certificates</ui-card-title>
<ui-card-description>SSL/TLS certificate status</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-3">
<!-- Status summary -->
<div class="space-y-2">
@if (health.valid > 0) {
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span class="text-sm">{{ health.valid }} valid</span>
</div>
}
@if (health.expiringSoon > 0) {
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span class="text-sm text-warning">{{ health.expiringSoon }} expiring soon</span>
</div>
}
@if (health.expired > 0) {
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="text-sm text-destructive">{{ health.expired }} expired</span>
</div>
}
@if (health.valid === 0 && health.expiringSoon === 0 && health.expired === 0) {
<div class="text-sm text-muted-foreground">No certificates</div>
}
</div>
<!-- Expiring domains list -->
@if (health.expiringDomains.length > 0) {
<div class="border-t pt-2 space-y-1">
@for (item of health.expiringDomains; track item.domain) {
<a [routerLink]="['/network']"
class="flex items-center justify-between text-sm py-1 hover:bg-muted/50 rounded px-1 -mx-1 transition-colors">
<span class="truncate text-muted-foreground">{{ item.domain }}</span>
<span
class="ml-2 whitespace-nowrap"
[class.text-warning]="item.daysRemaining > 7"
[class.text-destructive]="item.daysRemaining <= 7">
{{ item.daysRemaining }}d
</span>
</a>
}
</div>
}
</ui-card-content>
</ui-card>
`,
})
export class CertificatesCardComponent {
@Input() health: ICertificateHealth = {
valid: 0,
expiringSoon: 0,
expired: 0,
expiringDomains: [],
};
}

View File

@@ -14,6 +14,9 @@ import {
import { ButtonComponent } from '../../ui/button/button.component'; import { ButtonComponent } from '../../ui/button/button.component';
import { BadgeComponent } from '../../ui/badge/badge.component'; import { BadgeComponent } from '../../ui/badge/badge.component';
import { SkeletonComponent } from '../../ui/skeleton/skeleton.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({ @Component({
selector: 'app-dashboard', selector: 'app-dashboard',
@@ -28,6 +31,9 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
ButtonComponent, ButtonComponent,
BadgeComponent, BadgeComponent,
SkeletonComponent, SkeletonComponent,
TrafficCardComponent,
PlatformServicesCardComponent,
CertificatesCardComponent,
], ],
template: ` template: `
<div class="space-y-6"> <div class="space-y-6">
@@ -63,7 +69,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
} }
</div> </div>
} @else if (status()) { } @else if (status()) {
<!-- Stats Grid --> <!-- Row 1: Key Stats Grid -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<ui-card> <ui-card>
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2"> <ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -117,8 +123,20 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
</ui-card> </ui-card>
</div> </div>
<!-- System Status --> <!-- Row 2: Traffic & Platform Services (2-column) -->
<div class="grid gap-4 md:grid-cols-2">
<!-- Traffic Overview -->
<app-traffic-card />
<!-- Platform Services Status -->
<app-platform-services-card [services]="status()!.platformServices" />
</div>
<!-- Row 3: Certificates & System Status (3-column) -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Certificates Health -->
<app-certificates-card [health]="status()!.certificateHealth" />
<!-- Reverse Proxy --> <!-- Reverse Proxy -->
<ui-card> <ui-card>
<ui-card-header class="flex flex-col space-y-1.5"> <ui-card-header class="flex flex-col space-y-1.5">
@@ -139,56 +157,42 @@ 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">Certificates</span> <span class="text-sm">Routes</span>
<span class="text-sm font-medium">{{ status()!.reverseProxy.https.certificates }}</span> <span class="text-sm font-medium">{{ status()!.reverseProxy.routes }}</span>
</div> </div>
</ui-card-content> </ui-card-content>
</ui-card> </ui-card>
<!-- DNS --> <!-- DNS & SSL Combined -->
<ui-card> <ui-card>
<ui-card-header class="flex flex-col space-y-1.5"> <ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>DNS</ui-card-title> <ui-card-title>DNS & SSL</ui-card-title>
<ui-card-description>DNS configuration status</ui-card-description> <ui-card-description>Configuration status</ui-card-description>
</ui-card-header> </ui-card-header>
<ui-card-content> <ui-card-content class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm">Cloudflare</span> <span class="text-sm">Cloudflare DNS</span>
<ui-badge [variant]="status()!.dns.configured ? 'success' : 'secondary'"> <ui-badge [variant]="status()!.dns.configured ? 'success' : 'secondary'">
{{ status()!.dns.configured ? 'Configured' : 'Not configured' }} {{ status()!.dns.configured ? 'Configured' : 'Not configured' }}
</ui-badge> </ui-badge>
</div> </div>
</ui-card-content>
</ui-card>
<!-- SSL -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>SSL/TLS</ui-card-title>
<ui-card-description>Certificate management</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm">ACME</span> <span class="text-sm">ACME (Let's Encrypt)</span>
<ui-badge [variant]="status()!.ssl.configured ? 'success' : 'secondary'"> <ui-badge [variant]="status()!.ssl.configured ? 'success' : 'secondary'">
{{ status()!.ssl.configured ? 'Configured' : 'Not configured' }} {{ status()!.ssl.configured ? 'Configured' : 'Not configured' }}
</ui-badge> </ui-badge>
</div> </div>
<div class="flex items-center justify-between">
<span class="text-sm">Certificates</span>
<span class="text-sm font-medium">{{ status()!.ssl.certificateCount }} managed</span>
</div>
</ui-card-content> </ui-card-content>
</ui-card> </ui-card>
</div> </div>
<!-- Quick Actions --> <!-- Row 4: Quick Actions -->
<ui-card> <ui-card>
<ui-card-header class="flex flex-col space-y-1.5"> <ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Quick Actions</ui-card-title> <ui-card-title>Quick Actions</ui-card-title>
<ui-card-description>Common tasks and shortcuts</ui-card-description> <ui-card-description>Common tasks and shortcuts</ui-card-description>
</ui-card-header> </ui-card-header>
<ui-card-content class="flex gap-4"> <ui-card-content class="flex flex-wrap gap-4">
<a routerLink="/services/create"> <a routerLink="/services/create">
<button uiButton> <button uiButton>
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
@@ -200,6 +204,9 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
<a routerLink="/services"> <a routerLink="/services">
<button uiButton variant="outline">View All Services</button> <button uiButton variant="outline">View All Services</button>
</a> </a>
<a routerLink="/platform-services">
<button uiButton variant="outline">Platform Services</button>
</a>
<a routerLink="/network"> <a routerLink="/network">
<button uiButton variant="outline">Manage Domains</button> <button uiButton variant="outline">Manage Domains</button>
</a> </a>

View File

@@ -0,0 +1,110 @@
import { Component, Input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TPlatformServiceType, TPlatformServiceStatus } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
interface IPlatformServiceSummary {
type: TPlatformServiceType;
displayName: string;
status: TPlatformServiceStatus;
resourceCount: number;
}
@Component({
selector: 'app-platform-services-card',
standalone: true,
imports: [
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
],
template: `
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Platform Services</ui-card-title>
<ui-card-description>Infrastructure status</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-2">
@for (service of services; track service.type) {
<a [routerLink]="['/platform-services', service.type]"
class="flex items-center justify-between py-1 hover:bg-muted/50 rounded px-1 -mx-1 transition-colors">
<div class="flex items-center gap-2">
<!-- Status indicator -->
<span
class="h-2 w-2 rounded-full"
[class.bg-success]="service.status === 'running'"
[class.bg-muted-foreground]="service.status === 'not-deployed' || service.status === 'stopped'"
[class.bg-warning]="service.status === 'starting' || service.status === 'stopping'"
[class.bg-destructive]="service.status === 'failed'">
</span>
<span class="text-sm">{{ service.displayName }}</span>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
@if (service.status === 'running') {
@if (service.resourceCount > 0) {
<span>{{ service.resourceCount }} {{ service.resourceCount === 1 ? getResourceLabel(service.type) : getResourceLabelPlural(service.type) }}</span>
} @else {
<span>Running</span>
}
} @else {
<span class="capitalize">{{ formatStatus(service.status) }}</span>
}
</div>
</a>
} @empty {
<div class="text-sm text-muted-foreground">No platform services</div>
}
</ui-card-content>
</ui-card>
`,
})
export class PlatformServicesCardComponent {
@Input() services: IPlatformServiceSummary[] = [];
formatStatus(status: string): string {
return status.replace('-', ' ');
}
getResourceLabel(type: TPlatformServiceType): string {
switch (type) {
case 'mongodb':
case 'postgresql':
case 'clickhouse':
return 'DB';
case 'minio':
return 'bucket';
case 'redis':
return 'cache';
case 'rabbitmq':
return 'queue';
default:
return 'resource';
}
}
getResourceLabelPlural(type: TPlatformServiceType): string {
switch (type) {
case 'mongodb':
case 'postgresql':
case 'clickhouse':
return 'DBs';
case 'minio':
return 'buckets';
case 'redis':
return 'caches';
case 'rabbitmq':
return 'queues';
default:
return 'resources';
}
}
}

View File

@@ -0,0 +1,162 @@
import { Component, inject, signal, OnInit, OnDestroy } from '@angular/core';
import { ApiService } from '../../core/services/api.service';
import { ITrafficStats } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
@Component({
selector: 'app-traffic-card',
standalone: true,
imports: [
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
SkeletonComponent,
],
template: `
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Traffic (Last Hour)</ui-card-title>
<ui-card-description>Request metrics from access logs</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-3">
@if (loading() && !stats()) {
<ui-skeleton class="h-4 w-32" />
<ui-skeleton class="h-4 w-24" />
<ui-skeleton class="h-4 w-28" />
} @else if (stats()) {
<div class="space-y-2">
<!-- Request count -->
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Requests</span>
<span class="text-sm font-medium">{{ formatNumber(stats()!.requestCount) }}</span>
</div>
<!-- Error rate -->
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Errors</span>
<span class="text-sm font-medium" [class.text-destructive]="stats()!.errorRate > 5">
{{ stats()!.errorCount }} ({{ stats()!.errorRate }}%)
</span>
</div>
<!-- Avg response time -->
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Avg Response</span>
<span class="text-sm font-medium" [class.text-warning]="stats()!.avgResponseTime > 500">
{{ stats()!.avgResponseTime }}ms
</span>
</div>
<!-- Requests per minute -->
<div class="flex items-center justify-between">
<span class="text-sm text-muted-foreground">Req/min</span>
<span class="text-sm font-medium">{{ stats()!.requestsPerMinute }}</span>
</div>
<!-- Status code distribution -->
<div class="pt-2 border-t">
<div class="flex gap-1 h-2 rounded overflow-hidden bg-muted">
@if (getStatusPercent('2xx') > 0) {
<div
class="bg-success transition-all"
[style.width.%]="getStatusPercent('2xx')"
[title]="'2xx: ' + stats()!.statusCounts['2xx']">
</div>
}
@if (getStatusPercent('3xx') > 0) {
<div
class="bg-blue-500 transition-all"
[style.width.%]="getStatusPercent('3xx')"
[title]="'3xx: ' + stats()!.statusCounts['3xx']">
</div>
}
@if (getStatusPercent('4xx') > 0) {
<div
class="bg-warning transition-all"
[style.width.%]="getStatusPercent('4xx')"
[title]="'4xx: ' + stats()!.statusCounts['4xx']">
</div>
}
@if (getStatusPercent('5xx') > 0) {
<div
class="bg-destructive transition-all"
[style.width.%]="getStatusPercent('5xx')"
[title]="'5xx: ' + stats()!.statusCounts['5xx']">
</div>
}
</div>
<div class="flex justify-between mt-1 text-xs text-muted-foreground">
<span>2xx</span>
<span>3xx</span>
<span>4xx</span>
<span>5xx</span>
</div>
</div>
</div>
} @else {
<div class="text-sm text-muted-foreground">No traffic data available</div>
}
</ui-card-content>
</ui-card>
`,
})
export class TrafficCardComponent implements OnInit, OnDestroy {
private api = inject(ApiService);
stats = signal<ITrafficStats | null>(null);
loading = signal(false);
private refreshInterval: any;
ngOnInit(): void {
this.loadStats();
// Refresh every 30 seconds
this.refreshInterval = setInterval(() => this.loadStats(), 30000);
}
ngOnDestroy(): void {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
async loadStats(): Promise<void> {
this.loading.set(true);
try {
const response = await this.api.getTrafficStats(60);
if (response.success && response.data) {
this.stats.set(response.data);
}
} catch (err) {
console.error('Failed to load traffic stats:', err);
} finally {
this.loading.set(false);
}
}
formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}
getStatusPercent(status: string): number {
const s = this.stats();
if (!s || s.requestCount === 0) return 0;
const count = s.statusCounts[status] || 0;
return (count / s.requestCount) * 100;
}
}