feat(network): Add traffic stats endpoint and dashboard UI; enhance platform services and certificate health reporting
This commit is contained in:
@@ -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<IApiResponse<INetworkStats>> {
|
||||
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}`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, number>; // '2xx', '3xx', '4xx', '5xx'
|
||||
requestsPerMinute: number;
|
||||
errorRate: number; // percentage
|
||||
}
|
||||
|
||||
98
ui/src/app/features/dashboard/certificates-card.component.ts
Normal file
98
ui/src/app/features/dashboard/certificates-card.component.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
@@ -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: `
|
||||
<div class="space-y-6">
|
||||
@@ -63,7 +69,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
}
|
||||
</div>
|
||||
} @else if (status()) {
|
||||
<!-- Stats Grid -->
|
||||
<!-- Row 1: Key Stats Grid -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<ui-card>
|
||||
<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>
|
||||
</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">
|
||||
<!-- Certificates Health -->
|
||||
<app-certificates-card [health]="status()!.certificateHealth" />
|
||||
|
||||
<!-- Reverse Proxy -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
@@ -139,56 +157,42 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
</ui-badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Certificates</span>
|
||||
<span class="text-sm font-medium">{{ status()!.reverseProxy.https.certificates }}</span>
|
||||
<span class="text-sm">Routes</span>
|
||||
<span class="text-sm font-medium">{{ status()!.reverseProxy.routes }}</span>
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- DNS -->
|
||||
<!-- DNS & SSL Combined -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>DNS</ui-card-title>
|
||||
<ui-card-description>DNS configuration status</ui-card-description>
|
||||
<ui-card-title>DNS & SSL</ui-card-title>
|
||||
<ui-card-description>Configuration status</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<ui-card-content class="space-y-2">
|
||||
<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'">
|
||||
{{ status()!.dns.configured ? 'Configured' : 'Not configured' }}
|
||||
</ui-badge>
|
||||
</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">
|
||||
<span class="text-sm">ACME</span>
|
||||
<span class="text-sm">ACME (Let's Encrypt)</span>
|
||||
<ui-badge [variant]="status()!.ssl.configured ? 'success' : 'secondary'">
|
||||
{{ status()!.ssl.configured ? 'Configured' : 'Not configured' }}
|
||||
</ui-badge>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<!-- Row 4: Quick Actions -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Quick Actions</ui-card-title>
|
||||
<ui-card-description>Common tasks and shortcuts</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content class="flex gap-4">
|
||||
<ui-card-content class="flex flex-wrap gap-4">
|
||||
<a routerLink="/services/create">
|
||||
<button uiButton>
|
||||
<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">
|
||||
<button uiButton variant="outline">View All Services</button>
|
||||
</a>
|
||||
<a routerLink="/platform-services">
|
||||
<button uiButton variant="outline">Platform Services</button>
|
||||
</a>
|
||||
<a routerLink="/network">
|
||||
<button uiButton variant="outline">Manage Domains</button>
|
||||
</a>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
162
ui/src/app/features/dashboard/traffic-card.component.ts
Normal file
162
ui/src/app/features/dashboard/traffic-card.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user