feat: remove DNS and Domains from layout, add DNS and Domains content components
- Removed DNS and Domains entries from the layout navigation. - Added DnsContentComponent for managing DNS records with Cloudflare. - Added DomainsContentComponent for managing domains and SSL certificates. - Introduced TabsComponent and TabComponent for tab navigation. - Updated index.ts to export new TabsComponent and TabComponent.
This commit is contained in:
@@ -54,17 +54,17 @@ export const routes: Routes = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'domains',
|
path: 'network',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/domains/domains.component').then(
|
import('./features/network/network.component').then(
|
||||||
(m) => m.DomainsComponent
|
(m) => m.NetworkComponent
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':domain',
|
path: 'domains/:domain',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./features/domains/domain-detail.component').then(
|
import('./features/domains/domain-detail.component').then(
|
||||||
(m) => m.DomainDetailComponent
|
(m) => m.DomainDetailComponent
|
||||||
@@ -72,18 +72,6 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'dns',
|
|
||||||
loadComponent: () =>
|
|
||||||
import('./features/dns/dns.component').then((m) => m.DnsComponent),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'network',
|
|
||||||
loadComponent: () =>
|
|
||||||
import('./features/network/network.component').then(
|
|
||||||
(m) => m.NetworkComponent
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'registries',
|
path: 'registries',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
|||||||
<a routerLink="/services">
|
<a routerLink="/services">
|
||||||
<button uiButton variant="outline">View All Services</button>
|
<button uiButton variant="outline">View All Services</button>
|
||||||
</a>
|
</a>
|
||||||
<a routerLink="/domains">
|
<a routerLink="/network">
|
||||||
<button uiButton variant="outline">Manage Domains</button>
|
<button uiButton variant="outline">Manage Domains</button>
|
||||||
</a>
|
</a>
|
||||||
</ui-card-content>
|
</ui-card-content>
|
||||||
|
|||||||
198
ui/src/app/features/network/dns-content.component.ts
Normal file
198
ui/src/app/features/network/dns-content.component.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { IDnsRecord } from '../../core/types/api.types';
|
||||||
|
import {
|
||||||
|
CardComponent,
|
||||||
|
CardContentComponent,
|
||||||
|
} from '../../ui/card/card.component';
|
||||||
|
import { ButtonComponent } from '../../ui/button/button.component';
|
||||||
|
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||||
|
import {
|
||||||
|
TableComponent,
|
||||||
|
TableHeaderComponent,
|
||||||
|
TableBodyComponent,
|
||||||
|
TableRowComponent,
|
||||||
|
TableHeadComponent,
|
||||||
|
TableCellComponent,
|
||||||
|
} from '../../ui/table/table.component';
|
||||||
|
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||||
|
import {
|
||||||
|
DialogComponent,
|
||||||
|
DialogHeaderComponent,
|
||||||
|
DialogTitleComponent,
|
||||||
|
DialogDescriptionComponent,
|
||||||
|
DialogFooterComponent,
|
||||||
|
} from '../../ui/dialog/dialog.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dns-content',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CardComponent,
|
||||||
|
CardContentComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
BadgeComponent,
|
||||||
|
TableComponent,
|
||||||
|
TableHeaderComponent,
|
||||||
|
TableBodyComponent,
|
||||||
|
TableRowComponent,
|
||||||
|
TableHeadComponent,
|
||||||
|
TableCellComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
DialogComponent,
|
||||||
|
DialogHeaderComponent,
|
||||||
|
DialogTitleComponent,
|
||||||
|
DialogDescriptionComponent,
|
||||||
|
DialogFooterComponent,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-muted-foreground">Manage DNS records synced with Cloudflare</p>
|
||||||
|
<button uiButton (click)="syncRecords()" [disabled]="syncing()">
|
||||||
|
@if (syncing()) {
|
||||||
|
<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>
|
||||||
|
Syncing...
|
||||||
|
} @else {
|
||||||
|
Sync Cloudflare
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ui-card>
|
||||||
|
<ui-card-content class="p-0">
|
||||||
|
@if (loading() && records().length === 0) {
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
@for (_ of [1,2,3]; track $index) {
|
||||||
|
<ui-skeleton class="h-12 w-full" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (records().length === 0) {
|
||||||
|
<div class="p-12 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-4 text-lg font-semibold">No DNS records</h3>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">DNS records are created automatically when you deploy services with domains.</p>
|
||||||
|
<button uiButton class="mt-4" (click)="syncRecords()">Sync from Cloudflare</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<ui-table>
|
||||||
|
<ui-table-header>
|
||||||
|
<ui-table-row>
|
||||||
|
<ui-table-head>Domain</ui-table-head>
|
||||||
|
<ui-table-head>Type</ui-table-head>
|
||||||
|
<ui-table-head>Value</ui-table-head>
|
||||||
|
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||||
|
</ui-table-row>
|
||||||
|
</ui-table-header>
|
||||||
|
<ui-table-body>
|
||||||
|
@for (record of records(); track record.id) {
|
||||||
|
<ui-table-row>
|
||||||
|
<ui-table-cell class="font-medium">{{ record.domain }}</ui-table-cell>
|
||||||
|
<ui-table-cell>
|
||||||
|
<ui-badge variant="secondary">{{ record.type }}</ui-badge>
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell class="font-mono text-sm">{{ record.value }}</ui-table-cell>
|
||||||
|
<ui-table-cell class="text-right">
|
||||||
|
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(record)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</ui-table-cell>
|
||||||
|
</ui-table-row>
|
||||||
|
}
|
||||||
|
</ui-table-body>
|
||||||
|
</ui-table>
|
||||||
|
}
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
|
||||||
|
<ui-dialog-header>
|
||||||
|
<ui-dialog-title>Delete DNS Record</ui-dialog-title>
|
||||||
|
<ui-dialog-description>
|
||||||
|
Are you sure you want to delete the record for "{{ recordToDelete()?.domain }}"?
|
||||||
|
</ui-dialog-description>
|
||||||
|
</ui-dialog-header>
|
||||||
|
<ui-dialog-footer>
|
||||||
|
<button uiButton variant="outline" (click)="deleteDialogOpen.set(false)">Cancel</button>
|
||||||
|
<button uiButton variant="destructive" (click)="deleteRecord()">Delete</button>
|
||||||
|
</ui-dialog-footer>
|
||||||
|
</ui-dialog>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class DnsContentComponent implements OnInit {
|
||||||
|
private api = inject(ApiService);
|
||||||
|
private toast = inject(ToastService);
|
||||||
|
|
||||||
|
records = signal<IDnsRecord[]>([]);
|
||||||
|
loading = signal(false);
|
||||||
|
syncing = signal(false);
|
||||||
|
deleteDialogOpen = signal(false);
|
||||||
|
recordToDelete = signal<IDnsRecord | null>(null);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadRecords();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRecords(): Promise<void> {
|
||||||
|
this.loading.set(true);
|
||||||
|
try {
|
||||||
|
const response = await this.api.getDnsRecords();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.records.set(response.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to load DNS records');
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncRecords(): Promise<void> {
|
||||||
|
this.syncing.set(true);
|
||||||
|
try {
|
||||||
|
const response = await this.api.syncDnsRecords();
|
||||||
|
if (response.success) {
|
||||||
|
this.toast.success('DNS records synced');
|
||||||
|
this.loadRecords();
|
||||||
|
} else {
|
||||||
|
this.toast.error(response.error || 'Failed to sync DNS records');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to sync DNS records');
|
||||||
|
} finally {
|
||||||
|
this.syncing.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDelete(record: IDnsRecord): void {
|
||||||
|
this.recordToDelete.set(record);
|
||||||
|
this.deleteDialogOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRecord(): Promise<void> {
|
||||||
|
const record = this.recordToDelete();
|
||||||
|
if (!record) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.api.deleteDnsRecord(record.domain);
|
||||||
|
if (response.success) {
|
||||||
|
this.toast.success('DNS record deleted');
|
||||||
|
this.loadRecords();
|
||||||
|
} else {
|
||||||
|
this.toast.error(response.error || 'Failed to delete record');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to delete record');
|
||||||
|
} finally {
|
||||||
|
this.deleteDialogOpen.set(false);
|
||||||
|
this.recordToDelete.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
237
ui/src/app/features/network/domains-content.component.ts
Normal file
237
ui/src/app/features/network/domains-content.component.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { IDomainDetail } from '../../core/types/api.types';
|
||||||
|
import {
|
||||||
|
CardComponent,
|
||||||
|
CardHeaderComponent,
|
||||||
|
CardTitleComponent,
|
||||||
|
CardContentComponent,
|
||||||
|
} from '../../ui/card/card.component';
|
||||||
|
import { ButtonComponent } from '../../ui/button/button.component';
|
||||||
|
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||||
|
import {
|
||||||
|
TableComponent,
|
||||||
|
TableHeaderComponent,
|
||||||
|
TableBodyComponent,
|
||||||
|
TableRowComponent,
|
||||||
|
TableHeadComponent,
|
||||||
|
TableCellComponent,
|
||||||
|
} from '../../ui/table/table.component';
|
||||||
|
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-domains-content',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
CardComponent,
|
||||||
|
CardHeaderComponent,
|
||||||
|
CardTitleComponent,
|
||||||
|
CardContentComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
BadgeComponent,
|
||||||
|
TableComponent,
|
||||||
|
TableHeaderComponent,
|
||||||
|
TableBodyComponent,
|
||||||
|
TableRowComponent,
|
||||||
|
TableHeadComponent,
|
||||||
|
TableCellComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-muted-foreground">Manage domains and SSL certificates</p>
|
||||||
|
<button uiButton (click)="syncDomains()" [disabled]="syncing()">
|
||||||
|
@if (syncing()) {
|
||||||
|
<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>
|
||||||
|
Syncing...
|
||||||
|
} @else {
|
||||||
|
Sync Cloudflare
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<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">Total Domains</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="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||||
|
</svg>
|
||||||
|
</ui-card-header>
|
||||||
|
<ui-card-content>
|
||||||
|
<div class="text-2xl font-bold">{{ domains().length }}</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">Valid Certificates</ui-card-title>
|
||||||
|
<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="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 text-success">{{ countByStatus('valid') }}</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">Expiring Soon</ui-card-title>
|
||||||
|
<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 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</ui-card-header>
|
||||||
|
<ui-card-content>
|
||||||
|
<div class="text-2xl font-bold text-warning">{{ countByStatus('expiring-soon') }}</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">Expired/Pending</ui-card-title>
|
||||||
|
<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="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>
|
||||||
|
</ui-card-header>
|
||||||
|
<ui-card-content>
|
||||||
|
<div class="text-2xl font-bold text-destructive">{{ countByStatus('expired') + countByStatus('pending') }}</div>
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Domains Table -->
|
||||||
|
<ui-card>
|
||||||
|
<ui-card-content class="p-0">
|
||||||
|
@if (loading() && domains().length === 0) {
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
@for (_ of [1,2,3]; track $index) {
|
||||||
|
<ui-skeleton class="h-12 w-full" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (domains().length === 0) {
|
||||||
|
<div class="p-12 text-center">
|
||||||
|
<h3 class="text-lg font-semibold">No domains found</h3>
|
||||||
|
<p class="mt-2 text-sm text-muted-foreground">Sync domains from Cloudflare to get started.</p>
|
||||||
|
<button uiButton class="mt-4" (click)="syncDomains()">Sync Cloudflare Domains</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<ui-table>
|
||||||
|
<ui-table-header>
|
||||||
|
<ui-table-row>
|
||||||
|
<ui-table-head>Domain</ui-table-head>
|
||||||
|
<ui-table-head>Provider</ui-table-head>
|
||||||
|
<ui-table-head>Services</ui-table-head>
|
||||||
|
<ui-table-head>Certificate</ui-table-head>
|
||||||
|
<ui-table-head>Expires</ui-table-head>
|
||||||
|
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||||
|
</ui-table-row>
|
||||||
|
</ui-table-header>
|
||||||
|
<ui-table-body>
|
||||||
|
@for (d of domains(); track d.domain.id) {
|
||||||
|
<ui-table-row [class.opacity-50]="d.domain.isObsolete">
|
||||||
|
<ui-table-cell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium">{{ d.domain.domain }}</span>
|
||||||
|
@if (d.domain.isObsolete) {
|
||||||
|
<ui-badge variant="destructive">Obsolete</ui-badge>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell>
|
||||||
|
<ui-badge [variant]="d.domain.dnsProvider === 'cloudflare' ? 'default' : 'secondary'">
|
||||||
|
{{ d.domain.dnsProvider || 'None' }}
|
||||||
|
</ui-badge>
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell>{{ d.serviceCount }}</ui-table-cell>
|
||||||
|
<ui-table-cell>
|
||||||
|
<ui-badge [variant]="getCertStatusVariant(d.certificateStatus)">
|
||||||
|
{{ d.certificateStatus }}
|
||||||
|
</ui-badge>
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell>
|
||||||
|
@if (d.daysRemaining !== null) {
|
||||||
|
<span [class.text-destructive]="d.daysRemaining <= 30">
|
||||||
|
{{ d.daysRemaining }} days
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted-foreground">-</span>
|
||||||
|
}
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell class="text-right">
|
||||||
|
<a [routerLink]="['/network/domains', d.domain.domain]">
|
||||||
|
<button uiButton variant="outline" size="sm">View</button>
|
||||||
|
</a>
|
||||||
|
</ui-table-cell>
|
||||||
|
</ui-table-row>
|
||||||
|
}
|
||||||
|
</ui-table-body>
|
||||||
|
</ui-table>
|
||||||
|
}
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class DomainsContentComponent implements OnInit {
|
||||||
|
private api = inject(ApiService);
|
||||||
|
private toast = inject(ToastService);
|
||||||
|
|
||||||
|
domains = signal<IDomainDetail[]>([]);
|
||||||
|
loading = signal(false);
|
||||||
|
syncing = signal(false);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadDomains();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadDomains(): Promise<void> {
|
||||||
|
this.loading.set(true);
|
||||||
|
try {
|
||||||
|
const response = await this.api.getDomains();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.domains.set(response.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to load domains');
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncDomains(): Promise<void> {
|
||||||
|
this.syncing.set(true);
|
||||||
|
try {
|
||||||
|
const response = await this.api.syncCloudflareDomains();
|
||||||
|
if (response.success) {
|
||||||
|
this.toast.success('Domains synced');
|
||||||
|
this.loadDomains();
|
||||||
|
} else {
|
||||||
|
this.toast.error(response.error || 'Failed to sync domains');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to sync domains');
|
||||||
|
} finally {
|
||||||
|
this.syncing.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
countByStatus(status: string): number {
|
||||||
|
return this.domains().filter(d => d.certificateStatus === status).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCertStatusVariant(status: string): 'success' | 'warning' | 'destructive' | 'secondary' {
|
||||||
|
switch (status) {
|
||||||
|
case 'valid': return 'success';
|
||||||
|
case 'expiring-soon': return 'warning';
|
||||||
|
case 'expired': return 'destructive';
|
||||||
|
case 'pending': return 'secondary';
|
||||||
|
default: return 'secondary';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,11 @@ import {
|
|||||||
TableHeadComponent,
|
TableHeadComponent,
|
||||||
TableCellComponent,
|
TableCellComponent,
|
||||||
} from '../../ui/table/table.component';
|
} 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({
|
@Component({
|
||||||
selector: 'app-network',
|
selector: 'app-network',
|
||||||
@@ -40,6 +45,10 @@ import {
|
|||||||
TableRowComponent,
|
TableRowComponent,
|
||||||
TableHeadComponent,
|
TableHeadComponent,
|
||||||
TableCellComponent,
|
TableCellComponent,
|
||||||
|
TabsComponent,
|
||||||
|
TabComponent,
|
||||||
|
DnsContentComponent,
|
||||||
|
DomainsContentComponent,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@@ -47,210 +56,238 @@ import {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold tracking-tight">Network</h1>
|
<h1 class="text-3xl font-bold tracking-tight">Network</h1>
|
||||||
<p class="text-muted-foreground">Traffic targets and access logs</p>
|
<p class="text-muted-foreground">Manage proxy, DNS, and domains</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
@if (loading() && !stats()) {
|
<!-- Tabs -->
|
||||||
<!-- Loading skeleton -->
|
<ui-tabs class="block">
|
||||||
<div class="grid gap-4 md:grid-cols-4">
|
<ui-tab [active]="activeTab() === 'proxy'" (tabClick)="setTab('proxy')">Proxy</ui-tab>
|
||||||
@for (_ of [1,2,3,4]; track $index) {
|
<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>
|
||||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
<ui-skeleton class="h-4 w-24" />
|
<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-header>
|
||||||
<ui-card-content>
|
<ui-card-content>
|
||||||
<ui-skeleton class="h-8 w-16" />
|
@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-content>
|
||||||
</ui-card>
|
</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>
|
<!-- Access Logs -->
|
||||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
<ui-card>
|
||||||
<ui-card-title class="text-sm font-medium">Routes</ui-card-title>
|
<ui-card-header class="flex flex-row items-center justify-between">
|
||||||
<svg class="h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<div class="flex flex-col space-y-1.5">
|
||||||
<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" />
|
<ui-card-title>Access Logs</ui-card-title>
|
||||||
</svg>
|
<ui-card-description>
|
||||||
</ui-card-header>
|
@if (networkLogStream.isStreaming()) {
|
||||||
<ui-card-content>
|
<span class="flex items-center gap-2">
|
||||||
<div class="text-2xl font-bold">{{ stats()!.proxy.routes }}</div>
|
<span class="relative flex h-2 w-2">
|
||||||
</ui-card-content>
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||||
</ui-card>
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||||
|
</span>
|
||||||
<ui-card>
|
Live streaming
|
||||||
<ui-card-header class="flex flex-row items-center justify-between space-y-0 pb-2">
|
@if (activeFilter()) {
|
||||||
<ui-card-title class="text-sm font-medium">Certificates</ui-card-title>
|
<span class="text-muted-foreground">- filtered by {{ activeFilter() }}</span>
|
||||||
<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" />
|
</span>
|
||||||
</svg>
|
} @else {
|
||||||
</ui-card-header>
|
Real-time Caddy access logs
|
||||||
<ui-card-content>
|
}
|
||||||
<div class="text-2xl font-bold">{{ stats()!.proxy.certificates }}</div>
|
</ui-card-description>
|
||||||
</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>
|
</div>
|
||||||
}
|
<div class="flex items-center gap-2">
|
||||||
} @else if (networkLogStream.isStreaming()) {
|
@if (activeFilter()) {
|
||||||
<p class="text-zinc-500">Waiting for access logs...</p>
|
<button uiButton variant="ghost" size="sm" (click)="clearFilter()">
|
||||||
} @else {
|
<svg class="h-4 w-4 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<p class="text-zinc-500">Click "Stream" to start live access log streaming</p>
|
<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>
|
</div>
|
||||||
</ui-card-content>
|
}
|
||||||
</ui-card>
|
@case ('dns') {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<app-dns-content />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case ('domains') {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<app-domains-content />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
@@ -261,6 +298,10 @@ export class NetworkComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
|
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
|
// Tab state
|
||||||
|
activeTab = signal<TNetworkTab>('proxy');
|
||||||
|
|
||||||
|
// Proxy tab data
|
||||||
targets = signal<INetworkTarget[]>([]);
|
targets = signal<INetworkTarget[]>([]);
|
||||||
stats = signal<INetworkStats | null>(null);
|
stats = signal<INetworkStats | null>(null);
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
@@ -287,6 +328,10 @@ export class NetworkComponent implements OnInit, OnDestroy {
|
|||||||
this.networkLogStream.disconnect();
|
this.networkLogStream.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTab(tab: TNetworkTab): void {
|
||||||
|
this.activeTab.set(tab);
|
||||||
|
}
|
||||||
|
|
||||||
async loadData(): Promise<void> {
|
async loadData(): Promise<void> {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -121,8 +121,6 @@ export class LayoutComponent {
|
|||||||
{ label: 'Network', path: '/network', icon: 'activity' },
|
{ label: 'Network', path: '/network', icon: 'activity' },
|
||||||
{ label: 'Registries', path: '/registries', icon: 'database' },
|
{ label: 'Registries', path: '/registries', icon: 'database' },
|
||||||
{ label: 'Tokens', path: '/tokens', icon: 'key' },
|
{ label: 'Tokens', path: '/tokens', icon: 'key' },
|
||||||
{ label: 'DNS', path: '/dns', icon: 'globe' },
|
|
||||||
{ label: 'Domains', path: '/domains', icon: 'link' },
|
|
||||||
{ label: 'Settings', path: '/settings', icon: 'settings' },
|
{ label: 'Settings', path: '/settings', icon: 'settings' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,3 +60,6 @@ export {
|
|||||||
|
|
||||||
// Select
|
// Select
|
||||||
export { SelectComponent, SelectOption } from './select/select.component';
|
export { SelectComponent, SelectOption } from './select/select.component';
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
export { TabsComponent, TabComponent } from './tabs/tabs.component';
|
||||||
|
|||||||
40
ui/src/app/ui/tabs/tabs.component.ts
Normal file
40
ui/src/app/ui/tabs/tabs.component.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ui-tabs',
|
||||||
|
standalone: true,
|
||||||
|
template: `
|
||||||
|
<div class="border-b border-border">
|
||||||
|
<nav class="flex space-x-2" aria-label="Tabs">
|
||||||
|
<ng-content />
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class TabsComponent {}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'ui-tab',
|
||||||
|
standalone: true,
|
||||||
|
template: `
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
[class]="getClass()"
|
||||||
|
(click)="tabClick.emit()"
|
||||||
|
>
|
||||||
|
<ng-content />
|
||||||
|
</button>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class TabComponent {
|
||||||
|
@Input() active = false;
|
||||||
|
@Output() tabClick = new EventEmitter<void>();
|
||||||
|
|
||||||
|
getClass(): string {
|
||||||
|
const base = 'px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors focus:outline-none';
|
||||||
|
if (this.active) {
|
||||||
|
return `${base} border-primary text-foreground`;
|
||||||
|
}
|
||||||
|
return `${base} border-transparent text-muted-foreground hover:text-foreground hover:border-border`;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user