diff --git a/ui/src/app/app.routes.ts b/ui/src/app/app.routes.ts index f2e8952..7123e2d 100644 --- a/ui/src/app/app.routes.ts +++ b/ui/src/app/app.routes.ts @@ -54,17 +54,17 @@ export const routes: Routes = [ ], }, { - path: 'domains', + path: 'network', children: [ { path: '', loadComponent: () => - import('./features/domains/domains.component').then( - (m) => m.DomainsComponent + import('./features/network/network.component').then( + (m) => m.NetworkComponent ), }, { - path: ':domain', + path: 'domains/:domain', loadComponent: () => import('./features/domains/domain-detail.component').then( (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', loadComponent: () => diff --git a/ui/src/app/features/dashboard/dashboard.component.ts b/ui/src/app/features/dashboard/dashboard.component.ts index 05f6f1a..714d948 100644 --- a/ui/src/app/features/dashboard/dashboard.component.ts +++ b/ui/src/app/features/dashboard/dashboard.component.ts @@ -202,7 +202,7 @@ import { SkeletonComponent } from '../../ui/skeleton/skeleton.component'; - + diff --git a/ui/src/app/features/network/dns-content.component.ts b/ui/src/app/features/network/dns-content.component.ts new file mode 100644 index 0000000..dfb7b47 --- /dev/null +++ b/ui/src/app/features/network/dns-content.component.ts @@ -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: ` +
+
+

Manage DNS records synced with Cloudflare

+ +
+ + + + @if (loading() && records().length === 0) { +
+ @for (_ of [1,2,3]; track $index) { + + } +
+ } @else if (records().length === 0) { +
+ + + +

No DNS records

+

DNS records are created automatically when you deploy services with domains.

+ +
+ } @else { + + + + Domain + Type + Value + Actions + + + + @for (record of records(); track record.id) { + + {{ record.domain }} + + {{ record.type }} + + {{ record.value }} + + + + + } + + + } +
+
+
+ + + + Delete DNS Record + + Are you sure you want to delete the record for "{{ recordToDelete()?.domain }}"? + + + + + + + + `, +}) +export class DnsContentComponent implements OnInit { + private api = inject(ApiService); + private toast = inject(ToastService); + + records = signal([]); + loading = signal(false); + syncing = signal(false); + deleteDialogOpen = signal(false); + recordToDelete = signal(null); + + ngOnInit(): void { + this.loadRecords(); + } + + async loadRecords(): Promise { + 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 { + 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 { + 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); + } + } +} diff --git a/ui/src/app/features/network/domains-content.component.ts b/ui/src/app/features/network/domains-content.component.ts new file mode 100644 index 0000000..ee5b394 --- /dev/null +++ b/ui/src/app/features/network/domains-content.component.ts @@ -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: ` +
+
+

Manage domains and SSL certificates

+ +
+ + +
+ + + Total Domains + + + + + +
{{ domains().length }}
+
+
+ + + Valid Certificates + + + + + +
{{ countByStatus('valid') }}
+
+
+ + + Expiring Soon + + + + + +
{{ countByStatus('expiring-soon') }}
+
+
+ + + Expired/Pending + + + + + +
{{ countByStatus('expired') + countByStatus('pending') }}
+
+
+
+ + + + + @if (loading() && domains().length === 0) { +
+ @for (_ of [1,2,3]; track $index) { + + } +
+ } @else if (domains().length === 0) { +
+

No domains found

+

Sync domains from Cloudflare to get started.

+ +
+ } @else { + + + + Domain + Provider + Services + Certificate + Expires + Actions + + + + @for (d of domains(); track d.domain.id) { + + +
+ {{ d.domain.domain }} + @if (d.domain.isObsolete) { + Obsolete + } +
+
+ + + {{ d.domain.dnsProvider || 'None' }} + + + {{ d.serviceCount }} + + + {{ d.certificateStatus }} + + + + @if (d.daysRemaining !== null) { + + {{ d.daysRemaining }} days + + } @else { + - + } + + + + + + +
+ } +
+
+ } +
+
+
+ `, +}) +export class DomainsContentComponent implements OnInit { + private api = inject(ApiService); + private toast = inject(ToastService); + + domains = signal([]); + loading = signal(false); + syncing = signal(false); + + ngOnInit(): void { + this.loadDomains(); + } + + async loadDomains(): Promise { + 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 { + 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'; + } + } +} diff --git a/ui/src/app/features/network/network.component.ts b/ui/src/app/features/network/network.component.ts index b25e6b2..5fd8ee5 100644 --- a/ui/src/app/features/network/network.component.ts +++ b/ui/src/app/features/network/network.component.ts @@ -21,6 +21,11 @@ import { 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', @@ -40,6 +45,10 @@ import { TableRowComponent, TableHeadComponent, TableCellComponent, + TabsComponent, + TabComponent, + DnsContentComponent, + DomainsContentComponent, ], template: `
@@ -47,210 +56,238 @@ import {

Network

-

Traffic targets and access logs

+

Manage proxy, DNS, and domains

-
- @if (loading() && !stats()) { - -
- @for (_ of [1,2,3,4]; track $index) { + + + Proxy + DNS + Domains + + + + @switch (activeTab()) { + @case ('proxy') { + +
+
+ +
+ + @if (loading() && !stats()) { + +
+ @for (_ of [1,2,3,4]; track $index) { + + + + + + + + + } +
+ } @else if (stats()) { + +
+ + + Proxy Status + + + + + + + {{ stats()!.proxy.running ? 'Running' : 'Stopped' }} + + + + + + + Routes + + + + + +
{{ stats()!.proxy.routes }}
+
+
+ + + + Certificates + + + + + +
{{ stats()!.proxy.certificates }}
+
+
+ + + + Targets + + + + + +
{{ targets().length }}
+
+
+
+ } + + - - + + Traffic Targets + Services, registry, and platform services with their routing info. Click to filter logs. - + @if (targets().length === 0 && !loading()) { +

No traffic targets configured

+ } @else { + + + + Type + Name + Domain + Target + Status + + + + @for (target of targets(); track target.name) { + + + {{ target.type }} + + {{ target.name }} + + @if (target.domain) { + {{ target.domain }} + } @else { + - + } + + {{ target.targetHost }}:{{ target.targetPort }} + + {{ target.status }} + + + } + + + }
- } -
- } @else if (stats()) { - -
- - - Proxy Status - - - - - - - {{ stats()!.proxy.running ? 'Running' : 'Stopped' }} - - - - - - Routes - - - - - -
{{ stats()!.proxy.routes }}
-
-
- - - - Certificates - - - - - -
{{ stats()!.proxy.certificates }}
-
-
- - - - Targets - - - - - -
{{ targets().length }}
-
-
-
- } - - - - - Traffic Targets - Services, registry, and platform services with their routing info. Click to filter logs. - - - @if (targets().length === 0 && !loading()) { -

No traffic targets configured

- } @else { - - - - Type - Name - Domain - Target - Status - - - - @for (target of targets(); track target.name) { - - - {{ target.type }} - - {{ target.name }} - - @if (target.domain) { - {{ target.domain }} - } @else { - - - } - - {{ target.targetHost }}:{{ target.targetPort }} - - {{ target.status }} - - - } - - - } -
-
- - - - -
- Access Logs - - @if (networkLogStream.isStreaming()) { - - - - - - Live streaming - @if (activeFilter()) { - - filtered by {{ activeFilter() }} - } - - } @else { - Real-time Caddy access logs - } - -
-
- @if (activeFilter()) { - - } - @if (networkLogStream.isStreaming()) { - - } @else { - - } - -
-
- -
- @if (networkLogStream.state().error) { -

Error: {{ networkLogStream.state().error }}

- } @else if (networkLogStream.logs().length > 0) { - @for (log of networkLogStream.logs(); track $index) { -
- {{ formatLog(log) }} + + + +
+ Access Logs + + @if (networkLogStream.isStreaming()) { + + + + + + Live streaming + @if (activeFilter()) { + - filtered by {{ activeFilter() }} + } + + } @else { + Real-time Caddy access logs + } +
- } - } @else if (networkLogStream.isStreaming()) { -

Waiting for access logs...

- } @else { -

Click "Stream" to start live access log streaming

- } +
+ @if (activeFilter()) { + + } + @if (networkLogStream.isStreaming()) { + + } @else { + + } + +
+
+ +
+ @if (networkLogStream.state().error) { +

Error: {{ networkLogStream.state().error }}

+ } @else if (networkLogStream.logs().length > 0) { + @for (log of networkLogStream.logs(); track $index) { +
+ {{ formatLog(log) }} +
+ } + } @else if (networkLogStream.isStreaming()) { +

Waiting for access logs...

+ } @else { +

Click "Stream" to start live access log streaming

+ } +
+
+
- - + } + @case ('dns') { +
+ +
+ } + @case ('domains') { +
+ +
+ } + }
`, }) @@ -261,6 +298,10 @@ export class NetworkComponent implements OnInit, OnDestroy { @ViewChild('logContainer') logContainer!: ElementRef; + // Tab state + activeTab = signal('proxy'); + + // Proxy tab data targets = signal([]); stats = signal(null); loading = signal(false); @@ -287,6 +328,10 @@ export class NetworkComponent implements OnInit, OnDestroy { this.networkLogStream.disconnect(); } + setTab(tab: TNetworkTab): void { + this.activeTab.set(tab); + } + async loadData(): Promise { this.loading.set(true); try { diff --git a/ui/src/app/shared/components/layout/layout.component.ts b/ui/src/app/shared/components/layout/layout.component.ts index bd056dd..4c6322e 100644 --- a/ui/src/app/shared/components/layout/layout.component.ts +++ b/ui/src/app/shared/components/layout/layout.component.ts @@ -121,8 +121,6 @@ export class LayoutComponent { { label: 'Network', path: '/network', icon: 'activity' }, { label: 'Registries', path: '/registries', icon: 'database' }, { label: 'Tokens', path: '/tokens', icon: 'key' }, - { label: 'DNS', path: '/dns', icon: 'globe' }, - { label: 'Domains', path: '/domains', icon: 'link' }, { label: 'Settings', path: '/settings', icon: 'settings' }, ]; } diff --git a/ui/src/app/ui/index.ts b/ui/src/app/ui/index.ts index 40ecdc1..a623b83 100644 --- a/ui/src/app/ui/index.ts +++ b/ui/src/app/ui/index.ts @@ -60,3 +60,6 @@ export { // Select export { SelectComponent, SelectOption } from './select/select.component'; + +// Tabs +export { TabsComponent, TabComponent } from './tabs/tabs.component'; diff --git a/ui/src/app/ui/tabs/tabs.component.ts b/ui/src/app/ui/tabs/tabs.component.ts new file mode 100644 index 0000000..cb73a65 --- /dev/null +++ b/ui/src/app/ui/tabs/tabs.component.ts @@ -0,0 +1,40 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; + +@Component({ + selector: 'ui-tabs', + standalone: true, + template: ` +
+ +
+ `, +}) +export class TabsComponent {} + +@Component({ + selector: 'ui-tab', + standalone: true, + template: ` + + `, +}) +export class TabComponent { + @Input() active = false; + @Output() tabClick = new EventEmitter(); + + 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`; + } +}