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`;
+ }
+}