update
This commit is contained in:
@@ -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: () =>
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
187
ui/src/app/core/services/network-log-stream.service.ts
Normal file
187
ui/src/app/core/services/network-log-stream.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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]}`;
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user