update
This commit is contained in:
388
ui/src/app/features/network/network.component.ts
Normal file
388
ui/src/app/features/network/network.component.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { Component, inject, signal, OnInit, OnDestroy, ViewChild, ElementRef, effect } from '@angular/core';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { ToastService } from '../../core/services/toast.service';
|
||||
import { NetworkLogStreamService } from '../../core/services/network-log-stream.service';
|
||||
import type { INetworkTarget, INetworkStats, ICaddyAccessLog } from '../../core/types/api.types';
|
||||
import {
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
} from '../../ui/card/card.component';
|
||||
import { ButtonComponent } from '../../ui/button/button.component';
|
||||
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||
import {
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
} from '../../ui/table/table.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-network',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CardComponent,
|
||||
CardHeaderComponent,
|
||||
CardTitleComponent,
|
||||
CardDescriptionComponent,
|
||||
CardContentComponent,
|
||||
ButtonComponent,
|
||||
BadgeComponent,
|
||||
SkeletonComponent,
|
||||
TableComponent,
|
||||
TableHeaderComponent,
|
||||
TableBodyComponent,
|
||||
TableRowComponent,
|
||||
TableHeadComponent,
|
||||
TableCellComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Network</h1>
|
||||
<p class="text-muted-foreground">Traffic targets and access logs</p>
|
||||
</div>
|
||||
<button uiButton variant="outline" (click)="loadData()" [disabled]="loading()">
|
||||
@if (loading()) {
|
||||
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
}
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading() && !stats()) {
|
||||
<!-- Loading skeleton -->
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
@for (_ of [1,2,3,4]; track $index) {
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-skeleton class="h-4 w-24" />
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<ui-skeleton class="h-8 w-16" />
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
}
|
||||
</div>
|
||||
} @else if (stats()) {
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Proxy Status</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<ui-badge [variant]="stats()!.proxy.running ? 'success' : 'secondary'">
|
||||
{{ stats()!.proxy.running ? 'Running' : 'Stopped' }}
|
||||
</ui-badge>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Routes</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ stats()!.proxy.routes }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Certificates</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ stats()!.proxy.certificates }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<ui-card-title class="text-sm font-medium">Targets</ui-card-title>
|
||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div class="text-2xl font-bold">{{ targets().length }}</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Traffic Targets Table -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Traffic Targets</ui-card-title>
|
||||
<ui-card-description>Services, registry, and platform services with their routing info. Click to filter logs.</ui-card-description>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
@if (targets().length === 0 && !loading()) {
|
||||
<p class="text-muted-foreground text-center py-8">No traffic targets configured</p>
|
||||
} @else {
|
||||
<ui-table>
|
||||
<ui-table-header>
|
||||
<ui-table-row>
|
||||
<ui-table-head>Type</ui-table-head>
|
||||
<ui-table-head>Name</ui-table-head>
|
||||
<ui-table-head>Domain</ui-table-head>
|
||||
<ui-table-head>Target</ui-table-head>
|
||||
<ui-table-head>Status</ui-table-head>
|
||||
</ui-table-row>
|
||||
</ui-table-header>
|
||||
<ui-table-body>
|
||||
@for (target of targets(); track target.name) {
|
||||
<ui-table-row [class]="'cursor-pointer ' + (activeFilter() === target.domain ? 'bg-muted' : '')" (click)="onTargetClick(target)">
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="getTypeVariant(target.type)">{{ target.type }}</ui-badge>
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="font-medium">{{ target.name }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
@if (target.domain) {
|
||||
<span class="font-mono text-sm">{{ target.domain }}</span>
|
||||
} @else {
|
||||
<span class="text-muted-foreground">-</span>
|
||||
}
|
||||
</ui-table-cell>
|
||||
<ui-table-cell class="font-mono text-sm">{{ target.targetHost }}:{{ target.targetPort }}</ui-table-cell>
|
||||
<ui-table-cell>
|
||||
<ui-badge [variant]="getStatusVariant(target.status)">{{ target.status }}</ui-badge>
|
||||
</ui-table-cell>
|
||||
</ui-table-row>
|
||||
}
|
||||
</ui-table-body>
|
||||
</ui-table>
|
||||
}
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
|
||||
<!-- Access Logs -->
|
||||
<ui-card>
|
||||
<ui-card-header class="flex flex-row items-center justify-between">
|
||||
<div class="flex flex-col space-y-1.5">
|
||||
<ui-card-title>Access Logs</ui-card-title>
|
||||
<ui-card-description>
|
||||
@if (networkLogStream.isStreaming()) {
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
Live streaming
|
||||
@if (activeFilter()) {
|
||||
<span class="text-muted-foreground">- filtered by {{ activeFilter() }}</span>
|
||||
}
|
||||
</span>
|
||||
} @else {
|
||||
Real-time Caddy access logs
|
||||
}
|
||||
</ui-card-description>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (activeFilter()) {
|
||||
<button uiButton variant="ghost" size="sm" (click)="clearFilter()">
|
||||
<svg class="h-4 w-4 mr-1" 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>
|
||||
Clear Filter
|
||||
</button>
|
||||
}
|
||||
@if (networkLogStream.isStreaming()) {
|
||||
<button uiButton variant="outline" size="sm" (click)="stopLogStream()">
|
||||
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
} @else {
|
||||
<button uiButton variant="outline" size="sm" (click)="startLogStream()">
|
||||
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Stream
|
||||
</button>
|
||||
}
|
||||
<button uiButton variant="ghost" size="sm" (click)="clearLogs()" title="Clear logs">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</ui-card-header>
|
||||
<ui-card-content>
|
||||
<div
|
||||
#logContainer
|
||||
class="bg-zinc-950 text-zinc-100 rounded-md p-4 h-96 overflow-auto font-mono text-xs"
|
||||
>
|
||||
@if (networkLogStream.state().error) {
|
||||
<p class="text-red-400">Error: {{ networkLogStream.state().error }}</p>
|
||||
} @else if (networkLogStream.logs().length > 0) {
|
||||
@for (log of networkLogStream.logs(); track $index) {
|
||||
<div class="whitespace-pre hover:bg-zinc-800/50 py-0.5" [class]="getLogClass(log.status)">
|
||||
{{ formatLog(log) }}
|
||||
</div>
|
||||
}
|
||||
} @else if (networkLogStream.isStreaming()) {
|
||||
<p class="text-zinc-500">Waiting for access logs...</p>
|
||||
} @else {
|
||||
<p class="text-zinc-500">Click "Stream" to start live access log streaming</p>
|
||||
}
|
||||
</div>
|
||||
</ui-card-content>
|
||||
</ui-card>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class NetworkComponent implements OnInit, OnDestroy {
|
||||
private api = inject(ApiService);
|
||||
private toast = inject(ToastService);
|
||||
networkLogStream = inject(NetworkLogStreamService);
|
||||
|
||||
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
|
||||
|
||||
targets = signal<INetworkTarget[]>([]);
|
||||
stats = signal<INetworkStats | null>(null);
|
||||
loading = signal(false);
|
||||
activeFilter = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
// Auto-scroll when new logs arrive
|
||||
effect(() => {
|
||||
const logs = this.networkLogStream.logs();
|
||||
if (logs.length > 0 && this.logContainer?.nativeElement) {
|
||||
setTimeout(() => {
|
||||
const container = this.logContainer.nativeElement;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadData();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.networkLogStream.disconnect();
|
||||
}
|
||||
|
||||
async loadData(): Promise<void> {
|
||||
this.loading.set(true);
|
||||
try {
|
||||
const [targetsResponse, statsResponse] = await Promise.all([
|
||||
this.api.getNetworkTargets(),
|
||||
this.api.getNetworkStats(),
|
||||
]);
|
||||
|
||||
if (targetsResponse.success && targetsResponse.data) {
|
||||
this.targets.set(targetsResponse.data);
|
||||
}
|
||||
|
||||
if (statsResponse.success && statsResponse.data) {
|
||||
this.stats.set(statsResponse.data);
|
||||
}
|
||||
} catch (err) {
|
||||
this.toast.error('Failed to load network data');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
onTargetClick(target: INetworkTarget): void {
|
||||
if (target.domain) {
|
||||
this.activeFilter.set(target.domain);
|
||||
this.networkLogStream.setFilter({ domain: target.domain });
|
||||
|
||||
// Start streaming if not already
|
||||
if (!this.networkLogStream.isStreaming()) {
|
||||
this.startLogStream();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearFilter(): void {
|
||||
this.activeFilter.set(null);
|
||||
this.networkLogStream.setFilter(null);
|
||||
}
|
||||
|
||||
startLogStream(): void {
|
||||
const filter = this.activeFilter() ? { domain: this.activeFilter()! } : undefined;
|
||||
this.networkLogStream.connect(filter);
|
||||
}
|
||||
|
||||
stopLogStream(): void {
|
||||
this.networkLogStream.disconnect();
|
||||
}
|
||||
|
||||
clearLogs(): void {
|
||||
this.networkLogStream.clearLogs();
|
||||
}
|
||||
|
||||
getTypeVariant(type: string): 'default' | 'secondary' | 'outline' {
|
||||
switch (type) {
|
||||
case 'service': return 'default';
|
||||
case 'registry': return 'secondary';
|
||||
case 'platform': return 'outline';
|
||||
default: return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusVariant(status: string): 'success' | 'destructive' | 'warning' | 'secondary' {
|
||||
switch (status) {
|
||||
case 'running': return 'success';
|
||||
case 'stopped': return 'secondary';
|
||||
case 'failed': return 'destructive';
|
||||
case 'starting':
|
||||
case 'stopping': return 'warning';
|
||||
default: return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
getLogClass(status: number): string {
|
||||
if (status >= 500) return 'text-red-400';
|
||||
if (status >= 400) return 'text-yellow-400';
|
||||
if (status >= 300) return 'text-blue-400';
|
||||
return 'text-green-400';
|
||||
}
|
||||
|
||||
formatLog(log: ICaddyAccessLog): string {
|
||||
const time = new Date(log.ts * 1000).toLocaleTimeString();
|
||||
const duration = log.duration < 1 ? `${(log.duration * 1000).toFixed(1)}ms` : `${log.duration.toFixed(2)}s`;
|
||||
const size = this.formatBytes(log.size);
|
||||
const method = log.request.method.padEnd(7);
|
||||
const status = String(log.status).padStart(3);
|
||||
const host = log.request.host.substring(0, 30).padEnd(30);
|
||||
const uri = log.request.uri.substring(0, 40);
|
||||
|
||||
return `${time} ${status} ${method} ${host} ${uri.padEnd(40)} ${duration.padStart(8)} ${size.padStart(8)} ${log.request.remote_ip}`;
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user