163 lines
5.5 KiB
TypeScript
163 lines
5.5 KiB
TypeScript
|
|
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;
|
||
|
|
}
|
||
|
|
}
|