ui rebuild

This commit is contained in:
2025-11-24 19:52:35 +00:00
parent c9beae93c8
commit 9aa6906ca5
73 changed files with 8514 additions and 4537 deletions

View File

@@ -1,242 +1,262 @@
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, inject, signal, effect, OnInit, OnDestroy } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ApiService, SystemStatus } from '../../core/services/api.service';
import { ApiService } from '../../core/services/api.service';
import { WebSocketService } from '../../core/services/websocket.service';
import { Subscription } from 'rxjs';
import { ToastService } from '../../core/services/toast.service';
import { ISystemStatus } 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';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
BadgeComponent,
SkeletonComponent,
],
template: `
<div class="px-4 sm:px-0">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
<div class="flex items-center gap-4">
@if (lastUpdated()) {
<span class="text-sm text-gray-500">
Last updated: {{ lastUpdated()!.toLocaleTimeString() }}
</span>
}
<button (click)="refresh()" class="btn btn-secondary text-sm" [disabled]="loading()">
<svg class="w-4 h-4 mr-1" [class.animate-spin]="loading()" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Refresh
</button>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Dashboard</h1>
<p class="text-muted-foreground">System overview and quick actions</p>
</div>
<button uiButton variant="outline" (click)="loadStatus()" [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()) {
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
@if (loading() && !status()) {
<!-- Loading skeleton -->
<div class="grid gap-4 md:grid-cols-2 lg: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 (status()) {
<!-- Stats Grid -->
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
<!-- Total Services -->
<div class="card">
<div class="flex items-center">
<div class="flex-shrink-0 bg-primary-500 rounded-md p-3">
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Services</dt>
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.total }}</dd>
</dl>
</div>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2 lg: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 Services</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">{{ status()!.services.total }}</div>
</ui-card-content>
</ui-card>
<!-- Running Services -->
<div class="card">
<div class="flex items-center">
<div class="flex-shrink-0 bg-green-500 rounded-md p-3">
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Running</dt>
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.running }}</dd>
</dl>
</div>
</div>
</div>
<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">Running</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="M5 13l4 4L19 7" />
</svg>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold text-success">{{ status()!.services.running }}</div>
</ui-card-content>
</ui-card>
<!-- Stopped Services -->
<div class="card">
<div class="flex items-center">
<div class="flex-shrink-0 bg-gray-500 rounded-md p-3">
<svg class="h-6 w-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Stopped</dt>
<dd class="text-3xl font-semibold text-gray-900">{{ status()!.services.stopped }}</dd>
</dl>
</div>
</div>
</div>
<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">Stopped</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 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>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold">{{ status()!.services.stopped }}</div>
</ui-card-content>
</ui-card>
<!-- Docker Status -->
<div class="card">
<div class="flex items-center">
<div class="flex-shrink-0 rounded-md p-3" [ngClass]="status()!.docker.running ? 'bg-green-500' : 'bg-red-500'">
<svg class="h-6 w-6 text-white" fill="currentColor" viewBox="0 0 24 24">
<path d="M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338 0-.676.03-1.01.09-.458-1.314-1.605-2.16-2.898-2.16h-.048c-.328 0-.654.06-.969.18-.618-2.066-2.215-3.073-4.752-3.073-2.538 0-4.135 1.007-4.753 3.073-.315-.12-.64-.18-.969-.18h-.048c-1.293 0-2.44.846-2.898 2.16a8.39 8.39 0 00-1.01-.09c-1.282 0-1.889.459-1.954.51L0 10.2l.08.31s.935 3.605 4.059 4.794v.003c.563.215 1.156.322 1.756.322.71 0 1.423-.129 2.112-.385a8.804 8.804 0 002.208.275c.877 0 1.692-.165 2.411-.49a4.71 4.71 0 001.617.28c.606 0 1.201-.11 1.773-.328.572.219 1.167.327 1.772.327.71 0 1.423-.129 2.112-.385.79.251 1.57.376 2.315.376.606 0 1.2-.107 1.766-.322v-.003c3.124-1.189 4.059-4.794 4.059-4.794l.08-.31-.237-.31z"/>
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Docker</dt>
<dd class="text-lg font-semibold text-gray-900">
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
</dd>
</dl>
</div>
</div>
</div>
<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">Docker</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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</ui-card-header>
<ui-card-content>
<ui-badge [variant]="status()!.docker.running ? 'success' : 'destructive'">
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
</ui-badge>
</ui-card-content>
</ui-card>
</div>
<!-- System Status -->
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
<!-- Docker -->
<div class="card">
<h3 class="text-lg font-medium text-gray-900 mb-4">Docker</h3>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm text-gray-600">Status</span>
<span [ngClass]="status()!.docker.running ? 'badge-success' : 'badge-danger'" class="badge">
{{ status()!.docker.running ? 'Running' : 'Stopped' }}
</span>
</div>
@if (status()!.docker.version) {
<div class="flex justify-between">
<span class="text-sm text-gray-600">Version</span>
<span class="text-sm text-gray-900">{{ status()!.docker.version.Version }}</span>
</div>
}
</div>
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- Reverse Proxy -->
<div class="card">
<h3 class="text-lg font-medium text-gray-900 mb-4">Reverse Proxy</h3>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm text-gray-600">HTTP (Port {{ status()!.reverseProxy.http.port }})</span>
<span [ngClass]="status()!.reverseProxy.http.running ? 'badge-success' : 'badge-danger'" class="badge">
{{ status()!.reverseProxy.http.running ? 'Running' : 'Stopped' }}
</span>
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Reverse Proxy</ui-card-title>
<ui-card-description>HTTP/HTTPS proxy status</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm">HTTP ({{ status()!.reverseProxy.http.port }})</span>
<ui-badge [variant]="status()!.reverseProxy.http.running ? 'success' : 'secondary'">
{{ status()!.reverseProxy.http.running ? 'Active' : 'Inactive' }}
</ui-badge>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">HTTPS (Port {{ status()!.reverseProxy.https.port }})</span>
<span [ngClass]="status()!.reverseProxy.https.running ? 'badge-success' : 'badge-danger'" class="badge">
{{ status()!.reverseProxy.https.running ? 'Running' : 'Stopped' }}
</span>
<div class="flex items-center justify-between">
<span class="text-sm">HTTPS ({{ status()!.reverseProxy.https.port }})</span>
<ui-badge [variant]="status()!.reverseProxy.https.running ? 'success' : 'secondary'">
{{ status()!.reverseProxy.https.running ? 'Active' : 'Inactive' }}
</ui-badge>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">SSL Certificates</span>
<span class="badge badge-info">{{ status()!.reverseProxy.https.certificates }}</span>
<div class="flex items-center justify-between">
<span class="text-sm">Certificates</span>
<span class="text-sm font-medium">{{ status()!.reverseProxy.https.certificates }}</span>
</div>
</div>
</div>
</ui-card-content>
</ui-card>
<!-- DNS & SSL -->
<div class="card">
<h3 class="text-lg font-medium text-gray-900 mb-4">DNS & SSL</h3>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-sm text-gray-600">DNS Configured</span>
<span [ngClass]="status()!.dns.configured ? 'badge-success' : 'badge-warning'" class="badge">
{{ status()!.dns.configured ? 'Yes' : 'No' }}
</span>
<!-- DNS -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>DNS</ui-card-title>
<ui-card-description>DNS configuration status</ui-card-description>
</ui-card-header>
<ui-card-content>
<div class="flex items-center justify-between">
<span class="text-sm">Cloudflare</span>
<ui-badge [variant]="status()!.dns.configured ? 'success' : 'secondary'">
{{ status()!.dns.configured ? 'Configured' : 'Not configured' }}
</ui-badge>
</div>
<div class="flex justify-between">
<span class="text-sm text-gray-600">SSL Configured</span>
<span [ngClass]="status()!.ssl.configured ? 'badge-success' : 'badge-warning'" class="badge">
{{ status()!.ssl.configured ? 'Yes' : 'No' }}
</span>
</ui-card-content>
</ui-card>
<!-- SSL -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>SSL/TLS</ui-card-title>
<ui-card-description>Certificate management</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-sm">ACME</span>
<ui-badge [variant]="status()!.ssl.configured ? 'success' : 'secondary'">
{{ status()!.ssl.configured ? 'Configured' : 'Not configured' }}
</ui-badge>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Certbot</span>
<ui-badge [variant]="status()!.ssl.certbotInstalled ? 'success' : 'secondary'">
{{ status()!.ssl.certbotInstalled ? 'Installed' : 'Not installed' }}
</ui-badge>
</div>
</ui-card-content>
</ui-card>
</div>
<!-- Quick Actions -->
<div class="mt-8">
<h3 class="text-lg font-medium text-gray-900 mb-4">Quick Actions</h3>
<div class="flex space-x-4">
<a routerLink="/services/new" class="btn btn-primary">
Deploy New Service
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Quick Actions</ui-card-title>
<ui-card-description>Common tasks and shortcuts</ui-card-description>
</ui-card-header>
<ui-card-content class="flex gap-4">
<a routerLink="/services/create">
<button uiButton>
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Deploy Service
</button>
</a>
<a routerLink="/services" class="btn btn-secondary">
View All Services
<a routerLink="/services">
<button uiButton variant="outline">View All Services</button>
</a>
</div>
</div>
<a routerLink="/domains">
<button uiButton variant="outline">Manage Domains</button>
</a>
</ui-card-content>
</ui-card>
}
</div>
`,
})
export class DashboardComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService);
private wsService = inject(WebSocketService);
private api = inject(ApiService);
private ws = inject(WebSocketService);
private toast = inject(ToastService);
status = signal<SystemStatus | null>(null);
loading = signal(true);
lastUpdated = signal<Date | null>(null);
private wsSubscription?: Subscription;
private refreshInterval?: number;
status = signal<ISystemStatus | null>(null);
loading = signal(false);
ngOnInit(): void {
this.loadStatus();
private refreshInterval: any;
// Subscribe to WebSocket updates
this.wsSubscription = this.wsService.getMessages().subscribe((message: any) => {
// Reload status on any service or system update
if (message.type === 'service_update' || message.type === 'service_status' || message.type === 'system_status') {
constructor() {
// React to WebSocket updates
effect(() => {
const update = this.ws.serviceUpdates();
const systemStatus = this.ws.systemStatus();
if (update || systemStatus) {
this.loadStatus();
}
});
}
ngOnInit(): void {
this.loadStatus();
// Auto-refresh every 30 seconds
this.refreshInterval = window.setInterval(() => {
this.loadStatus();
}, 30000);
this.refreshInterval = setInterval(() => this.loadStatus(), 30000);
}
ngOnDestroy(): void {
this.wsSubscription?.unsubscribe();
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
loadStatus(): void {
async loadStatus(): Promise<void> {
this.loading.set(true);
this.apiService.getStatus().subscribe({
next: (response) => {
if (response.success && response.data) {
this.status.set(response.data);
this.lastUpdated.set(new Date());
}
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
refresh(): void {
this.loadStatus();
try {
const response = await this.api.getStatus();
if (response.success && response.data) {
this.status.set(response.data);
} else {
this.toast.error(response.error || 'Failed to load status');
}
} catch (err) {
this.toast.error('Failed to load status');
} finally {
this.loading.set(false);
}
}
}

View File

@@ -1,105 +1,207 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
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,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
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',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
BadgeComponent,
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
SkeletonComponent,
DialogComponent,
DialogHeaderComponent,
DialogTitleComponent,
DialogDescriptionComponent,
DialogFooterComponent,
],
template: `
<div class="px-4 sm:px-0">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">DNS Records</h1>
<button
(click)="syncRecords()"
[disabled]="syncing()"
class="btn btn-primary"
>
{{ syncing() ? 'Syncing...' : 'Sync Cloudflare' }}
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">DNS Records</h1>
<p class="text-muted-foreground">Manage DNS records synced with Cloudflare</p>
</div>
<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>
@if (records().length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (record of records(); track record.domain) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ record.domain }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ record.type }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ record.value }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<button (click)="deleteRecord(record)" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
<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" />
}
</tbody>
</table>
</div>
} @else {
<div class="card text-center py-12">
<p class="text-gray-500">No DNS records configured</p>
<p class="text-sm text-gray-400 mt-2">DNS records are created automatically when deploying services with domains</p>
<p class="text-sm text-gray-400 mt-2">Or click "Sync Cloudflare" to import existing DNS records from Cloudflare</p>
</div>
}
</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 DnsComponent implements OnInit {
private apiService = inject(ApiService);
private toastService = inject(ToastService);
records = signal<any[]>([]);
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();
}
loadRecords(): void {
this.apiService.getDnsRecords().subscribe({
next: (response) => {
if (response.success && response.data) {
this.records.set(response.data);
}
},
});
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);
}
}
syncRecords(): void {
async syncRecords(): Promise<void> {
this.syncing.set(true);
this.apiService.syncDnsRecords().subscribe({
next: (response) => {
if (response.success) {
this.toastService.success('Cloudflare DNS records synced successfully');
this.loadRecords();
} else {
this.toastService.error(response.error || 'Failed to sync DNS records');
}
this.syncing.set(false);
},
error: () => {
this.toastService.error('Failed to sync DNS records');
this.syncing.set(false);
},
});
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);
}
}
deleteRecord(record: any): void {
if (confirm(`Delete DNS record for ${record.domain}?`)) {
this.apiService.deleteDnsRecord(record.domain).subscribe({
next: () => this.loadRecords(),
});
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);
}
}
}

View File

@@ -1,356 +1,289 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, inject, signal, OnInit } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ApiService } from '../../core/services/api.service';
interface DomainDetail {
domain: {
id: number;
domain: string;
dnsProvider: 'cloudflare' | 'manual' | null;
cloudflareZoneId?: string;
isObsolete: boolean;
defaultWildcard: boolean;
createdAt: number;
updatedAt: number;
};
certificates: Array<{
id: number;
certDomain: string;
isWildcard: boolean;
expiryDate: number;
issuer: string;
isValid: boolean;
createdAt: number;
}>;
requirements: Array<{
id: number;
serviceId: number;
subdomain: string;
status: 'pending' | 'active' | 'renewing';
certificateId?: number;
}>;
services: Array<{
id: number;
name: string;
domain: string;
status: string;
}>;
}
import { ToastService } from '../../core/services/toast.service';
import { IDomainDetail, IService } 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 {
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
} from '../../ui/table/table.component';
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
@Component({
selector: 'app-domain-detail',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
BadgeComponent,
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
SkeletonComponent,
],
template: `
<div class="px-4 sm:px-0">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center mb-4">
<a
routerLink="/domains"
class="text-primary-600 hover:text-primary-900 mr-4"
>
← Back to Domains
</a>
</div>
<div class="space-y-6">
<div>
<a routerLink="/domains" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
<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="M15 19l-7-7 7-7" />
</svg>
Back to Domains
</a>
@if (loading()) {
<div class="card text-center py-12">
<p class="text-gray-500">Loading domain details...</p>
</div>
} @else if (domainDetail()) {
<div>
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-gray-900">
{{ domainDetail()!.domain.domain }}
</h1>
<div class="mt-2 flex items-center gap-3">
@if (domainDetail()!.domain.dnsProvider === 'cloudflare') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Cloudflare
</span>
} @else if (domainDetail()!.domain.dnsProvider === 'manual') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Manual DNS
</span>
}
@if (domainDetail()!.domain.defaultWildcard) {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
Wildcard Enabled
</span>
}
@if (domainDetail()!.domain.isObsolete) {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Obsolete
</span>
}
</div>
</div>
</div>
<!-- Stats Grid -->
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-3">
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Certificates</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ domainDetail()!.certificates.length }}
</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Requirements</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ domainDetail()!.requirements.length }}
</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Services</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">
{{ domainDetail()!.services.length }}
</dd>
</div>
</div>
<!-- Certificates Section -->
<div class="mt-8">
<h2 class="text-xl font-bold text-gray-900 mb-4">SSL Certificates</h2>
@if (domainDetail()!.certificates.length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expires</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Issuer</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (cert of domainDetail()!.certificates; track cert.id) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ cert.certDomain }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (cert.isWildcard) {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
Wildcard
</span>
} @else {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Standard
</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (getCertStatus(cert) === 'valid') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Valid
</span>
} @else if (getCertStatus(cert) === 'expiring') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Expiring Soon
</span>
} @else {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Expired/Invalid
</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ formatDate(cert.expiryDate) }}
<span class="text-gray-500">({{ getDaysRemaining(cert.expiryDate) }} days)</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ cert.issuer }}
</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="card text-center py-8">
<p class="text-gray-500">No certificates for this domain</p>
</div>
}
</div>
<!-- Certificate Requirements Section -->
<div class="mt-8">
<h2 class="text-xl font-bold text-gray-900 mb-4">Certificate Requirements</h2>
@if (domainDetail()!.requirements.length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Service</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Subdomain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Certificate ID</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (req of domainDetail()!.requirements; track req.id) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ getServiceName(req.serviceId) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ req.subdomain || '(root)' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (req.status === 'active') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Active
</span>
} @else if (req.status === 'pending') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Pending
</span>
} @else if (req.status === 'renewing') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Renewing
</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ req.certificateId || '—' }}
</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="card text-center py-8">
<p class="text-gray-500">No certificate requirements</p>
</div>
}
</div>
<!-- Services Section -->
<div class="mt-8">
<h2 class="text-xl font-bold text-gray-900 mb-4">Services Using This Domain</h2>
@if (domainDetail()!.services.length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (service of domainDetail()!.services; track service.id) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{{ service.name }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ service.domain }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (service.status === 'running') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Running
</span>
} @else if (service.status === 'stopped') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Stopped
</span>
} @else {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{{ service.status }}
</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<a
[routerLink]="['/services', service.name]"
class="text-primary-600 hover:text-primary-900"
>
View Service
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
} @else {
<div class="card text-center py-8">
<p class="text-gray-500">No services using this domain</p>
</div>
}
</div>
</div>
} @else {
<div class="card text-center py-12">
<p class="text-gray-500">Domain not found</p>
@if (loading() && !domain()) {
<ui-skeleton class="h-9 w-64" />
} @else if (domain()) {
<div class="flex items-center gap-4">
<h1 class="text-3xl font-bold tracking-tight">{{ domain()!.domain.domain }}</h1>
<ui-badge [variant]="domain()!.domain.dnsProvider === 'cloudflare' ? 'default' : 'secondary'">
{{ domain()!.domain.dnsProvider || 'Manual' }}
</ui-badge>
@if (domain()!.domain.defaultWildcard) {
<ui-badge variant="outline">Wildcard</ui-badge>
}
@if (domain()!.domain.isObsolete) {
<ui-badge variant="destructive">Obsolete</ui-badge>
}
</div>
}
</div>
@if (domain()) {
<!-- Stats -->
<div class="grid gap-4 md:grid-cols-3">
<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">{{ domain()!.certificates.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">Requirements</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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
</ui-card-header>
<ui-card-content>
<div class="text-2xl font-bold">{{ domain()!.requirements.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">Services</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">{{ domain()!.serviceCount }}</div>
</ui-card-content>
</ui-card>
</div>
<!-- Certificates -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>SSL Certificates</ui-card-title>
<ui-card-description>Active certificates for this domain</ui-card-description>
</ui-card-header>
<ui-card-content class="p-0">
@if (domain()!.certificates.length === 0) {
<div class="p-6 text-center text-muted-foreground">No certificates</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>Status</ui-table-head>
<ui-table-head>Expires</ui-table-head>
<ui-table-head>Issuer</ui-table-head>
<ui-table-head class="text-right">Actions</ui-table-head>
</ui-table-row>
</ui-table-header>
<ui-table-body>
@for (cert of domain()!.certificates; track cert.id) {
<ui-table-row>
<ui-table-cell class="font-medium">{{ cert.certDomain }}</ui-table-cell>
<ui-table-cell>
<ui-badge variant="outline">{{ cert.isWildcard ? 'Wildcard' : 'Standard' }}</ui-badge>
</ui-table-cell>
<ui-table-cell>
<ui-badge [variant]="getCertStatusVariant(cert)">
{{ getCertStatus(cert) }}
</ui-badge>
</ui-table-cell>
<ui-table-cell>
{{ formatDate(cert.expiryDate) }}
<span class="text-xs text-muted-foreground ml-1">
({{ getDaysRemaining(cert.expiryDate) }} days)
</span>
</ui-table-cell>
<ui-table-cell>{{ cert.issuer }}</ui-table-cell>
<ui-table-cell class="text-right">
<button uiButton variant="outline" size="sm" (click)="renewCertificate(cert.certDomain)">
Renew
</button>
</ui-table-cell>
</ui-table-row>
}
</ui-table-body>
</ui-table>
}
</ui-card-content>
</ui-card>
<!-- Services using this domain -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Services</ui-card-title>
<ui-card-description>Services using this domain</ui-card-description>
</ui-card-header>
<ui-card-content class="p-0">
@if (services().length === 0) {
<div class="p-6 text-center text-muted-foreground">No services using this domain</div>
} @else {
<ui-table>
<ui-table-header>
<ui-table-row>
<ui-table-head>Service</ui-table-head>
<ui-table-head>Domain</ui-table-head>
<ui-table-head>Status</ui-table-head>
<ui-table-head class="text-right">Actions</ui-table-head>
</ui-table-row>
</ui-table-header>
<ui-table-body>
@for (svc of services(); track svc.name) {
<ui-table-row>
<ui-table-cell class="font-medium">{{ svc.name }}</ui-table-cell>
<ui-table-cell>{{ svc.domain }}</ui-table-cell>
<ui-table-cell>
<ui-badge [variant]="svc.status === 'running' ? 'success' : 'secondary'">
{{ svc.status }}
</ui-badge>
</ui-table-cell>
<ui-table-cell class="text-right">
<a [routerLink]="['/services', svc.name]">
<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 DomainDetailComponent implements OnInit {
private route = inject(ActivatedRoute);
private apiService = inject(ApiService);
private api = inject(ApiService);
private toast = inject(ToastService);
domainDetail = signal<DomainDetail | null>(null);
loading = signal(true);
domain = signal<IDomainDetail | null>(null);
services = signal<IService[]>([]);
loading = signal(false);
ngOnInit(): void {
const domain = this.route.snapshot.paramMap.get('domain');
if (domain) {
this.loadDomainDetail(domain);
const domainName = this.route.snapshot.paramMap.get('domain');
if (domainName) {
this.loadDomain(domainName);
this.loadServices(domainName);
}
}
loadDomainDetail(domain: string): void {
async loadDomain(name: string): Promise<void> {
this.loading.set(true);
this.apiService.getDomainDetail(domain).subscribe({
next: (response) => {
if (response.success && response.data) {
this.domainDetail.set(response.data);
}
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
try {
const response = await this.api.getDomainDetail(name);
if (response.success && response.data) {
this.domain.set(response.data);
}
} catch {
this.toast.error('Failed to load domain');
} finally {
this.loading.set(false);
}
}
async loadServices(domainName: string): Promise<void> {
try {
const response = await this.api.getServices();
if (response.success && response.data) {
this.services.set(response.data.filter(s => s.domain?.includes(domainName)));
}
} catch {
// Silent fail
}
}
formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
return new Date(timestamp).toLocaleDateString();
}
getDaysRemaining(expiryDate: number): number {
getDaysRemaining(timestamp: number): number {
const now = Date.now();
const diff = expiryDate - now;
return Math.floor(diff / (24 * 60 * 60 * 1000));
return Math.floor((timestamp - now) / (1000 * 60 * 60 * 24));
}
getCertStatus(cert: any): 'valid' | 'expiring' | 'invalid' {
if (!cert.isValid) return 'invalid';
const daysRemaining = this.getDaysRemaining(cert.expiryDate);
if (daysRemaining < 0) return 'invalid';
if (daysRemaining <= 30) return 'expiring';
return 'valid';
getCertStatus(cert: any): string {
if (!cert.isValid) return 'Invalid';
const days = this.getDaysRemaining(cert.expiryDate);
if (days < 0) return 'Expired';
if (days <= 30) return 'Expiring';
return 'Valid';
}
getServiceName(serviceId: number): string {
const service = this.domainDetail()?.services.find((s) => s.id === serviceId);
return service?.name || `Service #${serviceId}`;
getCertStatusVariant(cert: any): 'success' | 'warning' | 'destructive' {
const status = this.getCertStatus(cert);
switch (status) {
case 'Valid': return 'success';
case 'Expiring': return 'warning';
default: return 'destructive';
}
}
async renewCertificate(domain: string): Promise<void> {
try {
const response = await this.api.renewCertificate(domain);
if (response.success) {
this.toast.success('Certificate renewal initiated');
} else {
this.toast.error(response.error || 'Failed to renew certificate');
}
} catch {
this.toast.error('Failed to renew certificate');
}
}
}

View File

@@ -1,216 +1,242 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
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';
interface DomainView {
domain: {
id: number;
domain: string;
dnsProvider: 'cloudflare' | 'manual' | null;
isObsolete: boolean;
defaultWildcard: boolean;
};
serviceCount: number;
certificateStatus: 'valid' | 'expiring-soon' | 'expired' | 'pending' | 'none';
daysRemaining: number | null;
certificates: any[];
requirements: any[];
}
import { IDomainDetail } 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 {
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
} from '../../ui/table/table.component';
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
@Component({
selector: 'app-domains',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
BadgeComponent,
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
SkeletonComponent,
],
template: `
<div class="px-4 sm:px-0">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">Domains</h1>
<button
(click)="syncDomains()"
[disabled]="syncing()"
class="btn btn-primary"
>
{{ syncing() ? 'Syncing...' : 'Sync Cloudflare' }}
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Domains</h1>
<p class="text-muted-foreground">Manage domains and SSL certificates</p>
</div>
<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>
@if (loading()) {
<div class="card text-center py-12">
<p class="text-gray-500">Loading domains...</p>
</div>
} @else if (domains().length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Domain</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Provider</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Services</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Certificate</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expiry</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (domainView of domains(); track domainView.domain.id) {
<tr [class.opacity-50]="domainView.domain.isObsolete">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ domainView.domain.domain }}</div>
@if (domainView.domain.isObsolete) {
<span class="text-xs text-red-600">Obsolete</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@if (domainView.domain.dnsProvider === 'cloudflare') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Cloudflare
</span>
} @else if (domainView.domain.dnsProvider === 'manual') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
Manual
</span>
} @else {
<span class="text-sm text-gray-400">None</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ domainView.serviceCount }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
@switch (domainView.certificateStatus) {
@case ('valid') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Valid
</span>
}
@case ('expiring-soon') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Expiring Soon
</span>
}
@case ('expired') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Expired
</span>
}
@case ('pending') {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Pending
</span>
}
@default {
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
None
</span>
}
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
@if (domainView.daysRemaining !== null) {
<span [ngClass]="domainView.daysRemaining <= 30 ? 'text-red-600 font-medium' : 'text-gray-500'">
{{ domainView.daysRemaining }} days
</span>
} @else {
<span class="text-gray-400">—</span>
}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<a
[routerLink]="['/domains', domainView.domain.domain]"
class="text-primary-600 hover:text-primary-900"
>
View Details
</a>
</td>
</tr>
}
</tbody>
</table>
</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>
<!-- Summary Stats -->
<div class="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-4">
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Total Domains</dt>
<dd class="mt-1 text-3xl font-semibold text-gray-900">{{ domains().length }}</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Valid Certificates</dt>
<dd class="mt-1 text-3xl font-semibold text-green-600">{{ getStatusCount('valid') }}</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Expiring Soon</dt>
<dd class="mt-1 text-3xl font-semibold text-yellow-600">{{ getStatusCount('expiring-soon') }}</dd>
</div>
<div class="card">
<dt class="text-sm font-medium text-gray-500 truncate">Expired/Pending</dt>
<dd class="mt-1 text-3xl font-semibold text-red-600">{{ getStatusCount('expired') + getStatusCount('pending') }}</dd>
</div>
</div>
} @else {
<div class="card text-center py-12">
<p class="text-gray-500">No domains found</p>
<p class="text-sm text-gray-400 mt-2">
Sync your Cloudflare zones or manually add domains to get started
</p>
<button
(click)="syncDomains()"
class="mt-4 btn btn-primary"
>
Sync Cloudflare Domains
</button>
</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]="['/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 DomainsComponent implements OnInit {
private apiService = inject(ApiService);
private toastService = inject(ToastService);
private api = inject(ApiService);
private toast = inject(ToastService);
domains = signal<DomainView[]>([]);
loading = signal(true);
domains = signal<IDomainDetail[]>([]);
loading = signal(false);
syncing = signal(false);
ngOnInit(): void {
this.loadDomains();
}
loadDomains(): void {
async loadDomains(): Promise<void> {
this.loading.set(true);
this.apiService.getDomains().subscribe({
next: (response) => {
if (response.success && response.data) {
this.domains.set(response.data);
}
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
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);
}
}
syncDomains(): void {
async syncDomains(): Promise<void> {
this.syncing.set(true);
this.apiService.syncCloudflareDomains().subscribe({
next: (response) => {
if (response.success) {
this.toastService.success('Cloudflare domains synced successfully');
this.loadDomains();
}
this.syncing.set(false);
},
error: (error) => {
this.toastService.error('Failed to sync Cloudflare domains: ' + (error.error?.error || error.message));
this.syncing.set(false);
},
});
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);
}
}
getStatusCount(status: string): number {
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';
}
}
}

View File

@@ -1,103 +1,163 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuthService } from '../../core/services/auth.service';
import { ThemeService } from '../../core/services/theme.service';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
CardFooterComponent,
} from '../../ui/card/card.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { InputComponent } from '../../ui/input/input.component';
import { LabelComponent } from '../../ui/label/label.component';
import { AlertComponent, AlertDescriptionComponent } from '../../ui/alert/alert.component';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [
FormsModule,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
CardFooterComponent,
ButtonComponent,
InputComponent,
LabelComponent,
AlertComponent,
AlertDescriptionComponent,
],
template: `
<div class="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
Onebox
</h2>
<p class="mt-2 text-center text-sm text-gray-600">
Sign in to your account
</p>
</div>
<form class="mt-8 space-y-6" (ngSubmit)="onSubmit()">
<div class="rounded-md shadow-sm -space-y-px">
<div>
<label for="username" class="sr-only">Username</label>
<div class="min-h-screen flex items-center justify-center bg-background p-4">
<div class="absolute top-4 right-4">
<button
uiButton
variant="ghost"
size="icon"
(click)="theme.toggle()"
>
@if (theme.resolvedTheme() === 'dark') {
<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="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
} @else {
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
}
</button>
</div>
<ui-card class="w-full max-w-md">
<ui-card-header class="text-center">
<div class="mx-auto mb-4">
<svg class="h-12 w-12 text-primary" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
</svg>
</div>
<ui-card-title>Welcome to Onebox</ui-card-title>
<ui-card-description>Enter your credentials to sign in</ui-card-description>
</ui-card-header>
<form (ngSubmit)="onSubmit()">
<ui-card-content class="space-y-4">
@if (error()) {
<ui-alert variant="destructive">
<ui-alert-description>{{ error() }}</ui-alert-description>
</ui-alert>
}
<div class="space-y-2">
<label uiLabel for="username">Username</label>
<input
uiInput
id="username"
name="username"
type="text"
[(ngModel)]="username"
name="username"
placeholder="Enter username"
autocomplete="username"
required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Username"
/>
</div>
<div>
<label for="password" class="sr-only">Password</label>
<div class="space-y-2">
<label uiLabel for="password">Password</label>
<input
uiInput
id="password"
name="password"
type="password"
[(ngModel)]="password"
name="password"
placeholder="Enter password"
autocomplete="current-password"
required
class="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Password"
/>
</div>
</div>
</ui-card-content>
@if (error) {
<div class="rounded-md bg-red-50 p-4">
<p class="text-sm text-red-800">{{ error }}</p>
</div>
}
<div>
<ui-card-footer>
<button
uiButton
type="submit"
[disabled]="loading"
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
class="w-full"
[disabled]="loading()"
>
{{ loading ? 'Signing in...' : 'Sign in' }}
@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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Signing in...
} @else {
Sign in
}
</button>
</div>
<div class="text-sm text-gray-600 text-center">
<p>Default credentials: admin / admin</p>
<p class="text-xs text-gray-500 mt-1">Please change after first login</p>
</div>
</ui-card-footer>
</form>
</div>
<div class="px-6 pb-6">
<p class="text-xs text-center text-muted-foreground">
Default credentials: admin / admin
</p>
</div>
</ui-card>
</div>
`,
})
export class LoginComponent {
private authService = inject(AuthService);
private auth = inject(AuthService);
private router = inject(Router);
theme = inject(ThemeService);
username = '';
password = '';
loading = false;
error = '';
loading = signal(false);
error = signal<string | null>(null);
onSubmit(): void {
this.error = '';
this.loading = true;
async onSubmit(): Promise<void> {
if (!this.username || !this.password) {
this.error.set('Please enter username and password');
return;
}
this.authService.login({ username: this.username, password: this.password }).subscribe({
next: (response) => {
this.loading = false;
if (response.success) {
this.router.navigate(['/dashboard']);
} else {
this.error = response.error || 'Login failed';
}
},
error: (err) => {
this.loading = false;
this.error = err.error?.error || 'An error occurred during login';
},
});
this.loading.set(true);
this.error.set(null);
const result = await this.auth.login(this.username, this.password);
this.loading.set(false);
if (result.success) {
this.router.navigate(['/dashboard']);
} else {
this.error.set(result.error || 'Invalid credentials');
}
}
}

View File

@@ -1,99 +1,229 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, inject, signal, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ApiService, Registry } from '../../core/services/api.service';
import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
import { IRegistry, IRegistryCreate } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { InputComponent } from '../../ui/input/input.component';
import { LabelComponent } from '../../ui/label/label.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-registries',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [
FormsModule,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
InputComponent,
LabelComponent,
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
SkeletonComponent,
DialogComponent,
DialogHeaderComponent,
DialogTitleComponent,
DialogDescriptionComponent,
DialogFooterComponent,
],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Docker Registries</h1>
<!-- Add Registry Form -->
<div class="card mb-8 max-w-2xl">
<h2 class="text-lg font-medium text-gray-900 mb-4">Add Registry</h2>
<form (ngSubmit)="addRegistry()" class="space-y-4">
<div>
<label for="url" class="label">Registry URL</label>
<input type="text" id="url" [(ngModel)]="newRegistry.url" name="url" required placeholder="registry.example.com" class="input" />
</div>
<div>
<label for="username" class="label">Username</label>
<input type="text" id="username" [(ngModel)]="newRegistry.username" name="username" required class="input" />
</div>
<div>
<label for="password" class="label">Password</label>
<input type="password" id="password" [(ngModel)]="newRegistry.password" name="password" required class="input" />
</div>
<button type="submit" class="btn btn-primary">Add Registry</button>
</form>
<div class="space-y-6">
<div>
<h1 class="text-3xl font-bold tracking-tight">Docker Registries</h1>
<p class="text-muted-foreground">Manage Docker registry credentials</p>
</div>
<!-- Add Registry Form -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Add Registry</ui-card-title>
<ui-card-description>Add credentials for a private Docker registry</ui-card-description>
</ui-card-header>
<ui-card-content>
<form (ngSubmit)="addRegistry()" class="grid gap-4 md:grid-cols-4">
<div class="space-y-2">
<label uiLabel>Registry URL</label>
<input uiInput [(ngModel)]="form.url" name="url" placeholder="registry.example.com" required />
</div>
<div class="space-y-2">
<label uiLabel>Username</label>
<input uiInput [(ngModel)]="form.username" name="username" required />
</div>
<div class="space-y-2">
<label uiLabel>Password</label>
<input uiInput type="password" [(ngModel)]="form.password" name="password" required />
</div>
<div class="flex items-end">
<button uiButton type="submit" [disabled]="loading()">Add Registry</button>
</div>
</form>
</ui-card-content>
</ui-card>
<!-- Registries List -->
@if (registries().length > 0) {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">URL</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Username</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Created</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (registry of registries(); track registry.id) {
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ registry.url }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ registry.username }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ formatDate(registry.createdAt) }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<button (click)="deleteRegistry(registry)" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Registered Registries</ui-card-title>
</ui-card-header>
<ui-card-content class="p-0">
@if (loading() && registries().length === 0) {
<div class="p-6 space-y-4">
@for (_ of [1,2]; track $index) {
<ui-skeleton class="h-12 w-full" />
}
</tbody>
</table>
</div>
}
</div>
} @else if (registries().length === 0) {
<div class="p-12 text-center">
<p class="text-muted-foreground">No registries configured</p>
</div>
} @else {
<ui-table>
<ui-table-header>
<ui-table-row>
<ui-table-head>URL</ui-table-head>
<ui-table-head>Username</ui-table-head>
<ui-table-head>Created</ui-table-head>
<ui-table-head class="text-right">Actions</ui-table-head>
</ui-table-row>
</ui-table-header>
<ui-table-body>
@for (registry of registries(); track registry.id) {
<ui-table-row>
<ui-table-cell class="font-medium">{{ registry.url }}</ui-table-cell>
<ui-table-cell>{{ registry.username }}</ui-table-cell>
<ui-table-cell>{{ formatDate(registry.createdAt) }}</ui-table-cell>
<ui-table-cell class="text-right">
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(registry)">
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 Registry</ui-dialog-title>
<ui-dialog-description>
Are you sure you want to delete "{{ registryToDelete()?.url }}"?
</ui-dialog-description>
</ui-dialog-header>
<ui-dialog-footer>
<button uiButton variant="outline" (click)="deleteDialogOpen.set(false)">Cancel</button>
<button uiButton variant="destructive" (click)="deleteRegistry()">Delete</button>
</ui-dialog-footer>
</ui-dialog>
`,
})
export class RegistriesComponent implements OnInit {
private apiService = inject(ApiService);
registries = signal<Registry[]>([]);
newRegistry = { url: '', username: '', password: '' };
private api = inject(ApiService);
private toast = inject(ToastService);
registries = signal<IRegistry[]>([]);
loading = signal(false);
deleteDialogOpen = signal(false);
registryToDelete = signal<IRegistry | null>(null);
form: IRegistryCreate = { url: '', username: '', password: '' };
ngOnInit(): void {
this.loadRegistries();
}
loadRegistries(): void {
this.apiService.getRegistries().subscribe({
next: (response) => {
if (response.success && response.data) {
this.registries.set(response.data);
}
},
});
async loadRegistries(): Promise<void> {
this.loading.set(true);
try {
const response = await this.api.getRegistries();
if (response.success && response.data) {
this.registries.set(response.data);
}
} catch {
this.toast.error('Failed to load registries');
} finally {
this.loading.set(false);
}
}
addRegistry(): void {
this.apiService.createRegistry(this.newRegistry).subscribe({
next: () => {
this.newRegistry = { url: '', username: '', password: '' };
async addRegistry(): Promise<void> {
if (!this.form.url || !this.form.username || !this.form.password) {
this.toast.error('Please fill in all fields');
return;
}
this.loading.set(true);
try {
const response = await this.api.createRegistry(this.form);
if (response.success) {
this.toast.success('Registry added');
this.form = { url: '', username: '', password: '' };
this.loadRegistries();
},
});
} else {
this.toast.error(response.error || 'Failed to add registry');
}
} catch {
this.toast.error('Failed to add registry');
} finally {
this.loading.set(false);
}
}
deleteRegistry(registry: Registry): void {
if (confirm(`Delete registry ${registry.url}?`)) {
this.apiService.deleteRegistry(registry.url).subscribe({
next: () => this.loadRegistries(),
});
confirmDelete(registry: IRegistry): void {
this.registryToDelete.set(registry);
this.deleteDialogOpen.set(true);
}
async deleteRegistry(): Promise<void> {
const registry = this.registryToDelete();
if (!registry?.id) return;
try {
const response = await this.api.deleteRegistry(registry.id);
if (response.success) {
this.toast.success('Registry deleted');
this.loadRegistries();
} else {
this.toast.error(response.error || 'Failed to delete registry');
}
} catch {
this.toast.error('Failed to delete registry');
} finally {
this.deleteDialogOpen.set(false);
this.registryToDelete.set(null);
}
}

View File

@@ -1,354 +1,317 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Component, inject, signal, OnInit } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
import { IServiceCreate, IDomainDetail } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
CardFooterComponent,
} from '../../ui/card/card.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { InputComponent } from '../../ui/input/input.component';
import { LabelComponent } from '../../ui/label/label.component';
import { CheckboxComponent } from '../../ui/checkbox/checkbox.component';
import { AlertComponent, AlertDescriptionComponent } from '../../ui/alert/alert.component';
import { SeparatorComponent } from '../../ui/separator/separator.component';
interface EnvVar {
key: string;
value: string;
}
interface Domain {
domain: string;
dnsProvider: 'cloudflare' | 'manual' | null;
isObsolete: boolean;
}
@Component({
selector: 'app-service-create',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
imports: [
FormsModule,
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
CardFooterComponent,
ButtonComponent,
InputComponent,
LabelComponent,
CheckboxComponent,
AlertComponent,
AlertDescriptionComponent,
SeparatorComponent,
],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Deploy New Service</h1>
<div class="max-w-2xl mx-auto space-y-6">
<!-- Header -->
<div>
<a routerLink="/services" class="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1 mb-2">
<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="M15 19l-7-7 7-7" />
</svg>
Back to Services
</a>
<h1 class="text-3xl font-bold tracking-tight">Deploy Service</h1>
<p class="text-muted-foreground">Deploy a new Docker service</p>
</div>
<div class="card max-w-3xl">
<form (ngSubmit)="onSubmit()">
<!-- Name -->
<div class="mb-6">
<label for="name" class="label">Service Name *</label>
<input
type="text"
id="name"
[(ngModel)]="name"
name="name"
required
placeholder="myapp"
class="input"
/>
<p class="mt-1 text-sm text-gray-500">Lowercase letters, numbers, and hyphens only</p>
</div>
<!-- Image -->
<div class="mb-6">
<label for="image" class="label">Docker Image *</label>
<input
type="text"
id="image"
[(ngModel)]="image"
name="image"
[required]="!useOneboxRegistry"
[disabled]="useOneboxRegistry"
placeholder="nginx:latest"
class="input"
/>
<p class="mt-1 text-sm text-gray-500">Format: image:tag or registry/image:tag</p>
</div>
<!-- Onebox Registry Option -->
<div class="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-center mb-2">
<input
type="checkbox"
id="useOneboxRegistry"
[(ngModel)]="useOneboxRegistry"
name="useOneboxRegistry"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="useOneboxRegistry" class="ml-2 block text-sm font-medium text-gray-900">
Use Onebox Registry
</label>
</div>
<p class="text-sm text-gray-600 mb-3">
Store your container image in the local Onebox registry instead of using an external image.
</p>
@if (useOneboxRegistry) {
<div class="space-y-3">
<div>
<label for="registryImageTag" class="label text-sm">Image Tag</label>
<input
type="text"
id="registryImageTag"
[(ngModel)]="registryImageTag"
name="registryImageTag"
placeholder="latest"
class="input text-sm"
/>
<p class="mt-1 text-xs text-gray-500">Tag to use (e.g., latest, v1.0, develop)</p>
</div>
<div class="flex items-center">
<input
type="checkbox"
id="autoUpdateOnPush"
[(ngModel)]="autoUpdateOnPush"
name="autoUpdateOnPush"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="autoUpdateOnPush" class="ml-2 block text-sm text-gray-700">
Auto-restart on new image push
</label>
</div>
<p class="text-xs text-gray-500 ml-6">
Automatically pull and restart the service when a new image is pushed to the registry
</p>
<form (ngSubmit)="onSubmit()">
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Service Configuration</ui-card-title>
<ui-card-description>Configure your service settings</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-6">
<!-- Basic Configuration -->
<div class="grid gap-4">
<div class="space-y-2">
<label uiLabel for="name">Service Name</label>
<input
uiInput
id="name"
[(ngModel)]="form.name"
name="name"
placeholder="my-service"
required
pattern="[a-z0-9-]+"
/>
<p class="text-xs text-muted-foreground">Lowercase letters, numbers, and hyphens only</p>
</div>
}
</div>
<!-- Port -->
<div class="mb-6">
<label for="port" class="label">Container Port *</label>
<input
type="number"
id="port"
[(ngModel)]="port"
name="port"
required
placeholder="80"
class="input"
/>
<p class="mt-1 text-sm text-gray-500">Port that your application listens on</p>
</div>
<!-- Domain -->
<div class="mb-6">
<label for="domain" class="label">Domain (Optional)</label>
<input
type="text"
id="domain"
[(ngModel)]="domain"
(ngModelChange)="onDomainChange()"
name="domain"
placeholder="app.example.com"
list="domainList"
class="input"
[class.border-red-300]="domainWarning()"
/>
<datalist id="domainList">
@for (domain of availableDomains(); track domain.domain) {
<option [value]="domain.domain">{{ domain.domain }}</option>
}
</datalist>
@if (domainWarning()) {
<div class="mt-2 rounded-md bg-yellow-50 p-3">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-yellow-800">
<strong>{{ domainWarningTitle() }}</strong>
</p>
<p class="mt-1 text-sm text-yellow-700">{{ domainWarningMessage() }}</p>
<div class="mt-2">
<a routerLink="/domains" class="text-sm font-medium text-yellow-800 hover:text-yellow-900 underline">
View domains &rarr;
</a>
</div>
</div>
</div>
<div class="space-y-2">
<label uiLabel for="image">Docker Image</label>
<input
uiInput
id="image"
[(ngModel)]="form.image"
name="image"
placeholder="nginx:latest"
required
/>
<p class="text-xs text-muted-foreground">e.g., nginx:latest, registry.example.com/image:tag</p>
</div>
} @else {
<p class="mt-1 text-sm text-gray-500">
Leave empty to skip automatic DNS & SSL.
@if (availableDomains().length > 0) {
<span>Or select from {{ availableDomains().length }} available domain(s).</span>
<div class="space-y-2">
<label uiLabel for="port">Container Port</label>
<input
uiInput
id="port"
type="number"
[(ngModel)]="form.port"
name="port"
placeholder="80"
required
min="1"
max="65535"
/>
</div>
<div class="space-y-2">
<label uiLabel for="domain">Domain (optional)</label>
<input
uiInput
id="domain"
[(ngModel)]="form.domain"
name="domain"
placeholder="app.example.com"
list="domains-list"
/>
<datalist id="domains-list">
@for (d of domains(); track d.domain.domain) {
<option [value]="d.domain.domain">{{ d.domain.domain }}</option>
}
</datalist>
@if (domainWarning()) {
<ui-alert variant="warning" class="mt-2">
<ui-alert-description>{{ domainWarning() }}</ui-alert-description>
</ui-alert>
}
</p>
}
</div>
</div>
</div>
<!-- Environment Variables -->
<div class="mb-6">
<label class="label">Environment Variables</label>
@for (env of envVars(); track $index) {
<div class="flex gap-2 mb-2">
<input
type="text"
[(ngModel)]="env.key"
[name]="'envKey' + $index"
placeholder="KEY"
class="input flex-1"
/>
<input
type="text"
[(ngModel)]="env.value"
[name]="'envValue' + $index"
placeholder="value"
class="input flex-1"
/>
<button type="button" (click)="removeEnvVar($index)" class="btn btn-danger">
Remove
<ui-separator />
<!-- Environment Variables -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-medium">Environment Variables</h3>
<p class="text-xs text-muted-foreground">Configure environment variables for your service</p>
</div>
<button uiButton variant="outline" size="sm" type="button" (click)="addEnvVar()">
<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="M12 4v16m8-8H4" />
</svg>
Add
</button>
</div>
}
<button type="button" (click)="addEnvVar()" class="btn btn-secondary mt-2">
Add Environment Variable
</button>
</div>
<!-- Options -->
<div class="mb-6">
<div class="flex items-center mb-2">
<input
type="checkbox"
id="autoDNS"
[(ngModel)]="autoDNS"
name="autoDNS"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="autoDNS" class="ml-2 block text-sm text-gray-900">
Configure DNS automatically
</label>
@if (envVars().length > 0) {
<div class="space-y-2">
@for (env of envVars(); track $index; let i = $index) {
<div class="flex gap-2">
<input
uiInput
[(ngModel)]="env.key"
[name]="'env-key-' + i"
placeholder="KEY"
class="flex-1"
/>
<input
uiInput
[(ngModel)]="env.value"
[name]="'env-value-' + i"
placeholder="value"
class="flex-1"
/>
<button
uiButton
variant="ghost"
size="icon"
type="button"
(click)="removeEnvVar(i)"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
}
</div>
}
</div>
<div class="flex items-center">
<input
type="checkbox"
id="autoSSL"
[(ngModel)]="autoSSL"
name="autoSSL"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="autoSSL" class="ml-2 block text-sm text-gray-900">
Obtain SSL certificate automatically
</label>
</div>
</div>
@if (error()) {
<div class="rounded-md bg-red-50 p-4 mb-6">
<p class="text-sm text-red-800">{{ error() }}</p>
</div>
}
<ui-separator />
<!-- Actions -->
<div class="flex justify-end space-x-4">
<button type="button" (click)="cancel()" class="btn btn-secondary">
Cancel
<!-- Onebox Registry -->
<div class="space-y-4">
<div class="flex items-center gap-3">
<ui-checkbox
[checked]="form.useOneboxRegistry ?? false"
(checkedChange)="form.useOneboxRegistry = $event"
/>
<div>
<label uiLabel class="cursor-pointer">Use Onebox Registry</label>
<p class="text-xs text-muted-foreground">Push images directly to this Onebox instance</p>
</div>
</div>
@if (form.useOneboxRegistry) {
<div class="pl-7 space-y-4">
<div class="space-y-2">
<label uiLabel for="registryImageTag">Image Tag</label>
<input
uiInput
id="registryImageTag"
[(ngModel)]="form.registryImageTag"
name="registryImageTag"
placeholder="latest"
/>
</div>
<div class="flex items-center gap-3">
<ui-checkbox
[checked]="form.autoUpdateOnPush ?? false"
(checkedChange)="form.autoUpdateOnPush = $event"
/>
<label uiLabel class="cursor-pointer">Auto-restart on push</label>
</div>
</div>
}
</div>
</ui-card-content>
<ui-card-footer class="flex justify-between">
<a routerLink="/services">
<button uiButton variant="outline" type="button">Cancel</button>
</a>
<button uiButton type="submit" [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>
Deploying...
} @else {
Deploy Service
}
</button>
<button type="submit" [disabled]="loading()" class="btn btn-primary">
{{ loading() ? 'Deploying...' : 'Deploy Service' }}
</button>
</div>
</form>
</div>
</ui-card-footer>
</ui-card>
</form>
</div>
`,
})
export class ServiceCreateComponent implements OnInit {
private apiService = inject(ApiService);
private api = inject(ApiService);
private router = inject(Router);
private toast = inject(ToastService);
form: IServiceCreate = {
name: '',
image: '',
port: 80,
domain: '',
useOneboxRegistry: false,
registryImageTag: 'latest',
autoUpdateOnPush: false,
};
name = '';
image = '';
port = 80;
domain = '';
autoDNS = true;
autoSSL = true;
envVars = signal<EnvVar[]>([]);
domains = signal<IDomainDetail[]>([]);
loading = signal(false);
error = signal('');
// Onebox Registry
useOneboxRegistry = false;
registryImageTag = 'latest';
autoUpdateOnPush = false;
// Domain validation
availableDomains = signal<Domain[]>([]);
domainWarning = signal(false);
domainWarningTitle = signal('');
domainWarningMessage = signal('');
domainWarning = signal<string | null>(null);
ngOnInit(): void {
this.loadDomains();
}
loadDomains(): void {
this.apiService.getDomains().subscribe({
next: (response) => {
if (response.success && response.data) {
const domains: Domain[] = response.data.map((d: any) => ({
domain: d.domain.domain,
dnsProvider: d.domain.dnsProvider,
isObsolete: d.domain.isObsolete,
}));
this.availableDomains.set(domains);
}
},
error: () => {
// Silently fail - domains list not critical
},
});
}
onDomainChange(): void {
if (!this.domain) {
this.domainWarning.set(false);
return;
}
// Extract base domain from entered domain
const parts = this.domain.split('.');
if (parts.length < 2) {
// Not a valid domain format
this.domainWarning.set(false);
return;
}
const baseDomain = parts.slice(-2).join('.');
// Check if base domain exists in available domains
const matchingDomain = this.availableDomains().find(
(d) => d.domain === baseDomain
);
if (!matchingDomain) {
this.domainWarning.set(true);
this.domainWarningTitle.set('Domain not found');
this.domainWarningMessage.set(
`The base domain "${baseDomain}" is not in the Domain table. The service will deploy, but certificate management may not work. Sync your Cloudflare domains or manually add the domain first.`
);
} else if (matchingDomain.isObsolete) {
this.domainWarning.set(true);
this.domainWarningTitle.set('Domain is obsolete');
this.domainWarningMessage.set(
`The domain "${baseDomain}" is marked as obsolete (likely removed from Cloudflare). Certificate management may not work properly.`
);
} else {
this.domainWarning.set(false);
async loadDomains(): Promise<void> {
try {
const response = await this.api.getDomains();
if (response.success && response.data) {
this.domains.set(response.data);
}
} catch {
// Silent fail - domain autocomplete is optional
}
}
addEnvVar(): void {
this.envVars.update((vars) => [...vars, { key: '', value: '' }]);
this.envVars.update(vars => [...vars, { key: '', value: '' }]);
}
removeEnvVar(index: number): void {
this.envVars.update((vars) => vars.filter((_, i) => i !== index));
this.envVars.update(vars => vars.filter((_, i) => i !== index));
}
onSubmit(): void {
this.error.set('');
validateDomain(): void {
if (!this.form.domain) {
this.domainWarning.set(null);
return;
}
const domain = this.domains().find(d => d.domain.domain === this.form.domain);
if (!domain) {
this.domainWarning.set('This domain is not in your domain list. DNS and SSL may not be configured automatically.');
} else if (domain.domain.isObsolete) {
this.domainWarning.set('This domain is marked as obsolete.');
} else {
this.domainWarning.set(null);
}
}
async onSubmit(): Promise<void> {
if (!this.form.name || !this.form.image || !this.form.port) {
this.toast.error('Please fill in all required fields');
return;
}
this.loading.set(true);
// Convert env vars to object
// Build env vars object
const envVarsObj: Record<string, string> = {};
for (const env of this.envVars()) {
if (env.key && env.value) {
@@ -356,36 +319,23 @@ export class ServiceCreateComponent implements OnInit {
}
}
const data = {
name: this.name,
image: this.image,
port: this.port,
domain: this.domain || undefined,
envVars: envVarsObj,
autoDNS: this.autoDNS,
autoSSL: this.autoSSL,
useOneboxRegistry: this.useOneboxRegistry,
registryImageTag: this.useOneboxRegistry ? this.registryImageTag : undefined,
autoUpdateOnPush: this.useOneboxRegistry ? this.autoUpdateOnPush : undefined,
const data: IServiceCreate = {
...this.form,
envVars: Object.keys(envVarsObj).length > 0 ? envVarsObj : undefined,
};
this.apiService.createService(data).subscribe({
next: (response) => {
this.loading.set(false);
if (response.success) {
this.router.navigate(['/services']);
} else {
this.error.set(response.error || 'Failed to deploy service');
}
},
error: (err) => {
this.loading.set(false);
this.error.set(err.error?.error || 'An error occurred');
},
});
}
cancel(): void {
this.router.navigate(['/services']);
try {
const response = await this.api.createService(data);
if (response.success) {
this.toast.success(`Service "${this.form.name}" deployed successfully`);
this.router.navigate(['/services']);
} else {
this.toast.error(response.error || 'Failed to deploy service');
}
} catch {
this.toast.error('Failed to deploy service');
} finally {
this.loading.set(false);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,180 +1,332 @@
import { Component, OnInit, OnDestroy, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, inject, signal, effect, OnInit } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ApiService, Service } from '../../core/services/api.service';
import { ApiService } from '../../core/services/api.service';
import { WebSocketService } from '../../core/services/websocket.service';
import { Subscription } from 'rxjs';
import { ToastService } from '../../core/services/toast.service';
import { IService } 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 {
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-services-list',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
BadgeComponent,
TableComponent,
TableHeaderComponent,
TableBodyComponent,
TableRowComponent,
TableHeadComponent,
TableCellComponent,
SkeletonComponent,
DialogComponent,
DialogHeaderComponent,
DialogTitleComponent,
DialogDescriptionComponent,
DialogFooterComponent,
],
template: `
<div class="px-4 sm:px-0">
<div class="sm:flex sm:items-center sm:justify-between mb-8">
<h1 class="text-3xl font-bold text-gray-900">Services</h1>
<div class="mt-4 sm:mt-0">
<a routerLink="/services/new" class="btn btn-primary">
Deploy New Service
</a>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Services</h1>
<p class="text-muted-foreground">Manage your deployed services</p>
</div>
<a routerLink="/services/create">
<button uiButton>
<svg class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
</svg>
Deploy Service
</button>
</a>
</div>
@if (loading()) {
<div class="text-center py-12">
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
} @else if (services().length === 0) {
<div class="card text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">No services</h3>
<p class="mt-1 text-sm text-gray-500">Get started by deploying a new service.</p>
<div class="mt-6">
<a routerLink="/services/new" class="btn btn-primary">
Deploy Service
</a>
</div>
</div>
} @else {
<div class="card overflow-hidden p-0">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Image</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Domain</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@for (service of services(); track service.id) {
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<a [routerLink]="['/services', service.name]" class="text-sm font-medium text-primary-600 hover:text-primary-900">
{{ service.name }}
</a>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{{ service.image }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ service.domain || '-' }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span [ngClass]="{
'badge-success': service.status === 'running',
'badge-danger': service.status === 'stopped' || service.status === 'failed',
'badge-warning': service.status === 'starting' || service.status === 'stopping'
}" class="badge">
{{ service.status }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
@if (service.status === 'stopped') {
<button (click)="startService(service)" class="text-green-600 hover:text-green-900">Start</button>
}
@if (service.status === 'running') {
<button (click)="stopService(service)" class="text-yellow-600 hover:text-yellow-900">Stop</button>
<button (click)="restartService(service)" class="text-blue-600 hover:text-blue-900">Restart</button>
}
<button (click)="deleteService(service)" class="text-red-600 hover:text-red-900">Delete</button>
</td>
</tr>
<!-- Services Table -->
<ui-card>
<ui-card-content class="p-0">
@if (loading() && services().length === 0) {
<div class="p-6 space-y-4">
@for (_ of [1,2,3]; track $index) {
<ui-skeleton class="h-12 w-full" />
}
</tbody>
</table>
</div>
}
</div>
} @else if (services().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="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>
<h3 class="mt-4 text-lg font-semibold">No services</h3>
<p class="mt-2 text-sm text-muted-foreground">Get started by deploying your first service.</p>
<a routerLink="/services/create" class="mt-4 inline-block">
<button uiButton>Deploy Service</button>
</a>
</div>
} @else {
<ui-table>
<ui-table-header>
<ui-table-row>
<ui-table-head>Name</ui-table-head>
<ui-table-head>Image</ui-table-head>
<ui-table-head>Domain</ui-table-head>
<ui-table-head>Status</ui-table-head>
<ui-table-head class="text-right">Actions</ui-table-head>
</ui-table-row>
</ui-table-header>
<ui-table-body>
@for (service of services(); track service.name) {
<ui-table-row>
<ui-table-cell>
<a [routerLink]="['/services', service.name]" class="font-medium hover:underline">
{{ service.name }}
</a>
</ui-table-cell>
<ui-table-cell class="text-muted-foreground">{{ service.image }}</ui-table-cell>
<ui-table-cell>
@if (service.domain) {
<a [href]="'https://' + service.domain" target="_blank" class="text-primary hover:underline">
{{ service.domain }}
</a>
} @else {
<span class="text-muted-foreground">-</span>
}
</ui-table-cell>
<ui-table-cell>
<ui-badge [variant]="getStatusVariant(service.status)">
{{ service.status }}
</ui-badge>
</ui-table-cell>
<ui-table-cell class="text-right">
<div class="flex items-center justify-end gap-2">
@if (service.status === 'stopped' || service.status === 'failed') {
<button
uiButton
variant="outline"
size="sm"
(click)="startService(service.name)"
[disabled]="actionLoading() === service.name"
>
Start
</button>
}
@if (service.status === 'running') {
<button
uiButton
variant="outline"
size="sm"
(click)="stopService(service.name)"
[disabled]="actionLoading() === service.name"
>
Stop
</button>
<button
uiButton
variant="outline"
size="sm"
(click)="restartService(service.name)"
[disabled]="actionLoading() === service.name"
>
Restart
</button>
}
<button
uiButton
variant="destructive"
size="sm"
(click)="confirmDelete(service)"
>
Delete
</button>
</div>
</ui-table-cell>
</ui-table-row>
}
</ui-table-body>
</ui-table>
}
</ui-card-content>
</ui-card>
</div>
<!-- Delete Confirmation Dialog -->
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
<ui-dialog-header>
<ui-dialog-title>Delete Service</ui-dialog-title>
<ui-dialog-description>
Are you sure you want to delete "{{ serviceToDelete()?.name }}"? This action cannot be undone.
</ui-dialog-description>
</ui-dialog-header>
<ui-dialog-footer>
<button uiButton variant="outline" (click)="deleteDialogOpen.set(false)">Cancel</button>
<button uiButton variant="destructive" (click)="deleteService()" [disabled]="!!actionLoading()">
Delete
</button>
</ui-dialog-footer>
</ui-dialog>
`,
})
export class ServicesListComponent implements OnInit, OnDestroy {
private apiService = inject(ApiService);
private wsService = inject(WebSocketService);
private wsSubscription?: Subscription;
export class ServicesListComponent implements OnInit {
private api = inject(ApiService);
private ws = inject(WebSocketService);
private toast = inject(ToastService);
services = signal<Service[]>([]);
loading = signal(true);
services = signal<IService[]>([]);
loading = signal(false);
actionLoading = signal<string | null>(null);
deleteDialogOpen = signal(false);
serviceToDelete = signal<IService | null>(null);
ngOnInit(): void {
// Initial load
this.loadServices();
// Subscribe to WebSocket updates
this.wsSubscription = this.wsService.getMessages().subscribe((message) => {
this.handleWebSocketMessage(message);
constructor() {
// React to WebSocket updates
effect(() => {
const update = this.ws.serviceUpdates();
const status = this.ws.serviceStatus();
if (update || status) {
this.loadServices();
}
});
}
ngOnDestroy(): void {
this.wsSubscription?.unsubscribe();
ngOnInit(): void {
this.loadServices();
}
private handleWebSocketMessage(message: any): void {
if (message.type === 'service_update') {
// Reload the full service list on any service update
this.loadServices();
} else if (message.type === 'service_status') {
// Update individual service status
const currentServices = this.services();
const updatedServices = currentServices.map(s =>
s.name === message.serviceName
? { ...s, status: message.status }
: s
);
this.services.set(updatedServices);
async loadServices(): Promise<void> {
this.loading.set(true);
try {
const response = await this.api.getServices();
if (response.success && response.data) {
this.services.set(response.data);
}
} catch {
this.toast.error('Failed to load services');
} finally {
this.loading.set(false);
}
}
loadServices(): void {
this.loading.set(true);
this.apiService.getServices().subscribe({
next: (response) => {
if (response.success && response.data) {
this.services.set(response.data);
}
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
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';
}
}
startService(service: Service): void {
this.apiService.startService(service.name).subscribe({
next: () => {
// WebSocket will handle the update
},
});
async startService(name: string): Promise<void> {
this.actionLoading.set(name);
try {
const response = await this.api.startService(name);
if (response.success) {
this.toast.success(`Service "${name}" started`);
this.loadServices();
} else {
this.toast.error(response.error || 'Failed to start service');
}
} catch {
this.toast.error('Failed to start service');
} finally {
this.actionLoading.set(null);
}
}
stopService(service: Service): void {
this.apiService.stopService(service.name).subscribe({
next: () => {
// WebSocket will handle the update
},
});
async stopService(name: string): Promise<void> {
this.actionLoading.set(name);
try {
const response = await this.api.stopService(name);
if (response.success) {
this.toast.success(`Service "${name}" stopped`);
this.loadServices();
} else {
this.toast.error(response.error || 'Failed to stop service');
}
} catch {
this.toast.error('Failed to stop service');
} finally {
this.actionLoading.set(null);
}
}
restartService(service: Service): void {
this.apiService.restartService(service.name).subscribe({
next: () => {
// WebSocket will handle the update
},
});
async restartService(name: string): Promise<void> {
this.actionLoading.set(name);
try {
const response = await this.api.restartService(name);
if (response.success) {
this.toast.success(`Service "${name}" restarted`);
this.loadServices();
} else {
this.toast.error(response.error || 'Failed to restart service');
}
} catch {
this.toast.error('Failed to restart service');
} finally {
this.actionLoading.set(null);
}
}
deleteService(service: Service): void {
if (confirm(`Are you sure you want to delete ${service.name}?`)) {
this.apiService.deleteService(service.name).subscribe({
next: () => {
// WebSocket will handle the update
},
});
confirmDelete(service: IService): void {
this.serviceToDelete.set(service);
this.deleteDialogOpen.set(true);
}
async deleteService(): Promise<void> {
const service = this.serviceToDelete();
if (!service) return;
this.actionLoading.set(service.name);
try {
const response = await this.api.deleteService(service.name);
if (response.success) {
this.toast.success(`Service "${service.name}" deleted`);
this.deleteDialogOpen.set(false);
this.loadServices();
} else {
this.toast.error(response.error || 'Failed to delete service');
}
} catch {
this.toast.error('Failed to delete service');
} finally {
this.actionLoading.set(null);
this.serviceToDelete.set(null);
}
}
}

View File

@@ -1,100 +1,337 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, inject, signal, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.service';
import { ThemeService } from '../../core/services/theme.service';
import { AuthService } from '../../core/services/auth.service';
import { ISettings } from '../../core/types/api.types';
import {
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
} from '../../ui/card/card.component';
import { ButtonComponent } from '../../ui/button/button.component';
import { InputComponent } from '../../ui/input/input.component';
import { LabelComponent } from '../../ui/label/label.component';
import { SwitchComponent } from '../../ui/switch/switch.component';
import { SeparatorComponent } from '../../ui/separator/separator.component';
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
@Component({
selector: 'app-settings',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [
FormsModule,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
CardDescriptionComponent,
CardContentComponent,
ButtonComponent,
InputComponent,
LabelComponent,
SwitchComponent,
SeparatorComponent,
SkeletonComponent,
],
template: `
<div class="px-4 sm:px-0">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Settings</h1>
<div class="space-y-6">
<div>
<h1 class="text-3xl font-bold tracking-tight">Settings</h1>
<p class="text-muted-foreground">Manage system configuration</p>
</div>
<div class="space-y-6">
<!-- Cloudflare Settings -->
<div class="card">
<h2 class="text-lg font-medium text-gray-900 mb-4">Cloudflare DNS</h2>
<div class="space-y-4">
<div>
<label class="label">API Key</label>
<input type="password" [(ngModel)]="settings.cloudflareAPIKey" class="input" />
</div>
<div>
<label class="label">Email</label>
<input type="email" [(ngModel)]="settings.cloudflareEmail" class="input" />
</div>
<div>
<label class="label">Zone ID</label>
<input type="text" [(ngModel)]="settings.cloudflareZoneID" class="input" />
</div>
</div>
@if (loading()) {
<div class="space-y-6">
@for (_ of [1,2,3]; track $index) {
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-skeleton class="h-6 w-48" />
</ui-card-header>
<ui-card-content>
<ui-skeleton class="h-20 w-full" />
</ui-card-content>
</ui-card>
}
</div>
} @else {
<!-- Appearance -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Appearance</ui-card-title>
<ui-card-description>Customize the look and feel</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<label uiLabel>Dark Mode</label>
<p class="text-sm text-muted-foreground">Toggle dark mode on or off</p>
</div>
<ui-switch [ngModel]="theme.isDark()" (ngModelChange)="theme.toggle()" />
</div>
</ui-card-content>
</ui-card>
<!-- Server Settings -->
<div class="card">
<h2 class="text-lg font-medium text-gray-900 mb-4">Server</h2>
<div class="space-y-4">
<div>
<label class="label">Server IP</label>
<input type="text" [(ngModel)]="settings.serverIP" class="input" placeholder="1.2.3.4" />
<!-- Cloudflare Integration -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Cloudflare Integration</ui-card-title>
<ui-card-description>Configure Cloudflare API for DNS management</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-4">
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<label uiLabel>API Token</label>
<input
uiInput
type="password"
[(ngModel)]="settings.cloudflareToken"
placeholder="Enter Cloudflare API token"
/>
</div>
<div class="space-y-2">
<label uiLabel>Zone ID (Optional)</label>
<input
uiInput
[(ngModel)]="settings.cloudflareZoneId"
placeholder="Default zone ID"
/>
</div>
</div>
<div>
<label class="label">HTTP Port</label>
<input type="number" [(ngModel)]="settings.httpPort" class="input" placeholder="3000" />
</div>
</div>
</div>
<p class="text-sm text-muted-foreground">
Get your API token from the Cloudflare dashboard with DNS edit permissions.
</p>
</ui-card-content>
</ui-card>
<!-- SSL Settings -->
<div class="card">
<h2 class="text-lg font-medium text-gray-900 mb-4">SSL / ACME</h2>
<div>
<label class="label">ACME Email</label>
<input type="email" [(ngModel)]="settings.acmeEmail" class="input" placeholder="admin@example.com" />
</div>
</div>
<!-- SSL/TLS Settings -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>SSL/TLS Settings</ui-card-title>
<ui-card-description>Configure certificate management</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-4">
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<label uiLabel>Auto-Renew Certificates</label>
<p class="text-sm text-muted-foreground">Automatically renew certificates before expiry</p>
</div>
<ui-switch [(ngModel)]="settings.autoRenewCerts" />
</div>
<ui-separator />
<div class="space-y-2">
<label uiLabel>Renewal Threshold (days)</label>
<input
uiInput
type="number"
[(ngModel)]="settings.renewalThreshold"
min="1"
max="90"
class="w-32"
/>
<p class="text-sm text-muted-foreground">
Renew certificates when they have fewer than this many days remaining.
</p>
</div>
<div class="space-y-2">
<label uiLabel>ACME Email</label>
<input
uiInput
type="email"
[(ngModel)]="settings.acmeEmail"
placeholder="admin@example.com"
/>
<p class="text-sm text-muted-foreground">
Email address for Let's Encrypt notifications.
</p>
</div>
</ui-card-content>
</ui-card>
<!-- Network Settings -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Network Settings</ui-card-title>
<ui-card-description>Configure network and proxy settings</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-4">
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<label uiLabel>HTTP Port</label>
<input
uiInput
type="number"
[(ngModel)]="settings.httpPort"
min="1"
max="65535"
/>
</div>
<div class="space-y-2">
<label uiLabel>HTTPS Port</label>
<input
uiInput
type="number"
[(ngModel)]="settings.httpsPort"
min="1"
max="65535"
/>
</div>
</div>
<div class="flex items-center justify-between">
<div class="space-y-0.5">
<label uiLabel>Force HTTPS</label>
<p class="text-sm text-muted-foreground">Redirect all HTTP traffic to HTTPS</p>
</div>
<ui-switch [(ngModel)]="settings.forceHttps" />
</div>
</ui-card-content>
</ui-card>
<!-- Account Settings -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Account</ui-card-title>
<ui-card-description>Manage your account settings</ui-card-description>
</ui-card-header>
<ui-card-content class="space-y-4">
<div class="space-y-2">
<label uiLabel>Current User</label>
<p class="text-sm font-medium">{{ auth.currentUser()?.username || 'Unknown' }}</p>
</div>
<ui-separator />
<div class="space-y-4">
<h4 class="text-sm font-medium">Change Password</h4>
<div class="grid gap-4 md:grid-cols-3">
<div class="space-y-2">
<label uiLabel>Current Password</label>
<input uiInput type="password" [(ngModel)]="passwordForm.current" />
</div>
<div class="space-y-2">
<label uiLabel>New Password</label>
<input uiInput type="password" [(ngModel)]="passwordForm.new" />
</div>
<div class="space-y-2">
<label uiLabel>Confirm Password</label>
<input uiInput type="password" [(ngModel)]="passwordForm.confirm" />
</div>
</div>
<button uiButton variant="outline" (click)="changePassword()">
Update Password
</button>
</div>
</ui-card-content>
</ui-card>
<!-- Save Button -->
<div class="flex justify-end">
<button (click)="saveSettings()" class="btn btn-primary">
Save Settings
<div class="flex justify-end gap-4">
<button uiButton variant="outline" (click)="loadSettings()">Reset</button>
<button uiButton (click)="saveSettings()" [disabled]="saving()">
@if (saving()) {
<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>
Saving...
} @else {
Save Settings
}
</button>
</div>
</div>
}
</div>
`,
})
export class SettingsComponent implements OnInit {
private apiService = inject(ApiService);
private toastService = inject(ToastService);
settings: any = {};
private api = inject(ApiService);
private toast = inject(ToastService);
protected theme = inject(ThemeService);
protected auth = inject(AuthService);
loading = signal(false);
saving = signal(false);
settings: ISettings = {
cloudflareToken: '',
cloudflareZoneId: '',
autoRenewCerts: true,
renewalThreshold: 30,
acmeEmail: '',
httpPort: 80,
httpsPort: 443,
forceHttps: true,
};
passwordForm = {
current: '',
new: '',
confirm: '',
};
ngOnInit(): void {
this.loadSettings();
}
loadSettings(): void {
this.apiService.getSettings().subscribe({
next: (response) => {
if (response.success && response.data) {
this.settings = response.data;
}
},
});
async loadSettings(): Promise<void> {
this.loading.set(true);
try {
const response = await this.api.getSettings();
if (response.success && response.data) {
this.settings = { ...this.settings, ...response.data };
}
} catch {
this.toast.error('Failed to load settings');
} finally {
this.loading.set(false);
}
}
saveSettings(): void {
// Save each setting individually
const promises = Object.entries(this.settings).map(([key, value]) =>
this.apiService.updateSetting(key, value as string).toPromise()
);
async saveSettings(): Promise<void> {
this.saving.set(true);
try {
const response = await this.api.updateSettings(this.settings);
if (response.success) {
this.toast.success('Settings saved');
} else {
this.toast.error(response.error || 'Failed to save settings');
}
} catch {
this.toast.error('Failed to save settings');
} finally {
this.saving.set(false);
}
}
Promise.all(promises).then(() => {
this.toastService.success('Settings saved successfully');
}).catch((error) => {
this.toastService.error('Failed to save settings: ' + (error.message || 'Unknown error'));
});
async changePassword(): Promise<void> {
if (!this.passwordForm.current || !this.passwordForm.new) {
this.toast.error('Please fill in all password fields');
return;
}
if (this.passwordForm.new !== this.passwordForm.confirm) {
this.toast.error('New passwords do not match');
return;
}
if (this.passwordForm.new.length < 6) {
this.toast.error('Password must be at least 6 characters');
return;
}
try {
const response = await this.api.changePassword(
this.passwordForm.current,
this.passwordForm.new
);
if (response.success) {
this.toast.success('Password changed');
this.passwordForm = { current: '', new: '', confirm: '' };
} else {
this.toast.error(response.error || 'Failed to change password');
}
} catch {
this.toast.error('Failed to change password');
}
}
}