This commit is contained in:
2025-11-26 12:16:50 +00:00
parent e6f7d70d51
commit c46ceccb6c
13 changed files with 1970 additions and 473 deletions

View File

@@ -77,6 +77,13 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/dns/dns.component').then((m) => m.DnsComponent),
},
{
path: 'network',
loadComponent: () =>
import('./features/network/network.component').then(
(m) => m.NetworkComponent
),
},
{
path: 'registries',
loadComponent: () =>

View File

@@ -20,6 +20,8 @@ import {
IPlatformService,
IPlatformResource,
TPlatformServiceType,
INetworkTarget,
INetworkStats,
} from '../types/api.types';
@Injectable({ providedIn: 'root' })
@@ -178,4 +180,13 @@ export class ApiService {
async getServicePlatformResources(serviceName: string): Promise<IApiResponse<IPlatformResource[]>> {
return firstValueFrom(this.http.get<IApiResponse<IPlatformResource[]>>(`/api/services/${serviceName}/platform-resources`));
}
// Network
async getNetworkTargets(): Promise<IApiResponse<INetworkTarget[]>> {
return firstValueFrom(this.http.get<IApiResponse<INetworkTarget[]>>('/api/network/targets'));
}
async getNetworkStats(): Promise<IApiResponse<INetworkStats>> {
return firstValueFrom(this.http.get<IApiResponse<INetworkStats>>('/api/network/stats'));
}
}

View File

@@ -0,0 +1,187 @@
import { Injectable, signal } from '@angular/core';
import type { ICaddyAccessLog, INetworkLogMessage } from '../types/api.types';
export interface INetworkLogStreamState {
connected: boolean;
error: string | null;
clientId: string | null;
}
export interface INetworkLogFilter {
domain?: string;
sampleRate?: number;
}
@Injectable({ providedIn: 'root' })
export class NetworkLogStreamService {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
// Signals for reactive state
state = signal<INetworkLogStreamState>({
connected: false,
error: null,
clientId: null,
});
logs = signal<ICaddyAccessLog[]>([]);
isStreaming = signal(false);
filter = signal<INetworkLogFilter | null>(null);
/**
* Connect to network log stream
*/
connect(initialFilter?: INetworkLogFilter): void {
// Disconnect any existing stream
this.disconnect();
this.isStreaming.set(true);
this.logs.set([]);
this.filter.set(initialFilter || null);
this.state.set({
connected: false,
error: null,
clientId: null,
});
if (typeof window === 'undefined') return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
let url = `${protocol}//${host}/api/network/logs/stream`;
// Add initial filter as query params
if (initialFilter?.domain) {
url += `?domain=${encodeURIComponent(initialFilter.domain)}`;
}
try {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data) as INetworkLogMessage;
if (message.type === 'connected') {
this.state.set({
connected: true,
error: null,
clientId: message.clientId || null,
});
if (message.filter) {
this.filter.set(message.filter);
}
return;
}
if (message.type === 'filter_updated') {
this.filter.set(message.filter || null);
return;
}
if (message.type === 'access_log' && message.data) {
this.logs.update((lines) => {
const newLines = [...lines, message.data!];
// Keep last 500 logs to prevent memory issues
if (newLines.length > 500) {
return newLines.slice(-500);
}
return newLines;
});
}
} catch (error) {
console.error('Failed to parse network log message:', error);
}
};
this.ws.onclose = () => {
this.state.update((s) => ({ ...s, connected: false }));
this.ws = null;
// Auto-reconnect with exponential backoff
if (this.isStreaming() && this.reconnectAttempts < this.maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
this.reconnectTimeout = setTimeout(() => {
this.connect(this.filter() || undefined);
}, delay);
} else {
this.isStreaming.set(false);
}
};
this.ws.onerror = () => {
this.state.update((s) => ({
...s,
connected: false,
error: 'WebSocket connection failed',
}));
};
} catch (error) {
this.state.set({
connected: false,
error: 'Failed to connect to network log stream',
clientId: null,
});
this.isStreaming.set(false);
}
}
/**
* Disconnect from log stream
*/
disconnect(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.isStreaming.set(false);
this.reconnectAttempts = 0;
this.state.set({
connected: false,
error: null,
clientId: null,
});
}
/**
* Update filter on existing connection
*/
setFilter(newFilter: INetworkLogFilter | null): void {
this.filter.set(newFilter);
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
type: 'set_filter',
domain: newFilter?.domain,
sampleRate: newFilter?.sampleRate,
}));
}
}
/**
* Clear logs buffer
*/
clearLogs(): void {
this.logs.set([]);
}
/**
* Check if connected
*/
isConnected(): boolean {
return this.state().connected;
}
}

View File

@@ -236,3 +236,55 @@ export interface IPlatformResource {
envVars: Record<string, string>;
createdAt: number;
}
// Network Types
export type TNetworkTargetType = 'service' | 'registry' | 'platform';
export interface INetworkTarget {
type: TNetworkTargetType;
name: string;
domain: string | null;
targetHost: string;
targetPort: number;
status: string;
}
export interface INetworkStats {
proxy: {
running: boolean;
httpPort: number;
httpsPort: number;
routes: number;
certificates: number;
};
logReceiver: {
running: boolean;
port: number;
clients: number;
connections: number;
sampleRate: number;
recentLogsCount: number;
};
}
export interface ICaddyAccessLog {
ts: number;
request: {
remote_ip: string;
method: string;
host: string;
uri: string;
proto: string;
};
status: number;
duration: number;
size: number;
}
export interface INetworkLogMessage {
type: 'connected' | 'access_log' | 'filter_updated';
clientId?: string;
filter?: { domain?: string; sampleRate?: number };
data?: ICaddyAccessLog;
timestamp: number;
}

View 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]}`;
}
}

View File

@@ -118,6 +118,7 @@ export class LayoutComponent {
navItems: NavItem[] = [
{ label: 'Dashboard', path: '/dashboard', icon: 'home' },
{ label: 'Services', path: '/services', icon: 'server' },
{ label: 'Network', path: '/network', icon: 'activity' },
{ label: 'Registries', path: '/registries', icon: 'database' },
{ label: 'Tokens', path: '/tokens', icon: 'key' },
{ label: 'DNS', path: '/dns', icon: 'globe' },