448 lines
19 KiB
TypeScript
448 lines
19 KiB
TypeScript
import { Component, inject, signal, OnInit, OnDestroy, ViewChild, ElementRef, effect } from '@angular/core';
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
import { Subscription } from 'rxjs';
|
|
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';
|
|
import { TabsComponent, TabComponent } from '../../ui/tabs/tabs.component';
|
|
import { DnsContentComponent } from './dns-content.component';
|
|
import { DomainsContentComponent } from './domains-content.component';
|
|
|
|
type TNetworkTab = 'proxy' | 'dns' | 'domains';
|
|
|
|
@Component({
|
|
selector: 'app-network',
|
|
standalone: true,
|
|
imports: [
|
|
CardComponent,
|
|
CardHeaderComponent,
|
|
CardTitleComponent,
|
|
CardDescriptionComponent,
|
|
CardContentComponent,
|
|
ButtonComponent,
|
|
BadgeComponent,
|
|
SkeletonComponent,
|
|
TableComponent,
|
|
TableHeaderComponent,
|
|
TableBodyComponent,
|
|
TableRowComponent,
|
|
TableHeadComponent,
|
|
TableCellComponent,
|
|
TabsComponent,
|
|
TabComponent,
|
|
DnsContentComponent,
|
|
DomainsContentComponent,
|
|
],
|
|
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">Manage proxy, DNS, and domains</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<ui-tabs class="block">
|
|
<ui-tab [active]="activeTab() === 'proxy'" (tabClick)="setTab('proxy')">Proxy</ui-tab>
|
|
<ui-tab [active]="activeTab() === 'dns'" (tabClick)="setTab('dns')">DNS</ui-tab>
|
|
<ui-tab [active]="activeTab() === 'domains'" (tabClick)="setTab('domains')">Domains</ui-tab>
|
|
</ui-tabs>
|
|
|
|
<!-- Tab Content -->
|
|
@switch (activeTab()) {
|
|
@case ('proxy') {
|
|
<!-- Proxy Tab Content -->
|
|
<div class="space-y-6">
|
|
<div class="flex justify-end">
|
|
<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>
|
|
}
|
|
@case ('dns') {
|
|
<div class="space-y-6">
|
|
<app-dns-content />
|
|
</div>
|
|
}
|
|
@case ('domains') {
|
|
<div class="space-y-6">
|
|
<app-domains-content />
|
|
</div>
|
|
}
|
|
}
|
|
</div>
|
|
`,
|
|
})
|
|
export class NetworkComponent implements OnInit, OnDestroy {
|
|
private api = inject(ApiService);
|
|
private toast = inject(ToastService);
|
|
private route = inject(ActivatedRoute);
|
|
private router = inject(Router);
|
|
private routeSub?: Subscription;
|
|
networkLogStream = inject(NetworkLogStreamService);
|
|
|
|
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
|
|
|
|
// Tab state
|
|
activeTab = signal<TNetworkTab>('proxy');
|
|
|
|
// Proxy tab data
|
|
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 {
|
|
// Subscribe to route params to sync tab state with URL
|
|
this.routeSub = this.route.paramMap.subscribe((params) => {
|
|
const tab = params.get('tab') as TNetworkTab;
|
|
if (tab && ['proxy', 'dns', 'domains'].includes(tab)) {
|
|
this.activeTab.set(tab);
|
|
}
|
|
});
|
|
|
|
this.loadData();
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.routeSub?.unsubscribe();
|
|
this.networkLogStream.disconnect();
|
|
}
|
|
|
|
setTab(tab: TNetworkTab): void {
|
|
this.router.navigate(['/network', tab]);
|
|
}
|
|
|
|
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]}`;
|
|
}
|
|
}
|